按用户目标把原来「一个策略 = 全套参数」拆开(已确认的设计决策):
- 公共配置 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)。
255 lines
10 KiB
Python
255 lines
10 KiB
Python
"""回测组合服务:把「组合 + 选股策略 + 公共配置」解析并执行成 BacktestResult。
|
||
|
||
职责(应用层用例,AGENT.md §16/§17):
|
||
- 装配行情数据(复用 ResearchService 的 load_daily_df / universe 过滤 / 名称回填);
|
||
- 为每个选股策略构造「as_of → 合格股票集」闭包(复用 selection 求值器,保证与
|
||
`/api/selections` 同口径,v2 §25);
|
||
- 调 combo_engine.run_combo_backtest(多策略 Borda + 持仓区间 + 日/周/月);
|
||
- 把可复现的 ComboRunSpec 写进结果 config_snapshot(已在引擎内完成)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import date, timedelta
|
||
from typing import Any
|
||
|
||
import pandas as pd
|
||
|
||
from app.domain.entities.combo import (
|
||
BacktestCombo,
|
||
ComboRunSpec,
|
||
GlobalConfig,
|
||
SelectionStrategyRef,
|
||
)
|
||
from app.domain.entities.research import BacktestResult, UniverseSpec
|
||
from app.domain.entities.strategy import SelectionStrategy
|
||
from app.quant.combo_engine import run_combo_backtest
|
||
from app.quant.selection import build_condition_fields, eligible_symbols
|
||
from app.quant.service import _fill_names, load_daily_df, split_factor_columns
|
||
from app.quant.universe import filter_stocks, names_as_of, resolve_members
|
||
|
||
|
||
class ComboService:
|
||
"""回测组合用例入口。依赖注入各 Repository + 引擎无关的数据装配函数。"""
|
||
|
||
def __init__(
|
||
self,
|
||
stock_repo,
|
||
daily_repo,
|
||
*,
|
||
index_repo=None,
|
||
basic_repo=None,
|
||
financial_repo=None,
|
||
name_repo=None,
|
||
) -> None:
|
||
self._stock_repo = stock_repo
|
||
self._daily_repo = daily_repo
|
||
self._index_repo = index_repo
|
||
self._basic_repo = basic_repo
|
||
self._financial_repo = financial_repo
|
||
self._name_repo = name_repo
|
||
self._last_stocks: list = []
|
||
|
||
def run(
|
||
self,
|
||
combo: BacktestCombo,
|
||
strategies: list[SelectionStrategy],
|
||
config: GlobalConfig,
|
||
on_stage=None,
|
||
) -> BacktestResult:
|
||
if not strategies:
|
||
raise ValueError("回测组合至少需要引用一个选股策略")
|
||
# 校验引用的策略 id 与传入一致(防御性:调用方应已按 combo.strategy_ids 取齐)
|
||
given = {s.id for s in strategies}
|
||
missing = [sid for sid in combo.strategy_ids if sid not in given]
|
||
if missing:
|
||
raise ValueError(f"组合引用的选股策略未提供:{missing}")
|
||
|
||
_stage(on_stage, "data_loading")
|
||
daily = self._load_daily(combo, strategies, config)
|
||
_stage(on_stage, "backtesting")
|
||
eligibility_fns = [self._build_eligibility(s, daily) for s in strategies]
|
||
refs = [_to_ref(s) for s in strategies]
|
||
result = run_combo_backtest(
|
||
combo=combo,
|
||
strategies=refs,
|
||
costs=config.to_cost_spec(),
|
||
price_adjustment=config.price_adjustment,
|
||
daily=daily,
|
||
eligibility_fns=eligibility_fns,
|
||
)
|
||
_stage(on_stage, "analysis")
|
||
return _fill_names(result, self._last_stocks)
|
||
|
||
# ---- 数据装配(与 ResearchService 同口径,复用底层函数) ----
|
||
|
||
def _merged_universe(self, strategies: list[SelectionStrategy]) -> UniverseSpec:
|
||
"""合并各策略的股票池口径用于「装哪些股票的行情」。
|
||
|
||
取并集语义:symbols 白名单取并集;exclude_st / min_listing_days 取**最宽松**
|
||
(任一策略不剔 ST 则不剔,min_listing_days 取最小)—— 因为最终选股由各策略
|
||
自己的 eligibility 闭包再过滤,这里只为「行情装配覆盖足够多的股票」。
|
||
index_code 不一致时无法合并 → 报错(同一组合里混用不同指数成分没有明确语义)。
|
||
"""
|
||
indices = {s.universe.index_code for s in strategies if s.universe.index_code}
|
||
if len(indices) > 1:
|
||
raise ValueError(
|
||
f"组合内各选股策略的指数成分不一致({sorted(indices)}),无法合并股票池;"
|
||
"请统一指数或改用 symbols 白名单"
|
||
)
|
||
symbols: set[str] = set()
|
||
for s in strategies:
|
||
symbols.update(s.universe.symbols)
|
||
return UniverseSpec(
|
||
market=strategies[0].universe.market,
|
||
exclude_st=all(s.universe.exclude_st for s in strategies),
|
||
exclude_suspended=all(s.universe.exclude_suspended for s in strategies),
|
||
min_listing_days=min(s.universe.min_listing_days for s in strategies),
|
||
index_code=indices.pop() if indices else None,
|
||
symbols=sorted(symbols),
|
||
)
|
||
|
||
def _load_daily(
|
||
self, combo: BacktestCombo, strategies: list[SelectionStrategy], config: GlobalConfig
|
||
) -> pd.DataFrame:
|
||
start, end = combo.period
|
||
data_start = start - timedelta(days=300) # 因子 warmup 余量
|
||
all_stocks = self._stock_repo.list()
|
||
merged = self._merged_universe(strategies)
|
||
name_at, _applied = names_as_of(all_stocks, start, self._name_repo)
|
||
stocks = filter_stocks(
|
||
all_stocks, merged, as_of=start,
|
||
members=resolve_members(self._index_repo, merged, start),
|
||
name_at=name_at,
|
||
)
|
||
self._last_stocks = stocks
|
||
# 所需列 = 所有策略因子 + 所有策略条件引用列 + close
|
||
needed = {"close"}
|
||
for s in strategies:
|
||
from app.domain.entities.research import ResearchSpec
|
||
from app.quant.engine import factor_required_columns
|
||
|
||
# 借用既有列裁剪逻辑:构造一个临时 spec 只为算 required_columns
|
||
tmp = ResearchSpec(
|
||
type="backtest", universe=s.universe, factors=s.factors,
|
||
conditions=s.conditions, period=combo.period,
|
||
)
|
||
needed |= factor_required_columns(tmp)
|
||
bar_cols, basic_cols = split_factor_columns(needed)
|
||
symbols = [st.symbol for st in stocks]
|
||
daily = load_daily_df(
|
||
self._daily_repo, symbols, data_start, end, sorted(bar_cols),
|
||
adjust="none", price_adjust=config.price_adjustment,
|
||
)
|
||
if basic_cols:
|
||
daily = self._attach_basic(daily, symbols, data_start, end, sorted(basic_cols))
|
||
return daily
|
||
|
||
def _attach_basic(self, daily, symbols, start, end, columns) -> pd.DataFrame:
|
||
from app.quant.service import load_basic_df, merge_basic_into_daily
|
||
|
||
if self._basic_repo is None:
|
||
raise ValueError(
|
||
f"选股策略条件/因子需要每日指标列 {columns}(daily_basic),但未注入 DailyBasicRepository"
|
||
)
|
||
basic = load_basic_df(self._basic_repo, symbols, start, end, columns)
|
||
if basic.empty:
|
||
raise ValueError(
|
||
f"daily_basic 在 {start}~{end} 无数据,无法计算需要 {columns} 的因子/条件"
|
||
)
|
||
return merge_basic_into_daily(daily, basic)
|
||
|
||
def _build_eligibility(self, strategy: SelectionStrategy, daily: pd.DataFrame):
|
||
"""单策略的「as_of → 合格股票集」闭包(与 ResearchService._build_eligibility 同口径)。"""
|
||
if not self._last_stocks:
|
||
if not strategy.conditions and not strategy.universe.exclude_st:
|
||
return None
|
||
raise ValueError("universe 过滤结果为空,无法构造选股条件求值器")
|
||
statics = {s.symbol: s.model_dump() for s in self._last_stocks}
|
||
candidates = sorted(statics)
|
||
st_fn = self._build_st_filter(strategy, candidates)
|
||
if not strategy.conditions:
|
||
if st_fn is None:
|
||
return None
|
||
allowed: dict[date, set[str]] = {}
|
||
|
||
def _st_only(as_of: date) -> set[str]:
|
||
if as_of not in allowed:
|
||
allowed[as_of] = set(candidates) - st_fn(as_of)
|
||
return allowed[as_of]
|
||
|
||
return _st_only
|
||
|
||
uses_fundamental = any(
|
||
f.startswith("fundamental.")
|
||
for c in strategy.conditions
|
||
for f in (c.field, c.ref or "")
|
||
)
|
||
cache: dict[date, set[str]] = {}
|
||
|
||
def _fn(as_of: date) -> set[str]:
|
||
if as_of in cache:
|
||
return cache[as_of]
|
||
financial = self._load_financial(candidates, as_of) if uses_fundamental else {}
|
||
fields = build_condition_fields(daily, strategy.conditions, pd.Timestamp(as_of))
|
||
if not fields:
|
||
cache[as_of] = set()
|
||
return cache[as_of]
|
||
passed = set(eligible_symbols(candidates, strategy.conditions, statics, fields, financial))
|
||
if st_fn is not None:
|
||
passed -= st_fn(as_of)
|
||
cache[as_of] = passed
|
||
return cache[as_of]
|
||
|
||
return _fn
|
||
|
||
def _build_st_filter(self, strategy: SelectionStrategy, candidates: list[str]):
|
||
if not strategy.universe.exclude_st or self._name_repo is None:
|
||
return None
|
||
cache: dict[date, set[str]] = {}
|
||
|
||
def _fn(as_of: date) -> set[str]:
|
||
if as_of not in cache:
|
||
name_at, applied = names_as_of(self._last_stocks, as_of, self._name_repo)
|
||
if not applied[0]:
|
||
cache[as_of] = set()
|
||
else:
|
||
st_syms: set[str] = set()
|
||
for st in self._last_stocks:
|
||
nm = (name_at or {}).get(st.symbol) or st.name
|
||
if nm and "ST" in nm.upper():
|
||
st_syms.add(st.symbol)
|
||
cache[as_of] = st_syms
|
||
return cache[as_of]
|
||
|
||
return _fn
|
||
|
||
def _load_financial(self, symbols: list[str], as_of: date) -> dict[str, Any]:
|
||
if self._financial_repo is None:
|
||
raise ValueError("条件引用了 fundamental.* 字段,但未注入 FinancialRepository")
|
||
getter = getattr(self._financial_repo, "list_announced_many", None)
|
||
rows = list(getter(symbols, as_of)) if getter else []
|
||
out: dict[str, Any] = {}
|
||
for r in rows:
|
||
out[r.symbol] = r
|
||
return out
|
||
|
||
|
||
def _to_ref(s: SelectionStrategy) -> SelectionStrategyRef:
|
||
return SelectionStrategyRef(
|
||
id=s.id,
|
||
name=s.name,
|
||
universe=s.universe.model_dump(),
|
||
factors=[f.model_dump() for f in s.factors],
|
||
conditions=[c.model_dump() for c in s.conditions],
|
||
)
|
||
|
||
|
||
def _stage(cb, name: str) -> None:
|
||
if cb is not None:
|
||
cb(name)
|
||
|
||
|
||
# 让 ComboRunSpec 在模块导入时完成前向引用重建(entities/combo.py 末尾已 rebuild,此处兜底)
|
||
ComboRunSpec.model_rebuild()
|