按用户目标把原来「一个策略 = 全套参数」拆开(已确认的设计决策):
- 公共配置 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)。
103 lines
4.2 KiB
Python
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,
|
|
)
|