"""回测组合与公共配置领域实体(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, ConfigDict, Field, field_validator, model_validator from app.domain.entities.research import CostSpec # ---------- 公共配置(全局唯一) ---------- class GlobalConfig(BaseModel): """全局交易成本与行情口径(单例,id 恒为 "default")。 为什么把复权口径也放这里:一次回测只能有一个复权口径(同一份行情不能既前复权 又后复权),而多个选股策略可能想混用 —— 与其让它们在组合里打架,不如统一为 全局口径,高股息默认 hfq。若将来确需按组合区分,再加字段即可(向前兼容)。 `extra="forbid"`:PUT /api/config 若带未知字段(拼错键名、旧版遗留键)直接报错, 避免「以为改了某项、其实被静默忽略」。 """ model_config = ConfigDict(extra="forbid") 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。 `extra="forbid"`:回测参数写错键名(如 hold_days、capital)时报错而非静默用默认值 —— 静默用默认值会让「我明明设了 30 天」变成「其实没生效」,是本项目明确禁止的降级方式。 """ model_config = ConfigDict(extra="forbid") 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()