471 lines
21 KiB
Python
471 lines
21 KiB
Python
# 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"
|