Files
qlib/backend/app/application/services/combo_service.py
T
Simon 2e90f3eeac feat(backend): 字段库(condition_field)+ 因子参数化(模板/受控参数)+ 单位换算底座
字段库(本次新增的表与接口):
- `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。
2026-10-01 16:33:32 +08:00

264 lines
11 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,
)
# 把价格口径写进 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()