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)。
This commit is contained in:
Simon
2026-09-30 21:43:28 +08:00
parent 50a1030afa
commit 40bd603b44
25 changed files with 2250 additions and 174 deletions
@@ -1,6 +1,8 @@
"""策略 Repository 的 SQLAlchemy 实现(M8.3)。
"""选股策略 Repository 的 SQLAlchemy 实现。
config 以 JSON 存(StrategyDefinition.model_dump);读取时重建实体。
config_json 只存选股相关字段(universe/factors/conditions);读取时重建 SelectionStrategy。
2026-09 重构:策略库不再持有回测执行参数(selection/rebalance/costs/portfolio/区间),
旧行若残留这些键,读出时由 Pydantic 的 extra 忽略策略丢弃(见 _to_entity)。
"""
from __future__ import annotations
@@ -11,7 +13,7 @@ from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.domain.entities.strategy import StrategyDefinition
from app.domain.entities.strategy import SelectionStrategy
from app.infrastructure.persistence.sqlalchemy.models.strategy import StrategyModel
@@ -19,7 +21,7 @@ class SqlAlchemyStrategyRepository:
def __init__(self, session: Session) -> None:
self._session = session
def save(self, definition: StrategyDefinition) -> StrategyDefinition:
def save(self, definition: SelectionStrategy) -> SelectionStrategy:
if not definition.id:
raise ValueError("需要 id(由调用方生成)")
dup = self._session.scalar(
@@ -51,17 +53,17 @@ class SqlAlchemyStrategyRepository:
self._session.flush()
return definition
def get(self, strategy_id: str) -> StrategyDefinition | None:
def get(self, strategy_id: str) -> SelectionStrategy | None:
row = self._session.get(StrategyModel, strategy_id)
return _to_entity(row) if row else None
def get_by_name(self, name: str) -> StrategyDefinition | None:
def get_by_name(self, name: str) -> SelectionStrategy | None:
row = self._session.scalar(
select(StrategyModel).where(StrategyModel.name == name).limit(1)
)
return _to_entity(row) if row else None
def list(self) -> list[StrategyDefinition]:
def list(self) -> list[SelectionStrategy]:
rows = self._session.scalars(
select(StrategyModel).order_by(StrategyModel.name)
).all()
@@ -75,7 +77,15 @@ class SqlAlchemyStrategyRepository:
return True
def _to_entity(row: StrategyModel) -> StrategyDefinition:
# 旧 strategy.config_json 可能残留的回测执行参数字段(重构前写入)—— 读出时丢弃,
# 因为 SelectionStrategy 不再承载它们(已迁到回测组合 / 公共配置)。
_LEGACY_BACKTEST_KEYS = frozenset({
"selection", "rebalance", "costs", "portfolio", "price_adjustment",
"selection_interval_months", "rebalance_interval_months",
})
def _to_entity(row: StrategyModel) -> SelectionStrategy:
data = json.loads(row.config_json)
# 列字段由 DB 行回填,避免与 config_json 重复。
# description 必须一并回填:它是列字段(String(300)),save() 会写入,
@@ -83,7 +93,10 @@ def _to_entity(row: StrategyModel) -> StrategyDefinition:
# (读写不对称:保存的说明看不到,策略库/编辑页都拿不到)。
for key in ("name", "version", "description", "spec_type"):
data.pop(key, None)
return StrategyDefinition(
# 丢弃旧行的回测参数字段(Pydantic 默认 forbid extra 会因这些键报错)
for key in _LEGACY_BACKTEST_KEYS:
data.pop(key, None)
return SelectionStrategy(
id=row.id, name=row.name, version=row.version, description=row.description,
created_at=row.created_at, **data,
)