Files
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

167 lines
5.9 KiB
Python

"""守护档案存储测试: 幂等基线、复验滚动上限、状态转换与路径防护。"""
from __future__ import annotations
import json
import pytest
from app.services.guardian_store import (
GuardianStore,
GuardianStoreError,
GuardianStoreValidationError,
)
def _baseline_kwargs(**overrides):
params = dict(
kind="mining_gate",
data_as_of="2026-01-09",
objective="sharpe",
metrics={"score": 0.9, "n_trades": 70, "max_drawdown": -0.12},
provenance={"origin_run_id": "run-1"},
)
params.update(overrides)
return params
def test_create_and_get_roundtrip(tmp_path):
store = GuardianStore(tmp_path)
profile = store.create_baseline("mined_factor_abc", **_baseline_kwargs())
fetched = store.get("mined_factor_abc")
assert fetched is not None
assert fetched["status"] == "pending"
assert fetched["baseline"]["kind"] == "mining_gate"
assert fetched["baseline"]["data_as_of"] == "2026-01-09"
assert fetched["baseline"]["objective"] == "sharpe"
assert fetched["baseline"]["metrics"]["score"] == 0.9
assert profile["strategy_id"] == "mined_factor_abc"
def test_create_baseline_idempotent_keeps_history(tmp_path):
store = GuardianStore(tmp_path)
store.create_baseline("mined_factor_abc", **_baseline_kwargs())
store.append_check("mined_factor_abc", {"verdict": "healthy"})
store.set_status("mined_factor_abc", "healthy")
again = store.create_baseline(
"mined_factor_abc", **_baseline_kwargs(metrics={"score": 9.9})
)
assert again["baseline"]["metrics"]["score"] == 0.9 # 未覆盖
assert again["status"] == "healthy"
assert len(again["checks"]) == 1
def test_create_baseline_validates(tmp_path):
store = GuardianStore(tmp_path)
with pytest.raises(GuardianStoreValidationError):
store.create_baseline("s", kind="bogus", data_as_of="2026-01-09",
objective="sharpe", metrics={})
with pytest.raises(GuardianStoreValidationError):
store.create_baseline("s", kind="mining_gate", data_as_of="not-a-date",
objective="sharpe", metrics={})
def test_unsafe_strategy_id_rejected(tmp_path):
store = GuardianStore(tmp_path)
with pytest.raises(GuardianStoreValidationError):
store.get("../../etc/passwd")
with pytest.raises(GuardianStoreValidationError):
store.drop("../escape")
def test_append_check_caps_at_52(tmp_path):
from app.backtest.guardian import MAX_CHECKS
store = GuardianStore(tmp_path)
store.create_baseline("mined_factor_abc", **_baseline_kwargs())
for i in range(MAX_CHECKS + 10):
store.append_check("mined_factor_abc", {"verdict": "healthy", "i": i})
checks = store.get("mined_factor_abc")["checks"]
assert len(checks) == MAX_CHECKS
assert checks[0]["i"] == 10 # 最旧的被滚动裁掉
def test_set_status_transitions_and_reason(tmp_path):
store = GuardianStore(tmp_path)
store.create_baseline("mined_factor_abc", **_baseline_kwargs())
profile = store.set_status("mined_factor_abc", "watch", reason="退化 30%")
assert profile["status"] == "watch"
assert profile["status_reason"] == "退化 30%"
assert profile["status_since"] is not None
with pytest.raises(GuardianStoreValidationError):
store.set_status("mined_factor_abc", "exploded")
def test_set_baseline_metrics_only_for_trailing(tmp_path):
store = GuardianStore(tmp_path)
store.create_baseline("mined_factor_abc", **_baseline_kwargs())
with pytest.raises(GuardianStoreValidationError):
store.set_baseline_metrics("mined_factor_abc", {"score": 1.0})
store.create_baseline(
"my_manual_strategy",
**_baseline_kwargs(kind="trailing_reference", metrics={}),
)
profile = store.set_baseline_metrics("my_manual_strategy", {"score": 1.2})
assert profile["baseline"]["metrics"]["score"] == 1.2
assert profile["baseline"]["calibrated_at"]
def test_drop_removes_only_our_file(tmp_path):
store = GuardianStore(tmp_path)
store.create_baseline("mined_factor_abc", **_baseline_kwargs())
assert store.drop("mined_factor_abc") is True
assert store.get("mined_factor_abc") is None
assert store.drop("mined_factor_abc") is False
def test_append_missing_profile_raises_key_error(tmp_path):
store = GuardianStore(tmp_path)
with pytest.raises(KeyError):
store.append_check("ghost", {"verdict": "healthy"})
def test_list_profiles_skips_unreadable(tmp_path):
store = GuardianStore(tmp_path)
store.create_baseline("mined_factor_abc", **_baseline_kwargs())
(tmp_path / "user_data" / "strategy_guardian" / "broken.json").write_text(
"{not json", encoding="utf-8"
)
profiles = store.list_profiles()
assert [p["strategy_id"] for p in profiles] == ["mined_factor_abc"]
def test_corrupt_profile_raises_on_direct_get(tmp_path):
store = GuardianStore(tmp_path)
path = tmp_path / "user_data" / "strategy_guardian" / "bad.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"status": "weird"}), encoding="utf-8")
with pytest.raises(GuardianStoreError):
store.get("bad")
def test_list_profiles_excludes_combinations_file(tmp_path):
"""combinations.json 是组合建议数据, 不能被当成策略档案列出 (P2d)。"""
from app.services.guardian_store import write_combinations
store = GuardianStore(tmp_path)
store.create_baseline("mined_factor_abc", **_baseline_kwargs())
write_combinations(
tmp_path,
window_start="2026-04-01",
window_end="2026-10-01",
pairs_evaluated=1,
suggestions=[
{
"a": "mined_factor_abc",
"b": "mined_factor_def",
"correlation": -0.1,
"common_days": 40,
}
],
)
profiles = store.list_profiles()
assert [p["strategy_id"] for p in profiles] == ["mined_factor_abc"]