字段库(本次新增的表与接口): - `condition_field` 表 + `/api/condition-fields`:中文名/说明可编辑、可停用; `kind`/单位阶梯/`base_unit` 由代码注册表收敛(改类型 422,伪字段 422, 越界单位 422),停用的字段不再进条件下拉,但既有策略仍按名字解析。 - 说明书里的数值条件按字段注册表补**基准单位**后缀(字段间比较不加,不猜单位)。 因子参数化(键即身份,冻结口径): - 模板 + 参数注册表(`quant/factors.py`):`ParamSpec`(类型/范围/枚举/默认值/说明)+ `FactorTemplate`(公式/依赖列/参数);规范键把**全部**参数写进名字,如 `momentum(window=90,direction=lower_is_better)`,所以改参数 = 新建一个身份, 旧因子/既有策略/已归档实验都不变义;`momentum(window=90)`(缺参数)明确拒绝 —— 缺项要靠模板默认值补齐,而默认值是可改的代码细节,一旦改动会追溯性改义。 - 参数只在受控范围内取值(窗口 2~500、方向二选一),越界/未知模板/多给参数一律 422 并列出允许范围,不静默截断、不悄悄取默认值;内置实例的启用开关由代码决定(422)。 - `/api/factors` 暴露 `template`/`params`/`param_specs`/`label`/`source`/`enabled`/ `resolvable`;新增 `/api/factors/templates`、`POST /api/factors`、`PATCH /api/factors`; `get_factor = resolve_factor` 兼容全部旧调用点,参数化键也是一等条件字段。 - 迁移链:c5d6(存量策略陈旧说明重算)→ d6e7(condition_field)→ a7c1 (factor_definition.enabled + name varchar(128))。 测试:新增 test_condition_fields.py / test_factor_params.py;全量 pytest 500 passed。
264 lines
11 KiB
Python
264 lines
11 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,
|
||
)
|
||
# 把价格口径写进 config_snapshot(与 ResearchService._annotate_price_basis 同口径),
|
||
# 否则归档页/结果头读不到 adjust_mode,会误显示「不复权」(组合实际用的是公共配置的复权)。
|
||
# 注意:不能覆盖整个 config_snapshot —— 引擎已把 ComboRunSpec 固化在里面(可复现依据)。
|
||
mode = config.price_adjustment
|
||
result.config_snapshot["price_basis"] = {
|
||
"adjust_mode": mode,
|
||
"price_basis": "adjust_factor" if mode != "none" else "raw_close",
|
||
"execution_price_basis": "close_adj" if mode != "none" else "close_raw",
|
||
}
|
||
_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()
|