Files
qlib/backend/app/infrastructure/persistence/sqlalchemy/repositories/strategy_impl.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

103 lines
4.2 KiB
Python

"""选股策略 Repository 的 SQLAlchemy 实现。
config_json 只存选股相关字段(universe/factors/conditions);读取时重建 SelectionStrategy。
2026-09 重构:策略库不再持有回测执行参数(selection/rebalance/costs/portfolio/区间),
旧行若残留这些键,读出时由 Pydantic 的 extra 忽略策略丢弃(见 _to_entity)。
"""
from __future__ import annotations
import json
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.domain.entities.strategy import SelectionStrategy
from app.infrastructure.persistence.sqlalchemy.models.strategy import StrategyModel
class SqlAlchemyStrategyRepository:
def __init__(self, session: Session) -> None:
self._session = session
def save(self, definition: SelectionStrategy) -> SelectionStrategy:
if not definition.id:
raise ValueError("需要 id(由调用方生成)")
dup = self._session.scalar(
select(StrategyModel).where(StrategyModel.name == definition.name).limit(1)
)
if dup is not None and dup.id != definition.id:
raise ValueError(f"策略名已存在:{definition.name}")
now = definition.created_at or datetime.now()
row = self._session.get(StrategyModel, definition.id)
config_json = json.dumps(definition.model_dump(exclude={"id", "created_at"}), ensure_ascii=False)
if row is None:
self._session.add(
StrategyModel(
id=definition.id,
name=definition.name,
description=definition.description,
spec_type=definition.spec_type,
config_json=config_json,
version=definition.version,
created_at=now,
)
)
else:
row.name = definition.name
row.description = definition.description
row.spec_type = definition.spec_type
row.config_json = config_json
row.version = definition.version
self._session.flush()
return definition
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) -> 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[SelectionStrategy]:
rows = self._session.scalars(
select(StrategyModel).order_by(StrategyModel.name)
).all()
return [_to_entity(r) for r in rows]
def delete(self, strategy_id: str) -> bool:
row = self._session.get(StrategyModel, strategy_id)
if row is None:
return False
self._session.delete(row)
return True
# 旧 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() 会写入,
# 但这里若只从 config_json 里 pop 掉却不回填,读回的策略说明会恒为空串
# (读写不对称:保存的说明看不到,策略库/编辑页都拿不到)。
for key in ("name", "version", "description", "spec_type"):
data.pop(key, None)
# 丢弃旧行的回测参数字段(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,
)