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:
@@ -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)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""公共配置 API:/api/config(全局唯一一份费率/滑点/复权口径)。
|
||||
|
||||
GET /api/config 读取(未配置过返回带默认值的实例)
|
||||
PUT /api/config 更新(upsert 单例)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.deps import DbSession, GlobalConfigRepoDep
|
||||
from app.domain.entities.combo import GlobalConfig
|
||||
|
||||
router = APIRouter(prefix="/config", tags=["config"])
|
||||
|
||||
|
||||
@router.get("", response_model=GlobalConfig, summary="读取公共配置")
|
||||
def get_config(repo: GlobalConfigRepoDep) -> GlobalConfig:
|
||||
return repo.get()
|
||||
|
||||
|
||||
@router.put("", response_model=GlobalConfig, summary="更新公共配置")
|
||||
def update_config(
|
||||
config: GlobalConfig,
|
||||
repo: GlobalConfigRepoDep,
|
||||
session: DbSession,
|
||||
) -> GlobalConfig:
|
||||
saved = repo.save(config)
|
||||
session.commit()
|
||||
return saved
|
||||
@@ -14,6 +14,7 @@ from app.application.services.chart_service import ChartService
|
||||
from app.application.services.replay_service import ReplayService
|
||||
from app.application.services.selection_service import SelectionService
|
||||
from app.application.services.signal_service import SignalService
|
||||
from app.domain.repositories.combo import ComboRepository, GlobalConfigRepository
|
||||
from app.domain.repositories.composite import CompositeRepository
|
||||
from app.domain.repositories.factor import FactorRepository
|
||||
from app.domain.repositories.index import IndexConstituentRepository
|
||||
@@ -29,6 +30,10 @@ from app.domain.repositories.market import (
|
||||
from app.domain.repositories.selection import SelectionRepository
|
||||
from app.domain.repositories.signal import SignalRepository
|
||||
from app.domain.repositories.strategy import StrategyRepository
|
||||
from app.infrastructure.persistence.sqlalchemy.repositories.combo_impl import (
|
||||
SqlAlchemyComboRepository,
|
||||
SqlAlchemyGlobalConfigRepository,
|
||||
)
|
||||
from app.infrastructure.persistence.sqlalchemy.repositories.composite_impl import (
|
||||
SqlAlchemyCompositeRepository,
|
||||
)
|
||||
@@ -181,6 +186,14 @@ def _strategy_repo_factory(session: DbSession) -> StrategyRepository:
|
||||
return SqlAlchemyStrategyRepository(session)
|
||||
|
||||
|
||||
def _global_config_repo_factory(session: DbSession) -> GlobalConfigRepository:
|
||||
return SqlAlchemyGlobalConfigRepository(session)
|
||||
|
||||
|
||||
def _combo_repo_factory(session: DbSession) -> ComboRepository:
|
||||
return SqlAlchemyComboRepository(session)
|
||||
|
||||
|
||||
StockRepoDep = Annotated[StockRepository, Depends(_stock_repo_factory)]
|
||||
DailyRepoDep = Annotated[DailyBarRepository, Depends(_daily_repo_factory)]
|
||||
EngineDep = Annotated[QuantEngine, Depends(_engine_factory)]
|
||||
@@ -194,6 +207,8 @@ SignalServiceDep = Annotated[SignalService, Depends(_signal_service_factory)]
|
||||
ReplayServiceDep = Annotated[ReplayService, Depends(_replay_service_factory)]
|
||||
ChartServiceDep = Annotated[ChartService, Depends(_chart_service_factory)]
|
||||
StrategyRepoDep = Annotated[StrategyRepository, Depends(_strategy_repo_factory)]
|
||||
GlobalConfigRepoDep = Annotated[GlobalConfigRepository, Depends(_global_config_repo_factory)]
|
||||
ComboRepoDep = Annotated[ComboRepository, Depends(_combo_repo_factory)]
|
||||
|
||||
|
||||
def _job_repo_factory(session: DbSession):
|
||||
|
||||
@@ -11,7 +11,9 @@ from fastapi import APIRouter
|
||||
from app.api import (
|
||||
agent,
|
||||
charts,
|
||||
combos,
|
||||
composites,
|
||||
config,
|
||||
experiments,
|
||||
factors,
|
||||
health,
|
||||
@@ -35,6 +37,8 @@ api_router.include_router(charts.router)
|
||||
api_router.include_router(replays.router)
|
||||
api_router.include_router(signals.router)
|
||||
api_router.include_router(strategies.router)
|
||||
api_router.include_router(config.router)
|
||||
api_router.include_router(combos.router)
|
||||
api_router.include_router(jobs.router)
|
||||
api_router.include_router(experiments.router)
|
||||
api_router.include_router(agent.router)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"""策略 API(M8.3):/api/strategies CRUD + 展开为 ResearchSpec + 说明/公式生成。
|
||||
"""选股策略 API:/api/strategies CRUD + 说明/公式生成。
|
||||
|
||||
2026-09 重构:策略库只存「选股条件组合」(股票池+因子+条件),不再持有回测执行参数;
|
||||
回测改由「回测组合」(/api/combos)驱动,故旧的 /{id}/expand(→ResearchSpec)已移除。
|
||||
|
||||
POST /api/strategies 保存策略(name 唯一;description 为空时自动补全)
|
||||
POST /api/strategies/describe body: ResearchSpec → StrategyDoc(未保存的策略也能预览)
|
||||
@@ -6,7 +9,6 @@ GET /api/strategies 列表
|
||||
GET /api/strategies/{id}
|
||||
PUT /api/strategies/{id} 原地更新(不新建、不刷新 created_at)
|
||||
DELETE /api/strategies/{id}
|
||||
POST /api/strategies/{id}/expand body: {period:[start,end], initial_capital?} → ResearchSpec
|
||||
GET /api/strategies/{id}/describe → StrategyDoc
|
||||
|
||||
路由顺序注意:`/describe` 这类**字面量路径**一律声明在 `/{strategy_id}` 之前 ——
|
||||
@@ -15,24 +17,17 @@ GET /api/strategies/{id}/describe → StrategyDoc
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.deps import DbSession, StrategyRepoDep
|
||||
from app.application.services.job_executor import new_id
|
||||
from app.domain.entities.research import ResearchSpec
|
||||
from app.domain.entities.strategy import StrategyDefinition
|
||||
from app.domain.entities.strategy import SelectionStrategy
|
||||
from app.quant.strategy_doc import StrategyDoc, describe_strategy
|
||||
|
||||
router = APIRouter(prefix="/strategies", tags=["strategies"])
|
||||
|
||||
|
||||
class ExpandRequest(BaseModel):
|
||||
period: tuple[date, date]
|
||||
initial_capital: float = Field(default=1_000_000.0, gt=0)
|
||||
|
||||
|
||||
# strategy.description 列宽(StrategyModel.description = String(300))。
|
||||
# 自动补全的说明必须落在列宽内,否则 MySQL 严格模式会直接报 Data too long(SQLite 不拦,
|
||||
@@ -41,7 +36,7 @@ class ExpandRequest(BaseModel):
|
||||
_DESCRIPTION_MAX_CHARS = 300
|
||||
|
||||
|
||||
def _ensure_description(definition: StrategyDefinition) -> StrategyDefinition:
|
||||
def _ensure_description(definition: SelectionStrategy) -> SelectionStrategy:
|
||||
"""说明为空/纯空白时,用 `describe_strategy(...).summary` 补全(需求:策略必须有说明)。
|
||||
|
||||
说明由 spec **真实推导**(AGENT.md §24:不许编造),只在空值时补、不覆盖显式说明。
|
||||
@@ -56,12 +51,12 @@ def _ensure_description(definition: StrategyDefinition) -> StrategyDefinition:
|
||||
return definition.model_copy(update={"description": summary})
|
||||
|
||||
|
||||
@router.post("", response_model=StrategyDefinition, summary="保存策略")
|
||||
@router.post("", response_model=SelectionStrategy, summary="保存选股策略")
|
||||
def create_strategy(
|
||||
definition: StrategyDefinition,
|
||||
definition: SelectionStrategy,
|
||||
strategy_repo: StrategyRepoDep,
|
||||
session: DbSession,
|
||||
) -> StrategyDefinition:
|
||||
) -> SelectionStrategy:
|
||||
try:
|
||||
saved = strategy_repo.save(
|
||||
_ensure_description(definition).model_copy(update={"id": new_id("STG")})
|
||||
@@ -84,13 +79,13 @@ def describe_research_spec(spec: ResearchSpec) -> StrategyDoc:
|
||||
return describe_strategy(spec)
|
||||
|
||||
|
||||
@router.get("", response_model=list[StrategyDefinition], summary="策略列表")
|
||||
def list_strategies(strategy_repo: StrategyRepoDep) -> list[StrategyDefinition]:
|
||||
@router.get("", response_model=list[SelectionStrategy], summary="选股策略列表")
|
||||
def list_strategies(strategy_repo: StrategyRepoDep) -> list[SelectionStrategy]:
|
||||
return strategy_repo.list()
|
||||
|
||||
|
||||
@router.get("/{strategy_id}", response_model=StrategyDefinition, summary="读取策略")
|
||||
def get_strategy(strategy_id: str, strategy_repo: StrategyRepoDep) -> StrategyDefinition:
|
||||
@router.get("/{strategy_id}", response_model=SelectionStrategy, summary="读取选股策略")
|
||||
def get_strategy(strategy_id: str, strategy_repo: StrategyRepoDep) -> SelectionStrategy:
|
||||
row = strategy_repo.get(strategy_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail=f"策略 {strategy_id} 不存在")
|
||||
@@ -104,7 +99,6 @@ def describe_saved_strategy(
|
||||
"""已保存策略的说明/公式(404 语义与 GET /{strategy_id} 一致)。
|
||||
|
||||
策略定义不含回测区间,说明里的区间为占位文本(`warnings` 中已如实标注),
|
||||
展开回测后(/expand)再用该 ResearchSpec 调 `POST /describe` 即为确定区间的版本。
|
||||
"""
|
||||
row = strategy_repo.get(strategy_id)
|
||||
if row is None:
|
||||
@@ -112,13 +106,13 @@ def describe_saved_strategy(
|
||||
return describe_strategy(row)
|
||||
|
||||
|
||||
@router.put("/{strategy_id}", response_model=StrategyDefinition, summary="原地更新策略")
|
||||
@router.put("/{strategy_id}", response_model=SelectionStrategy, summary="原地更新选股策略")
|
||||
def update_strategy(
|
||||
strategy_id: str,
|
||||
definition: StrategyDefinition,
|
||||
definition: SelectionStrategy,
|
||||
strategy_repo: StrategyRepoDep,
|
||||
session: DbSession,
|
||||
) -> StrategyDefinition:
|
||||
) -> SelectionStrategy:
|
||||
"""原地更新(策略库「编辑」用):id 以**路径**为准,created_at 沿用库中已有值。
|
||||
|
||||
为什么必须显式带上 created_at:仓储 `save()` 只在 created_at 为空时才写 now()
|
||||
@@ -152,16 +146,3 @@ def delete_strategy(
|
||||
session.commit()
|
||||
return {"deleted": strategy_id}
|
||||
|
||||
|
||||
@router.post("/{strategy_id}/expand", response_model=ResearchSpec, summary="展开为研究 Spec")
|
||||
def expand_strategy(
|
||||
strategy_id: str,
|
||||
req: ExpandRequest,
|
||||
strategy_repo: StrategyRepoDep,
|
||||
) -> ResearchSpec:
|
||||
row = strategy_repo.get(strategy_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail=f"策略 {strategy_id} 不存在")
|
||||
if req.period[0] >= req.period[1]:
|
||||
raise HTTPException(status_code=400, detail="period 必须满足 start < end")
|
||||
return row.to_research_spec(period=req.period, initial_capital=req.initial_capital)
|
||||
|
||||
Reference in New Issue
Block a user