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
+140
View File
@@ -0,0 +1,140 @@
"""回测组合 API:/api/combos(CRUD + 运行)。
POST /api/combos 保存组合(name 唯一)
GET /api/combos 列表
GET /api/combos/{id} 详情
PUT /api/combos/{id} 原地更新
DELETE /api/combos/{id} 删除
POST /api/combos/{id}/run 提交已保存组合为异步 Job(kind=combo)
POST /api/combos/run 提交临时组合(不保存)为异步 Job
运行时:按 combo.strategy_ids 取齐选股策略 + 读公共配置 → ComboService.run → 归档。
费率/复权来自公共配置,快照进归档 config_snapshot(可复现,AGENT.md §21)。
依赖注入说明:建 Job 与策略校验都走 FastAPI 注入的 session/repo(而非直接 SessionLocal),
这样测试用 dependency_overrides 替换数据库时也能命中同一份库,行为一致可测。
"""
from __future__ import annotations
from fastapi import APIRouter, BackgroundTasks, HTTPException
from app.api.deps import ComboRepoDep, DbSession, StrategyRepoDep
from app.application.services.job_executor import new_id, run_job_background
from app.domain.entities.combo import BacktestCombo
from app.domain.entities.research import JobRecord, JobStatus
from app.infrastructure.persistence.sqlalchemy.repositories.jobs_impl import (
SqlAlchemyJobRepository,
)
router = APIRouter(prefix="/combos", tags=["combos"])
def _ensure_strategies_exist(combo: BacktestCombo, strategy_repo) -> None:
"""引用的选股策略必须都存在;缺任何一个即 400(提前失败,不等后台 Job 才暴露)。"""
for sid in combo.strategy_ids:
if strategy_repo.get(sid) is None:
raise HTTPException(status_code=400, detail=f"组合引用的选股策略 {sid} 不存在")
@router.post("", response_model=BacktestCombo, summary="保存回测组合")
def create_combo(
combo: BacktestCombo,
repo: ComboRepoDep,
session: DbSession,
) -> BacktestCombo:
try:
saved = repo.save(combo.model_copy(update={"id": new_id("CMB")}))
session.commit()
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return repo.get(saved.id) or saved
@router.get("", response_model=list[BacktestCombo], summary="回测组合列表")
def list_combos(repo: ComboRepoDep) -> list[BacktestCombo]:
return repo.list()
@router.get("/{combo_id}", response_model=BacktestCombo, summary="读取回测组合")
def get_combo(combo_id: str, repo: ComboRepoDep) -> BacktestCombo:
row = repo.get(combo_id)
if row is None:
raise HTTPException(status_code=404, detail=f"回测组合 {combo_id} 不存在")
return row
@router.put("/{combo_id}", response_model=BacktestCombo, summary="原地更新回测组合")
def update_combo(
combo_id: str,
combo: BacktestCombo,
repo: ComboRepoDep,
session: DbSession,
) -> BacktestCombo:
existing = repo.get(combo_id)
if existing is None:
raise HTTPException(status_code=404, detail=f"回测组合 {combo_id} 不存在")
payload = combo.model_copy(update={"id": combo_id, "created_at": existing.created_at})
try:
saved = repo.save(payload)
session.commit()
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return repo.get(saved.id) or saved
@router.delete("/{combo_id}", summary="删除回测组合")
def delete_combo(combo_id: str, repo: ComboRepoDep, session: DbSession) -> dict:
if not repo.delete(combo_id):
raise HTTPException(status_code=404, detail=f"回测组合 {combo_id} 不存在")
session.commit()
return {"deleted": combo_id}
def _submit_combo_job(
combo: BacktestCombo,
*,
strategy_repo,
session,
background: BackgroundTasks,
) -> dict:
"""校验策略 → 建 Job(kind=combo)→ 入后台执行。
spec_json 存 BacktestCombo JSON;执行端(job_executor)识别 kind="combo",
再按 strategy_ids 取策略 + 读公共配置后调 ComboService。Job 表只存组合本身,
策略/配置的「当时快照」由 ComboService 写进归档 config_snapshot(可复现)。
校验与建 Job 都用注入的 session/repo,保证与测试覆写一致。
"""
_ensure_strategies_exist(combo, strategy_repo)
job = JobRecord(
id=new_id("JOB"), kind="combo", status=JobStatus.QUEUED,
spec_json=combo.model_dump_json(),
)
SqlAlchemyJobRepository(session).create(job)
session.commit()
background.add_task(run_job_background, job.id)
return {"job_id": job.id, "status": job.status}
@router.post("/{combo_id}/run", summary="运行已保存的回测组合(异步 Job)")
def run_saved_combo(
combo_id: str,
repo: ComboRepoDep,
strategy_repo: StrategyRepoDep,
session: DbSession,
background: BackgroundTasks,
) -> dict:
combo = repo.get(combo_id)
if combo is None:
raise HTTPException(status_code=404, detail=f"回测组合 {combo_id} 不存在")
return _submit_combo_job(combo, strategy_repo=strategy_repo, session=session, background=background)
@router.post("/run", summary="运行临时回测组合(不保存,异步 Job)")
def run_adhoc_combo(
combo: BacktestCombo,
strategy_repo: StrategyRepoDep,
session: DbSession,
background: BackgroundTasks,
) -> dict:
return _submit_combo_job(combo, strategy_repo=strategy_repo, session=session, background=background)