mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
在既有挖掘链路之上补齐「发布后持续验证与持续组合」闭环
(设计: docs/auto-strategy-lab-plan.md):
- P1 策略守护: 挖掘发布自动落 mining_gate 基线 (天然 OOS); 每日收盘
数据更新后在新数据上切 K 段真实回测复验, 按稳健分对比基线判定
健康/观察/退役三态 (段回测失败只降级观察不退役; 新数据 <30 交易日
跳过本轮; retired 不再自动复验)。任意策略可手动入护 (尾随参考基线,
判定只针对入护后新数据)。回测页稳健性验证新增「策略守护」子页。
- P2a 稳健选冠: 冠军排序分从外层折 Sharpe 均值改为 0.6×中位+0.4×最差
(复用 robust_score); artifact 新增可选列 oos_worst_sharpe (v2 旧档
兼容读取); strict 档发布额外要求最差折 Sharpe>0; 算法版本升
mining-v3 并进数据指纹, 防止升级后 claim 幂等复用旧口径结果。
- P2b 权重精调: 冠军组合在 {0.5,1.0,1.5,2.0} 比率网格 (首因子固定
1.0, 截面分位打分天然尺度不变) 两段式精调 — 外层测试窗选优 + 全窗
复验退化>20% 放弃; 消耗 run 剩余预算 (不足自动跳过); 产物为独立
签名候选行, run summary 带 refinement 摘要。
- P2d 组合建议: 守护运行时扫描健康冠军间近 126 个交易日日收益 Pearson
相关性 (≥30 共同交易日, ≤0.7 入选); 前端人工确认后生成等权
composite 策略 (fail-closed 建议对校验 + blake2b 幂等) 并自动以
尾随参考基线入护。
- P3 闭环编排: lab_mined_slot_limit 偏好 (默认 10, 0=不限) 在发布
门槛通过后校验, 名额满则新冠军停在 pending 等位;
/api/backtest/lab/report 聚合守护/挖掘/名额战报 (前端总览卡);
lab_schedule 单入口编排周度挖掘 + 日度守护 (失败互不阻断)。
测试: 新增 guardian 判定/运行时/存储/调度/API/组合与挖掘 v3 选冠/
精调用例; 后端全量 3224 通过, 前端 308 通过; 真实数据沙盒 E2E
(挖掘 v3 全链路 / 发布门槛 / 名额边界 / 复验语义 / 组合扫描 /
战报核对 / 性能延迟) 通过。P2c (overlay 候选) 按设计稿按需启动,
默认不做。
Signed-off-by: shy3130 <shy3130@qq.com>
116 lines
4.3 KiB
Python
116 lines
4.3 KiB
Python
"""自动策略实验室战报: 守护 + 挖掘 + 名额的聚合只读视图 (P3)。"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
|
|
from app.services import preferences
|
|
from app.services.guardian_store import read_combinations
|
|
from app.services.mining_jobs import MiningRunStoreError
|
|
|
|
router = APIRouter(prefix="/api/backtest/lab", tags=["backtest"])
|
|
|
|
|
|
def _project_mining_run(store: Any, manifest: dict[str, Any]) -> dict[str, Any]:
|
|
summary = store.read_summary(str(manifest["run_id"]))
|
|
return {
|
|
"run_id": manifest.get("run_id"),
|
|
"status": manifest.get("status"),
|
|
"created_at": manifest.get("created_at"),
|
|
"finished_at": manifest.get("finished_at"),
|
|
"algorithm_version": summary.get("algorithm_version"),
|
|
"confidence": summary.get("confidence"),
|
|
"candidate_count": summary.get("candidate_count"),
|
|
"valid_fold_count": summary.get("valid_fold_count"),
|
|
"refinement": summary.get("refinement"),
|
|
"trials_used": summary.get("trials_used"),
|
|
}
|
|
|
|
|
|
@router.get("/report")
|
|
def get_report(request: Request) -> dict[str, Any]:
|
|
data_dir = request.app.state.repo.store.data_dir
|
|
engine = getattr(request.app.state, "strategy_engine", None)
|
|
if engine is None:
|
|
raise HTTPException(status_code=503, detail="strategy engine is unavailable")
|
|
|
|
guardian_manager = getattr(request.app.state, "guardian_manager", None)
|
|
guardian: dict[str, Any] = {"profiles": 0, "by_status": {}, "alerts": 0,
|
|
"last_run": None}
|
|
try:
|
|
from app.services.guardian_store import GuardianStore
|
|
|
|
profiles = GuardianStore(data_dir).list_profiles()
|
|
except Exception: # noqa: BLE001 — 战报对档案读取失败降级为空统计
|
|
profiles = []
|
|
guardian["profiles"] = len(profiles)
|
|
by_status: dict[str, int] = {}
|
|
alerts = 0
|
|
for profile in profiles:
|
|
status = str(profile.get("status") or "pending")
|
|
by_status[status] = by_status.get(status, 0) + 1
|
|
checks = profile.get("checks") or []
|
|
streak = 0
|
|
for check in reversed(checks):
|
|
if check.get("verdict") == "watch" and not check.get("error"):
|
|
streak += 1
|
|
else:
|
|
break
|
|
alerts += 1 if streak >= 2 else 0
|
|
guardian["by_status"] = by_status
|
|
guardian["alerts"] = alerts
|
|
if guardian_manager is not None:
|
|
try:
|
|
runs = guardian_manager.store.list_runs(limit=5)
|
|
guardian["last_run"] = (
|
|
_project_guardian_run(runs[0]) if runs else None
|
|
)
|
|
except MiningRunStoreError:
|
|
guardian["last_run"] = None
|
|
|
|
mining_manager = getattr(request.app.state, "mining_manager", None)
|
|
mining: dict[str, Any] = {"last_run": None}
|
|
if mining_manager is not None:
|
|
try:
|
|
runs = mining_manager.store.list_runs(limit=5)
|
|
mining["last_run"] = _project_mining_run(mining_manager.store, runs[0]) if runs else None
|
|
except (MiningRunStoreError, ValueError):
|
|
mining["last_run"] = None
|
|
|
|
config = preferences.get_guardian_schedule()
|
|
active_mined = sorted(
|
|
str(meta.get("id"))
|
|
for meta in engine.list_strategies()
|
|
if str(meta.get("id", "")).startswith("mined_factor_")
|
|
)
|
|
combinations = read_combinations(data_dir) or {}
|
|
return {
|
|
"generated_at": datetime.now(UTC).isoformat(),
|
|
"mining": mining,
|
|
"guardian": guardian,
|
|
"combinations": {
|
|
"generated_at": combinations.get("generated_at"),
|
|
"suggestions": combinations.get("suggestions") or [],
|
|
},
|
|
"slots": {
|
|
"active_mined": len(active_mined),
|
|
"active_mined_ids": active_mined,
|
|
"limit": config["lab_mined_slot_limit"],
|
|
"full": (
|
|
config["lab_mined_slot_limit"] > 0
|
|
and len(active_mined) >= config["lab_mined_slot_limit"]
|
|
),
|
|
},
|
|
}
|
|
|
|
|
|
def _project_guardian_run(manifest: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"run_id": manifest.get("run_id"),
|
|
"status": manifest.get("status"),
|
|
"created_at": manifest.get("created_at"),
|
|
"finished_at": manifest.get("finished_at"),
|
|
}
|