Files
qlib/backend/app/application/services/combo_service.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

255 lines
10 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.
"""回测组合服务:把「组合 + 选股策略 + 公共配置」解析并执行成 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()