"""研究执行 API:/api/factor-tests 与 /api/backtests。 Phase 3 为同步执行(样本有限);Phase 4 将改为 Job + SSE 异步(接口契约不变)。 最近一次结果在内存中可读,便于前端展示;持久化实验归档在 Phase 4。 """ from __future__ import annotations from datetime import date from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field from app.api.deps import ResearchServiceDep from app.domain.entities.research import ( BacktestResult, FactorCorrelationReport, FactorSpec, FactorTestReport, ResearchSpec, SelectionSpec, UniverseSpec, ) from app.quant.factors import FactorError router = APIRouter(tags=["research"]) # 内存中的最近结果(Phase 4 迁移到 Experiment 表) _LAST_BACKTEST: dict[str, BacktestResult] = {} _LAST_FACTOR_TEST: dict[str, FactorTestReport] = {} @router.post("/factor-tests", response_model=FactorTestReport, summary="运行单因子测试(同步)") def run_factor_test( spec: ResearchSpec, service: ResearchServiceDep, ) -> FactorTestReport: try: report = service.run_factor_test(spec) except (ValueError, FactorError) as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc _LAST_FACTOR_TEST["default"] = report return report @router.post("/backtests", response_model=BacktestResult, summary="运行回测(同步)") def run_backtest( spec: ResearchSpec, service: ResearchServiceDep, ) -> BacktestResult: try: result = service.run_backtest(spec) except (ValueError, FactorError) as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc _LAST_BACKTEST["default"] = result return result @router.get("/backtests/last", response_model=BacktestResult, summary="最近一次回测结果") def last_backtest() -> BacktestResult: if "default" not in _LAST_BACKTEST: raise HTTPException(status_code=404, detail="尚无回测结果,请先 POST /api/backtests") return _LAST_BACKTEST["default"] class FactorCorrelationRequest(BaseModel): """因子相关性分析请求(v3 §12)。""" universe: UniverseSpec = UniverseSpec() factors: list[FactorSpec] = Field(min_length=2) period: tuple[date, date] price_adjustment: str = Field(default="none", pattern="^(none|qfq)$") @router.post( "/factor-correlations", response_model=FactorCorrelationReport, summary="多因子两两相关(横截面 Spearman)", ) def run_factor_correlation( req: FactorCorrelationRequest, service: ResearchServiceDep, ) -> FactorCorrelationReport: if req.period[0] >= req.period[1]: raise HTTPException(status_code=400, detail="period 必须满足 start < end") spec = ResearchSpec( type="factor_test", universe=req.universe, price_adjustment=req.price_adjustment, factors=req.factors, selection=SelectionSpec(top_n=10), rebalance="monthly", period=req.period, ) try: return service.run_factor_correlation(spec) except (ValueError, FactorError) as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc