# ruff: noqa: E501 - compact SQL fixtures intentionally mirror the legacy schema. from __future__ import annotations import base64 import hashlib import json import sqlite3 from cryptography.fernet import Fernet from backend.features.market.events import apply_event_revisions from backend.features.screener.catalog import strategy_by_id from backend.security.passwords import PasswordHasher from tools.legacy_migration import LegacyMigrator def _legacy_database(path, key: str) -> None: salt = b"0123456789abcdef" digest = hashlib.scrypt( b"Password123", salt=salt, n=2**14, r=8, p=1, dklen=32 ) encoded_salt = base64.urlsafe_b64encode(salt).decode() encoded_hash = base64.urlsafe_b64encode(digest).decode() fernet = Fernet(key.encode()) credentials = { "tushare_token": "secret-tushare", "ifind_refresh_token": "secret-refresh", "member_daily_limit": 61, "llm_models": [{ "id": "main", "name": "Primary", "base_url": "https://model.invalid/v1", "model": "model-a", "api_key": "secret-model", }], "primary_model_id": "main", } with sqlite3.connect(path) as connection: connection.executescript( """ CREATE TABLE users (id INTEGER PRIMARY KEY,username TEXT,password_salt TEXT, password_hash TEXT,created_at TEXT,updated_at TEXT,role TEXT,llm_mode TEXT, membership_status TEXT,membership_plan TEXT,membership_starts_at TEXT, membership_expires_at TEXT); CREATE TABLE user_birth_profiles (user_id INTEGER PRIMARY KEY,encrypted_payload TEXT,updated_at TEXT); CREATE TABLE llm_usage (id INTEGER PRIMARY KEY,user_id INTEGER,feature TEXT, source TEXT,model TEXT,status TEXT,latency_ms INTEGER,created_at TEXT, role TEXT,prompt_version TEXT,error_code TEXT,input_tokens INTEGER,output_tokens INTEGER); CREATE TABLE system_settings (setting_key TEXT PRIMARY KEY,encrypted_payload TEXT,updated_at TEXT); CREATE TABLE stock_master (ts_code TEXT PRIMARY KEY,code TEXT,name TEXT, industry TEXT,market TEXT,list_date TEXT,updated_at TEXT); CREATE TABLE daily_bars (trade_date TEXT,ts_code TEXT,open REAL,high REAL, low REAL,close REAL,pct_chg REAL,vol REAL,amount REAL); CREATE TABLE dashboard_snapshots (trade_date TEXT PRIMARY KEY,source TEXT,payload TEXT,record_count INTEGER,updated_at TEXT); CREATE TABLE watchlist (user_id INTEGER,code TEXT,name TEXT,sector TEXT,color TEXT, created_at TEXT,updated_at TEXT,remark TEXT); CREATE TABLE review_notes (id INTEGER PRIMARY KEY,code TEXT,stock_name TEXT, trade_date TEXT,content TEXT,plan TEXT,created_at TEXT,updated_at TEXT, user_id INTEGER,summary TEXT); CREATE TABLE trade_entries (id INTEGER PRIMARY KEY,user_id INTEGER,trade_date TEXT, code TEXT,name TEXT,action TEXT,price REAL,quantity INTEGER,position_pct REAL, pnl_amount REAL,pnl_pct REAL,thesis TEXT,execution TEXT,emotion TEXT,tags TEXT, created_at TEXT,updated_at TEXT); CREATE TABLE alerts (id INTEGER PRIMARY KEY,user_id INTEGER,kind TEXT,title TEXT, content TEXT,available_date TEXT,code TEXT,dedupe_key TEXT,is_read INTEGER, created_at TEXT,updated_at TEXT,read_at TEXT); CREATE TABLE assistant_messages (id INTEGER PRIMARY KEY,user_id INTEGER,role TEXT, content TEXT,context_date TEXT,created_at TEXT); CREATE TABLE mentor_preferences (user_id INTEGER,mentor_id TEXT,pinned INTEGER, sort_order INTEGER,updated_at TEXT); CREATE TABLE mentor_messages (id INTEGER PRIMARY KEY,user_id INTEGER,mentor_id TEXT, trade_date TEXT,role TEXT,content TEXT,meta TEXT,created_at TEXT); CREATE TABLE heaven_readings (id INTEGER PRIMARY KEY,user_id INTEGER,mode TEXT, context_date TEXT,subject TEXT,subject_detail TEXT,answer TEXT, context_snapshot TEXT,dedupe_key TEXT,created_at TEXT); CREATE TABLE screener_runs (id INTEGER PRIMARY KEY,trade_date TEXT,regime TEXT, strategy_name TEXT,formula TEXT,result TEXT,created_at TEXT,user_id INTEGER,mode TEXT); CREATE TABLE screener_strategies (id INTEGER PRIMARY KEY,name TEXT,description TEXT, regimes TEXT,formula TEXT,builtin INTEGER,created_at TEXT,updated_at TEXT,user_id INTEGER); CREATE TABLE strategy_tracks (id INTEGER PRIMARY KEY,user_id INTEGER,run_id INTEGER, selection_date TEXT,strategy_name TEXT,ts_code TEXT,code TEXT,name TEXT,sector TEXT, entry_price REAL,created_at TEXT,updated_at TEXT); CREATE TABLE data_snapshots (kind TEXT,cache_key TEXT,source TEXT,payload TEXT, updated_at TEXT,PRIMARY KEY(kind,cache_key)); CREATE TABLE reason_overrides (trade_date TEXT,code TEXT,reason TEXT, updated_at TEXT,PRIMARY KEY(trade_date,code)); CREATE TABLE seat_aliases (seat_name TEXT PRIMARY KEY,alias TEXT,updated_at TEXT); """ ) now = "2026-07-29T16:00:00+08:00" connection.execute( "INSERT INTO users VALUES (8,'leefer',?,?,?,?,'admin','auto','active','永久',?,NULL)", (encoded_salt, encoded_hash, now, now, now), ) 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)", (now,), ) encrypted = fernet.encrypt(json.dumps(credentials).encode()).decode() connection.execute("INSERT INTO system_settings VALUES ('credentials',?,?)", (encrypted, now)) connection.execute( "INSERT INTO stock_master VALUES ('000001.SZ','000001','平安银行','银行','主板','19910403',?)", (now,), ) connection.execute( "INSERT INTO daily_bars VALUES ('20260729','000001.SZ',10,11,9,10.5,5,100,1000)" ) 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": [], "down_limits": [], } ) connection.execute( "INSERT INTO dashboard_snapshots VALUES ('20260730','tushare',?,1,?)", (dashboard, now), ) connection.execute( "INSERT INTO watchlist VALUES (8,'000001','平安银行','银行','red',?,?, '长期')", (now, now), ) connection.execute( "INSERT INTO review_notes VALUES (1,'000001','平安银行','20260729','复盘','计划',?,?,8,'总结')", (now, now), ) connection.execute( "INSERT INTO trade_entries VALUES (1,8,'20260729','000001','平安银行','buy',10,100,20,NULL,NULL,'逻辑','执行','calm','[]',?,?)", (now, now), ) connection.execute( "INSERT INTO alerts VALUES (1,8,'manual','提醒','','20260730','','a',0,?,?,NULL)", (now, now), ) connection.execute("INSERT INTO assistant_messages VALUES (1,8,'user','问题','20260729',?)", (now,)) connection.execute("INSERT INTO mentor_preferences VALUES (8,'mentor',1,1,?)", (now,)) connection.execute( "INSERT INTO mentor_messages VALUES (1,8,'mentor','20260729','user','问题','',?)", (now,) ) connection.execute( "INSERT INTO heaven_readings VALUES (1,8,'fortune','20260729','','','结果','{}','d',?)", (now,) ) result = json.dumps({"candidates": [{"identifier": "000001.SZ", "close": 10.5}]}) connection.execute( "INSERT INTO screener_runs VALUES (1,'20260729','','策略','{}',?, ?,8,'curated')", (result, now), ) connection.execute( "INSERT INTO screener_runs VALUES (2,'20260729','','退潮防守观察','{}',?, ?,8,'smart')", (result, now), ) connection.execute( "INSERT INTO screener_runs VALUES (3,'20260729','','退潮防守观察','{}',?, ?,NULL,'smart')", (result, now), ) multi_factor = strategy_by_id("curated-25") assert multi_factor is not None connection.execute( "INSERT INTO screener_runs VALUES (4,'20260729','','多因子综合打分(IC动态加权)',?, ?, ?,NULL,'curated')", (json.dumps(multi_factor["formula"]), result, now), ) connection.execute( "INSERT INTO screener_strategies VALUES (1,'自定义','','[]','{}',0,?,?,8)", (now, now), ) connection.execute( "INSERT INTO screener_strategies VALUES (2,'legacy builtin','','[]','{}',1,?,?,NULL)", (now, now), ) connection.execute( "INSERT INTO strategy_tracks VALUES (1,8,1,'20260729','策略','000001.SZ','000001','平安银行','银行',10.5,?,?)", (now, now), ) connection.execute( "INSERT INTO strategy_tracks VALUES (2,8,2,'20260729','退潮防守观察','000001.SZ','000001','平安银行','银行',10.5,?,?)", (now, now), ) connection.execute( "INSERT INTO data_snapshots VALUES ('popularity_v1','20260729','legacy','{}',?)", (now,) ) connection.execute( "INSERT INTO reason_overrides VALUES ('20260729','000001','admin reason',?)", (now,), ) connection.execute( "INSERT INTO seat_aliases VALUES ('seat-a','trader-a',?)", (now,) ) snapshots = { "ifind_event_enrichment_v1": ( "20260729", { "trade_date": "20260729", "generated_at": now, "limits": { "000001": { "reason": "ifind reason", "first_time": "09:31:03", "last_time": "14:52:01", "open_times": 1, } }, "broken": {}, "down_limits": {}, }, ), "rotation_sector_members_v1": ( "20260729:Bank", { "meta": {"sector_code": "801780.SI"}, "rows": [ { "ts_code": "000001.SZ", "code": "000001", "name": "Ping An Bank", "quoted": True, } ], }, ), "theme_directory_v1": ( "ths", {"items": [{"ts_code": "885001.TI", "name": "Theme A"}]}, ), "theme_library_v1": ( "20260729", { "meta": {"updated_at": now}, "summary": {"theme_count": 1}, "items": [{"code": "885001.TI", "name": "Theme A"}], }, ), "theme_detail_v1": ( "20260729:885001.TI", { "meta": {"updated_at": now}, "theme": {"code": "885001.TI", "name": "Theme A"}, "series": [ { "trade_date": "20260728", "open": 10, "high": 11, "low": 9, "close": 10, "volume": 100, }, { "trade_date": "20260729", "open": 10, "high": 12, "low": 10, "close": 11, "volume": 120, }, ], "members": [ { "ts_code": "000001.SZ", "code": "000001", "name": "Ping An Bank", "price": 10.5, "change": 5, "amount_billion": 1.2, "has_quote": True, } ], "summary": {"member_count": 1}, }, ), "hot_money_profiles_v1": ( "directory", { "profiles": [ { "name": "trader-a", "description": "profile", "organizations": ["seat-a"], } ] }, ), "dragon_tiger": ( "20260729", { "meta": {"updated_at": now}, "rows": [ { "ts_code": "000001.SZ", "name": "Ping An Bank", "change": 5, "reason": "listed", "institutions": [ { "seat_name": "seat-a", "buy_million": 2, "sell_million": 1, "net_buy_million": 1, } ], } ], }, ), "hot_money_detail_v3": ( "20260729", { "meta": {"updated_at": now}, "traders": [ { "name": "trader-a", "operations": [ { "ts_code": "000001.SZ", "name": "Ping An Bank", "change": 5, "seat_name": "seat-a", "buy_million": 2, "sell_million": 1, "net_buy_million": 1, "reason": "listed", } ], } ], }, ), } connection.executemany( "INSERT INTO data_snapshots VALUES (?,?, 'legacy',?,?)", [ (kind, cache_key, json.dumps(payload), now) for kind, (cache_key, payload) in snapshots.items() ], ) def test_legacy_migration_is_idempotent_and_preserves_login(tmp_path) -> None: source = tmp_path / "legacy.db" target = tmp_path / "next.db" key = Fernet.generate_key().decode() _legacy_database(source, key) first = LegacyMigrator(source, target, key).run() second = LegacyMigrator(source, target, key).run() assert first["integrity"] == second["integrity"] == "ok" assert first["unmapped_tables"] == [] assert first["unmapped_snapshot_kinds"] == [] assert first["source_tables"]["reason_overrides"] == 1 assert first["intentionally_skipped_rows"]["screener_strategies_builtin"]["count"] == 1 assert "market_event_revisions" in first["target_counts"] rendered = json.dumps(first) assert "secret-tushare" not in rendered assert "secret-model" not in rendered with sqlite3.connect(target) as connection: connection.row_factory = sqlite3.Row 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] == 3 stage_run = connection.execute( """SELECT id,owner_user_id,strategy_id,strategy_version FROM screener_runs WHERE mode='stage'""" ).fetchone() assert dict(stage_run) == { "id": 3, "owner_user_id": None, "strategy_id": "stage-06", "strategy_version": 1, } renamed = connection.execute( """SELECT strategy_id,strategy_name FROM screener_runs WHERE strategy_id='curated-25'""" ).fetchone() assert dict(renamed) == { "strategy_id": "curated-25", "strategy_name": "动态多因子(基础版)", } assert connection.execute("SELECT count(*) FROM strategy_tracks").fetchone()[0] == 2 assert connection.execute( "SELECT run_id FROM strategy_tracks WHERE id=2" ).fetchone()[0] == 3 assert connection.execute("SELECT daily_llm_limit FROM memberships").fetchone()[0] == 61 assert connection.execute("SELECT count(*) FROM seat_aliases").fetchone()[0] == 1 assert connection.execute( "SELECT count(*) FROM sector_member_snapshots" ).fetchone()[0] == 1 assert connection.execute( "SELECT count(*) FROM chart_series WHERE entity_type='theme'" ).fetchone()[0] == 1 summary = json.loads( connection.execute( "SELECT payload_json FROM market_summaries WHERE trade_date='2026-07-29'" ).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' ORDER BY priority DESC,id DESC""" ).fetchall() ) assert len(all_revisions) == 2 revisions = all_revisions[:1] revised = apply_event_revisions(summary, revisions) assert revised["limits"][0]["reason"] == "admin reason" raw_dragon = json.loads( connection.execute( """SELECT payload_json FROM market_insight_snapshots WHERE kind='dragon-list' AND trade_date='2026-07-29'""" ).fetchone()[0] ) assert raw_dragon["profiles"][0]["desc"] == "profile" assert raw_dragon["official"][0]["hm_name"] == "trader-a"