字段库(本次新增的表与接口): - `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。
163 lines
7.3 KiB
Python
163 lines
7.3 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, 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()
|