diff --git a/docs/TEST_PLAN.md b/docs/TEST_PLAN.md new file mode 100644 index 0000000..4258534 --- /dev/null +++ b/docs/TEST_PLAN.md @@ -0,0 +1,264 @@ +# MoFin 测试文档 + +> 版本:1.0 | 日期:2026-07-03 | 维护:Sisyphus + +--- + +## 一、数据管道测试 + +### 1.1 价格源测试 + +| ID | 用例 | 步骤 | 预期 | +|----|------|------|------| +| P1 | A股价格拉取正确 | 调用 fetch_all_prices(['000657','600519']) | 返回 dict,price > 0,change_pct 有效 | +| P2 | 港股价格拉取正确 | 调用 fetch_hk_eastmoney(['00700','01888']) | 返回 dict,price > 0(HKD),change_pct 有效 | +| P3 | 东财失败→腾讯兜底 | 东财不可用时 | 自动切 `_fetch_hk_tencent_fallback`,有数据返回 | +| P4 | 价格写入 DB | 运行 price_monitor.refresh_data_prices() | holdings.price 更新,portfolio_summary.updated_at 刷新 | +| P5 | 价格不变时不写 DB | 连续两次 refresh_data_prices() | 第二次 changed=False,不写入 | + +### 1.2 数据读取测试 + +| ID | 用例 | 步骤 | 预期 | +|----|------|------|------| +| R1 | read_portfolio 返回正确 | `pf = read_portfolio()` | holdings 数组 + total_assets/cash/frozen_cash | +| R2 | read_decisions 返回正确 | `dec = read_decisions()` | decisions 数组,每条含 code/price/stop_loss/currency | +| R3 | read_watchlist 返回正确 | `wl = read_watchlist()` | stocks 数组,每条含 code/price | +| R4 | 无数据时不崩溃 | DB 为空时调用 read_*() | 返回空数组,不抛异常 | + +--- + +## 二、币种测试 + +### 2.1 存储测试 + +| ID | 用例 | 步骤 | 预期 | +|----|------|------|------| +| C1 | 港股 holdings.currency=HKD | 查询 01888 | `currency = 'HKD'` | +| C2 | A股 holdings.currency=CNY | 查询 000657 | `currency = 'CNY'` | +| C3 | 港股决策 currency=HKD | 查询 01888 的 holding_strategies | `currency = 'HKD'` | +| C4 | A股决策 currency=CNY | 查询 000657 的 holding_strategies | `currency = 'CNY'` | +| C5 | 非法币种被拒绝 | INSERT holdings WITH currency='USD' | CHECK 约束拒绝,写入失败 | + +### 2.2 币种转换测试 + +| ID | 用例 | 步骤 | 预期 | +|----|------|------|------| +| C6 | is_hk_stock 正确 | 传入 '00700', '01888', '000657', 'AAPL' | HK=True×2, A=False, US=False | +| C7 | to_cny 港股转换 | `to_cny(100, '00700')` | = 100 × get_hk_rate() | +| C8 | to_cny A股不转换 | `to_cny(100, '000657')` | = 100.0(原样返回) | +| C9 | get_hk_rate 返回有效值 | 调用 get_hk_rate() | 0.85 < rate < 0.95 | +| C10 | get_hk_rate 兜底 | hk_rate 模块不可达 | 返回 0.87 | + +### 2.3 跨币种隔离测试(关键!) + +| ID | 用例 | 步骤 | 预期 | +|----|------|------|------| +| C11 | 港股price(HKD)不直接参与CNY加法 | `sum(hk_price + a_price)` 不经过转换 | **应被 calc_total_mv 拦截转换** | +| C12 | calc_total_mv 港股自动转CNY | holdings 含港股,调用 calc_total_mv() | 港股市值 × 汇率,总值 CNY | +| C13 | calc_total_assets 含港股正确 | holdings 含港股 | = calc_total_mv + cash + frozen(全部CNY) | +| C14 | P&L 同币种比较 | `(price_hkd - cost_hkd) / cost_hkd` | 同币种,不混算 | +| C15 | P&L 跨币种比较被阻止 | `(price_hkd - cost_cny)` 未经转换 | **不应出现此路径** | + +--- + +## 三、总资产计算测试 + +| ID | 用例 | 步骤 | 预期 | +|----|------|------|------| +| A1 | total_mv = Σ(shares × price) | 计算并对比 | 估值一致 | +| A2 | total_assets = total_mv + cash + frozen_cash | 计算并对比 | stored = calculated | +| A3 | frozen_cash=0 不影响计算 | 冻结已清零 | total_assets = total_mv + cash | +| A4 | position_pct = total_mv / total_assets × 100 | 验证 | 值 0-100,合理 | +| A5 | total_pnl = total_mv - total_cost | 对比 stored vs calculated | 差值 < 1 元 | +| A6 | 零持仓时 total_mv=0 | holdings 为空 | total_mv = 0,total_assets = cash | + +--- + +## 四、DB 完整性测试 + +### 4.1 约束测试 + +| ID | 用例 | 步骤 | 预期 | +|----|------|------|------| +| D1 | holdings.code UNIQUE | 重复 code INSERT | ON CONFLICT DO UPDATE | +| D2 | holdings.currency CHECK | INSERT currency='EUR' | 拒绝 | +| D3 | portfolio_summary.id=1 唯一 | INSERT id=1 两次 | ON CONFLICT DO UPDATE | +| D4 | holding_strategies.code UNIQUE INDEX | 同 code 重复 INSERT | 唯一索引约束 | +| D5 | FK: holdings.code → stocks.code | 插入不存在股票的持仓 | 外键约束 | + +### 4.2 数据一致性测试 + +| ID | 用例 | 步骤 | 预期 | +|----|------|------|------| +| D6 | holdings 与 portfolio_summary 一致 | 对比 total_mv | 一致 | +| D7 | decisions 与 holdings 代码一一对应 | 有持仓必有决策,反之亦然 | 对上的数 ≥ 90% | +| D8 | shares > 0 必须有 cost > 0 | 检查所有持仓 | 无 cost=0 的持仓(00700 除外,已知问题) | +| D9 | 无 holding_strategies 孤岛(无对应 holdings 的决策) | 检查 | 全部有对应持仓 | +| D10 | watchlist 中的股票不在 holdings 中 | 检查 | 自选股和持仓股无重叠 | + +--- + +## 五、JSON 移除验证 + +| ID | 用例 | 步骤 | 预期 | +|----|------|------|------| +| J1 | 无 json.load(open(portfolio.json)) | grep 代码库 | 0 匹配 | +| J2 | 无 json.load(open(decisions.json)) | grep 代码库 | 0 匹配 | +| J3 | 无 json.load(open(watchlist.json)) | grep 代码库 | 0 匹配 | +| J4 | 无 json.dump(open(portfolio.json)) | grep 代码库 | 0 匹配(注释除外) | +| J5 | mo_data.py 无 JSON fallback | 读文件内容 | 无 `json.load(open(` 出现 | +| J6 | server.py 不写 JSON 数据文件 | `_save_json` 调用 | 仅用于 market/reports/stocks(非核心数据) | + +--- + +## 六、LLM Prompt 测试 + +### 6.1 Prompt 内容标准 + +每个 LLM Prompt 必须满足: + +| 标准 | 要求 | +|------|------| +| S1 | **不引用 JSON 文件** — 所有数据来源标注为 "DB 表名" | +| S2 | **币种明确** — 提及港股时标注 "(HKD)",A股标注 "(CNY)" | +| S3 | **禁止跨币种比较** — 如涉及计算,明确"同币种比较"或"已转 CNY 后比较" | +| S4 | **输出格式标注币种** — 港股输出价格带 "(HKD)" 后缀 | +| S5 | **数据源正确** — 不引用已废弃的 API 或 JSON 路径 | + +### 6.2 Prompt 测试用例 + +| ID | 用例 | Prompt ID | 检查项 | 预期 | +|----|------|-----------|--------|------| +| L1 | evaluation-daily 不引用 JSON | evaluation-daily v2 | 全文搜索 `.json` | 0 匹配 | +| L2 | evaluation-daily 引用 DB 表 | evaluation-daily v2 | 搜索 `strategy_evaluations` / `accuracy_stats` | 存在 | +| L3 | knowledge-extraction 不引用 JSON | knowledge-extraction v1 | 全文搜索 `.json` | 0 匹配 | +| L4 | knowledge-extraction 引用 DB | knowledge-extraction v1 | 搜索 `holding_strategies` | 存在 | +| L5 | system-health-check 不引用 JSON | system-health-check v2 | 全文搜索 `.json` | 0 匹配 | +| L6 | 所有 prompt 无 "读 JSON" 措辞 | 全部 prompt | grep `读.*\.json` | 0 匹配 | +| L7 | 币种标注检查 | 全部 prompt | 如涉港股,检查有 "(HKD)" 标注 | 存在 | +| L8 | 无 "portfolio.json" 引用 | 全部 prompt | grep `portfolio\.json` | 0 匹配 | +| L9 | 无 "decisions.json" 引用 | 全部 prompt | grep `decisions\.json` | 0 匹配 | +| L10 | 无 "直接存 HKD 原值" 等过时措辞 | 全部 prompt | grep `直接存.*HKD\|存CNY` | 0 匹配(或不导致误解) | + +### 6.3 Prompt 内容自检脚本 + +```python +"""Check all LLM prompts for compliance""" +import re +from prompt_manager.init_registry import ALL_VERSIONS + +issues = [] +for ver in ALL_VERSIONS: + content = ver.get('content', '') + pid = ver.get('prompt_id', '?') + + # S1: no JSON file references + if '.json' in content and 'DB' not in content.split('.json')[0][-20:]: + issues.append(f"[{pid}] references .json file: {content}") + + # S2/S7: HK stock price should mention HKD + if '港股' in content and 'HKD' not in content and 'CNY' not in content: + issues.append(f"[{pid}] mentions HK stock but no currency label") + + # S3: should not instruct cross-currency comparison + if re.search(r'(港|HK).*直[接比]', content): + issues.append(f"[{pid}] may imply cross-currency comparison") + +if issues: + for i in issues: print(f"❌ {i}") +else: + print("✅ All prompts pass") +``` + +--- + +## 七、API 测试 + +| ID | 用例 | 端点 | 预期 | +|----|------|------|------| +| API1 | 获取持仓 | GET /api/portfolio | 200, holdings[] 数组 | +| API2 | 获取概览 | GET /api/overview | 200, total_assets > 0 | +| API3 | 获取决策 | GET /api/decisions | 200, decisions[] 数组 | +| API4 | 写入持仓 | POST /api/update/portfolio | 200, DB 写入成功 | +| API5 | 写入决策 | POST /api/decisions/add | 200, holding_strategies 更新 | +| API6 | API 响应含 currency 字段 | GET /api/portfolio | 每个 holding 有 currency='HKD' 或 'CNY' | +| API7 | 重启后 API 正常 | kill + restart Flask | 200 响应 | + +--- + +## 八、Cron 测试 + +| ID | 用例 | 命令 | 预期 | +|----|------|------|------| +| CR1 | price_monitor 能跑完 | `python3 price_monitor.py` | 30 秒内完成,无 TypeError | +| CR2 | market_watch 能跑完 | `python3 market_watch.py` | 正常退出 | +| CR3 | market_screener 能跑完 | `python3 market_screener.py` | 正常退出 | +| CR4 | stale_push_wlin 能跑完 | `python3 scripts/stale_push_wlin.py` | 正常退出 | +| CR5 | 东财不可用时走腾讯 | 东财 down | price_monitor 仍完整拉取 HK 价格 | +| CR6 | cron 日志无错误 | 查看 /tmp/price_monitor.log | 无 Exception/Error | + +--- + +## 九、边界用例 + +| ID | 用例 | 步骤 | 预期 | +|----|------|------|------| +| E1 | price=None 不崩溃 | holdings 中某行 price=NULL | `s.get('price') or 0` → 0 | +| E2 | cost=None 不崩溃 | holdings 中某行 cost=NULL | 计算时用 0 | +| E3 | shares=0 不崩溃 | 已清仓的残留持仓 | 市值计算正确(0) | +| E4 | 港股 0 开头代码不被误判 | '000657' vs '00700' | is_hk_stock 正确区分 | +| E5 | 汇率 API 不可达时用缓存 | 断网 | hk_rate 返回上次缓存值 | +| E6 | 数据库被锁 | 多进程写入 | SQLite WAL 模式,不阻塞 | +| E7 | 空 holdings 列表 | 全部清仓 | total_mv=0, total_assets=cash 正常 | + +--- + +## 十、快速验证命令 + +```bash +# 1. 数据完整性 +python3 -c "from mo_data import read_portfolio,read_decisions; pf=read_portfolio(); dec=read_decisions(); print(f'holds={len(pf.get(\"holdings\",[]))} dec={len(dec.get(\"decisions\",[]))} cash={pf.get(\"cash\")}')" + +# 2. 币种审计 +python3 -c " +from mo_data import read_portfolio +from mo_models import is_hk_stock +pf=read_portfolio() +ok=True +for h in pf.get('holdings',[]): + code=h.get('code',''); c=h.get('currency','') + if is_hk_stock(str(code)) and c!='HKD': ok=False; print(f'FAIL HK: {code} {c}') + elif not is_hk_stock(str(code)) and c!='CNY': ok=False; print(f'FAIL A: {code} {c}') +if ok: print('ALL CORRECT') +" + +# 3. 总资产验证 +python3 -c " +from mo_data import read_portfolio +from mo_models import calc_total_assets +pf=read_portfolio() +stored=pf.get('total_assets',0) +calc=calc_total_assets(pf) +print(f'stored={stored:.2f} calc={calc:.2f} diff={abs(stored-calc):.2f}') +print('PASS' if abs(stored-calc)<1 else 'FAIL') +" + +# 4. LLM Prompt 审计 +python3 -c " +from prompt_manager.init_registry import ALL_VERSIONS +import re +issues=[] +for v in ALL_VERSIONS: + c=v.get('content','') + pid=v.get('prompt_id','?') + if '.json' in c and not any(x in c for x in ['DB','表','sqlite']): + issues.append(f'{pid} refs .json') +if issues: + for i in issues: print(f'FAIL: {i}') +else: + print('ALL CLEAN') +" + +# 5. JSON 代码残留检查 +grep -rn 'json\.load\|json\.dump' --include='*.py' . | grep -v '#\|__pycache__\|test_\|inspect_\|check_\|verify_\|diagnose_\|audit_\|deep_\|list_\|rollback_\|fix_' | grep 'portfolio.json\|decisions.json\|watchlist.json' && echo "FAIL" || echo "PASS" +```