Files
qlib/backend/app/quant/combo_engine.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

724 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""组合回测引擎(2026-09 重构):多选股策略 + 持仓天数区间 + 日/周/月调仓。
与 `local_engine.TopKBacktestRunner`(单策略、固定 m/y、全卖全买)并存:
旧 runner 继续服务 `/api/backtests`(ResearchSpec)与因子测试相关的既有路径,
本模块服务新的「回测组合」产品。两者产出**同一种 BacktestResult**,前端可视化无需改动。
执行模型(用户确认的语义):
- **打分 = 并集 + Borda 秩和**:每个选股策略各自对「自己的股票池 ∩ 条件」内的股票
按复合因子分排名;合并取并集,综合分 = Σ(1 / 该策略内名次),未进入某策略排名的
股票在该策略贡献 0。好处是不假设不同策略的因子分值可比、能容纳各策略股票池不同。
- **调仓时机 daily/weekly/monthly**:决定「重新打分 + 调向目标」的节奏。
- **持仓天数区间 [Tmin, Tmax]**:
* 每个交易日都检查 Tmax —— 持有超过 Tmax 的个股**强制了结**(安全阀,
即便调仓是月频也不能让个股远超 Tmax);
* 仅在调仓日:把「掉出 TopN 且已持 ≥ Tmin」的卖出(Tmin 防频繁换手),
再从 TopN 里补买到 N 只(等权目标,只买不主动减持以尊重 Tmin)。
- 成交仍在调仓日收盘(与旧引擎同一时序纪律,无未来函数);涨跌停/停牌沿用旧近似。
复用 local_engine 的纯工具(涨跌停幅度、NaN 判定、调仓日集合),其余记账逻辑
为本模块自包含 —— 刻意不继承旧 runner,避免改动那条已验证的路径。
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from datetime import date
import pandas as pd
from app.domain.entities.combo import BacktestCombo, ComboRunSpec, SelectionStrategyRef
from app.domain.entities.research import (
ActionRecord,
BacktestResult,
BacktestSummary,
ConditionSpec,
CostSpec,
CurvePoint,
FactorSpec,
MonthlyReturn,
Position,
RankedPick,
SymbolCurve,
Trade,
UniverseSpec,
YearlyReturn,
)
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
# ---------- 多策略打分:Borda 秩和 ----------
def _ref_to_specs(ref: SelectionStrategyRef) -> tuple[UniverseSpec, list[FactorSpec], list[ConditionSpec]]:
"""把归档快照里的 dict 还原成强类型 spec(喂给既有因子/条件求值器)。"""
universe = UniverseSpec.model_validate(ref.universe)
factors = [FactorSpec.model_validate(f) for f in ref.factors]
conditions = [ConditionSpec.model_validate(c) for c in ref.conditions]
return universe, factors, conditions
def borda_combine(panels: list[pd.DataFrame]) -> pd.DataFrame:
"""把多张「复合分面板」按 Borda 秩和合并成一张综合分面板。
每张面板先按日降序排名(1 = 最高分),综合分 = Σ(1/名次);某面板里 NaN(该股
不在该策略可评分集)贡献 0。index/columns 取所有面板的并集。纯函数,便于单测。
"""
if not panels:
return pd.DataFrame()
dates = sorted({d for p in panels for d in p.index})
symbols = sorted({s for p in panels for s in p.columns})
borda = pd.DataFrame(0.0, index=dates, columns=symbols)
for panel in panels:
ranks = panel.rank(axis=1, ascending=False, na_option="keep")
contrib = (1.0 / ranks).fillna(0.0)
borda = borda.add(contrib.reindex(index=borda.index, columns=borda.columns), fill_value=0.0)
return borda
def combine_strategy_scores(
daily: pd.DataFrame,
strategies: list[SelectionStrategyRef],
eligibility_fns: list,
) -> 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)
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)
def combined(as_of: date) -> set[str] | None:
sets = []
any_filter = False
for fn in eligibility_fns:
if fn is None:
continue
s = fn(as_of)
if s is not None:
sets.append(s)
any_filter = True
if not any_filter:
return None
# 并集:任一策略认为合格即合格(Borda 会给没被某策略覆盖的股票较低分,自然靠后)
out: set[str] = set()
for s in sets:
out |= s
return out
return borda, combined, raw_panels
# ---------- 持仓区间回测 runner ----------
@dataclass
class _Holding:
qty: float
entry_date: date
entry_price: float
entry_ts: object = None # pd.Timestamp:按「交易日」计持仓天数用(自然日会跨周末失真)
# 建仓理由(结构化):了结时原样写进 Trade.entry_reason,保证「为什么买」在
# 成交明细里能一路带出来 —— 持仓中途没有别的机会把它丢掉。
entry_reason: object = None
_UNIMPLEMENTED_BASE = [
"涨跌停按收盘价相对上一有效收盘近似判定(未建模开盘一字 / 集合竞价路径)",
"成交假设发生在调仓日收盘(未建模盘中价格路径与流动性冲击)",
(
"调仓日为「增量调仓」:只卖出超 Tmax / 掉出 TopN 且满 Tmin 的仓位,"
"并从 TopN 补买至 N 只;**不主动减持超重仓位**以尊重 Tmin,权重会随行情漂移"
"(非严格等权,买入侧按等权目标分配可用现金)"
),
"Tmax 强制卖出每个交易日检查;Tmin 保护与 TopN 重排仅在调仓日执行",
(
"多策略打分采用 Borda 秩和(各策略 1/名次 求和):不假设不同策略的因子分值可比,"
"但极端情况下某策略覆盖极少股票会使其秩和贡献偏大"
),
]
class HoldingBandRunner:
"""持仓天数区间 + 日/周/月调仓的组合回测 runner。"""
def __init__(
self,
*,
combo: BacktestCombo,
costs: CostSpec,
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
close = close.copy()
close.index = pd.to_datetime(close.index)
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]] = {}
# ---- 主循环 ----
def run(self) -> BacktestResult:
start, end = self.spec_period()
dates = [d for d in self.close.index if start <= d.date() <= end]
if not dates:
raise ValueError(f"回测区间 {start}~{end} 内没有任何行情数据,无法回测")
cadence = set(
rebalance_dates(self.close.index, self.combo.rebalance_freq, start, end)
)
cash = float(self.combo.initial_capital)
holdings: dict[str, _Holding] = {}
equity_rows: dict[pd.Timestamp, float] = {}
trades: list[Trade] = []
positions: list[Position] = []
notional: list[float] = []
cum: dict[str, float] = {}
curve_rows: dict[str, list[CurvePoint]] = {}
n = self.combo.hold_count
tmin = self.combo.hold_min_days
tmax = self.combo.hold_max_days
# 交易日位置索引:持仓天数按「交易日」计(Tmin/Tmax 的自然单位),避免跨周末失真
self._tday_pos = {ts: i for i, ts in enumerate(self.close.index)}
def _equity(d: pd.Timestamp) -> float:
total = cash
for s, h in holdings.items():
px = self.close.at[d, s] if d in self.close.index else None
if px is None or (isinstance(px, float) and math.isnan(px)):
continue
total += h.qty * float(px)
return total
for d in dates:
day = d.date()
# 1) 用昨日持仓结算当日个股收益(与组合净值同一时序口径)
self._accrue(d, holdings, cum, curve_rows)
# 2) Tmax 安全阀:**每个交易日**强制了结超期仓位(不只调仓日)
if tmax is not None:
cash = self._force_exit_over_max(d, day, holdings, cash, trades, tmax)
# 3) 调仓日:重新打分 + 增量调向目标 N 只
if d in cadence:
topn = self._top_n(d, n)
cash = self._rebalance_to_target(
d, day, topn, holdings, cash, trades, positions, notional, tmin, n
)
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)
def spec_period(self) -> tuple[date, date]:
return self.combo.period
# ---- 打分 / 选股 ----
def _top_n(self, d: pd.Timestamp, n: int) -> list[str]:
score_d = self.score.loc[d].dropna()
elig = self.eligibility_fn(d.date()) if self.eligibility_fn else None
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):
self.selection_history.append(
RankedPick(date=day, symbol=sym, rank=r, score=round(float(ranked[sym]), 6))
)
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:
prev_d = self.prev_close_at(d)
close_d = self.close.loc[d]
for s in [s for s in list(holdings)]:
h = holdings[s]
held = self._held_trading_days(h.entry_ts, d)
if held <= tmax:
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},但当日无行情,顺延",
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},但跌停无法卖出,顺延",
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,
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
# ---- 调仓日:增量调向目标 ----
def _rebalance_to_target(self, d, day, topn, holdings, cash, trades, positions, notional, tmin, n) -> float:
close_d = self.close.loc[d]
prev_d = self.prev_close_at(d)
topn_set = set(topn)
# a) 卖出:掉出 TopN 且已满 Tmin 的(Tmin 保护:未满 Tmin 即使掉出也暂留)
for s in [s for s in list(holdings)]:
if s in topn_set:
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},暂留",
reason=sell_deferred(SELL_DEFER_TMIN, cause="tmin", hold_days=held,
tmin=tmin, **ctx))
)
continue
c = close_d.get(s)
p = prev_d.get(s) if prev_d is not None else None
if _nan(c):
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
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,但跌停无法卖出,保留到下一调仓",
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,
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]
need = [s for s in topn if s not in holdings or holdings[s].qty <= 0]
slots_left = max(0, n - len(current))
buys = need[:slots_left]
if not buys:
self._record_positions(d, day, holdings, positions)
return cash
# 等权目标:每只 ≈ 当前权益 / N;单只预算 = min(目标, 可用现金均分)
equity_now = cash + sum(
holdings[s].qty * float(self.close.at[d, s])
for s in holdings
if holdings[s].qty > 0 and not _nan(self.close.at[d, s])
)
target_each = equity_now / n if n > 0 else 0.0
per_budget = min(target_each, cash / len(buys)) if buys else 0.0
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="无行情(停牌),无法买入",
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="涨停,无法追买",
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="可用现金不足,未成交",
reason=buy_skipped(BUY_SKIP_NO_CASH, budget=budget, **ctx))
)
continue
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="预算不足以覆盖最低佣金,未成交",
reason=buy_skipped(BUY_SKIP_MIN_COMMISSION, budget=budget,
min_commission=self.costs.min_commission,
**ctx))
)
continue
cash -= spent
self._record_positions(d, day, holdings, positions)
return cash
# ---- 买卖原子操作 ----
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,
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, 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
if invest <= 0:
return False, 0.0
qty = invest / price_in
prev = self.prev_close_at(d)
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, entry_reason=reason
)
notional.append(budget)
self.signal_history.append(
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)
self.traded_symbols.append(s)
return True, budget
# ---- 辅助 ----
def _held_trading_days(self, entry_ts, current_ts) -> int:
"""从入场到当前经过的**交易日**数(不含入场当日)。"""
e = self._tday_pos.get(entry_ts)
c = self._tday_pos.get(current_ts)
if e is None or c is None:
return 0
return max(0, c - e)
def prev_close_at(self, d: pd.Timestamp):
"""d 的前一有效收盘行(用于涨跌停判定);不存在返回 None。"""
idx = self.close.index
pos = idx.get_loc(d) if d in idx else None
if pos is None or pos == 0:
return None
prev_ts = idx[pos - 1]
return self.close.loc[prev_ts]
def _accrue(self, d, holdings, cum, curve_rows) -> None:
prev = self.prev_close_at(d)
if prev is None:
return
close_d = self.close.loc[d]
for s in holdings:
c, p = close_d.get(s), prev.get(s)
if _nan(c) or _nan(p) or p <= 0:
continue
cum[s] = cum.get(s, 1.0) * (float(c) / float(p))
curve_rows.setdefault(s, []).append(
CurvePoint(date=d.date(), value=round((cum[s] - 1.0) * 100, 4))
)
def _mark_curve(self, d, holdings, cum, curve_rows) -> None:
day = d.date()
for s in holdings:
pts = curve_rows.setdefault(s, [])
if pts and pts[-1].date == day:
continue
pts.append(CurvePoint(date=day, value=round((cum.get(s, 1.0) - 1.0) * 100, 4)))
def _record_positions(self, d, day, holdings, positions) -> None:
total = sum(
h.qty * float(self.close.at[d, s])
for s, h in holdings.items()
if h.qty > 0 and not _nan(self.close.at[d, s])
)
if total <= 0:
return
for s, h in holdings.items():
if h.qty > 0 and not _nan(self.close.at[d, s]):
positions.append(
Position(date=day, symbol=s, weight=float(h.qty * self.close.at[d, s] / total))
)
# ---- 结果装配(与旧 runner 同构,保证前端可视化不变) ----
def _to_result(self, equity, trades, positions, notional, cum, curve_rows) -> BacktestResult:
start, end = equity.index[0].date(), equity.index[-1].date()
init = float(self.combo.initial_capital)
final = float(equity.iloc[-1])
rets = equity.pct_change().dropna()
nn = len(rets)
total_ret = (final / init - 1.0) * 100 if init else 0.0
annual = (
((final / init) ** (TRADING_DAYS / max(nn, 1)) - 1.0) * 100
if final > 0 and init > 0 else -100.0
)
mean_r = float(rets.mean()) if nn else 0.0
std_r = float(rets.std(ddof=1)) if nn else 0.0
sharpe = mean_r / std_r * math.sqrt(TRADING_DAYS) if std_r and mean_r else 0.0
vol = std_r * math.sqrt(TRADING_DAYS) * 100
dd = (equity / equity.cummax() - 1.0).min() * 100
wins = [t for t in trades if t.return_pct > 0]
win_rate = len(wins) / len(trades) * 100 if trades else 0.0
avg_turn = (sum(notional) / len(notional) / ((init + final) / 2)) * 100 if notional else 0.0
eq_pts = [CurvePoint(date=d.date(), value=round(float(v), 2)) for d, v in equity.items()]
dd_series = (equity / equity.cummax() - 1.0) * 100
drawdown = [CurvePoint(date=d.date(), value=round(float(v), 3)) for d, v in dd_series.items()]
monthly: list[MonthlyReturn] = []
yearly: list[YearlyReturn] = []
if len(equity) > 1:
m = equity.resample("ME").last().pct_change().dropna()
monthly = [
MonthlyReturn(year=int(d.year), month=int(d.month), return_pct=round(float(v) * 100, 3))
for d, v in m.items()
]
y = equity.resample("YE").last().pct_change().dropna()
yearly = [
YearlyReturn(year=int(d.year), return_pct=round(float(v) * 100, 3))
for d, v in y.items()
]
summary = BacktestSummary(
start=start, end=end, initial_capital=round(init, 2), final_equity=round(final, 2),
total_return_pct=round(total_ret, 3), annual_return_pct=round(annual, 3),
sharpe=round(sharpe, 3), max_drawdown_pct=round(float(dd), 3),
volatility_pct=round(vol, 3), win_rate_pct=round(win_rate, 2),
total_trades=len(trades), avg_turnover_pct=round(avg_turn, 2),
)
curves = self._symbol_curves(curve_rows, cum)
return BacktestResult(
summary=summary, equity_curve=eq_pts, drawdown=drawdown,
monthly_returns=monthly, yearly_returns=yearly,
positions=positions, trades=trades,
selection_history=self.selection_history,
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(含策略+成本快照)
)
def _symbol_curves(self, curve_rows, cum) -> list[SymbolCurve]:
marks: dict[str, list[ActionRecord]] = {}
for a in self.signal_history:
if a.filled and a.symbol:
marks.setdefault(a.symbol, []).append(a)
out: list[SymbolCurve] = []
for s, points in curve_rows.items():
if not points:
continue
out.append(SymbolCurve(
symbol=s, points=points, marks=marks.get(s, []),
final_return_pct=round((cum.get(s, 1.0) - 1.0) * 100, 4),
))
out.sort(key=lambda c: abs(c.final_return_pct), reverse=True)
return out
def _unimplemented(self) -> list[str]:
notes = list(_UNIMPLEMENTED_BASE)
if self._no_prev_close:
notes.append(
f"有 {len(self._no_prev_close)} 只标的成交时缺少上一有效收盘价,"
"无法判定涨停(数据窗口起点或长期停牌后复牌),按可买处理"
)
c = self.combo
notes.append(
f"组合参数:N={c.hold_count}、持仓区间 [{c.hold_min_days}, "
f"{c.hold_max_days if c.hold_max_days is not None else '∞'}] 天、"
f"调仓 {c.rebalance_freq}、引用 {len(c.strategy_ids)} 个选股策略"
)
return notes
def run_combo_backtest(
*,
combo: BacktestCombo,
strategies: list[SelectionStrategyRef],
costs: CostSpec,
price_adjustment: str,
daily: pd.DataFrame,
eligibility_fns: list,
) -> BacktestResult:
"""组合回测入口:多策略 Borda 打分 → 持仓区间 runner → BacktestResult。
`eligibility_fns` 与 `strategies` 一一对应(每个策略一个「as_of→合格集」闭包,
可为 None 表示该策略无额外过滤);由服务层用既有 selection 求值器装配。
返回结果的 config_snapshot 由调用方填入 ComboRunSpec(含策略+成本快照)以保证可复现。
"""
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):组合参数 + 当时各策略定义 + 当时成本/复权
result.config_snapshot = ComboRunSpec(
combo=combo, strategies=strategies, costs=costs, price_adjustment=price_adjustment,
).model_dump(mode="json")
return result