feat(backtest): 买卖点理由(用数据说话)+ 因子曲线 + 曲线新页面放大

用户要求:「所有买卖点详细说明买卖理由,用数据说话」「回测图上增加因子相关曲线
(买卖依据是股息率,就加股息率曲线)」「所有曲线能弹出新页面放大」。

一、买卖理由(后端产出结构化数据,前端只展示)
- 新增 `quant/trade_reasons.py`:封闭词表 + 文案构造器,组合引擎与单策略引擎共用,
  避免两个引擎对同一件事写出两种说法。理由里带**引擎当时的真实数字**:
  综合分名次/候选数/综合分/各因子原始值/持有交易日/预算与最低佣金/涨停比值等。
- 买入:按名次建仓、顺延成交、涨停未买、停牌未买、现金不足、不足最低佣金;
  卖出:跌出 TopN(含第几名掉出)、被股票池过滤(与「跌出 TopN」分开写)、
  超 Tmax 强制了结、Tmin 保护暂留、停牌/跌停顺延。
- `ActionRecord.reason` 覆盖**成交与未成交**全部买卖点(原 `reject_reason` 保留不动,
  老归档仍可读);`Trade.entry_reason / exit_reason` 跟着成交记录走。
- 名次来自调仓日完整排名(新增 `_ranked_by_day`),拿不到名次时如实写「未给出名次」,
  绝不编造一个名次填进去。
- 未成交明细不再只写执行层原因:把「为什么选中它、当时各因子多少」一并给出。

二、因子曲线
- `FactorCurve`:每个策略因子一条曲线,值为**当日持仓按市值加权平均的原始值**
  (不做 z-score、不按方向取反,空仓日不落点、不插值、不用 0 填充),并带
  label/direction/unit 供界面说明口径;`FactorDef/FactorTemplate` 新增 `unit`
  (股息率 %、量比/接近新高 倍数、动量等 小数),11 个内置因子实例已逐一核对。
- 归档体积预算照旧按整包计量,无需改迁移。

三、界面
- 结果页新增「买卖说明」区块:全部买卖点 + 理由 + 数字标签,支持方向/成交状态/关键字
  筛选与日期排序;成交明细表加「为什么买 / 为什么卖」两列;新增「因子曲线」区块,
  每条曲线标出组合成交日,直接对照「买卖发生在什么水平」。
- 「新页面放大」:每条曲线(净值/回撤/因子/个股/月度)都能开 `/charts/{归档id}?s=...`
  整页看大图;放大页是 Server Component,数据从归档直出,URL 可分享且与归档一致。
  未归档的结果如实说明「未归档,无法放大」,不给坏链接。
- 数字格式与后端 `f"{v:.4f}"` 同规则(四舍六入五成双):修掉 0.03125 在理由原文里
  显示 0.0312、旁边标签显示 0.0313 的不一致(17 组边界值与 Python 逐一比对一致)。
- `/factors/compose` 结果区改用同一个 `BacktestResultView`,两处口径不会再漂移。

验证:
- 新增 `tests/test_trade_reasons.py` 8 条(买入数字、跌出 TopN 名次、不在候选池、
  Tmax、Tmin 暂留、涨停未成交、因子曲线加权值、空仓不落点);后端 510 条全过,ruff clean。
- 真实数据端到端:`/api/combos/run` 6 个月高股息组合(EXP-8EA2819B)13 个买卖点
  100% 带理由与数字,因子曲线 dividend_yield 117 点、单位 %;
  `scripts/verify_backtest_page_contract.py`(4 年、301 个买卖点、140 笔成交)扩展断言
  理由词表/名次/因子值/曲线单调性后通过。
- 浏览器实测:归档详情页与放大页 `/charts/...?s=factor:dividend_yield` 等 5 种曲线
  全部 200 渲染,截图确认表格与曲线数值正确。
