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
@@ -76,10 +76,42 @@ def _execute_inner(
session.commit()
try:
is_selection = job.kind == "selection"
is_combo = job.kind == "combo"
basic_repo = basic_repo_factory(session) if basic_repo_factory else None
index_repo = index_repo_factory(session) if index_repo_factory else None
name_repo = name_repo_factory(session) if name_repo_factory else None
if is_selection:
if is_combo:
# 回测组合:解析 combo + 取齐选股策略 + 读公共配置 → ComboService.run
from app.application.services.combo_service import ComboService
from app.domain.entities.combo import BacktestCombo
from app.infrastructure.persistence.sqlalchemy.repositories.combo_impl import (
SqlAlchemyGlobalConfigRepository,
)
from app.infrastructure.persistence.sqlalchemy.repositories.strategy_impl import (
SqlAlchemyStrategyRepository,
)
combo = BacktestCombo.model_validate_json(job.spec_json)
strategy_repo = SqlAlchemyStrategyRepository(session)
strategies = []
for sid in combo.strategy_ids:
st = strategy_repo.get(sid)
if st is None:
raise ValueError(f"组合引用的选股策略 {sid} 不存在(可能已被删除)")
strategies.append(st)
config = SqlAlchemyGlobalConfigRepository(session).get()
service = ComboService(
stock_repo_factory(session),
daily_repo_factory(session),
index_repo=index_repo,
basic_repo=basic_repo,
financial_repo=(
financial_repo_factory(session) if financial_repo_factory else None
),
name_repo=name_repo,
)
spec = combo # 仅用于下方分支判断占位;实际执行用 combo
elif is_selection:
from app.application.services.selection_service import SelectionService
from app.domain.entities.selection import SelectionQuery
@@ -118,7 +150,9 @@ def _execute_inner(
except Exception: # noqa: BLE001 —— 阶段上报失败不阻断执行
pass
if is_selection:
if is_combo:
result = service.run(spec, strategies, config, on_stage=_set_stage)
elif is_selection:
_set_stage("selection")
result = service.select(spec)
elif spec.type == "backtest":
@@ -126,9 +160,12 @@ def _execute_inner(
else:
result = service.run_factor_test(spec, on_stage=_set_stage)
# combo 的结果是 BacktestResult,归档 kind 记为 "backtest" 以便前端按回测渲染;
# spec_json 仍存原始 combo(含 strategy_ids),可复现快照在 result.config_snapshot。
archive_kind = "backtest" if is_combo else job.kind
experiment = archive_experiment(
session=session,
kind=job.kind,
kind=archive_kind,
spec_json=job.spec_json,
result=result,
job_id=job.id,