Files
qlib/backend/app/quant/combo_engine.py
T
Simon 57d6082f91 fix: 如实说明买卖理由的覆盖边界(哪些不算买卖点)
`unimplemented` 与「买卖说明」里都没写清一件事:理由覆盖的是 **signal_history 里的
每个买卖点(含涨停/停牌/跌停/现金不足等未成交情形)**,而「当日排名在 TopN 之外、
策略本来就无意买入」的候选根本不算买卖点 —— 用户看不到某只票的买入理由时,
应该能立刻分清「是漏了记录」还是「策略本来就没打算买」。

- 组合引擎与单策略引擎的 `_unimplemented` 各加一条说明,指向 `selection_history`
  可查完整候选与名次(AGENT.md §24:没实现/有边界的要显式写出)。
- 「买卖说明」卡片底部同步写出这条边界。
2026-10-01 18:15:26 +08:00

729 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/名次 求和):不假设不同策略的因子分值可比,"
"但极端情况下某策略覆盖极少股票会使其秩和贡献偏大"
),
(
"买卖说明覆盖 signal_history 里的每个买卖点(含涨停/停牌/现金不足等未成交情形);"
"但「当日排名在 TopN 之外、策略本来就无意买入」的候选不计为买卖点,"
"要看完整候选与名次请查选股明细(selection_history)"
),
]
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