feat(strategy): M8.3 策略模型 + /api/strategies(命名配置资产,可展开为 ResearchSpec)

- StrategyDefinition:universe/factors/selection/rebalance/costs/portfolio +
  price_adjustment(除 period 外完整策略定义);to_research_spec(period) 展开为标准 Spec
- strategy 表(migration e1f2a3b4c5d6,MySQL 已应用;name 唯一)+ StrategyRepository
- /api/strategies:POST/GET/DELETE + POST /{id}/expand(period+initial_capital → ResearchSpec)
- tests/test_strategies.py(repo CRUD/同名/expand、API CRUD/400/404);全量 pytest 通过
This commit is contained in:
Simon
2026-09-09 00:38:09 +08:00
parent 692bdb3be5
commit 9d25d466e5
10 changed files with 436 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
"""策略 API(M8.3):/api/strategies CRUD + 展开为 ResearchSpec。
POST /api/strategies 保存策略(name 唯一)
GET /api/strategies 列表
GET /api/strategies/{id}
DELETE /api/strategies/{id}
POST /api/strategies/{id}/expand body: {period:[start,end], initial_capital?} → ResearchSpec
"""
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
router = APIRouter(prefix="/strategies", tags=["strategies"])
class ExpandRequest(BaseModel):
period: tuple[date, date]
initial_capital: float = Field(default=1_000_000.0, gt=0)
@router.post("", response_model=StrategyDefinition, summary="保存策略")
def create_strategy(
definition: StrategyDefinition,
strategy_repo: StrategyRepoDep,
session: DbSession,
) -> StrategyDefinition:
try:
saved = strategy_repo.save(definition.model_copy(update={"id": new_id("STG")}))
session.commit()
return saved
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("", response_model=list[StrategyDefinition], summary="策略列表")
def list_strategies(strategy_repo: StrategyRepoDep) -> list[StrategyDefinition]:
return strategy_repo.list()
@router.get("/{strategy_id}", response_model=StrategyDefinition, summary="读取策略")
def get_strategy(strategy_id: str, strategy_repo: StrategyRepoDep) -> StrategyDefinition:
row = strategy_repo.get(strategy_id)
if row is None:
raise HTTPException(status_code=404, detail=f"策略 {strategy_id} 不存在")
return row
@router.delete("/{strategy_id}", summary="删除策略")
def delete_strategy(
strategy_id: str,
strategy_repo: StrategyRepoDep,
session: DbSession,
) -> dict:
if not strategy_repo.delete(strategy_id):
raise HTTPException(status_code=404, detail=f"策略 {strategy_id} 不存在")
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)