Files
Simon 23972e7063 feat: 股息率案例口径 + 策略库与图表统一 + 回测存档完整化
汇总三轮未提交的开发(每轮均在本机 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 逐类验证归档页)。
2026-09-20 07:31:04 +08:00

166 lines
5.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""研究执行 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