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
@@ -0,0 +1,254 @@
"""回测组合服务:把「组合 + 选股策略 + 公共配置」解析并执行成 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()
@@ -76,10 +76,42 @@ def _execute_inner(
session.commit()
try:
is_selection = job.kind == "selection"
is_combo = job.kind == "combo"
basic_repo = basic_repo_factory(session) if basic_repo_factory else None
index_repo = index_repo_factory(session) if index_repo_factory else None
name_repo = name_repo_factory(session) if name_repo_factory else None
if is_selection:
if is_combo:
# 回测组合:解析 combo + 取齐选股策略 + 读公共配置 → ComboService.run
from app.application.services.combo_service import ComboService
from app.domain.entities.combo import BacktestCombo
from app.infrastructure.persistence.sqlalchemy.repositories.combo_impl import (
SqlAlchemyGlobalConfigRepository,
)
from app.infrastructure.persistence.sqlalchemy.repositories.strategy_impl import (
SqlAlchemyStrategyRepository,
)
combo = BacktestCombo.model_validate_json(job.spec_json)
strategy_repo = SqlAlchemyStrategyRepository(session)
strategies = []
for sid in combo.strategy_ids:
st = strategy_repo.get(sid)
if st is None:
raise ValueError(f"组合引用的选股策略 {sid} 不存在(可能已被删除)")
strategies.append(st)
config = SqlAlchemyGlobalConfigRepository(session).get()
service = ComboService(
stock_repo_factory(session),
daily_repo_factory(session),
index_repo=index_repo,
basic_repo=basic_repo,
financial_repo=(
financial_repo_factory(session) if financial_repo_factory else None
),
name_repo=name_repo,
)
spec = combo # 仅用于下方分支判断占位;实际执行用 combo
elif is_selection:
from app.application.services.selection_service import SelectionService
from app.domain.entities.selection import SelectionQuery
@@ -118,7 +150,9 @@ def _execute_inner(
except Exception: # noqa: BLE001 —— 阶段上报失败不阻断执行
pass
if is_selection:
if is_combo:
result = service.run(spec, strategies, config, on_stage=_set_stage)
elif is_selection:
_set_stage("selection")
result = service.select(spec)
elif spec.type == "backtest":
@@ -126,9 +160,12 @@ def _execute_inner(
else:
result = service.run_factor_test(spec, on_stage=_set_stage)
# combo 的结果是 BacktestResult,归档 kind 记为 "backtest" 以便前端按回测渲染;
# spec_json 仍存原始 combo(含 strategy_ids),可复现快照在 result.config_snapshot。
archive_kind = "backtest" if is_combo else job.kind
experiment = archive_experiment(
session=session,
kind=job.kind,
kind=archive_kind,
spec_json=job.spec_json,
result=result,
job_id=job.id,