Files
qlib/backend/tests/test_trade_reasons.py
T
Simon 48a97c2a12 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 渲染,截图确认表格与曲线数值正确。
2026-10-01 17:57:00 +08:00

241 lines
9.1 KiB
Python

"""买卖理由(数据化)与因子曲线的单测。
用户要求:「所有买卖点详细说明买卖理由,用数据说话」「回测图上增加因子相关曲线」。
因此这里验证的是**数字真的来自引擎当时计算**,而不是后补的文案:
1. 买入理由带名次 / 候选数 / 综合分 / 每个因子当时的原始值;
2. 卖出理由区分「跌出 TopN(第几名)」「被股票池过滤」「持有超 Tmax」;
3. Tmin 保护、涨停未买等未成交点也有结构化理由;
4. `Trade.entry_reason / exit_reason` 跟着成交记录走;
5. `factor_curves` = 持仓权重加权平均的**原始值**,空仓日不落点。
"""
from __future__ import annotations
from datetime import date, timedelta
import pandas as pd
import pytest
from app.domain.entities.combo import BacktestCombo
from app.domain.entities.research import CostSpec
from app.quant.combo_engine import HoldingBandRunner
from app.quant.factors import get_factor
def _days(n: int = 12, start: date = date(2024, 1, 2)) -> list[pd.Timestamp]:
out: list[date] = []
d = start
while len(out) < n:
if d.weekday() < 5:
out.append(d)
d += timedelta(days=1)
return [pd.Timestamp(x) for x in out]
def _close(days, series: dict[str, list[float]]) -> pd.DataFrame:
return pd.DataFrame(series, index=pd.DatetimeIndex(days))
def _combo(**over) -> BacktestCombo:
base = dict(
name="理由单测",
strategy_ids=["S1"],
initial_capital=1_000_000.0,
hold_count=1,
hold_min_days=0,
hold_max_days=None,
rebalance_freq="daily",
period=(date(2024, 1, 2), date(2024, 1, 31)),
)
base.update(over)
return BacktestCombo(**base)
def _factor_panels(days, values: dict[str, float]):
"""用一个真实注册因子(momentum_20)承载合成面板:label/方向/单位来自注册表。"""
panel = pd.DataFrame(
{sym: [v] * len(days) for sym, v in values.items()}, index=pd.DatetimeIndex(days)
)
return {"momentum_20": (get_factor("momentum_20")[0], panel)}
def _run(
*,
score_rows: list[dict[str, float]],
close_series: dict[str, list[float]],
factor_values: dict[str, float] | None = None,
eligibility_fn=None,
**combo_over,
):
days = _days(len(score_rows))
score = pd.DataFrame(score_rows, index=pd.DatetimeIndex(days))
close = _close(days, close_series)
runner = HoldingBandRunner(
combo=_combo(**combo_over),
costs=CostSpec(),
score=score,
close=close,
eligibility_fn=eligibility_fn,
factor_panels=_factor_panels(days, factor_values or {"A": 0.1, "B": 0.2}),
)
return runner.run(), days
# ---------- 买入理由 ----------
def test_buy_reason_has_real_numbers():
"""买入成交的理由 = 名次 + 候选数 + 综合分 + 各因子当时的原始值(全部来自引擎)。"""
result, _ = _run(
score_rows=[{"A": 0.9, "B": 0.5}] * 6,
close_series={"A": [100.0] * 6, "B": [100.0] * 6},
factor_values={"A": 0.123, "B": 0.456},
)
buys = [a for a in result.signal_history if a.signal == "BUY" and a.filled]
assert len(buys) == 1
r = buys[0].reason
assert r is not None
assert r.code == "buy_enter_topn"
assert r.data["rank"] == 1
assert r.data["total"] == 2
assert r.data["top_n"] == 1
assert r.data["score"] == pytest.approx(0.9)
# 因子原始值(面板里 A=0.123)——区间内买入理由必须能对上这个数
assert r.data["factors"]["momentum_20"] == pytest.approx(0.123)
assert "第 1" in r.text and "0.9000" in r.text
def test_sell_reason_ranks_and_hold_days():
"""跌出 TopN 的卖出理由要说出「第几名掉出去」与持有交易日。"""
# 第 1 天 A 第一 → 买入 A;第 3 天起 B 第一 → 卖出 A(名次 2/2)
rows = [{"A": 0.9, "B": 0.5}, {"A": 0.9, "B": 0.5}, {"A": 0.1, "B": 0.9}] + [
{"A": 0.1, "B": 0.9}
] * 3
result, _ = _run(
score_rows=rows,
close_series={"A": [100.0] * 6, "B": [100.0] * 6},
hold_min_days=0,
)
sells = [a for a in result.signal_history if a.signal == "SELL" and a.filled]
assert len(sells) == 1
r = sells[0].reason
assert r is not None
assert r.code == "sell_drop_topn"
assert r.data["rank"] == 2
assert r.data["total"] == 2
assert r.data["hold_days"] == 2 # 第 1 天买、第 3 天卖 → 2 个交易日
assert "第 2/2" in r.text
# 成交明细里的买卖理由两端齐全
trade = result.trades[0]
assert trade.entry_reason is not None and trade.entry_reason.code == "buy_enter_topn"
assert trade.exit_reason is not None and trade.exit_reason.code == "sell_drop_topn"
def test_sell_reason_not_in_pool_is_distinct():
"""被股票池/条件过滤掉(不在候选池)≠ 排名掉出去:理由要分开写。"""
result, _ = _run(
score_rows=[{"A": 0.9, "B": 0.5}] * 4,
close_series={"A": [100.0] * 4, "B": [100.0] * 4},
# 第 4 天把 B 之外的 A 挡在候选池外(A 持有中,属于「已不在候选池」)
eligibility_fn=lambda as_of: {"B"} if as_of >= date(2024, 1, 5) else None,
)
sells = [a for a in result.signal_history if a.signal == "SELL" and a.filled]
assert sells, "A 不在候选池后应被卖出"
r = sells[0].reason
assert r is not None and r.code == "sell_drop_topn"
assert r.data["in_pool"] is False
assert "已不在候选池" in r.text
def test_sell_reason_tmax_force_exit():
"""Tmax 强制了结:理由说明「持有 N 天 > Tmax」,与排名无关。"""
result, _ = _run(
score_rows=[{"A": 0.9, "B": 0.5}] * 8,
close_series={"A": [100.0] * 8, "B": [100.0] * 8},
hold_max_days=3,
)
forced = [
a for a in result.signal_history if a.signal == "SELL" and a.filled
and a.reason is not None and a.reason.code == "sell_force_tmax"
]
assert forced, "超 Tmax 应有强制了结"
r = forced[0].reason
assert r is not None
assert r.data["hold_days"] > r.data["tmax"] == 3
assert "Tmax=3" in r.text
def test_tmin_protection_reason():
"""未满 Tmin 掉出 TopN:理由写明「仅持 N 天 < Tmin,暂留」。"""
rows = [{"A": 0.9, "B": 0.5}, {"A": 0.1, "B": 0.9}] + [{"A": 0.1, "B": 0.9}] * 4
result, _ = _run(
score_rows=rows,
close_series={"A": [100.0] * 6, "B": [100.0] * 6},
hold_min_days=3,
)
deferred = [
a for a in result.signal_history
if not a.filled and a.reason is not None and a.reason.code == "sell_defer_tmin"
]
assert deferred, "未满 Tmin 应记录暂留理由"
r = deferred[0].reason
assert r is not None
assert r.data["hold_days"] < r.data["tmin"] == 3
assert "Tmin=3" in r.text and "暂留" in r.text
def test_buy_blocked_by_limit_up_reason():
"""涨停无法追买:理由里带「收盘 / 前收 = 比值 ≥ 阈值」的真实数字。"""
# 第 3 天 A 相对前收涨 10% 以上(600xxx 主板阈值 1.099)→ 当日买不进
a = [100.0, 100.0, 111.0, 111.0]
result, _ = _run(
score_rows=[{"A": 0.9, "B": 0.5}] * 2 + [{"A": 0.9, "B": 0.5}] * 2,
close_series={"A": a, "B": [100.0] * 4},
# 前几天 A 不可选,逼到第 3 天涨停时才想买
eligibility_fn=lambda as_of: {"B"} if as_of < date(2024, 1, 4) else {"A", "B"},
)
blocked = [
a_ for a_ in result.signal_history
if a_.signal == "BUY" and not a_.filled and a_.reason is not None
and a_.reason.code == "buy_skip_limit_up"
]
assert blocked, "涨停日应记录未成交理由"
r = blocked[0].reason
assert r is not None
assert r.data["close_prev_ratio"] == pytest.approx(1.11, abs=1e-3)
assert r.data["limit_ratio"] == pytest.approx(1.099)
assert "涨停" in r.text
# ---------- 因子曲线 ----------
def test_factor_curve_is_holding_weighted_raw_value():
"""因子曲线 = 持仓权重加权平均的原始值(有 label/方向/单位),空仓日不落点。"""
rows = [{"A": 0.9, "B": 0.5}] * 6
result, days = _run(
score_rows=rows,
close_series={"A": [100.0] * 6, "B": [200.0] * 6},
factor_values={"A": 0.2, "B": 0.8},
)
assert len(result.factor_curves) == 1
fc = result.factor_curves[0]
assert fc.name == "momentum_20"
assert fc.direction == "higher_is_better"
assert fc.unit == "小数"
assert fc.label.startswith("动量")
# 只买 A(N=1),因子值恒为 A 的 0.2;第一天调仓在收盘后建仓 → 第一天也落点
assert all(p.value == pytest.approx(0.2) for p in fc.points)
assert len(fc.points) == len(days)
def test_factor_curve_skips_empty_holding_days():
"""空仓日不落点(不插值、不用 0 假填充),曲线点数少于交易日数。"""
# 只有第 3 天有股票可选:之前空仓,之后持仓
rows = [{"A": float("nan"), "B": float("nan")}] * 2 + [{"A": 0.9, "B": 0.5}] * 4
result, days = _run(
score_rows=rows,
close_series={"A": [100.0] * 6, "B": [100.0] * 6},
)
fc = result.factor_curves[0]
assert 0 < len(fc.points) < len(days)