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>
251 lines
8.6 KiB
Python
251 lines
8.6 KiB
Python
"""守护每日调度与运行管理器参数化测试。"""
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
import time
|
|
from datetime import date, datetime
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from app.services.guardian_schedule import (
|
|
build_guardian_fingerprint,
|
|
guardian_request,
|
|
run_daily_guardian,
|
|
)
|
|
from app.services.mining_jobs import MiningRunStore, MiningRunValidationError
|
|
from app.services.mining_manager import MiningJobManager
|
|
|
|
|
|
class _Repo:
|
|
def __init__(self, data_dir: Path) -> None:
|
|
self.store = SimpleNamespace(data_dir=data_dir)
|
|
|
|
@staticmethod
|
|
def latest_enriched_date(asset_type="stock"):
|
|
return date(2026, 10, 23)
|
|
|
|
|
|
class _RecordingManager:
|
|
def __init__(self, data_dir: Path) -> None:
|
|
self.store = MiningRunStore(data_dir, namespace="guardian")
|
|
self.starts: list[dict[str, Any]] = []
|
|
|
|
def start(self, request, fingerprint, force=False, source="manual", run_id=None):
|
|
self.starts.append(
|
|
{"request": request, "fingerprint": fingerprint, "force": force,
|
|
"source": source, "run_id": run_id}
|
|
)
|
|
return self.store.create(request, fingerprint, run_id=run_id)
|
|
|
|
|
|
def _app_state(data_dir: Path, manager=None):
|
|
return SimpleNamespace(
|
|
repo=_Repo(data_dir),
|
|
guardian_manager=manager or _RecordingManager(data_dir),
|
|
)
|
|
|
|
|
|
def _friday() -> datetime:
|
|
# 2026-10-23 是周五
|
|
return datetime(2026, 10, 23, 18, 0)
|
|
|
|
|
|
def test_disabled_returns_disabled(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},
|
|
)
|
|
assert run_daily_guardian(_app_state(tmp_path), now=_friday()) == {
|
|
"status": "disabled"
|
|
}
|
|
|
|
|
|
def test_weekend_mismatch(tmp_path, monkeypatch):
|
|
from app.services import preferences
|
|
|
|
monkeypatch.setattr(
|
|
preferences, "get_guardian_schedule",
|
|
lambda: {"guardian_schedule_enabled": True, "guardian_watch_pct": 0.2,
|
|
"guardian_retire_pct": 0.5, "guardian_k_max": 4},
|
|
)
|
|
saturday = datetime(2026, 10, 24, 18, 0)
|
|
assert run_daily_guardian(_app_state(tmp_path), now=saturday) == {
|
|
"status": "weekday_mismatch"
|
|
}
|
|
|
|
|
|
def test_enqueues_once_then_already_claimed(tmp_path, monkeypatch):
|
|
from app.services import preferences
|
|
|
|
config = {"guardian_schedule_enabled": True, "guardian_watch_pct": 0.3,
|
|
"guardian_retire_pct": 0.6, "guardian_k_max": 3}
|
|
monkeypatch.setattr(preferences, "get_guardian_schedule", lambda: config)
|
|
state = _app_state(tmp_path)
|
|
manager = state.guardian_manager
|
|
|
|
first = run_daily_guardian(state, now=_friday())
|
|
assert first["status"] == "enqueued"
|
|
assert first["run_id"] == "guardian-daily-2026-10-23"
|
|
assert len(manager.starts) == 1
|
|
started = manager.starts[0]
|
|
assert started["source"] == "scheduled"
|
|
assert started["run_id"] == "guardian-daily-2026-10-23"
|
|
assert started["request"]["thresholds"] == {
|
|
"watch_pct": 0.3, "retire_pct": 0.6, "k_max": 3,
|
|
}
|
|
assert started["fingerprint"]["source_claim"] == "guardian-daily-2026-10-23"
|
|
assert started["fingerprint"]["latest_enriched_date"] == "2026-10-23"
|
|
|
|
second = run_daily_guardian(state, now=_friday())
|
|
assert second["status"] == "already_claimed"
|
|
assert len(manager.starts) == 1
|
|
|
|
|
|
def test_missing_manager_raises(tmp_path, monkeypatch):
|
|
from app.services import preferences
|
|
|
|
monkeypatch.setattr(
|
|
preferences, "get_guardian_schedule",
|
|
lambda: {"guardian_schedule_enabled": True, "guardian_watch_pct": 0.2,
|
|
"guardian_retire_pct": 0.5, "guardian_k_max": 4},
|
|
)
|
|
state = SimpleNamespace(repo=_Repo(tmp_path), guardian_manager=None)
|
|
with pytest.raises(RuntimeError, match="guardian"):
|
|
run_daily_guardian(state, now=_friday())
|
|
|
|
|
|
def test_fingerprint_stable_and_sensitive(tmp_path):
|
|
repo = _Repo(tmp_path)
|
|
first = build_guardian_fingerprint(repo)
|
|
second = build_guardian_fingerprint(repo)
|
|
assert first == second
|
|
assert first["profiles"] == {"count": 0, "digest": first["profiles"]["digest"]}
|
|
|
|
(tmp_path / "user_data" / "strategy_guardian").mkdir(parents=True, exist_ok=True)
|
|
(tmp_path / "user_data" / "strategy_guardian" / "mined_factor_a.json").write_text(
|
|
"{}", encoding="utf-8"
|
|
)
|
|
third = build_guardian_fingerprint(repo)
|
|
assert third["profiles"]["count"] == 1
|
|
assert third["profiles"]["digest"] != first["profiles"]["digest"]
|
|
|
|
|
|
def test_guardian_request_shape():
|
|
request = guardian_request({
|
|
"guardian_watch_pct": 0.2, "guardian_retire_pct": 0.5, "guardian_k_max": 4,
|
|
})
|
|
assert request == {
|
|
"strategy_ids": None,
|
|
"thresholds": {"watch_pct": 0.2, "retire_pct": 0.5, "k_max": 4},
|
|
}
|
|
|
|
|
|
# ── run store namespace / manager 参数化 ───────────────────────────
|
|
|
|
|
|
def test_run_store_namespace(tmp_path):
|
|
store = MiningRunStore(tmp_path, namespace="guardian")
|
|
assert store.runs_root == (tmp_path / "research" / "guardian" / "runs").resolve()
|
|
assert MiningRunStore(tmp_path).runs_root == (
|
|
tmp_path / "research" / "mining" / "runs"
|
|
).resolve()
|
|
with pytest.raises(MiningRunValidationError):
|
|
MiningRunStore(tmp_path, namespace="../escape")
|
|
|
|
|
|
def test_manager_kind_dispatches_guardian_worker(tmp_path):
|
|
"""manager kind=guardian: 线程名/worker 任务 kind/存储 namespace 全部生效。"""
|
|
seen: dict[str, Any] = {}
|
|
|
|
def fake_runner(task, progress_cb, cancel_event):
|
|
seen.update(task)
|
|
progress_cb({"type": "guardian_progress", "done": 1, "total": 1})
|
|
return {"status": "succeeded", "checked": 1, "skipped": 0, "errors": 0,
|
|
"results": []}
|
|
|
|
manager = MiningJobManager(
|
|
tmp_path,
|
|
worker_runner=fake_runner,
|
|
kind="guardian",
|
|
store=MiningRunStore(tmp_path, namespace="guardian"),
|
|
)
|
|
manifest = manager.start({"strategy_ids": None}, {"version": "t"}, force=True,
|
|
source="manual")
|
|
run_id = manifest["run_id"]
|
|
|
|
deadline = time.monotonic() + 2.0
|
|
while time.monotonic() < deadline:
|
|
current = manager.store.get(run_id)
|
|
if current and current["status"] == "succeeded":
|
|
break
|
|
time.sleep(0.01)
|
|
assert manager.store.get(run_id)["status"] == "succeeded"
|
|
assert seen["kind"] == "guardian"
|
|
assert seen["config"]["request"] == {"strategy_ids": None}
|
|
assert seen["config"]["run_id"] == run_id
|
|
assert (tmp_path / "research" / "guardian" / "runs").exists()
|
|
manager.shutdown()
|
|
|
|
|
|
def test_manager_thread_named_by_kind(tmp_path):
|
|
names: list[str] = []
|
|
started = threading.Event()
|
|
|
|
def fake_runner(task, progress_cb, cancel_event):
|
|
names.append(threading.current_thread().name)
|
|
started.set()
|
|
return {"status": "succeeded", "checked": 0, "skipped": 0, "errors": 0,
|
|
"results": []}
|
|
|
|
manager = MiningJobManager(
|
|
tmp_path,
|
|
worker_runner=fake_runner,
|
|
kind="guardian",
|
|
store=MiningRunStore(tmp_path, namespace="guardian"),
|
|
)
|
|
manager.start({"strategy_ids": None}, {"version": "t"}, force=True)
|
|
assert started.wait(timeout=2.0)
|
|
assert names and names[0].startswith("guardian-")
|
|
manager.shutdown()
|
|
|
|
|
|
def test_run_scheduled_lab_triggers_both(monkeypatch, tmp_path):
|
|
"""P3 单入口: 依次触发周度挖掘与每日守护, 各自异常互不阻断。"""
|
|
import app.services.lab_schedule as lab_schedule_module
|
|
|
|
calls: list[str] = []
|
|
|
|
def fake_mining(state):
|
|
calls.append("mining")
|
|
return {"status": "enqueued"}
|
|
|
|
def fake_guardian(state):
|
|
calls.append("guardian")
|
|
return {"status": "enqueued"}
|
|
|
|
def boom(state):
|
|
raise RuntimeError("boom")
|
|
|
|
monkeypatch.setattr(
|
|
"app.services.mining_schedule.run_weekly_mining", fake_mining
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.services.guardian_schedule.run_daily_guardian", fake_guardian
|
|
)
|
|
result = lab_schedule_module.run_scheduled_lab(SimpleNamespace())
|
|
assert calls == ["mining", "guardian"]
|
|
assert result == {"mining": {"status": "enqueued"},
|
|
"guardian": {"status": "enqueued"}}
|
|
|
|
monkeypatch.setattr("app.services.mining_schedule.run_weekly_mining", boom)
|
|
result = lab_schedule_module.run_scheduled_lab(SimpleNamespace())
|
|
assert result["mining"] is None
|
|
assert result["guardian"] == {"status": "enqueued"}
|
|
del tmp_path
|