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
+166 -23
View File
@@ -45,8 +45,26 @@ from app.domain.entities.research import (
UniverseSpec,
YearlyReturn,
)
from app.quant.composite import build_score_panel
from app.quant.composite import build_factor_panels_full, composite_score
from app.quant.factors import FactorDef
from app.quant.local_engine import _limit_up_ratio, _nan, rebalance_dates
from app.quant.trade_reasons import (
BUY_SKIP_HALTED,
BUY_SKIP_LIMIT_UP,
BUY_SKIP_MIN_COMMISSION,
BUY_SKIP_NO_CASH,
SELL_DEFER_HALTED,
SELL_DEFER_LIMIT_DOWN,
SELL_DEFER_TMIN,
SELL_DROP_TOPN,
SELL_FORCE_TMAX,
build_factor_curves,
buy_filled,
buy_skipped,
factor_values,
sell_deferred,
sell_filled,
)
TRADING_DAYS = 252
@@ -84,18 +102,26 @@ def combine_strategy_scores(
daily: pd.DataFrame,
strategies: list[SelectionStrategyRef],
eligibility_fns: list,
) -> tuple[pd.DataFrame, object]:
"""多策略 → (综合分面板, 合并合格集闭包)。
) -> tuple[pd.DataFrame, object, dict[str, tuple[FactorDef, pd.DataFrame]]]:
"""多策略 → (综合分面板, 合并合格集闭包, 原始因子面板)。
综合分面板 index=trade_date, columns=symbol,值为 Borda 秩和(越大越优先)。
合并合格集闭包 `combined(as_of) -> set[symbol] | None`:各策略合格集的并集;
全部策略都不过滤时返回 None(= 不过滤,交给面板的 dropna 处理)。
第三个返回值是「策略用到的每个因子的**原始**面板」(key = 因子键):
复合分是 z-score 后的无量纲分,解释不了「股息率到底几厘」,因此买卖理由与
因子曲线必须回到原始值。同一因子被多个策略引用时只算一次。
"""
# 每个策略一张「复合 zscore 面板」(已按方向加权求和)
panels: list[pd.DataFrame] = []
raw_panels: dict[str, tuple[FactorDef, pd.DataFrame]] = {}
for ref in strategies:
_universe, factors, _conditions = _ref_to_specs(ref)
panels.append(build_score_panel(daily, factors))
full = build_factor_panels_full(daily, factors)
for defn, panel, _weight in full:
raw_panels.setdefault(defn.name, (defn, panel))
panels.append(composite_score([(d.name, p, w, d.direction) for d, p, w in full]))
borda = borda_combine(panels)
@@ -117,7 +143,7 @@ def combine_strategy_scores(
out |= s
return out
return borda, combined
return borda, combined, raw_panels
# ---------- 持仓区间回测 runner ----------
@@ -129,6 +155,9 @@ class _Holding:
entry_date: date
entry_price: float
entry_ts: object = None # pd.Timestamp:按「交易日」计持仓天数用(自然日会跨周末失真)
# 建仓理由(结构化):了结时原样写进 Trade.entry_reason,保证「为什么买」在
# 成交明细里能一路带出来 —— 持仓中途没有别的机会把它丢掉。
entry_reason: object = None
_UNIMPLEMENTED_BASE = [
@@ -158,6 +187,7 @@ class HoldingBandRunner:
score: pd.DataFrame,
close: pd.DataFrame,
eligibility_fn=None,
factor_panels: dict[str, tuple[FactorDef, pd.DataFrame]] | None = None,
) -> None:
self.combo = combo
self.costs = costs
@@ -166,11 +196,19 @@ class HoldingBandRunner:
self.close = close.sort_index()
self.score = score.reindex(self.close.index).sort_index()
self.eligibility_fn = eligibility_fn
# 策略用到的因子原始面板:买卖理由里的因子值、以及因子曲线都从这里取
self.factor_panels = factor_panels or {}
self.selection_history: list[RankedPick] = []
self.signal_history: list[ActionRecord] = []
self.traded_symbols: list[str] = []
self._traded: set[str] = set()
self._no_prev_close: set[str] = set()
# 调仓日的完整排名与合格集:卖出理由要能说出「第几名掉出去的」,
# 以及「是掉出 TopN 还是根本不在候选池(被股票池/条件过滤)」
self._ranked_by_day: dict[pd.Timestamp, pd.Series] = {}
self._elig_by_day: dict[pd.Timestamp, set[str] | None] = {}
# 每个交易日的持仓市值权重(因子曲线用;空仓日空 dict → 不落点)
self._weights_by_day: dict[pd.Timestamp, dict[str, float]] = {}
# ---- 主循环 ----
@@ -226,6 +264,7 @@ class HoldingBandRunner:
equity_rows[d] = _equity(d)
self._mark_curve(d, holdings, cum, curve_rows)
self._weights_by_day[d] = self._holding_weights(d, holdings)
equity = pd.Series(equity_rows).sort_index()
return self._to_result(equity, trades, positions, notional, cum, curve_rows)
@@ -241,6 +280,9 @@ class HoldingBandRunner:
if elig is not None:
score_d = score_d[score_d.index.isin(elig)]
ranked = score_d.sort_values(ascending=False)
# 完整排名留下来:卖出理由要说「第几名掉出去的」,只有 TopN 说不出这个数
self._ranked_by_day[d] = ranked
self._elig_by_day[d] = set(elig) if elig is not None else None
top = ranked.head(n).index.tolist()
day = d.date()
for r, sym in enumerate(top, start=1):
@@ -249,6 +291,50 @@ class HoldingBandRunner:
)
return top
def _rank_of(self, d: pd.Timestamp, symbol: str) -> dict:
"""该股在 `d` 日的排名上下文:rank / total / score / in_pool。
卖出理由必须能区分三件事:**在池但排名掉出去**、**已被股票池/条件过滤**
(如转为 ST)、**当日没有分数**(数据缺失)。都写成「跌出 TopN」会掩盖真相。
"""
out: dict = {"rank": None, "total": None, "score": None, "in_pool": None}
ranked = self._ranked_by_day.get(d)
if ranked is None:
return out # 非调仓日(如 Tmax 强制了结发生在普通交易日):没有当日排名
elig = self._elig_by_day.get(d)
out["total"] = int(len(ranked))
out["in_pool"] = True if elig is None else (symbol in elig)
if symbol in ranked.index:
loc = ranked.index.get_loc(symbol)
if isinstance(loc, int):
out["rank"] = loc + 1
out["score"] = round(float(ranked.loc[symbol]), 6)
return out
def _holding_weights(self, d: pd.Timestamp, holdings) -> dict[str, float]:
"""当日持仓市值权重(因子曲线用)。取不到价的持仓不参与,空仓日返回空 dict。"""
out: dict[str, float] = {}
for s, h in holdings.items():
if h.qty <= 0 or s not in self.close.columns:
continue
px = self.close.at[d, s]
if _nan(px) or px <= 0:
continue
out[s] = float(h.qty) * float(px)
return out
def _reason_ctx(self, d: pd.Timestamp, symbol: str, *, top_n: int | None) -> dict:
"""构造理由所需的公共上下文(排名 + 各因子当时的原始值)。"""
ctx = self._rank_of(d, symbol)
return {
"rank": ctx["rank"],
"total": ctx["total"],
"top_n": top_n,
"score": ctx["score"],
"factors": factor_values(self.factor_panels, d, symbol) or None,
"not_in_pool": ctx["in_pool"] is False,
}
# ---- Tmax 强制了结(每日) ----
def _force_exit_over_max(self, d, day, holdings, cash, trades, tmax) -> float:
@@ -261,19 +347,33 @@ class HoldingBandRunner:
continue
c = close_d.get(s)
p = prev_d.get(s) if prev_d is not None else None
ctx = self._reason_ctx(d, s, top_n=None)
if _nan(c):
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
reject_reason=f"持有 {held} 天超 Tmax={tmax},但当日无行情,顺延")
reject_reason=f"持有 {held} 天超 Tmax={tmax},但当日无行情,顺延",
reason=sell_deferred(SELL_DEFER_HALTED, cause="halted",
hold_days=held, tmax=tmax, **ctx))
)
continue
if not _nan(p) and p > 0 and c / p <= 1.0 - (_limit_up_ratio(s) - 1.0):
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
reject_reason=f"持有 {held} 天超 Tmax={tmax},但跌停无法卖出,顺延")
reject_reason=f"持有 {held} 天超 Tmax={tmax},但跌停无法卖出,顺延",
reason=sell_deferred(SELL_DEFER_LIMIT_DOWN, cause="limit_down",
hold_days=held, tmax=tmax,
close=float(c), prev_close=float(p),
limit_ratio=1.0 - (_limit_up_ratio(s) - 1.0),
**ctx))
)
continue
cash = self._sell(s, h, float(c), day, cash, trades, holdings)
cash = self._sell(
s, h, float(c), day, cash, trades, holdings,
reason=sell_filled(code=SELL_FORCE_TMAX, rank=None, total=ctx["total"],
top_n=None, score=ctx["score"], factors=ctx["factors"],
hold_days=held, tmax=tmax, price=float(c),
return_pct=(float(c) / h.entry_price - 1.0) * 100),
)
return cash
# ---- 调仓日:增量调向目标 ----
@@ -289,10 +389,13 @@ class HoldingBandRunner:
continue
h = holdings[s]
held = self._held_trading_days(h.entry_ts, d)
ctx = self._reason_ctx(d, s, top_n=n)
if held < tmin:
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
reject_reason=f"掉出 TopN 但仅持 {held} 天 < Tmin={tmin},暂留")
reject_reason=f"掉出 TopN 但仅持 {held} 天 < Tmin={tmin},暂留",
reason=sell_deferred(SELL_DEFER_TMIN, cause="tmin", hold_days=held,
tmin=tmin, **ctx))
)
continue
c = close_d.get(s)
@@ -300,16 +403,30 @@ class HoldingBandRunner:
if _nan(c):
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
reject_reason="掉出 TopN,但当日无行情,保留到下一调仓")
reject_reason="掉出 TopN,但当日无行情,保留到下一调仓",
reason=sell_deferred(SELL_DEFER_HALTED, cause="halted",
hold_days=held, tmin=tmin, **ctx))
)
continue
if not _nan(p) and p > 0 and c / p <= 1.0 - (_limit_up_ratio(s) - 1.0):
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
reject_reason="掉出 TopN,但跌停无法卖出,保留到下一调仓")
reject_reason="掉出 TopN,但跌停无法卖出,保留到下一调仓",
reason=sell_deferred(SELL_DEFER_LIMIT_DOWN, cause="limit_down",
hold_days=held, tmin=tmin,
close=float(c), prev_close=float(p),
limit_ratio=1.0 - (_limit_up_ratio(s) - 1.0),
**ctx))
)
continue
cash = self._sell(s, h, float(c), day, cash, trades, holdings)
cash = self._sell(
s, h, float(c), day, cash, trades, holdings,
reason=sell_filled(code=SELL_DROP_TOPN, rank=ctx["rank"], total=ctx["total"],
top_n=n, score=ctx["score"], factors=ctx["factors"],
hold_days=held, tmin=tmin, price=float(c),
return_pct=(float(c) / h.entry_price - 1.0) * 100,
not_in_pool=ctx["not_in_pool"]),
)
# b) 补买:从 TopN 里挑尚未持有的,按等权目标用可用现金买入,直到 N 只或现金耗尽
current = [s for s in topn if s in holdings and holdings[s].qty > 0]
@@ -332,30 +449,45 @@ class HoldingBandRunner:
for s in buys:
c = close_d.get(s)
p = prev_d.get(s) if prev_d is not None else None
ctx = self._reason_ctx(d, s, top_n=n)
if _nan(c):
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="BUY", filled=False,
reject_reason="无行情(停牌),无法买入")
reject_reason="无行情(停牌),无法买入",
reason=buy_skipped(BUY_SKIP_HALTED, **ctx))
)
continue
if not _nan(p) and p > 0 and c / p >= _limit_up_ratio(s):
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="BUY", filled=False,
reject_reason="涨停,无法追买")
reject_reason="涨停,无法追买",
reason=buy_skipped(BUY_SKIP_LIMIT_UP, close=float(c),
prev_close=float(p),
limit_ratio=_limit_up_ratio(s), **ctx))
)
continue
budget = min(per_budget, cash)
if budget <= 1e-9:
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="BUY", filled=False,
reject_reason="可用现金不足,未成交")
reject_reason="可用现金不足,未成交",
reason=buy_skipped(BUY_SKIP_NO_CASH, budget=budget, **ctx))
)
continue
ok, spent = self._buy(s, budget, d, float(c), day, holdings, notional)
buy_reason = buy_filled(
rank=ctx["rank"], total=ctx["total"], top_n=n, score=ctx["score"],
factors=ctx["factors"], price=float(c) * (1 + self.costs.slippage_rate),
budget=budget,
)
ok, spent = self._buy(s, budget, d, float(c), day, holdings, notional,
reason=buy_reason)
if not ok:
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="BUY", filled=False,
reject_reason="预算不足以覆盖最低佣金,未成交")
reject_reason="预算不足以覆盖最低佣金,未成交",
reason=buy_skipped(BUY_SKIP_MIN_COMMISSION, budget=budget,
min_commission=self.costs.min_commission,
**ctx))
)
continue
cash -= spent
@@ -365,25 +497,29 @@ class HoldingBandRunner:
# ---- 买卖原子操作 ----
def _sell(self, s, h, close_price, day, cash, trades, holdings) -> float:
def _sell(self, s, h, close_price, day, cash, trades, holdings, reason=None) -> float:
proceeds = h.qty * close_price * (1 - self.costs.slippage_rate)
commission = max(proceeds * self.costs.commission_rate, self.costs.min_commission)
fee = commission + proceeds * self.costs.stamp_tax_rate
cash += proceeds - fee
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=True, price=close_price)
ActionRecord(date=day, symbol=s, signal="SELL", filled=True, price=close_price,
reason=reason)
)
trades.append(
Trade(
entry_date=h.entry_date, exit_date=day, symbol=s,
entry_price=h.entry_price, exit_price=close_price,
return_pct=(close_price / h.entry_price - 1.0) * 100,
# 买卖理由跟着成交走:成交明细里「为什么买、为什么卖」都齐
entry_reason=h.entry_reason,
exit_reason=reason,
)
)
holdings.pop(s, None)
return cash
def _buy(self, s, budget, d, close_price, day, holdings, notional) -> tuple[bool, float]:
def _buy(self, s, budget, d, close_price, day, holdings, notional, reason=None) -> tuple[bool, float]:
price_in = close_price * (1 + self.costs.slippage_rate)
commission = max(budget * self.costs.commission_rate, self.costs.min_commission)
invest = budget - commission
@@ -394,10 +530,13 @@ class HoldingBandRunner:
pv = prev.get(s) if prev is not None else float("nan")
if _nan(pv) or pv <= 0:
self._no_prev_close.add(s)
holdings[s] = _Holding(qty=qty, entry_date=day, entry_price=price_in, entry_ts=d)
holdings[s] = _Holding(
qty=qty, entry_date=day, entry_price=price_in, entry_ts=d, entry_reason=reason
)
notional.append(budget)
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="BUY", filled=True, price=round(price_in, 4))
ActionRecord(date=day, symbol=s, signal="BUY", filled=True,
price=round(price_in, 4), reason=reason)
)
if s not in self._traded:
self._traded.add(s)
@@ -515,6 +654,7 @@ class HoldingBandRunner:
signal_history=self.signal_history,
fills=[a for a in self.signal_history if a.filled],
symbol_curves=curves,
factor_curves=build_factor_curves(self.factor_panels, self._weights_by_day),
turnover_pct=round(sum(notional) / max(init, 1) * 100, 2),
unimplemented=self._unimplemented(),
config_snapshot={}, # 由服务层填入 ComboRunSpec(含策略+成本快照)
@@ -567,10 +707,13 @@ def run_combo_backtest(
可为 None 表示该策略无额外过滤);由服务层用既有 selection 求值器装配。
返回结果的 config_snapshot 由调用方填入 ComboRunSpec(含策略+成本快照)以保证可复现。
"""
score, combined_elig = combine_strategy_scores(daily, strategies, eligibility_fns)
score, combined_elig, factor_panels = combine_strategy_scores(
daily, strategies, eligibility_fns
)
close = daily.pivot(index="trade_date", columns="symbol", values="close").sort_index()
runner = HoldingBandRunner(
combo=combo, costs=costs, score=score, close=close, eligibility_fn=combined_elig,
factor_panels=factor_panels,
)
result = runner.run()
# 固化可复现规格(AGENT.md §21):组合参数 + 当时各策略定义 + 当时成本/复权