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