汇总三轮未提交的开发(每轮均在本机 MariaDB + 真实浏览器上验证):
1) 股息率案例(全市场股息率最高 n 只,默认 20,每 m 月择股)
- 新增日频估值表 daily_basic + 迁移;股息率因子(dv_ratio / dividend_yield / TTM)
- 名称历史表 stock_name_history:剔除 ST 按**择股日当时名称**判定,消除
「曾高股息后 ST」的股息陷阱(实测 3.70pp 偏差)
- 区间择股/调仓双周期(m 择股 / y 调仓)、指数成分与白名单、停牌近似剔除
- 复权因子口径核对(4,164,742 行、缺失 0.0%)、收盘价成交与涨跌停拦单
- 案例实测:2020-01-01~2026-09-04 总收益 +24.86%(年化 3.52%、回撤 -28.58%)
2) 策略库与前端统一
- strategy 表 + CRUD/PUT 原地更新 + `describe_strategy` 按 spec 真实推导
「一句话说明 + 计算公式 + 执行步骤 + 注意事项」(与引擎实执行规则同源)
- 任何出现股票代码处都成对显示名称且可点击进个股页
- 全站图表基座统一 TradingView Lightweight Charts(ECharts 依赖、
锁文件、组件与文档标注一并清除),买卖点标记只落在真实交易日上
3) 回测存档完整化(可往复查看)
- 同步端点(POST /api/backtests、/api/factor-tests)此前完全不落库 → 现在同样归档,
归档 id 经响应头 X-Experiment-Id 返回(不破坏 response_model)
- data_version 首次真实写入(数据快照指纹:最新交易日 + 各表规模)
- 个股收益曲线默认**全量保存**(此前硬截断 60 只);超出体积预算才裁剪,
并写 archive_meta(机器可读)+ unimplemented(人可读)如实标注
- 列表 kind/q 过滤 + X-Total-Count(此前 limit=50 静默截断)、DELETE 归档
- 只读归档页 /experiments/{id}(Server Component,SSR 直出**选股条件**与
**交易执行依据**);结果视图按 kind 分发(backtest/factor_test/selection),
非回测归档不套用回测口径
- 新增 CLI:prune_experiments(保留策略,默认 dry-run)、
restore_experiment_from_job(从 Job 副本按原 id 重建被删的历史归档,默认 dry-run)
门禁:pytest 388 passed、ruff All checks passed、tsc 0 错误、图表单测 7 passed、
next build 成功、契约脚本 verify_strategy_workspace 59/59(含按 kind 逐类验证归档页)。
166 lines
5.8 KiB
Python
166 lines
5.8 KiB
Python
"""研究执行 API:/api/factor-tests 与 /api/backtests。
|
||
|
||
Phase 3 为同步执行(样本有限);Phase 4 将改为 Job + SSE 异步(接口契约不变)。
|
||
|
||
**归档(2026-09 补齐)**:同步端点此前只把结果塞进进程内存 `_LAST_*`,重启即丢,
|
||
完全不落库 —— 与「研究可复现」(AGENT.md §21)矛盾。现改为:返回结果前调用
|
||
`archive_experiment` 落库(复用与异步 Job 完全相同的归档实现),并通过响应头
|
||
`X-Experiment-Id` 暴露归档 id。body 形状保持不变(前端与既有测试依赖它)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from datetime import date
|
||
|
||
from fastapi import APIRouter, HTTPException, Response
|
||
from pydantic import BaseModel, Field
|
||
|
||
from app.api.deps import DbSession, ExperimentRepoDep, ResearchServiceDep
|
||
from app.application.services.experiment_archive import archive_experiment
|
||
from app.domain.entities.research import (
|
||
BacktestResult,
|
||
FactorCorrelationReport,
|
||
FactorSpec,
|
||
FactorTestReport,
|
||
ResearchSpec,
|
||
SelectionSpec,
|
||
UniverseSpec,
|
||
)
|
||
from app.quant.factors import FactorError
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(tags=["research"])
|
||
|
||
# 内存中的最近结果(仅作「最近一次」便捷读取;持久化归档见 archive_experiment)
|
||
_LAST_BACKTEST: dict[str, BacktestResult] = {}
|
||
_LAST_FACTOR_TEST: dict[str, FactorTestReport] = {}
|
||
|
||
|
||
def _archive_or_expose(
|
||
response: Response,
|
||
*,
|
||
session,
|
||
kind: str,
|
||
spec_json: str,
|
||
result,
|
||
experiment_repo,
|
||
) -> None:
|
||
"""归档同步端点的计算结果,并把归档结果如实反映到响应头。
|
||
|
||
取舍(AGENT.md §7「不静默」+ §24):研究结果是用户真实等待数十秒得到的产出,
|
||
归档是副产物。若数据库故障导致归档失败,直接抛 500 会把**已经算出来的可用结果**
|
||
一并丢掉;故此处捕获异常、接口仍然 200 返回完整结果,同时:
|
||
- `logger.warning` 落盘(服务端可观测);
|
||
- 响应头 `X-Archive-Error: <Type>: <msg>` 如实暴露失败原因(客户端可判读)。
|
||
即「结果不丢 + 失败不静默」两者兼顾;成功时给 `X-Experiment-Id`。
|
||
"""
|
||
try:
|
||
experiment = archive_experiment(
|
||
session=session,
|
||
kind=kind,
|
||
spec_json=spec_json,
|
||
result=result,
|
||
experiment_repo=experiment_repo,
|
||
)
|
||
except Exception as exc: # noqa: BLE001 —— 归档失败不得吞掉计算结果
|
||
logger.warning("同步端点归档失败(kind=%s):%s: %s", kind, type(exc).__name__, exc)
|
||
# HTTP 头只能承载 latin-1:中文错误信息降级为 ASCII 转义,避免编码异常掩盖原因
|
||
reason = f"{type(exc).__name__}: {exc}"[:180]
|
||
response.headers["X-Archive-Error"] = reason.encode("ascii", "backslashreplace").decode(
|
||
"ascii"
|
||
)
|
||
return
|
||
response.headers["X-Experiment-Id"] = experiment.id
|
||
|
||
|
||
@router.post("/factor-tests", response_model=FactorTestReport, summary="运行单因子测试(同步)")
|
||
def run_factor_test(
|
||
spec: ResearchSpec,
|
||
service: ResearchServiceDep,
|
||
session: DbSession,
|
||
experiment_repo: ExperimentRepoDep,
|
||
response: Response,
|
||
) -> 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
|
||
_archive_or_expose(
|
||
response,
|
||
session=session,
|
||
kind="factor_test",
|
||
spec_json=spec.model_dump_json(),
|
||
result=report,
|
||
experiment_repo=experiment_repo,
|
||
)
|
||
return report
|
||
|
||
|
||
@router.post("/backtests", response_model=BacktestResult, summary="运行回测(同步)")
|
||
def run_backtest(
|
||
spec: ResearchSpec,
|
||
service: ResearchServiceDep,
|
||
session: DbSession,
|
||
experiment_repo: ExperimentRepoDep,
|
||
response: Response,
|
||
) -> 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
|
||
_archive_or_expose(
|
||
response,
|
||
session=session,
|
||
kind="backtest",
|
||
spec_json=spec.model_dump_json(),
|
||
result=result,
|
||
experiment_repo=experiment_repo,
|
||
)
|
||
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
|