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:
@@ -285,6 +285,24 @@ class BacktestSummary(BaseModel):
|
||||
benchmark_return_pct: float | None = None
|
||||
|
||||
|
||||
class TradeReason(BaseModel):
|
||||
"""一次交易意图 / 成交的**结构化理由**:用当时的真实数字解释「为什么买 / 为什么卖」。
|
||||
|
||||
为什么不让前端自己推:界面上出现的每个数字(排名、综合分、因子值、持有天数)
|
||||
都必须来自引擎当时的计算,否则就是「看着像真的」。理由因此分三层:
|
||||
|
||||
- `code`:机器可判定的原因分类(封闭取值,见 `quant/trade_reasons.py`)。
|
||||
前端据此筛选 / 上色,不去解析文案;
|
||||
- `text`:给人读的一句话(自带关键数字,可单独展示);
|
||||
- `data`:当时真实数值(`rank` / `total` / `score` / `top_n` / `hold_days` /
|
||||
`factors`(各因子当时的原始值)/ `budget` …)。前端只展示,不推算。
|
||||
"""
|
||||
|
||||
code: str
|
||||
text: str
|
||||
data: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class Trade(BaseModel):
|
||||
entry_date: date
|
||||
exit_date: date
|
||||
@@ -293,6 +311,12 @@ class Trade(BaseModel):
|
||||
entry_price: float
|
||||
exit_price: float
|
||||
return_pct: float
|
||||
entry_reason: TradeReason | None = Field(
|
||||
default=None, description="买入理由(建仓当日引擎给出的结构化理由)"
|
||||
)
|
||||
exit_reason: TradeReason | None = Field(
|
||||
default=None, description="卖出理由(了结当日引擎给出的结构化理由)"
|
||||
)
|
||||
|
||||
|
||||
class Position(BaseModel):
|
||||
@@ -318,6 +342,10 @@ class ActionRecord(BaseModel):
|
||||
signal=BUY/SELL(策略意图);filled=是否实际成交;reject_reason 给出未成交原因
|
||||
(涨停/跌停/无价/现金不足等)。fills = [a for a in signal_history if a.filled]。
|
||||
|
||||
`reason` 是**数据化**的为什么:`reject_reason` 只说「没成交」(执行层),
|
||||
`reason` 同时覆盖成交与未成交(策略层 + 执行层),并带上当时的排名 / 综合分 /
|
||||
各因子原始值,前端「买卖说明」直接用,不再二次推断。
|
||||
|
||||
`name` 为展示增强字段:由服务层按股票池统一回填(未命中则为 None),
|
||||
引擎自身不感知名称 —— 引擎只处理 symbol,保持纯行情计算职责。
|
||||
"""
|
||||
@@ -329,6 +357,9 @@ class ActionRecord(BaseModel):
|
||||
filled: bool
|
||||
reject_reason: str | None = None
|
||||
price: float | None = Field(default=None, description="成交价(fill)或意图参考价")
|
||||
reason: TradeReason | None = Field(
|
||||
default=None, description="结构化理由(成交与未成交都有;旧归档为 null)"
|
||||
)
|
||||
|
||||
|
||||
class SymbolCurve(BaseModel):
|
||||
@@ -352,6 +383,25 @@ class SymbolCurve(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class FactorCurve(BaseModel):
|
||||
"""单个因子在回测期内的时间序列(**持仓组合加权平均原始值**)。
|
||||
|
||||
口径必须写死,否则读图会读反:
|
||||
|
||||
- 值为该因子在**当日持仓股票**上的权重加权平均(权重 = 该股当日市值 / 组合权益),
|
||||
是**原始值**:不做 z-score、不按方向取负 —— 图上看到的就是因子本身;
|
||||
- 空仓日不落点(不插值、不用 0 假填充),曲线中间会出现空档;
|
||||
- `direction` 一并归档:低为好的因子,曲线升高不等于「更好」;
|
||||
- `unit` 是代码注册表里的事实(`%` / `倍数` / `小数`),用于坐标轴与提示文案。
|
||||
"""
|
||||
|
||||
name: str
|
||||
label: str
|
||||
direction: str = "higher_is_better"
|
||||
unit: str | None = None
|
||||
points: list[CurvePoint] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BacktestResult(BaseModel):
|
||||
"""标准化回测结果(ARCHITECTURE §14)。前端只依赖该结构。"""
|
||||
|
||||
@@ -375,6 +425,13 @@ class BacktestResult(BaseModel):
|
||||
default_factory=list,
|
||||
description="个股收益率曲线 + 买卖点标注(按期末收益绝对值降序,体积可控)",
|
||||
)
|
||||
factor_curves: list[FactorCurve] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"策略用到的每个因子的时间序列(持仓加权平均原始值):"
|
||||
"用来解释「买卖依据的那个因子在各时点是什么水平」"
|
||||
),
|
||||
)
|
||||
turnover_pct: float
|
||||
unimplemented: list[str] = Field(
|
||||
default_factory=list,
|
||||
|
||||
Reference in New Issue
Block a user