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>
224 lines
8.3 KiB
Python
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
|