Files
qlib/backend/app/quant/combo_engine.py
T
Simon 40bd603b44 feat(backend): 策略库重构为「选股策略 + 公共配置 + 回测组合」三件套
按用户目标把原来「一个策略 = 全套参数」拆开(已确认的设计决策):
- 公共配置 GlobalConfig(全局唯一):佣金/印花税/滑点/最低佣金/复权口径/基准
- 选股策略 SelectionStrategy(原 StrategyDefinition 改名):只剩股票池+因子+条件,
  不再持有 selection/rebalance/costs/portfolio/区间/资金
- 回测组合 BacktestCombo:引用若干选股策略 + 回测时才定的参数
  (起始资金、持仓数 N、持仓天数区间 [Tmin,Tmax]、调仓时机 日/周/月、区间)

引擎(app/quant/combo_engine.py,新增):
- 多策略打分 = 并集 + Borda 秩和(各策略 1/名次 求和;不假设不同策略分值可比,
  能容纳各策略股票池不同);抽出纯函数 borda_combine 便于单测
- 持仓天数区间 [Tmin,Tmax]:Tmax **每个交易日**强制了结(安全阀,月频下也不超期);
  Tmin 仅在调仓日保护(掉出 TopN 但未满 Tmin 暂留,防频繁换手);调仓日为增量调仓
  (只卖超期/掉队且满 Tmin 的,从 TopN 补买至 N 只,不主动减持以尊重 Tmin)
- 调仓时机 daily/weekly/monthly(local_engine.rebalance_dates 新增日频分支)
- 产出与旧 runner 同构的 BacktestResult,前端可视化无需改动;config_snapshot 固化
  ComboRunSpec(组合+当时各策略定义+当时成本/复权)保证可复现

数据层:
- 新表 global_config(默认行:万三/hfq/最低佣金5元)、backtest_combo
- 迁移 b4c5d6e7f8a9:建两表 + 把存量 strategy.config_json 的回测参数键剥掉、
  spec_type 收敛为 selection(已在真实 MariaDB 验证:STG-16BFBF08 清洗后只剩
  universe/factors/conditions)
- 仓储 SqlAlchemyGlobalConfigRepository / SqlAlchemyComboRepository + Protocol

API:
- /api/config GET/PUT;/api/combos CRUD + /{id}/run + /run(kind=combo 异步 Job)
- job_executor 新增 combo 分支:取齐策略+读公共配置→ComboService.run,归档 kind
  记 backtest(结果结构相同)
- /api/strategies 切到 SelectionStrategy,移除已废弃的 /{id}/expand
- strategy_doc.describe_strategy 支持 SelectionStrategy(只讲「怎么选」,如实声明
  资金/持仓/调仓/成本/区间在回测组合里定)

旧的 ResearchSpec + /api/backtests 保留(因子测试与既有契约自检仍用),
作为底层 escape hatch;用户产品路径改为回测组合。

测试:新增 test_combo_engine(6)/test_combo_service(3)/test_combo_api(5),
改写 test_strategies/test_strategy_doc 适配新模型。全量 403 passed(原 388)。
2026-09-30 21:43:28 +08:00

581 lines
24 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_score_panel
from app.quant.local_engine import _limit_up_ratio, _nan, rebalance_dates
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]:
"""多策略 → (综合分面板, 合并合格集闭包)。
综合分面板 index=trade_date, columns=symbol,值为 Borda 秩和(越大越优先)。
合并合格集闭包 `combined(as_of) -> set[symbol] | None`:各策略合格集的并集;
全部策略都不过滤时返回 None(= 不过滤,交给面板的 dropna 处理)。
"""
# 每个策略一张「复合 zscore 面板」(已按方向加权求和)
panels: list[pd.DataFrame] = []
for ref in strategies:
_universe, factors, _conditions = _ref_to_specs(ref)
panels.append(build_score_panel(daily, factors))
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
# ---------- 持仓区间回测 runner ----------
@dataclass
class _Holding:
qty: float
entry_date: date
entry_price: float
entry_ts: object = None # pd.Timestamp:按「交易日」计持仓天数用(自然日会跨周末失真)
_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,
) -> 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.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()
# ---- 主循环 ----
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)
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)
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
# ---- 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
if _nan(c):
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
reject_reason=f"持有 {held} 天超 Tmax={tmax},但当日无行情,顺延")
)
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},但跌停无法卖出,顺延")
)
continue
cash = self._sell(s, h, float(c), day, cash, trades, holdings)
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)
if held < tmin:
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
reject_reason=f"掉出 TopN 但仅持 {held} 天 < Tmin={tmin},暂留")
)
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,但当日无行情,保留到下一调仓")
)
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,但跌停无法卖出,保留到下一调仓")
)
continue
cash = self._sell(s, h, float(c), day, cash, trades, holdings)
# 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
if _nan(c):
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="BUY", filled=False,
reject_reason="无行情(停牌),无法买入")
)
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="涨停,无法追买")
)
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="可用现金不足,未成交")
)
continue
ok, spent = self._buy(s, budget, d, float(c), day, holdings, notional)
if not ok:
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="BUY", filled=False,
reject_reason="预算不足以覆盖最低佣金,未成交")
)
continue
cash -= spent
self._record_positions(d, day, holdings, positions)
return cash
# ---- 买卖原子操作 ----
def _sell(self, s, h, close_price, day, cash, trades, holdings) -> 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)
)
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,
)
)
holdings.pop(s, None)
return cash
def _buy(self, s, budget, d, close_price, day, holdings, notional) -> 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)
notional.append(budget)
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="BUY", filled=True, price=round(price_in, 4))
)
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,
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 = 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,
)
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