按用户目标把原来「一个策略 = 全套参数」拆开(已确认的设计决策):
- 公共配置 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)。
153 lines
6.7 KiB
Python
153 lines
6.7 KiB
Python
"""回测组合与公共配置领域实体(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()
|