Files
tick-stock-panel/backend/tests/test_guardian_api.py
T
shy3130 21279ecfa2 feat(lab): 自动策略实验室 — 守护复验 + 稳健选冠/权重精调 + 组合建议 + 闭环编排
在既有挖掘链路之上补齐「发布后持续验证与持续组合」闭环
(设计: 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>
2026-10-25 23:43:34 +08:00

224 lines
8.3 KiB
Python

"""守护 API 契约测试: overview/adopt/drop/rerun/config/runs。"""
from __future__ import annotations
from datetime import date
from pathlib import Path
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.guardian import router
from app.services.guardian_store import GuardianStore
from app.services.mining_jobs import MiningRunStore
class _Repo:
def __init__(self, data_dir: Path, latest: date | None = date(2026, 10, 23)) -> None:
self.store = SimpleNamespace(data_dir=data_dir)
self._latest = latest
def latest_enriched_date(self, asset_type="stock"):
return self._latest
class _StrategyEngine:
def get(self, strategy_id):
if strategy_id == "ghost":
raise ValueError(f"unknown strategy: {strategy_id}")
return SimpleNamespace(
meta={"asset_types": ["stock"], "name": f"策略 {strategy_id}", "params": []}
)
class _Manager:
def __init__(self, data_dir: Path) -> None:
self.store = MiningRunStore(data_dir, namespace="guardian")
self.starts: list[dict] = []
def start(self, request, fingerprint, force=False, source="manual", run_id=None):
self.starts.append({"request": request, "force": force, "source": source})
manifest = self.store.create(request, fingerprint)
self.store.transition_status(manifest["run_id"], "running")
return manifest
@pytest.fixture()
def client(tmp_path, monkeypatch):
from app.services import preferences
monkeypatch.setattr(
preferences, "get_guardian_schedule",
lambda: {"guardian_schedule_enabled": False, "guardian_watch_pct": 0.2,
"guardian_retire_pct": 0.5, "guardian_k_max": 4,
"lab_mined_slot_limit": 10},
)
def fake_set(enabled, watch, retire, k_max, lab_mined_slot_limit=10):
from app.backtest.guardian import GuardianThresholds
GuardianThresholds(watch_pct=watch, retire_pct=retire, k_max=k_max)
if not 0 <= lab_mined_slot_limit <= 50:
raise ValueError("lab mined slot limit must be between 0 and 50")
return {
"guardian_schedule_enabled": enabled,
"guardian_watch_pct": watch,
"guardian_retire_pct": retire,
"guardian_k_max": k_max,
"lab_mined_slot_limit": lab_mined_slot_limit,
}
monkeypatch.setattr(preferences, "set_guardian_schedule", fake_set)
app = FastAPI()
app.include_router(router)
app.state.repo = _Repo(tmp_path)
app.state.strategy_engine = _StrategyEngine()
app.state.guardian_manager = _Manager(tmp_path)
return TestClient(app)
def test_overview_empty(client):
body = client.get("/api/backtest/guardian/overview").json()
assert body["items"] == []
assert body["config"]["guardian_schedule_enabled"] is False
assert body["active_run"] is None
def test_overview_projects_profiles(client, tmp_path):
store = GuardianStore(tmp_path)
store.create_baseline(
"mined_factor_x",
kind="mining_gate",
data_as_of="2026-01-09",
objective="sharpe",
metrics={"score": 0.9, "n_trades": 70, "max_drawdown": -0.12},
)
store.append_check("mined_factor_x", {"verdict": "watch", "degradation_pct": 0.3})
store.append_check("mined_factor_x", {"verdict": "watch", "degradation_pct": 0.4})
store.set_status("mined_factor_x", "watch", reason="退化")
item = client.get("/api/backtest/guardian/overview").json()["items"][0]
assert item["strategy_id"] == "mined_factor_x"
assert item["name"] == "策略 mined_factor_x"
assert item["status"] == "watch"
assert item["consecutive_watch"] == 2
assert item["alert"] is True
assert item["baseline"]["score"] == 0.9
assert item["last_check"]["verdict"] == "watch"
def test_overview_survives_missing_strategy(client, tmp_path):
store = GuardianStore(tmp_path)
store.create_baseline(
"ghost", kind="mining_gate", data_as_of="2026-01-09",
objective="sharpe", metrics={"score": 1.0},
)
item = client.get("/api/backtest/guardian/overview").json()["items"][0]
assert item["name"] is None
assert item["strategy_id"] == "ghost"
def test_adopt_rejects_unknown_strategy_and_objective(client):
assert client.post(
"/api/backtest/guardian/ghost/adopt", json={"objective": "sharpe"}
).status_code == 404
assert client.post(
"/api/backtest/guardian/tune_overnight/adopt", json={"objective": "win_rate"}
).status_code == 400
def test_adopt_rejects_missing_enriched(client, tmp_path):
client.app.state.repo = _Repo(tmp_path, latest=None)
response = client.post(
"/api/backtest/guardian/tune_overnight/adopt", json={"objective": "sharpe"}
)
assert response.status_code == 400
assert "enriched" in response.json()["detail"]
def test_adopt_creates_trailing_profile_and_enqueues(client, tmp_path):
response = client.post(
"/api/backtest/guardian/tune_overnight/adopt", json={"objective": "sortino"}
)
assert response.status_code == 200
body = response.json()
assert body["ok"] is True and body["run_id"]
profile = GuardianStore(tmp_path).get("tune_overnight")
assert profile["baseline"]["kind"] == "trailing_reference"
assert profile["baseline"]["objective"] == "sortino"
assert profile["baseline"]["data_as_of"] == "2026-10-23"
assert profile["baseline"]["metrics"] == {}
manager = client.app.state.guardian_manager
assert manager.starts[-1]["request"]["strategy_ids"] == ["tune_overnight"]
assert manager.starts[-1]["force"] is True
def test_drop_and_rerun(client, tmp_path):
store = GuardianStore(tmp_path)
assert client.post("/api/backtest/guardian/nope/drop").status_code == 404
assert client.post("/api/backtest/guardian/nope/rerun").status_code == 404
store.create_baseline(
"mined_factor_x", kind="mining_gate", data_as_of="2026-01-09",
objective="sharpe", metrics={"score": 0.9},
)
response = client.post("/api/backtest/guardian/mined_factor_x/rerun")
assert response.status_code == 200
assert response.json()["run_id"]
response = client.post("/api/backtest/guardian/mined_factor_x/drop")
assert response.json()["ok"] is True
assert GuardianStore(tmp_path).get("mined_factor_x") is None
def test_run_all_enqueues(client):
response = client.post("/api/backtest/guardian/run")
assert response.status_code == 200
manager = client.app.state.guardian_manager
assert manager.starts[-1]["request"]["strategy_ids"] is None
def test_active_run_conflict(client):
manager = client.app.state.guardian_manager
manifest = manager.store.create({"x": 1}, {"v": 1}) # 默认 queued = 活跃
assert manifest["status"] == "queued"
assert client.post("/api/backtest/guardian/run").status_code == 409
def test_runs_listing_and_detail(client):
manager = client.app.state.guardian_manager
manifest = manager.start({"strategy_ids": None}, {"v": 1})
manager.store.transition_status(manifest["run_id"], "succeeded")
manager.store.write_summary(manifest["run_id"], {"progress": {"done": 1}})
items = client.get("/api/backtest/guardian/runs").json()["items"]
assert items and items[0]["run_id"] == manifest["run_id"]
detail = client.get(f"/api/backtest/guardian/runs/{manifest['run_id']}").json()
assert detail["status"] == "succeeded"
assert detail["summary"]["progress"] == {"done": 1}
assert client.get("/api/backtest/guardian/runs/missing").status_code == 404
def test_config_patch_validation(client):
assert client.get("/api/backtest/guardian/config").json()["guardian_k_max"] == 4
assert client.patch("/api/backtest/guardian/config", json={}).status_code == 400
# watch >= retire 违反阈值约束 → 400
assert client.patch(
"/api/backtest/guardian/config",
json={"guardian_watch_pct": 0.6, "guardian_retire_pct": 0.5},
).status_code == 400
response = client.patch(
"/api/backtest/guardian/config",
json={"guardian_schedule_enabled": True, "guardian_watch_pct": 0.3},
)
assert response.status_code == 200
body = response.json()
assert body["guardian_schedule_enabled"] is True
assert body["guardian_watch_pct"] == 0.3
assert body["guardian_retire_pct"] == 0.5