用户要求:「所有买卖点详细说明买卖理由,用数据说话」「回测图上增加因子相关曲线
(买卖依据是股息率,就加股息率曲线)」「所有曲线能弹出新页面放大」。
一、买卖理由(后端产出结构化数据,前端只展示)
- 新增 `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 渲染,截图确认表格与曲线数值正确。
87 lines
3.1 KiB
Python
87 lines
3.1 KiB
Python
"""Composite Factor Engine(M7.2,ARCHITECTURE_v2 §13)。
|
||
|
||
把「多因子 → 加权复合分面板」独立成模块,供:
|
||
- 选股(SelectionEngine.run_score_selection)
|
||
- 回测(LocalEngine.run_backtest)
|
||
共用同一实现(v2 §25 一致性)。
|
||
method 先落地 fixed(截面 zscore × 方向 × 权重 求和);Rank/Z/IC 加权留接口。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
|
||
import pandas as pd
|
||
|
||
from app.quant.factors import FactorDef, compute_factor
|
||
|
||
|
||
def cross_sectional_zscore(panel: pd.DataFrame) -> pd.DataFrame:
|
||
"""截面 z-score。
|
||
|
||
候选不足 2 只(如单股票池)时退化为 0:无比较基准,但保留为可候选值;
|
||
全列缺失才为 NaN(该日不可选股)。
|
||
"""
|
||
|
||
def _row_z(row: pd.Series) -> pd.Series:
|
||
valid = row.dropna()
|
||
if len(valid) == 0:
|
||
return pd.Series(float("nan"), index=row.index)
|
||
if len(valid) == 1:
|
||
return pd.Series(0.0, index=row.index)
|
||
mu, sd = valid.mean(), valid.std()
|
||
if sd == 0 or math.isnan(sd):
|
||
return pd.Series(0.0, index=row.index)
|
||
return (row - mu) / sd
|
||
|
||
return panel.apply(_row_z, axis=1)
|
||
|
||
|
||
def composite_score(panels: list[tuple[str, pd.DataFrame, float, str]]) -> pd.DataFrame:
|
||
"""按 (name, panel, weight, direction) 计算加权复合 zscore。
|
||
|
||
direction="lower_is_better" 的因子取负号后相加(统一为「得分高者优先」)。
|
||
"""
|
||
total = None
|
||
for _name, panel, weight, direction in panels:
|
||
z = cross_sectional_zscore(panel)
|
||
if direction == "lower_is_better":
|
||
z = -z
|
||
contribution = z * weight
|
||
total = contribution if total is None else total.add(contribution, fill_value=0)
|
||
assert total is not None
|
||
return total
|
||
|
||
|
||
def build_factor_panels(
|
||
daily: pd.DataFrame, factor_specs
|
||
) -> list[tuple[str, pd.DataFrame, float, str]]:
|
||
"""按 spec.factors 计算面板与权重(因子不存在即报错)。"""
|
||
return [
|
||
(defn.name, panel, weight, defn.direction)
|
||
for defn, panel, weight in build_factor_panels_full(daily, factor_specs)
|
||
]
|
||
|
||
|
||
def build_factor_panels_full(
|
||
daily: pd.DataFrame, factor_specs
|
||
) -> list[tuple[FactorDef, pd.DataFrame, float]]:
|
||
"""同 `build_factor_panels`,但把 `FactorDef` 一并带出来。
|
||
|
||
回测的「买卖理由」与「因子曲线」需要用到因子的显示名 / 方向 / 单位(`FactorDef`),
|
||
而只拿 name 就得回注册表再查一遍 —— 这里一次算完,避免同一次回测里重复计算面板。
|
||
"""
|
||
panels: list[tuple[FactorDef, pd.DataFrame, float]] = []
|
||
for fs in factor_specs:
|
||
defn, panel = compute_factor(fs.name, daily)
|
||
panels.append((defn, panel, fs.weight))
|
||
return panels
|
||
|
||
|
||
def build_score_panel(daily: pd.DataFrame, factor_specs) -> pd.DataFrame:
|
||
"""因子加权复合分面板(index=trade_date, columns=symbol)。
|
||
|
||
回测与选股共用的统一入口 —— 保证 v2 §25/§27 一致性。
|
||
"""
|
||
return composite_score(build_factor_panels(daily, factor_specs))
|