"""组合回测引擎(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