Files
qlib/backend/app/domain/entities/combo.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

153 lines
6.7 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.
"""回测组合与公共配置领域实体(2026-09 重构)。
把原来「一个策略 = 全套参数」拆成三件独立的事(用户目标):
1. **GlobalConfig(公共配置,全局唯一)** —— 费率 / 印花税 / 滑点 / 最低佣金 /
复权口径 / 基准。所有回测共用,不再塞进每个策略。
2. **SelectionStrategy(选股策略,见 strategy.py)** —— 只剩「选股条件组合」:
股票池 + 因子 + 过滤条件。**不含**资金 / 持仓数 / 持仓时间 / 调仓 / 费率 / 区间。
3. **BacktestCombo(回测组合)** —— 引用若干选股策略 + 回测时才定的参数:
起始资金、持仓数量 N、持仓天数区间 [Tmin, Tmax]、调仓时机(日/周/月)、回测区间。
执行时由服务层把「组合 + 被引用的选股策略 + 公共配置快照」解析成一个
`ComboRunSpec`,喂给组合引擎;该 spec 会原样写进归档的 config_snapshot,
保证事后可复现(AGENT.md §21),即使之后公共配置被改也不影响历史结果。
"""
from __future__ import annotations
from datetime import date, datetime
from pydantic import BaseModel, Field, field_validator, model_validator
from app.domain.entities.research import CostSpec
# ---------- 公共配置(全局唯一) ----------
class GlobalConfig(BaseModel):
"""全局交易成本与行情口径(单例,id 恒为 "default")。
为什么把复权口径也放这里:一次回测只能有一个复权口径(同一份行情不能既前复权
又后复权),而多个选股策略可能想混用 —— 与其让它们在组合里打架,不如统一为
全局口径,高股息默认 hfq。若将来确需按组合区分,再加字段即可(向前兼容)。
"""
id: str = Field(default="default", description="单例主键,恒为 default")
commission_rate: float = Field(default=0.0003, ge=0, le=0.01, description="佣金率(如 0.0003 = 万三)")
stamp_tax_rate: float = Field(default=0.0005, ge=0, le=0.01, description="印花税率(仅卖出)")
slippage_rate: float = Field(default=0.001, ge=0, le=0.05, description="滑点率")
min_commission: float = Field(default=5.0, ge=0, le=100.0, description="单笔最低佣金(元)")
price_adjustment: str = Field(
default="hfq", pattern="^(none|qfq|hfq)$",
description="行情复权口径:none / qfq / hfq(高股息类建议 hfq)",
)
benchmark: str = Field(default="000300.SH", description="对照基准指数代码")
updated_at: datetime | None = None
def to_cost_spec(self) -> CostSpec:
"""转成引擎用的 CostSpec(benchmark 一并带入)。"""
return CostSpec(
commission_rate=self.commission_rate,
stamp_tax_rate=self.stamp_tax_rate,
slippage_rate=self.slippage_rate,
min_commission=self.min_commission,
benchmark=self.benchmark,
)
# ---------- 回测组合 ----------
# 调仓时机:日 / 周 / 月(在原有 weekly/monthly 之上新增 daily)
REBALANCE_FREQS = ("daily", "weekly", "monthly")
class BacktestCombo(BaseModel):
"""一个可保存、可复跑的回测组合。
持仓模型(用户确认的语义):
- `hold_count` = N:目标持仓只数(等权)。
- `hold_min_days` = Tmin:个股**最少**持有天数 —— 掉出 TopN 时若未满 Tmin 不卖
(防止频繁换手);但超过 Tmax 仍强制卖(安全阀优先)。
- `hold_max_days` = Tmax:个股**最多**持有天数 —— 超过即强制了结(None = 不限)。
- `rebalance_freq`:多久重新打分排序并调仓一次(日/周/月)。
⚠️ Tmax 强制卖出**每个交易日**都检查(不只调仓日),否则月频下会远超 Tmax。
"""
id: str = ""
name: str = Field(min_length=1, max_length=64)
description: str = ""
strategy_ids: list[str] = Field(
min_length=1, description="引用的选股策略 id(≥1 个;多策略取并集后 Borda 秩和打分)"
)
initial_capital: float = Field(default=1_000_000.0, gt=0, description="起始资金(元)")
hold_count: int = Field(ge=1, le=1000, description="目标持仓只数 N")
hold_min_days: int = Field(default=0, ge=0, description="个股最少持有天数 Tmin")
hold_max_days: int | None = Field(
default=None, ge=1, description="个股最多持有天数 Tmax;None = 不强制了结"
)
rebalance_freq: str = Field(
default="monthly", description="调仓时机:daily / weekly / monthly"
)
period: tuple[date, date]
version: str = "1"
created_at: datetime | None = None
@field_validator("rebalance_freq")
@classmethod
def _freq(cls, v: str) -> str:
if v not in REBALANCE_FREQS:
raise ValueError(f"rebalance_freq 必须是 {REBALANCE_FREQS} 之一,收到 {v!r}")
return v
@field_validator("period")
@classmethod
def _period_ordered(cls, period: tuple[date, date]) -> tuple[date, date]:
if period[0] >= period[1]:
raise ValueError("period 必须满足 start < end")
return period
@model_validator(mode="after")
def _hold_band_and_strategies(self) -> BacktestCombo:
if (
self.hold_max_days is not None
and self.hold_min_days > 0
and self.hold_max_days < self.hold_min_days
):
raise ValueError(
f"持仓上限 Tmax={self.hold_max_days} 不能小于下限 Tmin={self.hold_min_days}"
)
if len(set(self.strategy_ids)) != len(self.strategy_ids):
raise ValueError("strategy_ids 存在重复的策略 id")
return self
class ComboRunSpec(BaseModel):
"""解析后的、可复现的组合运行规格(写入归档 config_snapshot)。
为什么不直接存 BacktestCombo:组合只引用 strategy_ids,且费率/复权来自公共配置;
若事后策略被删改、公共配置被调整,光凭 combo 无法复现。这里把「当时用到的策略定义
+ 当时的成本/复权快照」一起固化,归档即可独立复现(AGENT.md §21)。
"""
combo: BacktestCombo
strategies: list[SelectionStrategyRef] = Field(
description="运行时刻各选股策略的快照(name/universe/factors/conditions)"
)
costs: CostSpec
price_adjustment: str = Field(pattern="^(none|qfq|hfq)$")
config_version: str = "1"
class SelectionStrategyRef(BaseModel):
"""ComboRunSpec 内嵌的策略快照(只取选股相关字段,避免把已废弃字段带进归档)。"""
id: str
name: str
universe: dict # UniverseSpec.model_dump()
factors: list[dict] # [{name, weight}]
conditions: list[dict] = Field(default_factory=list)
ComboRunSpec.model_rebuild()