This commit is contained in:
Simon
2026-10-01 17:57:00 +08:00
parent 36fe018075
commit 48a97c2a12
17 changed files with 2103 additions and 158 deletions
+54 -1
View File
@@ -93,7 +93,7 @@ def main() -> int:
required = [
"summary", "equity_curve", "drawdown", "monthly_returns", "yearly_returns",
"positions", "trades", "selection_history", "signal_history", "fills",
"symbol_curves", "turnover_pct", "unimplemented", "config_snapshot",
"symbol_curves", "factor_curves", "turnover_pct", "unimplemented", "config_snapshot",
]
missing = [k for k in required if k not in res]
assert not missing, f"结果缺少页面读取的字段:{missing}"
@@ -139,6 +139,59 @@ def main() -> int:
print(f"[contract] config_snapshot.selection={sel}")
print(f"[contract] config_snapshot.price_basis={basis}")
# —— 买卖理由(「用数据说话」的字段契约)——
# 每个买卖点都必须带结构化理由,且关键数字(名次/综合分/因子值)齐全;
# 文案由后端词表生成,前端只展示 —— 这里断言的是「页面要读的字段真的在」。
KNOWN = {
"buy_enter_topn", "buy_defer_filled", "buy_skip_limit_up", "buy_skip_halted",
"buy_skip_no_cash", "buy_skip_min_commission", "sell_drop_topn", "sell_force_tmax",
"sell_defer_tmin", "sell_defer_halted", "sell_defer_limit_down",
}
missing_reason = [a for a in res["signal_history"] if not a.get("reason")]
assert not missing_reason, f"有买卖点没有理由:{missing_reason[:3]}"
bad_code = [a["reason"]["code"] for a in res["signal_history"] if a["reason"]["code"] not in KNOWN]
assert not bad_code, f"出现词表外的理由代码:{sorted(set(bad_code))}"
for a in res["signal_history"]:
r = a["reason"]
assert r["text"], f"{a['date']} {a['symbol']} 理由文案为空"
assert isinstance(r["data"], dict), f"{a['date']} {a['symbol']} 理由缺少结构化数据"
# 成交类理由必须能回答「第几名 / 多少候选 / 综合分 / 因子当时的值」
with_rank = [a for a in res["signal_history"] if isinstance(a["reason"]["data"].get("rank"), int)]
assert with_rank, "没有任何理由给出名次(名次是买入选股的核心依据)"
sample = next(a for a in res["signal_history"] if a["reason"]["code"] == "buy_enter_topn")
sd = sample["reason"]["data"]
assert sd["rank"] >= 1 and sd["total"] >= sd["rank"] and sd["top_n"] >= 1, sd
assert "score" in sd and sd.get("factors"), f"买入理由缺少综合分/因子值:{sd}"
print(f"[contract] 买卖理由 {len(res['signal_history'])} 条全部带理由与数字;样例 "
f"{sample['date']} {sample['symbol']} {sample['reason']['code']} "
f"rank={sd['rank']}/{sd['total']} score={sd['score']} factors={sd['factors']}")
# —— 成交明细两端的理由(页面「为什么买 / 为什么卖」两列)——
trades = res["trades"]
no_entry = [t for t in trades if not t.get("entry_reason")]
no_exit = [t for t in trades if not t.get("exit_reason")]
assert not no_entry, f"{len(no_entry)} 笔成交缺建仓理由"
assert not no_exit, f"{len(no_exit)} 笔成交缺卖出理由"
t0_ = trades[0]
print(f"[contract] 成交明细两端理由齐全({len(trades)} 笔);样例 {t0_['symbol']} "
f"买={t0_['entry_reason']['code']} 卖={t0_['exit_reason']['code']}")
# —— 因子曲线(页面「因子曲线」区块 + 放大页的数据源)——
fcs = res["factor_curves"]
assert fcs, "结果里没有因子曲线(页面会少一整块「买卖依据的水平」)"
for fc in fcs:
assert fc["name"] and fc["label"] and fc["direction"], fc
assert fc["points"], f"因子 {fc['name']} 曲线无数据点"
dates = [p_["date"] for p_ in fc["points"]]
assert dates == sorted(dates), f"因子 {fc['name']} 曲线日期未按时间升序"
assert len(set(dates)) == len(dates), f"因子 {fc['name']} 曲线有重复日期"
used = {f["name"] for f in spec["factors"]} | {"dividend_yield"}
names = {fc["name"] for fc in fcs}
assert names & used, f"因子曲线与本次策略用到的因子对不上:{names} vs {used}"
fc = fcs[0]
print(f"[contract] 因子曲线 {len(fcs)} 条;首条 {fc['name']}({fc['label']},"
f"单位 {fc.get('unit')},{len(fc['points'])} 点,方向 {fc['direction']})")
# —— 未成交意图卡片(signal_history 中 filled=False 且带原因)——
rejects = [a for a in res["signal_history"] if not a["filled"] and a["reject_reason"]]
print(f"[contract] 未成交意图 {len(rejects)} 条,样例:"