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)。
This commit is contained in:
Simon
2026-09-30 21:43:28 +08:00
parent 50a1030afa
commit 40bd603b44
25 changed files with 2250 additions and 174 deletions
+580
View File
@@ -0,0 +1,580 @@
"""组合回测引擎(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
+9 -2
View File
@@ -145,10 +145,12 @@ def rebalance_dates(
⚠️ 刻意**不丢弃锚点月**:若把锚点月整体过滤掉,m=y=6 且起始日非月初时
会白等 6 个月才首次建仓(净值在前期恒等于初始资金,指标明显失真)。
every_months=None:沿用 weekly / monthly 频率(原行为,保持向后兼容)。
every_months=None:沿用 weekly / monthly / **daily** 频率。
- daily:区间内**每个交易日**都是调仓日(回测组合的「日频调仓」)。
- weekly / monthly:原行为,保持向后兼容。
"""
firsts = _week_firsts(index) if rebalance == "weekly" else _month_firsts(index)
if every_months and every_months > 0:
firsts = _week_firsts(index) if rebalance == "weekly" else _month_firsts(index)
# 锚点 = start 所在月内首个 >= start 的交易日(可能不是该月首个交易日)
days = pd.DatetimeIndex(index)
after_start = days[days >= pd.Timestamp(start)]
@@ -165,7 +167,12 @@ def rebalance_dates(
and ts.date() >= start
and ts != anchor_ts
]
elif rebalance == "daily":
# 日频:区间内每个交易日
days = pd.DatetimeIndex(index)
out = [ts for ts in days if ts.date() >= start]
else:
firsts = _week_firsts(index) if rebalance == "weekly" else _month_firsts(index)
out = [ts for ts in firsts if ts.date() >= start]
if end is not None:
out = [ts for ts in out if ts.date() <= end]
+117 -18
View File
@@ -1,4 +1,4 @@
"""策略说明书生成器:ResearchSpec / StrategyDefinition → 一句话说明 + 计算公式 + 步骤 + 注意事项。
"""策略说明书生成器:ResearchSpec(回测)/ SelectionStrategy(选股策略)→ 说明 + 公式 + 步骤 + 注意事项。
纯函数模块:无 IO、无 DB、不调用引擎,因而可被 API 复用(含**未保存**的策略即时预览)
并被单测直接覆盖。
@@ -29,12 +29,12 @@ from app.domain.entities.market import (
DAILY_BASIC_NUMERIC_FIELDS,
)
from app.domain.entities.research import ResearchSpec
from app.domain.entities.strategy import StrategyDefinition
from app.domain.entities.strategy import SelectionStrategy, StrategyDefinition
from app.quant.factors import FactorDef, FactorError, get_factor
# StrategyDefinition 没有回测区间字段(区间在回测时补全),但 to_research_spec 的
# period 是必填的 —— 用一个不可能被误读为真实区间的占位区间满足校验,
# 真正展示时以 `period_known=False` 走占位文案(不抛错、也不假装知道区间)。
# 选股策略(SelectionStrategy)不含回测参数,走 _describe_selection_only 专用分支;
# 以下占位常量仅保留给历史 ResearchSpec 路径兼容(当前 describe_strategy 不再对
# SelectionStrategy 调用 to_research_spec)。
_PLACEHOLDER_PERIOD: tuple[date, date] = (date(1900, 1, 1), date(1900, 1, 2))
_PERIOD_UNKNOWN_TEXT = "(回测区间未指定:策略定义本身不含 period,展开回测后才确定)"
@@ -75,21 +75,25 @@ class StrategyDoc(BaseModel):
def describe_strategy(
spec: ResearchSpec | StrategyDefinition,
spec: ResearchSpec | SelectionStrategy,
*,
factor_meta: Mapping[str, FactorDef] | None = None,
) -> StrategyDoc:
"""生成策略说明书。
`spec`:ResearchSpec(回测页参数,含 period)或 StrategyDefinition(策略库资产,
无 period)。后者经 `to_research_spec(period=占位)` 展开 —— 区间缺失只影响展示文案
(走 `_PERIOD_UNKNOWN_TEXT`),不影响其余推导,更不抛错。
`spec`:ResearchSpec(回测页参数,含 period/costs/selection)或 SelectionStrategy
(策略库资产,只有选股条件)。后者走 `_describe_selection_only`,只讲「怎么选」,
如实声明资金/持仓/调仓/成本/区间不在策略内(在回测组合里定)。
`factor_meta`:可选的因子元数据覆盖/补充(如内置注册表之外的实验因子)。
查找顺序为 `factor_meta` → `app.quant.factors.get_factor`;两处都没有则记入
warnings(说明该因子的含义/公式/方向未知,执行期会直接报错)。
"""
rspec, period_text, period_known = _coerce_spec(spec)
coerced = _coerce_spec(spec)
# 选股策略(无回测参数)走专用说明;回测 spec 走原有完整路径
if isinstance(coerced, (SelectionStrategy, StrategyDefinition)) and not isinstance(coerced, ResearchSpec):
return _describe_selection_only(coerced, factor_meta)
rspec, period_text, period_known = coerced
warnings: list[str] = []
factors = _describe_factors(rspec, factor_meta, warnings)
conditions = _describe_conditions(rspec, warnings)
@@ -101,22 +105,117 @@ def describe_strategy(
return StrategyDoc(summary=summary, formula=formula, steps=steps, warnings=warnings)
def _describe_selection_only(st, factor_meta) -> StrategyDoc:
"""选股策略的说明:只讲「怎么选」(股票池 + 因子 + 条件),不涉及回测执行参数。
资金 / 持仓数 / 持仓时间 / 调仓时机 / 费率 / 复权 / 区间都不属于选股策略,
在回测组合里才确定 —— 这里如实声明,避免读者以为策略自带这些口径。
"""
warnings: list[str] = []
factors = _describe_factors_from_specs(st.factors, factor_meta, warnings)
pool = _universe_text(st.universe)
cond_lines = _condition_lines(st.conditions, warnings) if st.conditions else []
factor_names = "、".join(f["name"] for f in factors) or "(无)"
pool_desc = _short_pool(st.universe)
summary = (
f"选股策略「{st.name}」:在{pool_desc}内"
+ (f"先通过 {len(st.conditions)} 条过滤条件,再" if st.conditions else "")
+ f"按因子({factor_names})打分排序,供回测组合取 TopN 持仓。"
)
formula_lines = [
"【选股口径】(仅定义「怎么选」,不含回测执行参数)",
f" 股票池:{pool}",
" 打分:score = Σ 权重 × 因子值(截面 z-score 标准化后加权,越大越优先)",
]
for f in factors:
formula_lines.append(f" · {f['line']}")
if cond_lines:
formula_lines.append(" 过滤条件(AND,先于打分执行):")
formula_lines.extend(f" · {ln}" for ln in cond_lines)
formula_lines.append(
" ⚠ 持仓数量 / 持仓天数区间 / 调仓时机 / 起始资金 / 费率 / 复权口径 / 回测区间"
"均不在本策略内 —— 它们在「回测组合」中指定,运行时与公共配置合并。"
)
steps = [
"1. 按股票池口径筛出候选 universe(市场 / 剔 ST / 上市天数 / 指数成分)。",
"2." + (" 逐条求值过滤条件(AND),剔除不满足者。" if st.conditions else " (未设过滤条件,候选 = universe。)"),
"3. 对剩余股票按上述因子打分并降序排列 → 得到候选排名(TopN 在回测组合里截取)。",
]
warnings.append(
"本说明只覆盖选股口径;回测的资金/持仓/调仓/成本/区间由「回测组合」+「公共配置」决定,"
"此处无法给出收益公式与成交口径。"
)
return StrategyDoc(summary=summary, formula="\n".join(formula_lines), steps=steps, warnings=warnings)
def _short_pool(universe) -> str:
parts = []
if universe.index_code:
parts.append(f"{universe.index_code} 成分股")
elif universe.symbols:
parts.append(f"指定的 {len(universe.symbols)} 只白名单股票")
else:
parts.append("全市场 A 股")
if universe.exclude_st:
parts.append("剔除 ST ")
return "、".join(parts)
def _describe_factors_from_specs(factor_specs, factor_meta, warnings) -> list[dict]:
"""从 FactorSpec 列表生成 [{label, line}](复用既有因子元数据查找逻辑)。"""
out: list[dict] = []
for fs in factor_specs:
meta = _lookup_factor(fs.name, factor_meta)
label = fs.name
direction = "越高越好"
formula_hint = ""
if meta is None:
warnings.append(
f"因子 {fs.name} 未在注册表中找到元数据:含义/公式/方向未知,"
"执行期会报错;说明里只能给出名字与权重。"
)
else:
label = meta.brief or meta.description or fs.name
direction = "越低越好" if meta.direction == "lower_is_better" else "越高越好"
formula_hint = f"({meta.formula})" if meta.formula else ""
out.append({
"name": fs.name,
"label": label if label != fs.name else fs.name,
"line": f"{fs.name}{formula_hint},权重 {fs.weight},{direction}",
})
return out
def _condition_lines(conditions, warnings) -> list[str]:
"""把 ConditionSpec 列表渲染成可读行(复用既有字段域校验逻辑)。"""
lines: list[str] = []
for c in conditions:
op = _OP_TEXT.get(c.op, c.op)
right = f"字段 {c.ref}" if c.ref else f"{c.value}"
lines.append(f"{c.field} {op} {right}")
return lines
# ---------- 入参归一化 ----------
def _coerce_spec(spec) -> tuple[ResearchSpec, str, bool]:
"""归一化为 (ResearchSpec, 区间展示文本, 区间是否已知)。
def _coerce_spec(spec):
"""归一化输入。
StrategyDefinition 无 period:按需求用占位区间展开并如实标记「区间未知」,
而不是抛错(策略库列表/详情页也要能看说明)。
- ResearchSpec(回测页参数,含 period/costs/selection)→ (ResearchSpec, 区间文本, True),
走完整回测说明书路径。
- SelectionStrategy(策略库资产,**只有选股条件**,无 period/costs/selection)→
直接返回该对象,由 `describe_selection_strategy` 生成「选股口径」说明。
重构后策略库不再持有回测执行参数,故不能也不应假装展开成 ResearchSpec。
"""
if isinstance(spec, StrategyDefinition):
return spec.to_research_spec(period=_PLACEHOLDER_PERIOD), _PERIOD_UNKNOWN_TEXT, False
if isinstance(spec, ResearchSpec):
start, end = spec.period
return spec, f"{start.isoformat()} ~ {end.isoformat()}", True
return (spec, f"{start.isoformat()} ~ {end.isoformat()}", True)
if isinstance(spec, (SelectionStrategy, StrategyDefinition)):
return spec # 选股策略:单独处理
raise TypeError(
"describe_strategy 只接受 ResearchSpec 或 StrategyDefinition,"
"describe_strategy 只接受 ResearchSpec 或 SelectionStrategy,"
f"收到 {type(spec).__name__}"
)