rebuild(migration): validate local legacy cutover
This commit is contained in:
@@ -128,7 +128,7 @@ def _stats(snapshot: dict[str, Any]) -> dict[str, float]:
|
||||
max_height = max(streaks, default=0)
|
||||
active = _number(overview.get("up_count")) + _number(overview.get("down_count"))
|
||||
changes = [_number(row.get("current_change")) for row in yesterday]
|
||||
previous_count = len(yesterday)
|
||||
yesterday_count = len(yesterday)
|
||||
return {
|
||||
"breadth_ratio": _number(overview.get("up_count")) / max(active, 1) * 100,
|
||||
"limit_up": _number(overview.get("limit_up")),
|
||||
@@ -145,22 +145,22 @@ def _stats(snapshot: dict[str, Any]) -> dict[str, float]:
|
||||
if max_height
|
||||
else 0
|
||||
),
|
||||
"previous_count": previous_count,
|
||||
"positive_rate": sum(change > 0 for change in changes) / max(previous_count, 1) * 100,
|
||||
"yesterday_count": yesterday_count,
|
||||
"positive_rate": sum(change > 0 for change in changes) / max(yesterday_count, 1) * 100,
|
||||
"advance_rate": sum(row.get("outcome") == "晋级" for row in yesterday)
|
||||
/ max(previous_count, 1)
|
||||
/ max(yesterday_count, 1)
|
||||
* 100,
|
||||
"average_change": mean(changes) if changes else 0,
|
||||
"median_change": median(changes) if changes else 0,
|
||||
"severe_loss_rate": sum(change <= -5 for change in changes) / max(previous_count, 1) * 100,
|
||||
"severe_loss_rate": sum(change <= -5 for change in changes) / max(yesterday_count, 1) * 100,
|
||||
"previous_down_rate": sum(row.get("outcome") == "跌停" for row in yesterday)
|
||||
/ max(previous_count, 1)
|
||||
/ max(yesterday_count, 1)
|
||||
* 100,
|
||||
}
|
||||
|
||||
|
||||
def _profit(stats: dict[str, float]) -> float:
|
||||
if not stats["previous_count"]:
|
||||
if not stats["yesterday_count"]:
|
||||
return 50
|
||||
median_score = _clamp(50 + stats["median_change"] * 7)
|
||||
average_score = _clamp(50 + stats["average_change"] * 6)
|
||||
|
||||
@@ -210,16 +210,16 @@ class AccountService:
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(self._cipher.decrypt(str(row["encrypted_payload"])))
|
||||
return BirthProfile(
|
||||
birth_date=str(payload["birth_date"]),
|
||||
birth_time=str(payload["birth_time"]),
|
||||
gender=str(payload["gender"]),
|
||||
updated_at=datetime.fromisoformat(str(row["updated_at"])),
|
||||
)
|
||||
except (ValueError, json.JSONDecodeError, KeyError) as exc:
|
||||
raise BusinessError(
|
||||
"profile_unavailable", "个人资料暂时无法读取,请联系管理员。"
|
||||
) from exc
|
||||
return BirthProfile(
|
||||
birth_date=str(payload["birth_date"]),
|
||||
birth_time=str(payload["birth_time"]),
|
||||
gender=str(payload["gender"]),
|
||||
updated_at=datetime.fromisoformat(str(row["updated_at"])),
|
||||
)
|
||||
|
||||
def save_profile(
|
||||
self, user_id: int, birth_date: date, birth_time: str, gender: str
|
||||
|
||||
@@ -68,7 +68,6 @@ class MarketSnapshotService:
|
||||
history = [json.loads(str(row["payload_json"])) for row in rows]
|
||||
sentiment = calculate_sentiment(snapshot, history)
|
||||
snapshot["sentiment"] = sentiment
|
||||
snapshot.update(snapshot["overview"])
|
||||
snapshot["temperature"] = sentiment["score"]
|
||||
observed_at = datetime.combine(
|
||||
date.fromisoformat(trade_date), time(15), tzinfo=SHANGHAI
|
||||
@@ -144,7 +143,6 @@ class MarketSnapshotService:
|
||||
history = [json.loads(str(row["payload_json"])) for row in rows]
|
||||
sentiment = calculate_sentiment(snapshot, history)
|
||||
snapshot["sentiment"] = sentiment
|
||||
snapshot.update(snapshot["overview"])
|
||||
snapshot["temperature"] = sentiment["score"]
|
||||
observed_at = clock.isoformat(timespec="seconds")
|
||||
with self._database.transaction() as connection:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
@@ -147,8 +148,28 @@ def public_job(row: sqlite3.Row) -> dict[str, Any]:
|
||||
result = dict(row)
|
||||
result["source_set"] = json.loads(str(result.pop("source_set_json")))
|
||||
result["payload"] = json.loads(str(result.pop("payload_json")))
|
||||
result["error_message"] = _public_error_message(str(result["error_message"]))
|
||||
return result
|
||||
|
||||
|
||||
def _public_error_message(value: str) -> str:
|
||||
"""Normalize AppError tuples written before structured error storage."""
|
||||
if not value.startswith("("):
|
||||
return value
|
||||
try:
|
||||
legacy = ast.literal_eval(value)
|
||||
except (SyntaxError, ValueError):
|
||||
return value
|
||||
if (
|
||||
isinstance(legacy, tuple)
|
||||
and len(legacy) == 3
|
||||
and isinstance(legacy[0], str)
|
||||
and isinstance(legacy[1], str)
|
||||
and isinstance(legacy[2], int)
|
||||
):
|
||||
return legacy[1]
|
||||
return value
|
||||
|
||||
|
||||
def _json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
||||
|
||||
@@ -57,7 +57,7 @@ class JobService:
|
||||
finished_at=finished,
|
||||
duration_ms=_duration(started, finished),
|
||||
error_code=type(exc).__name__,
|
||||
error_message=str(exc) or "任务执行失败",
|
||||
error_message=_exception_message(exc),
|
||||
)
|
||||
raise
|
||||
finished = datetime.now(SHANGHAI)
|
||||
@@ -120,3 +120,10 @@ class JobService:
|
||||
|
||||
def _duration(started: datetime, finished: datetime) -> int:
|
||||
return max(0, round((finished - started).total_seconds() * 1000))
|
||||
|
||||
|
||||
def _exception_message(error: Exception) -> str:
|
||||
message = getattr(error, "message", None)
|
||||
if isinstance(message, str) and message.strip():
|
||||
return message.strip()
|
||||
return str(error).strip() or "任务执行失败"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# 本地迁移预检证据
|
||||
|
||||
日期:2026-07-30
|
||||
|
||||
## 范围
|
||||
|
||||
- 新版地址:`http://127.0.0.1:8877`
|
||||
- 新版数据库:`next/data/local-preflight/xiaobai-normalized-v5.db`
|
||||
- 旧版`8765`未启动、未占用、未修改。
|
||||
- 本轮未连接NAS,未执行Docker构建或容器切换。
|
||||
|
||||
## 迁移校验
|
||||
|
||||
- SQLite integrity:`ok`
|
||||
- schema版本:12
|
||||
- 用户:3;自选:6;复盘笔记:3;问师消息:28;问天记录:31;策略跟踪:16
|
||||
- 未映射旧表:0;未映射快照类型:0;外键异常:0
|
||||
- 情绪快照按真实交易日去重;旧金额单位、涨跌停字段、情绪样本和个人出生信息已转换为新版契约。
|
||||
|
||||
## 真实浏览器
|
||||
|
||||
- 管理员会员`leefer`:同时显示管理员、会员;后台刷新和系统管理可见。
|
||||
- 会员非管理员`xiaobai`:仅显示会员;后台刷新和系统管理不可见。
|
||||
- 普通用户`ceshi`:显示普通用户;后台刷新和系统管理不可见;智能选股、问师、问天和复盘助手保持完整灰态结构。
|
||||
- 默认数据日期:`2026-07-30`;情绪周期温度34、退潮、20条交易日明细。
|
||||
- 涨停池56、炸板池23、跌停池83、昨日涨停81;复盘自选6;问师24位思维模型。
|
||||
- 龙虎榜`2026-07-24`归档:70只上榜股票、244条操作、23位活跃游资。`2026-07-29`和`2026-07-30`源缓存为空,不冒充解析结果。
|
||||
- 系统管理任务失败只显示“收盘行情读取失败,已保留原有快照”,不显示内部错误码或元组。
|
||||
|
||||
## 分辨率与持久化
|
||||
|
||||
- 1920×1080:情绪、轮动、智能选股、问天、复盘、系统管理均整页纵向滚动且无横向溢出。
|
||||
- 3840×2160:内容区最大宽度2200px,居中且无横向溢出,基础字号不随视口缩放。
|
||||
- 390×844:侧栏隐藏、底部导航和移动上下文栏显示;表格转卡片;交互控件触控高度不低于44px。
|
||||
- 停止并重启`8877`后:健康检查通过,管理员会话、默认日期、20行情绪明细、6只自选和系统配置保持。
|
||||
|
||||
## 自动质量门
|
||||
|
||||
- Ruff:通过
|
||||
- pytest:111项通过
|
||||
- Vue类型检查:通过
|
||||
- Vitest:7项通过
|
||||
- Vite生产构建:通过
|
||||
- Playwright:26项通过;在全新空白测试数据库中重跑,包含注册、权限、16工作区、日夜、390px和4K结构断言。
|
||||
|
||||
## 未关闭项
|
||||
|
||||
- 4K信息密度和审美仍需用户人工确认。
|
||||
- Docker/NAS构建、数据卷持久化、回退和正式切换未执行。
|
||||
- 旧运行时与一次性迁移器在正式切换和回退保留期结束前不清理。
|
||||
@@ -26,6 +26,17 @@
|
||||
1. `U04` 已增加4K内容最大宽度、基础字号、Shell高度和无横向溢出断言,仍需最终人工视觉验收信息密度。
|
||||
2. 第26节逐页状态证据已登记在`page-state-matrix.md`;4K人工视觉单元格在正式切换前保持未关闭。
|
||||
|
||||
## 本地迁移预检
|
||||
|
||||
本轮按用户确认仅执行本地预检,未连接NAS或Docker。新版使用`127.0.0.1:8877`,旧版`8765`
|
||||
未被占用或替换。完整证据见`../evidence/local-preflight-2026-07-30.md`。
|
||||
|
||||
- 旧库只读迁移到独立新版数据库后,SQLite完整性、外键、迁移版本和未映射类型均通过校验。
|
||||
- 管理员会员、会员非管理员、普通用户三个真实账户已验证双维权限;普通用户智能工具保持完整结构并锁定。
|
||||
- 情绪、四类股池、天梯、轮动、竞价、题材、热榜、龙虎榜归档、智能选股、问师、问天、复盘和系统管理均读取到迁移后的真实数据。
|
||||
- 1920×1080、3840×2160和390×844均无页面级横向溢出;移动端侧栏、底部导航、表格卡片化和44px触控目标已直接验证。
|
||||
- 服务进程停止并重新启动后,会话、默认数据日期、20行情绪明细、6只自选和系统配置均保持一致。
|
||||
|
||||
## 已在本轮纠正
|
||||
|
||||
- 阶段选股不再在当前阶段无结果时错误回退到第一条运行结果。
|
||||
@@ -55,12 +66,13 @@
|
||||
没有补造无法验证的策略元信息。
|
||||
- 图表夜间加载首帧、主题同步切换、7板布局和观心夜间关键文字对比度均已有浏览器直接断言,不再只依赖截图。
|
||||
- 16个主工作区、策略跟踪、提醒、复盘助手、账户/会员及系统管理已逐项区分加载、真实空和失败;统一API客户端不会向页面泄露上游错误正文。
|
||||
- 本轮验证为Ruff、106项pytest、Vue类型检查、7项Vitest、生产构建和26项Playwright全部通过。
|
||||
- 后台任务不再把`AppError`内部元组直接展示到系统管理;历史记录读取时兼容清理,新增失败按用户可读说明入库。
|
||||
- 本轮验证为Ruff、111项pytest、Vue类型检查、7项Vitest、生产构建和26项Playwright全部通过。
|
||||
|
||||
## 外部环境阻断项
|
||||
## 本轮范围外项目
|
||||
|
||||
- 当前本机没有可用 Docker 引擎,镜像尚未在真实 Docker 环境构建和启动。
|
||||
- NAS 生产容器尚未切换;持久化重启、健康检查和回退必须在 Docker/NAS 上验证。
|
||||
- 按用户确认,本轮不执行Docker和NAS验收;镜像构建、容器启动和生产数据卷验证仍待正式切换阶段执行。
|
||||
- NAS 生产容器尚未切换;容器级持久化重启、健康检查和回退必须在Docker/NAS中另行验证。
|
||||
- 旧运行时在正式切换前继续保留。切换 NAS 和清理旧实现必须取得用户最终明确确认。
|
||||
- 旧实现的逐路径清理、数据归档和清理后验收已固化在 `legacy-cleanup-plan.md`;
|
||||
一次性旧库迁移器也将在回退保留期结束后删除,不进入长期维护面。
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
- `A账户`:`test_accounts.py`;`A行情`:`test_market_data.py`、`test_market_insights.py`。
|
||||
- `A智能`:`test_screener.py`、`test_mentor.py`、`test_heaven.py`;`A私有`:`test_review.py`。
|
||||
- `A运维`:`test_operations.py`、`test_model_pool.py`、`test_backup_restore.py`及migration测试。
|
||||
- `L8877`:迁移数据库在本地生产模式`127.0.0.1:8877`的真实账户、真实数据、重启及三档视口预检。
|
||||
|
||||
| 页面/能力 | 权限 | 正常数据 | 空/缺失 | 加载/失败 | 持久化 | 日夜 | 1080P/390px | 4K |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -38,6 +39,7 @@
|
||||
## 当前结论
|
||||
|
||||
- 权限、正常、空、缺失、加载、失败、持久化、日间、夜间、1080P和390px均已有直接代码或浏览器证据。
|
||||
- `L8877`已覆盖管理员会员、会员非管理员、普通用户,1920×1080、3840×2160、390×844,以及进程重启后的真实迁移数据保持。
|
||||
- 市场工作区、智能工具、私有页面、全局弹窗和系统管理不会再把请求中或请求失败伪装成“暂无数据”。
|
||||
- 4K已自动验证Shell尺寸、内容最大宽度、基础字号和无横向溢出;情绪、股池、系统管理和问师保留直接4K截图。
|
||||
- 4K“信息密度是否舒适”属于审美判断,仍需正式切换前人工验收,不能由尺寸断言冒充完成。
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
button,
|
||||
.btn,
|
||||
.icon-button,
|
||||
.input,
|
||||
.select,
|
||||
.review-remark,
|
||||
.tracking-entry,
|
||||
.seg-control button,
|
||||
.segmented button,
|
||||
.mobile-nav-item,
|
||||
|
||||
@@ -9,6 +9,7 @@ async function authenticate(page) {
|
||||
if (!(await page.locator(".sidebar").isVisible())) {
|
||||
await page.getByRole("tab", { name: "注册" }).click();
|
||||
await page.getByRole("button", { name: "注册并登录" }).click();
|
||||
await expect(page.locator(".sidebar")).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ async function authenticate(page, username, password) {
|
||||
if (!(await page.locator(".sidebar").isVisible())) {
|
||||
await page.getByRole("tab", { name: "注册" }).click();
|
||||
await page.getByRole("button", { name: "注册并登录" }).click();
|
||||
await expect(page.locator(".sidebar")).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ async function authenticate(page, username, password) {
|
||||
if (!(await page.locator(".sidebar").isVisible())) {
|
||||
await page.getByRole("tab", { name: "注册" }).click();
|
||||
await page.getByRole("button", { name: "注册并登录" }).click();
|
||||
await expect(page.locator(".sidebar")).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ async function authenticate(page, username, password) {
|
||||
if (!(await page.locator(".sidebar").isVisible())) {
|
||||
await page.getByRole("tab", { name: "注册" }).click();
|
||||
await page.getByRole("button", { name: "注册并登录" }).click();
|
||||
await expect(page.locator(".sidebar")).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ async function authenticate(page) {
|
||||
if (!(await page.locator(".sidebar").isVisible())) {
|
||||
await page.getByRole("tab", { name: "注册" }).click();
|
||||
await page.getByRole("button", { name: "注册并登录" }).click();
|
||||
await expect(page.locator(".sidebar")).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ async function authenticate(page) {
|
||||
if (!(await page.locator(".sidebar").isVisible())) {
|
||||
await page.getByRole("tab", { name: "注册" }).click();
|
||||
await page.getByRole("button", { name: "注册并登录" }).click();
|
||||
await expect(page.locator(".sidebar")).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ async function authenticate(page) {
|
||||
if (!(await page.locator(".sidebar").isVisible())) {
|
||||
await page.getByRole("tab", { name: "注册" }).click();
|
||||
await page.getByRole("button", { name: "注册并登录" }).click();
|
||||
await expect(page.locator(".sidebar")).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ async function authenticate(page) {
|
||||
if (!(await page.locator(".sidebar").isVisible())) {
|
||||
await page.getByRole("tab", { name: "注册" }).click();
|
||||
await page.getByRole("button", { name: "注册并登录" }).click();
|
||||
await expect(page.locator(".sidebar")).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ async function authenticate(page) {
|
||||
if (!(await page.locator(".sidebar").isVisible())) {
|
||||
await page.getByRole("tab", { name: "注册" }).click();
|
||||
await page.getByRole("button", { name: "注册并登录" }).click();
|
||||
await expect(page.locator(".sidebar")).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -187,6 +187,25 @@ def test_birth_profile_is_encrypted_and_isolated_by_account(tmp_path) -> None:
|
||||
run_scenario(application, scenario)
|
||||
|
||||
|
||||
def test_incomplete_encrypted_profile_returns_controlled_error(tmp_path) -> None:
|
||||
application = create_application(Settings.for_test(tmp_path))
|
||||
|
||||
async def scenario(client: httpx.AsyncClient) -> None:
|
||||
response, _session = await register(client, "broken-profile", USER_PASSWORD)
|
||||
user_id = response.json()["account"]["id"]
|
||||
encrypted = application.state.container.accounts._cipher.encrypt('{"gender":"male"}')
|
||||
with sqlite3.connect(application.state.settings.database_path) as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO birth_profiles VALUES (?,?,datetime('now'),datetime('now'))",
|
||||
(user_id, encrypted),
|
||||
)
|
||||
result = await client.get("/api/account/profile")
|
||||
assert result.status_code == 503
|
||||
assert result.json()["error"]["code"] == "profile_unavailable"
|
||||
|
||||
run_scenario(application, scenario)
|
||||
|
||||
|
||||
def test_membership_and_admin_are_independent_dimensions(tmp_path) -> None:
|
||||
application = create_application(Settings.for_test(tmp_path))
|
||||
|
||||
|
||||
@@ -92,7 +92,9 @@ def _legacy_database(path, key: str) -> None:
|
||||
"INSERT INTO users VALUES (8,'leefer',?,?,?,?,'admin','auto','active','永久',?,NULL)",
|
||||
(encoded_salt, encoded_hash, now, now, now),
|
||||
)
|
||||
profile = fernet.encrypt(b'{"gender":"male"}').decode()
|
||||
profile = fernet.encrypt(
|
||||
b'{"birth_datetime":"1990-03-08T08:30:00","gender":"male"}'
|
||||
).decode()
|
||||
connection.execute("INSERT INTO user_birth_profiles VALUES (8,?,?)", (profile, now))
|
||||
connection.execute(
|
||||
"INSERT INTO llm_usage VALUES (1,8,'mentor','model','','success',3,?,'','','',0,0)",
|
||||
@@ -109,12 +111,37 @@ def _legacy_database(path, key: str) -> None:
|
||||
)
|
||||
dashboard = json.dumps(
|
||||
{
|
||||
"meta": {
|
||||
"requested_date": "2026-07-30",
|
||||
"trade_date": "2026-07-29",
|
||||
"previous_trade_date": "2026-07-28",
|
||||
},
|
||||
"overview": {
|
||||
"up_count": 1,
|
||||
"down_count": 0,
|
||||
"flat_count": 0,
|
||||
"limit_up_count": 1,
|
||||
"limit_down_count": 0,
|
||||
"broken_count": 0,
|
||||
"amount_billion": 12.5,
|
||||
"seal_rate": 100,
|
||||
"sentiment_score": 64,
|
||||
"sentiment_label": "情绪偏强",
|
||||
"sentiment_phase": "修复",
|
||||
"sentiment_direction": "升温",
|
||||
"sentiment_components": {
|
||||
"breadth": {"label": "市场宽度", "score": 100, "weight": 20}
|
||||
},
|
||||
"sentiment_engine_version": 2,
|
||||
},
|
||||
"limits": [
|
||||
{
|
||||
"ts_code": "000001.SZ",
|
||||
"code": "000001",
|
||||
"name": "Ping An Bank",
|
||||
"reason": "legacy",
|
||||
"amount_billion": 1.25,
|
||||
"seal_amount_million": 20,
|
||||
}
|
||||
],
|
||||
"broken": [],
|
||||
@@ -122,7 +149,7 @@ def _legacy_database(path, key: str) -> None:
|
||||
}
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO dashboard_snapshots VALUES ('20260729','tushare',?,1,?)",
|
||||
"INSERT INTO dashboard_snapshots VALUES ('20260730','tushare',?,1,?)",
|
||||
(dashboard, now),
|
||||
)
|
||||
connection.execute(
|
||||
@@ -347,6 +374,18 @@ def test_legacy_migration_is_idempotent_and_preserves_login(tmp_path) -> None:
|
||||
password = connection.execute("SELECT password_hash FROM users WHERE id=8").fetchone()[0]
|
||||
assert PasswordHasher().verify("Password123", password)
|
||||
assert connection.execute("SELECT count(*) FROM watchlist_entries").fetchone()[0] == 1
|
||||
profile_payload = json.loads(
|
||||
Fernet(key.encode()).decrypt(
|
||||
connection.execute(
|
||||
"SELECT encrypted_payload FROM birth_profiles WHERE user_id=8"
|
||||
).fetchone()[0].encode()
|
||||
)
|
||||
)
|
||||
assert profile_payload == {
|
||||
"birth_date": "1990-03-08",
|
||||
"birth_time": "08:30",
|
||||
"gender": "male",
|
||||
}
|
||||
assert connection.execute("SELECT count(*) FROM screener_runs").fetchone()[0] == 1
|
||||
assert connection.execute("SELECT count(*) FROM strategy_tracks").fetchone()[0] == 1
|
||||
assert connection.execute("SELECT daily_llm_limit FROM memberships").fetchone()[0] == 61
|
||||
@@ -363,6 +402,14 @@ def test_legacy_migration_is_idempotent_and_preserves_login(tmp_path) -> None:
|
||||
).fetchone()[0]
|
||||
)
|
||||
assert summary["limits"][0]["identifier"] == "000001.SZ"
|
||||
assert summary["trade_date"] == "2026-07-29"
|
||||
assert summary["overview"]["limit_up"] == 1
|
||||
assert summary["overview"]["amount"] == 1_250_000_000
|
||||
assert summary["limits"][0]["amount"] == 125_000_000
|
||||
assert summary["limits"][0]["seal_amount"] == 20_000_000
|
||||
assert summary["sentiment"]["score"] == 64
|
||||
assert summary["sentiment"]["phase"] == "修复"
|
||||
assert summary["sentiment"]["components"][0]["key"] == "breadth"
|
||||
all_revisions = tuple(
|
||||
connection.execute(
|
||||
"""SELECT * FROM market_event_revisions WHERE trade_date='2026-07-29'
|
||||
|
||||
@@ -475,6 +475,7 @@ def test_sentiment_has_all_weighted_components_and_extreme_risk_cap() -> None:
|
||||
sentiment = calculate_sentiment(snapshot, [])
|
||||
assert sentiment["score"] <= 15
|
||||
assert sentiment["phase"] == "冰点"
|
||||
assert sentiment["stats"]["yesterday_count"] == 0
|
||||
assert {item["key"]: item["weight"] for item in sentiment["components"]} == {
|
||||
"breadth": 20,
|
||||
"limit_ecology": 25,
|
||||
@@ -551,3 +552,53 @@ def test_incomplete_daily_snapshot_is_rejected_without_overwriting(tmp_path) ->
|
||||
service.sync("2026-07-29", datetime(2026, 7, 30, 16, tzinfo=SHANGHAI))
|
||||
with database.read() as connection:
|
||||
assert repository.latest_summary(connection, "2026-07-29") is None
|
||||
|
||||
|
||||
def test_synced_snapshot_keeps_event_lists_separate_from_overview_counts(tmp_path) -> None:
|
||||
database = Database(tmp_path / "sync-complete.db")
|
||||
MigrationRunner(database).upgrade(MIGRATIONS)
|
||||
repository = MarketRepository()
|
||||
with database.transaction() as connection:
|
||||
repository.replace_calendar(
|
||||
connection,
|
||||
(
|
||||
{"cal_date": "20260728", "is_open": 1, "pretrade_date": "20260727"},
|
||||
{"cal_date": "20260729", "is_open": 1, "pretrade_date": "20260728"},
|
||||
),
|
||||
"tushare",
|
||||
"2026-07-29T15:00:00+08:00",
|
||||
)
|
||||
repository.replace_stocks(
|
||||
connection,
|
||||
({
|
||||
"ts_code": "000001.SZ", "symbol": "000001", "name": "平安银行",
|
||||
"industry": "银行", "list_status": "L",
|
||||
},),
|
||||
"tushare",
|
||||
"2026-07-29T15:00:00+08:00",
|
||||
)
|
||||
provider = TushareProvider("test-token")
|
||||
stock = {
|
||||
"ts_code": "000001.SZ", "name": "平安银行", "industry": "银行",
|
||||
"close": 10, "pct_chg": 5, "amount": 100, "limit_times": 1,
|
||||
}
|
||||
provider.snapshot_inputs = lambda *_: {
|
||||
"daily": calculation_result([stock]),
|
||||
"limit_up": calculation_result([]),
|
||||
"limit_down": calculation_result([]),
|
||||
"broken": calculation_result([stock]),
|
||||
"previous_limit_up": calculation_result([]),
|
||||
"price_limits": calculation_result([{"ts_code": "000001.SZ", "up_limit": 11}]),
|
||||
}
|
||||
service = MarketSnapshotService(
|
||||
database,
|
||||
repository,
|
||||
DataGateway(database, repository, (provider,), DataSourcePolicy()),
|
||||
)
|
||||
service.sync("2026-07-29", datetime(2026, 7, 30, 16, tzinfo=SHANGHAI))
|
||||
|
||||
broken = service.workspace("broken", "2026-07-29")
|
||||
emotion = service.workspace("emotion", "2026-07-29")
|
||||
assert len(broken["items"]) == 1
|
||||
assert broken["items"][0]["identifier"] == "000001.SZ"
|
||||
assert emotion["overview"]["broken"] == 1
|
||||
|
||||
@@ -21,6 +21,7 @@ from backend.database.connection import Database
|
||||
from backend.database.migrations import MIGRATIONS, MigrationRunner
|
||||
from backend.features.market.events import MarketEventService, apply_event_revisions
|
||||
from backend.features.market.snapshot import build_realtime_inputs, build_snapshot
|
||||
from backend.http.errors import AppError
|
||||
from backend.jobs.repository import JobRepository
|
||||
from backend.jobs.service import JobAlreadyRunning, JobService
|
||||
from tests.support import run_scenario
|
||||
@@ -108,6 +109,36 @@ def test_job_service_records_success_failure_attempts_and_exclusion(tmp_path) ->
|
||||
)
|
||||
|
||||
|
||||
def test_job_service_exposes_app_error_message_without_internal_tuple(tmp_path) -> None:
|
||||
database = Database(tmp_path / "jobs.db")
|
||||
MigrationRunner(database).upgrade(MIGRATIONS)
|
||||
jobs = JobService(database, JobRepository())
|
||||
|
||||
with pytest.raises(AppError):
|
||||
jobs.execute(
|
||||
kind="market.refresh",
|
||||
run_key="2026-07-30:failed",
|
||||
requested_date="2026-07-30",
|
||||
trigger="administrator",
|
||||
operation=lambda: (_ for _ in ()).throw(
|
||||
AppError("market_data_unavailable", "收盘行情读取失败,已保留原有快照", 503)
|
||||
),
|
||||
stale_after_seconds=120,
|
||||
)
|
||||
|
||||
assert jobs.latest()[0]["error_message"] == "收盘行情读取失败,已保留原有快照"
|
||||
|
||||
with database.transaction() as connection:
|
||||
connection.execute(
|
||||
"UPDATE job_runs SET error_message = ? WHERE id = ?",
|
||||
(
|
||||
"('market_data_unavailable', '收盘行情读取失败,已保留原有快照', 503)",
|
||||
jobs.latest()[0]["id"],
|
||||
),
|
||||
)
|
||||
assert jobs.latest()[0]["error_message"] == "收盘行情读取失败,已保留原有快照"
|
||||
|
||||
|
||||
class EventGateway:
|
||||
reason = "银行板块走强"
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Any
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from backend.data.sentiment import calculate_sentiment
|
||||
from backend.database import MIGRATIONS, Database, MigrationRunner
|
||||
|
||||
ARCHIVE_VERSION = "legacy-archive-v1"
|
||||
@@ -146,6 +147,121 @@ def _normalize_identifiers(value: Any) -> Any:
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_market_units(value: Any) -> Any:
|
||||
if isinstance(value, list):
|
||||
return [_normalize_market_units(item) for item in value]
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
normalized = {key: _normalize_market_units(item) for key, item in value.items()}
|
||||
for legacy, canonical, multiplier in (
|
||||
("amount_billion", "amount", 100_000_000),
|
||||
("seal_amount_million", "seal_amount", 1_000_000),
|
||||
("float_mv_billion", "float_mv", 100_000_000),
|
||||
):
|
||||
raw = normalized.pop(legacy, None)
|
||||
if canonical not in normalized and raw not in (None, ""):
|
||||
normalized[canonical] = float(raw) * multiplier
|
||||
return normalized
|
||||
|
||||
|
||||
def _legacy_sentiment(
|
||||
payload: dict[str, Any], history: list[dict[str, Any]]
|
||||
) -> dict[str, Any]:
|
||||
calculated = calculate_sentiment(payload, history)
|
||||
overview = payload.get("overview") or {}
|
||||
if int(overview.get("sentiment_engine_version") or 0) < 2:
|
||||
return calculated
|
||||
score = overview.get("sentiment_score")
|
||||
if score is None or not overview.get("sentiment_phase"):
|
||||
return calculated
|
||||
previous_scores = [
|
||||
float(item["sentiment"]["score"])
|
||||
for item in history[-3:]
|
||||
if (item.get("sentiment") or {}).get("score") is not None
|
||||
]
|
||||
numeric_score = float(score)
|
||||
previous_score = previous_scores[-1] if previous_scores else numeric_score
|
||||
baseline = sum(previous_scores) / len(previous_scores) if previous_scores else numeric_score
|
||||
components = overview.get("sentiment_components") or {}
|
||||
if isinstance(components, dict):
|
||||
calculated["components"] = [
|
||||
{"key": key, **dict(component)}
|
||||
for key, component in components.items()
|
||||
if isinstance(component, dict)
|
||||
]
|
||||
calculated.update(
|
||||
{
|
||||
"score": int(round(numeric_score)),
|
||||
"label": str(overview.get("sentiment_label") or calculated["label"]),
|
||||
"phase": str(overview["sentiment_phase"]),
|
||||
"direction": str(overview.get("sentiment_direction") or calculated["direction"]),
|
||||
"day_change": round(numeric_score - previous_score, 1),
|
||||
"momentum": round(numeric_score - baseline, 1),
|
||||
}
|
||||
)
|
||||
return calculated
|
||||
|
||||
|
||||
def _normalize_market_snapshot(
|
||||
value: dict[str, Any], trade_date: str, history: list[dict[str, Any]]
|
||||
) -> dict[str, Any]:
|
||||
payload = _normalize_market_units(_normalize_identifiers(value))
|
||||
overview = dict(payload.get("overview") or {})
|
||||
for legacy, canonical, fallback in (
|
||||
("limit_up_count", "limit_up", len(payload.get("limits") or [])),
|
||||
("limit_down_count", "limit_down", len(payload.get("down_limits") or [])),
|
||||
("broken_count", "broken", len(payload.get("broken") or [])),
|
||||
):
|
||||
raw = overview.pop(legacy, None)
|
||||
if canonical not in overview:
|
||||
overview[canonical] = raw if raw is not None else fallback
|
||||
if "amount" not in overview:
|
||||
raw_amount = overview.pop("amount_billion", None)
|
||||
overview["amount"] = float(raw_amount or 0) * 100_000_000
|
||||
meta = payload.get("meta") if isinstance(payload.get("meta"), dict) else {}
|
||||
payload["trade_date"] = trade_date
|
||||
previous_date = payload.get("previous_trade_date") or meta.get("previous_trade_date")
|
||||
payload["previous_trade_date"] = _iso_date(str(previous_date)) if previous_date else ""
|
||||
payload["overview"] = overview
|
||||
sentiment = _legacy_sentiment(payload, history)
|
||||
payload["sentiment"] = sentiment
|
||||
payload["temperature"] = sentiment["score"]
|
||||
for key in tuple(overview):
|
||||
if key.startswith("sentiment_"):
|
||||
overview.pop(key)
|
||||
return payload
|
||||
|
||||
|
||||
def _normalize_birth_profile(encrypted_payload: str, encryption_key: str | None) -> str | None:
|
||||
if not encryption_key:
|
||||
raise ValueError("legacy birth profiles require APP_ENCRYPTION_KEY")
|
||||
fernet = Fernet(encryption_key.encode("ascii"))
|
||||
decrypted = fernet.decrypt(encrypted_payload.encode("ascii"))
|
||||
try:
|
||||
payload = json.loads(decrypted)
|
||||
gender = str(payload.get("gender") or "")
|
||||
birth_date = str(payload.get("birth_date") or "")
|
||||
birth_time = str(payload.get("birth_time") or "")
|
||||
if (not birth_date or not birth_time) and payload.get("birth_datetime"):
|
||||
stamp = datetime.fromisoformat(str(payload["birth_datetime"]).replace(" ", "T", 1))
|
||||
birth_date = stamp.date().isoformat()
|
||||
birth_time = stamp.time().replace(tzinfo=None, second=0, microsecond=0).isoformat(
|
||||
timespec="minutes"
|
||||
)
|
||||
datetime.fromisoformat(f"{birth_date}T{birth_time}")
|
||||
if gender not in {"male", "female"}:
|
||||
return None
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
return None
|
||||
normalized = json.dumps(
|
||||
{"birth_date": birth_date, "birth_time": birth_time, "gender": gender},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
)
|
||||
return fernet.encrypt(normalized.encode("utf-8")).decode("ascii")
|
||||
|
||||
|
||||
def _observed_at(payload: dict[str, Any], fallback: str) -> str:
|
||||
meta = payload.get("meta") if isinstance(payload.get("meta"), dict) else {}
|
||||
return str(
|
||||
@@ -295,6 +411,18 @@ class LegacyMigrator:
|
||||
self.admin_id = min(administrators or self.user_ids)
|
||||
if _table_exists(source, "user_birth_profiles"):
|
||||
for row in source.execute("SELECT * FROM user_birth_profiles"):
|
||||
encrypted_profile = _normalize_birth_profile(
|
||||
str(row["encrypted_payload"]), self.key
|
||||
)
|
||||
if encrypted_profile is None:
|
||||
self.row_skips["birth_profiles_invalid"] = {
|
||||
"count": self.row_skips.get("birth_profiles_invalid", {}).get(
|
||||
"count", 0
|
||||
)
|
||||
+ 1,
|
||||
"reason": "legacy profile is incomplete and must be configured again",
|
||||
}
|
||||
continue
|
||||
target.execute(
|
||||
"""INSERT INTO birth_profiles
|
||||
(user_id,encrypted_payload,created_at,updated_at) VALUES (?,?,?,?)
|
||||
@@ -302,7 +430,7 @@ class LegacyMigrator:
|
||||
encrypted_payload=excluded.encrypted_payload,updated_at=excluded.updated_at""",
|
||||
(
|
||||
row["user_id"],
|
||||
row["encrypted_payload"],
|
||||
encrypted_profile,
|
||||
row["updated_at"],
|
||||
row["updated_at"],
|
||||
),
|
||||
@@ -425,9 +553,21 @@ class LegacyMigrator:
|
||||
)
|
||||
previous = trade_date
|
||||
self.counts["trading_days"] = len(dates)
|
||||
for row in source.execute("SELECT * FROM dashboard_snapshots"):
|
||||
payload = _normalize_identifiers(_json(row["payload"], {}))
|
||||
trade_date = _iso_date(row["trade_date"])
|
||||
snapshots: dict[str, tuple[sqlite3.Row, dict[str, Any]]] = {}
|
||||
for row in source.execute(
|
||||
"SELECT * FROM dashboard_snapshots ORDER BY trade_date,updated_at"
|
||||
):
|
||||
payload = _json(row["payload"], {})
|
||||
meta = payload.get("meta") if isinstance(payload.get("meta"), dict) else {}
|
||||
trade_date = _iso_date(
|
||||
str(payload.get("trade_date") or meta.get("trade_date") or row["trade_date"])
|
||||
)
|
||||
snapshots[trade_date] = (row, payload)
|
||||
history: list[dict[str, Any]] = []
|
||||
for trade_date in sorted(snapshots):
|
||||
row, raw_payload = snapshots[trade_date]
|
||||
payload = _normalize_market_snapshot(raw_payload, trade_date, history)
|
||||
history.append(payload)
|
||||
events: dict[str, str] = {}
|
||||
for event_type, key in (
|
||||
("limit_up", "limits"),
|
||||
@@ -456,7 +596,7 @@ class LegacyMigrator:
|
||||
row["updated_at"],
|
||||
),
|
||||
)
|
||||
self.counts["market_summaries"] += 1
|
||||
self.counts["market_summaries"] = len(snapshots)
|
||||
query = """WITH ranked AS (
|
||||
SELECT *,row_number() OVER (PARTITION BY ts_code ORDER BY trade_date DESC) rank
|
||||
FROM daily_bars) SELECT * FROM ranked WHERE rank<=90 ORDER BY ts_code,trade_date"""
|
||||
|
||||
Reference in New Issue
Block a user