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:
@@ -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)} 条,样例:"
|
||||
|
||||
Reference in New Issue
Block a user