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