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:
@@ -7,6 +7,10 @@
|
||||
from app.infrastructure.persistence.sqlalchemy.models.composite import ( # noqa: F401
|
||||
FactorCompositeModel,
|
||||
)
|
||||
from app.infrastructure.persistence.sqlalchemy.models.combo import ( # noqa: F401
|
||||
BacktestComboModel,
|
||||
GlobalConfigModel,
|
||||
)
|
||||
from app.infrastructure.persistence.sqlalchemy.models.factor import ( # noqa: F401
|
||||
FactorDefinitionModel,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""公共配置 + 回测组合表(2026-09 重构)。
|
||||
|
||||
- `global_config`:全局唯一一行(id="default"),存费率/滑点/最低佣金/复权口径/基准。
|
||||
- `backtest_combo`:回测组合,引用若干选股策略(strategy_ids JSON)+ 回测参数
|
||||
(资金/持仓数/持仓天数区间/调仓时机/区间)。费率与复权不在此表 —— 运行时从
|
||||
global_config 快照进归档的 config_snapshot,保证可复现。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, Integer, Numeric, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.infrastructure.persistence.sqlalchemy.base import Base
|
||||
|
||||
|
||||
class GlobalConfigModel(Base):
|
||||
__tablename__ = "global_config"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True) # 恒为 "default"
|
||||
commission_rate: Mapped[float] = mapped_column(Numeric(10, 6), default=0.0003)
|
||||
stamp_tax_rate: Mapped[float] = mapped_column(Numeric(10, 6), default=0.0005)
|
||||
slippage_rate: Mapped[float] = mapped_column(Numeric(10, 6), default=0.001)
|
||||
min_commission: Mapped[float] = mapped_column(Numeric(10, 4), default=5.0)
|
||||
price_adjustment: Mapped[str] = mapped_column(String(8), default="hfq")
|
||||
benchmark: Mapped[str] = mapped_column(String(16), default="000300.SH")
|
||||
updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class BacktestComboModel(Base):
|
||||
__tablename__ = "backtest_combo"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(64), unique=True)
|
||||
description: Mapped[str] = mapped_column(String(300), default="")
|
||||
strategy_ids_json: Mapped[str] = mapped_column(Text) # JSON list[str]
|
||||
initial_capital: Mapped[float] = mapped_column(Numeric(20, 2), default=1_000_000.0)
|
||||
hold_count: Mapped[int] = mapped_column(Integer, default=20)
|
||||
hold_min_days: Mapped[int] = mapped_column(Integer, default=0)
|
||||
hold_max_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
rebalance_freq: Mapped[str] = mapped_column(String(12), default="monthly")
|
||||
start_date: Mapped[date] = mapped_column(Date)
|
||||
end_date: Mapped[date] = mapped_column(Date)
|
||||
version: Mapped[str] = mapped_column(String(16), default="1")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime)
|
||||
@@ -0,0 +1,135 @@
|
||||
"""回测组合 + 公共配置 Repository 的 SQLAlchemy 实现(2026-09 重构)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.domain.entities.combo import BacktestCombo, GlobalConfig
|
||||
from app.infrastructure.persistence.sqlalchemy.models.combo import (
|
||||
BacktestComboModel,
|
||||
GlobalConfigModel,
|
||||
)
|
||||
|
||||
DEFAULT_CONFIG_ID = "default"
|
||||
|
||||
|
||||
class SqlAlchemyGlobalConfigRepository:
|
||||
def __init__(self, session: Session) -> None:
|
||||
self._session = session
|
||||
|
||||
def get(self) -> GlobalConfig:
|
||||
row = self._session.get(GlobalConfigModel, DEFAULT_CONFIG_ID)
|
||||
if row is None:
|
||||
return GlobalConfig() # 未配置过 → 返回默认值(不写库,由调用方决定是否 save)
|
||||
return GlobalConfig(
|
||||
id=row.id,
|
||||
commission_rate=float(row.commission_rate),
|
||||
stamp_tax_rate=float(row.stamp_tax_rate),
|
||||
slippage_rate=float(row.slippage_rate),
|
||||
min_commission=float(row.min_commission),
|
||||
price_adjustment=row.price_adjustment,
|
||||
benchmark=row.benchmark,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
def save(self, config: GlobalConfig) -> GlobalConfig:
|
||||
row = self._session.get(GlobalConfigModel, DEFAULT_CONFIG_ID)
|
||||
if row is None:
|
||||
row = GlobalConfigModel(id=DEFAULT_CONFIG_ID)
|
||||
self._session.add(row)
|
||||
row.commission_rate = config.commission_rate
|
||||
row.stamp_tax_rate = config.stamp_tax_rate
|
||||
row.slippage_rate = config.slippage_rate
|
||||
row.min_commission = config.min_commission
|
||||
row.price_adjustment = config.price_adjustment
|
||||
row.benchmark = config.benchmark
|
||||
row.updated_at = datetime.now()
|
||||
self._session.flush()
|
||||
return config.model_copy(update={"id": DEFAULT_CONFIG_ID, "updated_at": row.updated_at})
|
||||
|
||||
|
||||
class SqlAlchemyComboRepository:
|
||||
def __init__(self, session: Session) -> None:
|
||||
self._session = session
|
||||
|
||||
def save(self, combo: BacktestCombo) -> BacktestCombo:
|
||||
if not combo.id:
|
||||
raise ValueError("需要 id(由调用方生成)")
|
||||
dup = self._session.scalar(
|
||||
select(BacktestComboModel).where(BacktestComboModel.name == combo.name).limit(1)
|
||||
)
|
||||
if dup is not None and dup.id != combo.id:
|
||||
raise ValueError(f"回测组合名已存在:{combo.name}")
|
||||
now = combo.created_at or datetime.now()
|
||||
row = self._session.get(BacktestComboModel, combo.id)
|
||||
strategy_ids_json = json.dumps(combo.strategy_ids, ensure_ascii=False)
|
||||
if row is None:
|
||||
self._session.add(
|
||||
BacktestComboModel(
|
||||
id=combo.id,
|
||||
name=combo.name,
|
||||
description=combo.description,
|
||||
strategy_ids_json=strategy_ids_json,
|
||||
initial_capital=combo.initial_capital,
|
||||
hold_count=combo.hold_count,
|
||||
hold_min_days=combo.hold_min_days,
|
||||
hold_max_days=combo.hold_max_days,
|
||||
rebalance_freq=combo.rebalance_freq,
|
||||
start_date=combo.period[0],
|
||||
end_date=combo.period[1],
|
||||
version=combo.version,
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
else:
|
||||
row.name = combo.name
|
||||
row.description = combo.description
|
||||
row.strategy_ids_json = strategy_ids_json
|
||||
row.initial_capital = combo.initial_capital
|
||||
row.hold_count = combo.hold_count
|
||||
row.hold_min_days = combo.hold_min_days
|
||||
row.hold_max_days = combo.hold_max_days
|
||||
row.rebalance_freq = combo.rebalance_freq
|
||||
row.start_date = combo.period[0]
|
||||
row.end_date = combo.period[1]
|
||||
row.version = combo.version
|
||||
self._session.flush()
|
||||
return combo
|
||||
|
||||
def get(self, combo_id: str) -> BacktestCombo | None:
|
||||
row = self._session.get(BacktestComboModel, combo_id)
|
||||
return _to_combo(row) if row else None
|
||||
|
||||
def list(self) -> list[BacktestCombo]:
|
||||
rows = self._session.scalars(
|
||||
select(BacktestComboModel).order_by(BacktestComboModel.created_at.desc())
|
||||
).all()
|
||||
return [_to_combo(r) for r in rows]
|
||||
|
||||
def delete(self, combo_id: str) -> bool:
|
||||
row = self._session.get(BacktestComboModel, combo_id)
|
||||
if row is None:
|
||||
return False
|
||||
self._session.delete(row)
|
||||
return True
|
||||
|
||||
|
||||
def _to_combo(row: BacktestComboModel) -> BacktestCombo:
|
||||
return BacktestCombo(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
description=row.description,
|
||||
strategy_ids=json.loads(row.strategy_ids_json),
|
||||
initial_capital=float(row.initial_capital),
|
||||
hold_count=row.hold_count,
|
||||
hold_min_days=row.hold_min_days,
|
||||
hold_max_days=row.hold_max_days,
|
||||
rebalance_freq=row.rebalance_freq,
|
||||
period=(row.start_date, row.end_date),
|
||||
version=row.version,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user