字段库(本次新增的表与接口): - `condition_field` 表 + `/api/condition-fields`:中文名/说明可编辑、可停用; `kind`/单位阶梯/`base_unit` 由代码注册表收敛(改类型 422,伪字段 422, 越界单位 422),停用的字段不再进条件下拉,但既有策略仍按名字解析。 - 说明书里的数值条件按字段注册表补**基准单位**后缀(字段间比较不加,不猜单位)。 因子参数化(键即身份,冻结口径): - 模板 + 参数注册表(`quant/factors.py`):`ParamSpec`(类型/范围/枚举/默认值/说明)+ `FactorTemplate`(公式/依赖列/参数);规范键把**全部**参数写进名字,如 `momentum(window=90,direction=lower_is_better)`,所以改参数 = 新建一个身份, 旧因子/既有策略/已归档实验都不变义;`momentum(window=90)`(缺参数)明确拒绝 —— 缺项要靠模板默认值补齐,而默认值是可改的代码细节,一旦改动会追溯性改义。 - 参数只在受控范围内取值(窗口 2~500、方向二选一),越界/未知模板/多给参数一律 422 并列出允许范围,不静默截断、不悄悄取默认值;内置实例的启用开关由代码决定(422)。 - `/api/factors` 暴露 `template`/`params`/`param_specs`/`label`/`source`/`enabled`/ `resolvable`;新增 `/api/factors/templates`、`POST /api/factors`、`PATCH /api/factors`; `get_factor = resolve_factor` 兼容全部旧调用点,参数化键也是一等条件字段。 - 迁移链:c5d6(存量策略陈旧说明重算)→ d6e7(condition_field)→ a7c1 (factor_definition.enabled + name varchar(128))。 测试:新增 test_condition_fields.py / test_factor_params.py;全量 pytest 500 passed。
143 lines
5.4 KiB
Python
143 lines
5.4 KiB
Python
"""回测组合 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 datetime import datetime
|
||
|
||
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(), created_at=datetime.now(),
|
||
)
|
||
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)
|