rebuild(migration): validate local legacy cutover

This commit is contained in:
leefer
2026-07-30 17:26:15 +08:00
parent 37bd9fea85
commit 198806c1bd
23 changed files with 418 additions and 27 deletions
+7 -7
View File
@@ -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)
+6 -6
View File
@@ -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
-2
View File
@@ -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:
+21
View File
@@ -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)
+8 -1
View File
@@ -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:通过
- pytest111项通过
- Vue类型检查:通过
- Vitest7项通过
- Vite生产构建:通过
- Playwright:26项通过;在全新空白测试数据库中重跑,包含注册、权限、16工作区、日夜、390px和4K结构断言。
## 未关闭项
- 4K信息密度和审美仍需用户人工确认。
- Docker/NAS构建、数据卷持久化、回退和正式切换未执行。
- 旧运行时与一次性迁移器在正式切换和回退保留期结束前不清理。
+16 -4
View File
@@ -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`
一次性旧库迁移器也将在回退保留期结束后删除,不进入长期维护面。
+2
View File
@@ -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,
+1
View File
@@ -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();
}
}
+1
View File
@@ -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();
}
}
+1
View File
@@ -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();
}
}
+1
View File
@@ -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();
}
}
+1
View File
@@ -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();
}
}
+1
View File
@@ -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();
}
}
+1
View File
@@ -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();
}
}
+1
View File
@@ -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();
}
}
+1
View File
@@ -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();
}
}
+19
View File
@@ -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))
+49 -2
View File
@@ -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'
+51
View File
@@ -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
+31
View File
@@ -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 = "银行板块走强"
+145 -5
View File
@@ -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"""