Files
MoFin/docs/TEST_PLAN.md
T

265 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# MoFin 测试文档
> 版本:1.0 | 日期:2026-07-03 | 维护:Sisyphus
---
## 一、数据管道测试
### 1.1 价格源测试
| ID | 用例 | 步骤 | 预期 |
|----|------|------|------|
| P1 | A股价格拉取正确 | 调用 fetch_all_prices(['000657','600519']) | 返回 dictprice > 0change_pct 有效 |
| P2 | 港股价格拉取正确 | 调用 fetch_hk_eastmoney(['00700','01888']) | 返回 dictprice > 0HKD),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 = 0total_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"
```