From 21279ecfa276316ddd4e8ee42a70d0545f6d19a4 Mon Sep 17 00:00:00 2001 From: shy3130 Date: Sun, 25 Oct 2026 23:43:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(lab):=20=E8=87=AA=E5=8A=A8=E7=AD=96?= =?UTF-8?q?=E7=95=A5=E5=AE=9E=E9=AA=8C=E5=AE=A4=20=E2=80=94=20=E5=AE=88?= =?UTF-8?q?=E6=8A=A4=E5=A4=8D=E9=AA=8C=20+=20=E7=A8=B3=E5=81=A5=E9=80=89?= =?UTF-8?q?=E5=86=A0/=E6=9D=83=E9=87=8D=E7=B2=BE=E8=B0=83=20+=20=E7=BB=84?= =?UTF-8?q?=E5=90=88=E5=BB=BA=E8=AE=AE=20+=20=E9=97=AD=E7=8E=AF=E7=BC=96?= =?UTF-8?q?=E6=8E=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在既有挖掘链路之上补齐「发布后持续验证与持续组合」闭环 (设计: 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 --- README.md | 1 + backend/app/api/guardian.py | 361 +++++++++ backend/app/api/lab.py | 115 +++ backend/app/backtest/candidates.py | 1 + backend/app/backtest/guardian.py | 227 ++++++ backend/app/backtest/guardian_runtime.py | 709 ++++++++++++++++++ backend/app/backtest/mining.py | 16 +- backend/app/backtest/mining_runtime.py | 242 +++++- backend/app/backtest/worker.py | 16 + backend/app/jobs/daily_pipeline.py | 12 +- backend/app/main.py | 21 + backend/app/services/guardian_combinations.py | 173 +++++ backend/app/services/guardian_schedule.py | 96 +++ backend/app/services/guardian_store.py | 324 ++++++++ backend/app/services/lab_schedule.py | 28 + backend/app/services/mining_candidates.py | 94 ++- backend/app/services/mining_jobs.py | 9 +- backend/app/services/mining_manager.py | 12 +- backend/app/services/mining_schedule.py | 2 +- backend/app/services/preferences.py | 69 ++ backend/tests/backtest/test_guardian.py | 237 ++++++ backend/tests/backtest/test_mining_runtime.py | 226 ++++++ backend/tests/test_guardian_api.py | 223 ++++++ backend/tests/test_guardian_combinations.py | 377 ++++++++++ backend/tests/test_guardian_runtime.py | 306 ++++++++ backend/tests/test_guardian_schedule.py | 250 ++++++ backend/tests/test_guardian_store.py | 166 ++++ backend/tests/test_mining_candidates.py | 25 + docs/auto-strategy-lab-plan.md | 165 ++++ docs/features.md | 4 +- docs/mining.md | 6 + frontend/src/lib/api.ts | 157 ++++ frontend/src/lib/queryKeys.ts | 3 + .../pages/backtest/RobustnessValidation.tsx | 10 +- .../src/pages/backtest/StrategyGuardian.tsx | 446 +++++++++++ 35 files changed, 5104 insertions(+), 25 deletions(-) create mode 100644 backend/app/api/guardian.py create mode 100644 backend/app/api/lab.py create mode 100644 backend/app/backtest/guardian.py create mode 100644 backend/app/backtest/guardian_runtime.py create mode 100644 backend/app/services/guardian_combinations.py create mode 100644 backend/app/services/guardian_schedule.py create mode 100644 backend/app/services/guardian_store.py create mode 100644 backend/app/services/lab_schedule.py create mode 100644 backend/tests/backtest/test_guardian.py create mode 100644 backend/tests/test_guardian_api.py create mode 100644 backend/tests/test_guardian_combinations.py create mode 100644 backend/tests/test_guardian_runtime.py create mode 100644 backend/tests/test_guardian_schedule.py create mode 100644 backend/tests/test_guardian_store.py create mode 100644 docs/auto-strategy-lab-plan.md create mode 100644 frontend/src/pages/backtest/StrategyGuardian.tsx diff --git a/README.md b/README.md index 10e072be..ce2f8548 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,7 @@ | 📁 **文件型零运维存储** | Parquet + DuckDB,单容器零外部数据库,数据 100% 落在本地 | | 🔁 **回测 = 实盘同路径** | 策略执行只此一条 `StrategyEngine.run`;分钟策略逐日回放 +「当日已知」纪律,杜绝未来函数 | | 🛡️ **稳健参数筛选** | 参数按 K 段折外稳健分(0.6×中位+0.4×最差)排名而非样本内得分:主目标白名单 + 最小交易数门槛 + Top-20 全窗退化率 + 冠军收敛/高原判据;滚动样本外检验每折重优化防过拟合,冠军/最新折参数一键应用到策略 | +| 🛡️ **策略守护** | 发布的策略自动落基线,每日用新数据 K 段真实回测复验:退化自动判定 健康/观察/退役(交易数枯竭即退役),连续观察告警;退役只标记不删档,支持任意策略手动入护(尾随参考基线)。冠军按稳健分(中位+最差折)选出并做权重精调;健康冠军间低相关(日收益 Pearson≤0.7)可一键生成组合策略并入护;实验室战报聚合守护/挖掘/名额,挖掘名额满时新候选等位 | | 🏛️ **网关居中的开放单体** | 面板密码会话与 Token 网关双通道并行;65 端点契约快照 + CI 守护,SSE 事件流与 MCP 平等开放 | | 🚀 **重活进程隔离** | 回测跑在 spawn worker 子进程,刷新/切页重连不丢任务,主服务永不被计算卡住 | diff --git a/backend/app/api/guardian.py b/backend/app/api/guardian.py new file mode 100644 index 00000000..405daa9b --- /dev/null +++ b/backend/app/api/guardian.py @@ -0,0 +1,361 @@ +"""策略守护 (发布后持续复验) HTTP API。""" +from __future__ import annotations + +import logging +from collections.abc import Mapping +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.backtest.guards import validate_primary_objective +from app.services import preferences +from app.services.guardian_combinations import create_guardian_composite +from app.services.guardian_schedule import build_guardian_fingerprint, guardian_request +from app.services.guardian_store import ( + GuardianStore, + GuardianStoreError, + GuardianStoreValidationError, + read_combinations, +) +from app.services.mining_jobs import ( + ACTIVE_RUN_STATUSES, + SUCCESS_RUN_STATUSES, + MiningRunStoreError, +) +from app.services.mining_manager import MiningJobManager + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/backtest/guardian", tags=["backtest"]) + + +class GuardianConfigPatch(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + + guardian_schedule_enabled: bool | None = None + guardian_watch_pct: float | None = Field(None, gt=0.0, lt=1.0, allow_inf_nan=False) + guardian_retire_pct: float | None = Field(None, gt=0.0, le=1.0, allow_inf_nan=False) + guardian_k_max: int | None = Field(None, ge=2, le=4) + lab_mined_slot_limit: int | None = Field(None, ge=0, le=50) + + +class GuardianAdoptRequest(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + + objective: str = "sharpe" + + +def _manager(request: Request) -> MiningJobManager: + manager = getattr(request.app.state, "guardian_manager", None) + if manager is None: + raise HTTPException(status_code=503, detail="guardian manager is unavailable") + return manager + + +def _store(request: Request) -> GuardianStore: + return GuardianStore(request.app.state.repo.store.data_dir) + + +def _data_dir(request: Request): + return request.app.state.repo.store.data_dir + + +def _project_last_check(check: Mapping[str, Any] | None) -> dict[str, Any] | None: + if not isinstance(check, Mapping): + return None + return { + "checked_at": check.get("checked_at"), + "verdict": check.get("verdict"), + "skipped": bool(check.get("skipped")), + "reason": check.get("reason"), + "error": check.get("error"), + "degradation_pct": check.get("degradation_pct"), + "objective": check.get("objective"), + "window_start": check.get("window_start"), + "window_end": check.get("window_end"), + "k": check.get("k"), + "reasons": check.get("reasons") or [], + } + + +def _project_profile(profile: Mapping[str, Any], name: str | None) -> dict[str, Any]: + baseline = profile.get("baseline") or {} + metrics = baseline.get("metrics") or {} + checks = profile.get("checks") or [] + from app.backtest.guardian import consecutive_watch + + streak = consecutive_watch(list(checks)) + return { + "strategy_id": profile.get("strategy_id"), + "name": name, + "status": profile.get("status"), + "status_reason": profile.get("status_reason"), + "status_since": profile.get("status_since"), + "baseline": { + "kind": baseline.get("kind"), + "data_as_of": baseline.get("data_as_of"), + "objective": baseline.get("objective"), + "score": metrics.get("score"), + "n_trades": metrics.get("n_trades"), + "max_drawdown": metrics.get("max_drawdown"), + }, + "checks_count": len(checks), + "consecutive_watch": streak, + "alert": streak >= 2, + "last_check": _project_last_check(checks[-1] if checks else None), + "updated_at": profile.get("updated_at"), + } + + +@router.get("/overview") +def get_overview(request: Request) -> dict[str, Any]: + store = _store(request) + engine = getattr(request.app.state, "strategy_engine", None) + try: + profiles = store.list_profiles() + except GuardianStoreError as exc: + raise HTTPException(status_code=500, detail="failed to read guardian profiles") from exc + items = [] + for profile in profiles: + strategy_id = str(profile.get("strategy_id")) + name = None + if engine is not None: + try: + name = str(engine.get(strategy_id).meta.get("name") or "") or None + except ValueError: + name = None + items.append(_project_profile(profile, name)) + active = _active_run(_manager(request)) + return { + "items": items, + "config": preferences.get_guardian_schedule(), + "active_run": active, + "combinations": read_combinations(_data_dir(request)), + } + + +@router.post("/{strategy_id}/adopt") +def adopt_strategy( + strategy_id: str, + payload: GuardianAdoptRequest, + request: Request, +) -> dict[str, Any]: + try: + validate_primary_objective(payload.objective) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + engine = getattr(request.app.state, "strategy_engine", None) + if engine is None: + raise HTTPException(status_code=503, detail="strategy engine is unavailable") + try: + strategy = engine.get(strategy_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=f"strategy not found: {strategy_id}") from exc + asset_type = str((strategy.meta.get("asset_types") or ["stock"])[0]) + latest = request.app.state.repo.latest_enriched_date(asset_type) + if latest is None: + raise HTTPException( + status_code=400, detail=f"{asset_type} enriched 数据不可用, 无法建立守护基线" + ) + manager = _manager(request) + _reject_active_run(manager) + store = _store(request) + try: + # metrics 留空: trailing_reference 基线由守护运行时首跑标定 (尾随窗口), + # 语义上不是 OOS, 判定只比较此后新数据上的退化。 + store.create_baseline( + strategy_id, + kind="trailing_reference", + data_as_of=latest.isoformat(), + objective=payload.objective, + metrics={}, + ) + except GuardianStoreValidationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except GuardianStoreError as exc: + raise HTTPException(status_code=500, detail="failed to write guardian profile") from exc + run = _enqueue_run(manager, request, strategy_ids=[strategy_id]) + return {"ok": True, "strategy_id": strategy_id, "run_id": run.get("run_id")} + + +@router.post("/{strategy_id}/drop") +def drop_strategy(strategy_id: str, request: Request) -> dict[str, Any]: + store = _store(request) + try: + removed = store.drop(strategy_id) + except GuardianStoreValidationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if not removed: + raise HTTPException(status_code=404, detail=f"guardian profile not found: {strategy_id}") + return {"ok": True, "strategy_id": strategy_id} + + +@router.post("/{strategy_id}/rerun") +def rerun_strategy(strategy_id: str, request: Request) -> dict[str, Any]: + store = _store(request) + if store.get(strategy_id) is None: + raise HTTPException(status_code=404, detail=f"guardian profile not found: {strategy_id}") + manager = _manager(request) + _reject_active_run(manager) + run = _enqueue_run(manager, request, strategy_ids=[strategy_id]) + return {"ok": True, "strategy_id": strategy_id, "run_id": run.get("run_id")} + + +@router.post("/run") +def run_all(request: Request) -> dict[str, Any]: + manager = _manager(request) + _reject_active_run(manager) + run = _enqueue_run(manager, request, strategy_ids=None) + return {"ok": True, "run_id": run.get("run_id")} + + +@router.get("/runs") +def list_runs(request: Request, limit: int = 20) -> dict[str, Any]: + if not 1 <= limit <= 100: + raise HTTPException(status_code=400, detail="limit must be between 1 and 100") + manager = _manager(request) + try: + manifests = manager.store.list_runs(limit=limit) + except (MiningRunStoreError, ValueError) as exc: + raise HTTPException(status_code=500, detail="failed to read guardian runs") from exc + return {"items": [_project_run(manager, item) for item in manifests]} + + +@router.get("/runs/{run_id}") +def get_run(run_id: str, request: Request) -> dict[str, Any]: + manager = _manager(request) + manifest = manager.store.get(run_id) + if manifest is None: + raise HTTPException(status_code=404, detail=f"guardian run not found: {run_id}") + projected = _project_run(manager, manifest) + if manifest.get("status") in SUCCESS_RUN_STATUSES: + projected["summary"] = manager.store.read_summary(run_id) + return projected + + +@router.post("/combinations/scan") +def scan_combinations(request: Request) -> dict[str, Any]: + """触发组合相关性扫描 (守护运行时 mode=combinations, 后台执行)。""" + manager = _manager(request) + _reject_active_run(manager) + config = preferences.get_guardian_schedule() + req = guardian_request(config) + req["mode"] = "combinations" + run = manager.start( + req, + { + **build_guardian_fingerprint(request.app.state.repo), + "source": "manual", + "mode": "combinations", + }, + force=True, + source="manual", + ) + return {"ok": True, "run_id": run.get("run_id")} + + +class GuardianComboCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + + a: str + b: str + + @field_validator("a", "b") + @classmethod + def _nonempty(cls, value: str) -> str: + if not value or len(value) > 120: + raise ValueError("strategy id must contain 1 to 120 characters") + return value + + +@router.post("/combinations/create") +def create_combination( + payload: GuardianComboCreateRequest, request: Request +) -> dict[str, Any]: + try: + result = create_guardian_composite( + request.app.state.repo.store.data_dir, + request.app.state.strategy_engine, + left=payload.a, + right=payload.b, + latest_enriched=request.app.state.repo.latest_enriched_date("stock"), + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + return result + + +@router.get("/config") +def get_config() -> dict[str, Any]: + return preferences.get_guardian_schedule() + + +@router.patch("/config") +def update_config(payload: GuardianConfigPatch) -> dict[str, Any]: + current = preferences.get_guardian_schedule() + updates = payload.model_dump(exclude_none=True) + if not updates: + raise HTTPException(status_code=400, detail="at least one guardian config field is required") + merged = {**current, **updates} + try: + return preferences.set_guardian_schedule( + merged["guardian_schedule_enabled"], + merged["guardian_watch_pct"], + merged["guardian_retire_pct"], + merged["guardian_k_max"], + merged["lab_mined_slot_limit"], + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +def _active_run(manager: MiningJobManager) -> dict[str, Any] | None: + manifests = manager.store.list_runs(limit=1, statuses=ACTIVE_RUN_STATUSES) + if not manifests: + return None + return _project_run(manager, manifests[0]) + + +def _reject_active_run(manager: MiningJobManager) -> None: + if manager.store.list_runs(limit=1, statuses=ACTIVE_RUN_STATUSES): + raise HTTPException(status_code=409, detail="已有守护复验任务在运行") + + +def _enqueue_run( + manager: MiningJobManager, + request: Request, + *, + strategy_ids: list[str] | None, +) -> dict[str, Any]: + config = preferences.get_guardian_schedule() + req = guardian_request(config) + req["strategy_ids"] = strategy_ids + fingerprint = { + **build_guardian_fingerprint(request.app.state.repo), + "source": "manual", + "strategy_ids": strategy_ids, + } + return manager.start(req, fingerprint, force=True, source="manual") + + +def _project_run(manager: MiningJobManager, manifest: Mapping[str, Any]) -> dict[str, Any]: + run_id = str(manifest["run_id"]) + summary = manager.store.read_summary(run_id) + return { + "run_id": run_id, + "status": manifest.get("status"), + "request": manifest.get("request") or {}, + "created_at": manifest.get("created_at"), + "updated_at": manifest.get("updated_at"), + "started_at": manifest.get("started_at"), + "finished_at": manifest.get("finished_at"), + "error": manifest.get("error"), + "progress": ( + summary.get("progress") + if isinstance(summary.get("progress"), Mapping) + else None + ), + } diff --git a/backend/app/api/lab.py b/backend/app/api/lab.py new file mode 100644 index 00000000..3d208a17 --- /dev/null +++ b/backend/app/api/lab.py @@ -0,0 +1,115 @@ +"""自动策略实验室战报: 守护 + 挖掘 + 名额的聚合只读视图 (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"), + } diff --git a/backend/app/backtest/candidates.py b/backend/app/backtest/candidates.py index 52b55f2d..98d14ffc 100644 --- a/backend/app/backtest/candidates.py +++ b/backend/app/backtest/candidates.py @@ -77,6 +77,7 @@ _CONFIG_FIELDS: dict[str, frozenset[str]] = { _MINING_METRIC_FIELDS = frozenset( { "oos_sharpe", + "oos_worst_sharpe", "oos_return", "oos_max_drawdown", "oos_positive_fold_ratio", diff --git a/backend/app/backtest/guardian.py b/backend/app/backtest/guardian.py new file mode 100644 index 00000000..4a002993 --- /dev/null +++ b/backend/app/backtest/guardian.py @@ -0,0 +1,227 @@ +"""策略守护纯逻辑: 退化判定、复验窗口规划与档案记录形状。 + +与挖掘 (mining) 的分工: 挖掘用嵌套样本内/外验证选冠军; 守护只在"发布/入护 +基线之后的新数据"上复验, 结果绝不反馈进取参加权 (逐层 OOS 纪律, 设计见 +docs/auto-strategy-lab-plan.md 第三节)。本模块不做任何 IO, 判定全部为纯函数。 +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import date, timedelta +from typing import Any + +from app.backtest.guards import PRIMARY_OBJECTIVES + +GUARDIAN_ALGORITHM_VERSION = "guardian-v1" +GUARDIAN_STATUSES: frozenset[str] = frozenset( + {"pending", "healthy", "watch", "retired"} +) +BASELINE_KINDS: frozenset[str] = frozenset({"mining_gate", "trailing_reference"}) + +# 新数据不足时跳过本轮复验: 太短的窗口切出的稳健分只是噪声, 判定只会制造假告警。 +MIN_NEW_TRADING_DAYS = 30 +MIN_SEGMENT_DAYS = 14 # 与稳健优化器同口径 (自然日) +K_SEGMENTS_MIN = 2 +K_SEGMENTS_MAX = 4 +MAX_CHECKS = 52 + +# 回撤类目标本身为负值 (最大化带符号值 = 回撤越小越好), 软底线不同。 +_DRAWDOWN_OBJECTIVES = frozenset({"max_drawdown", "mc_maxdd_p50", "mc_maxdd_p95"}) +_TRADES_RATIO_SOFT = 1.0 / 3.0 +_TRADES_RATIO_HARD = 0.1 +_TRADES_DROUGHT_ABS = 3 + + +@dataclass(frozen=True) +class GuardianThresholds: + """守护判定阈值。watch/retire 为相对基线退化的百分比 (0-1 小数)。""" + + watch_pct: float = 0.20 + retire_pct: float = 0.50 + max_soft_drawdown: float = -0.30 + k_max: int = 4 + min_new_trading_days: int = MIN_NEW_TRADING_DAYS + + def __post_init__(self) -> None: + if not 0.0 < self.watch_pct < self.retire_pct <= 1.0: + raise ValueError( + "守护阈值必须满足 0 < watch_pct < retire_pct <= 1 " + f"(当前 {self.watch_pct}/{self.retire_pct})" + ) + if not -1.0 <= self.max_soft_drawdown <= 0.0: + raise ValueError("max_soft_drawdown 必须在 [-1, 0] (回撤为负值)") + if not K_SEGMENTS_MIN <= self.k_max <= K_SEGMENTS_MAX: + raise ValueError(f"k_max 必须在 {K_SEGMENTS_MIN}-{K_SEGMENTS_MAX} 之间") + if self.min_new_trading_days < 1: + raise ValueError("min_new_trading_days 必须为正") + + +@dataclass(frozen=True) +class GuardianVerdict: + status: str # healthy | watch | retired + reasons: tuple[str, ...] = () + degradation_pct: float | None = None + incomplete_data: bool = False + + +def objective_soft_floor(objective: str) -> float: + """目标得分的软底线: 回撤类目标为 max_soft_drawdown 口径, 其余为 0。""" + if objective not in PRIMARY_OBJECTIVES: + raise ValueError( + f"守护目标必须在稳健优化白名单内: {sorted(PRIMARY_OBJECTIVES)}" + ) + return -0.30 if objective in _DRAWDOWN_OBJECTIVES else 0.0 + + +def degradation_pct(baseline_score: float, current_score: float) -> float | None: + """方向感知的相对退化 (正数=退化)。白名单目标全部为 max 方向。 + + 基线 ≤0 时相对退化在数学上失真 (除以接近 0 的数会爆炸), 返回 None — + 此时判定退化为只看绝对底线, UI 显示 "—"。改进视为 0 退化。 + """ + if baseline_score <= 0.0: + return None + pct = (baseline_score - current_score) / baseline_score + return max(0.0, pct) + + +def evaluate_check( + *, + objective: str, + baseline_score: float, + baseline_n_trades: int, + current_score: float | None, + current_n_trades: int, + worst_drawdown: float | None, + thresholds: GuardianThresholds, + incomplete_data: bool = False, + missing_segments: int = 0, +) -> GuardianVerdict: + """一次复验 → 三态判定。 + + incomplete_data=True (存在段落回测失败/得分缺失) 时最多只降到 watch: + 数据残缺时按最差段假设 (robust_score 把缺失计 -inf) 直接退役过于激进, + 宁可连续观察; 退役只在数据完整的复验里发生 (含交易数枯竭判定 — 段落 + 失败的交易数按 0 计入, 残缺时的"枯竭"可能是数据问题而非策略失效)。 + """ + reasons: list[str] = [] + soft_floor = objective_soft_floor(objective) + if missing_segments > 0: + reasons.append(f"{missing_segments} 个验证段回测失败, 退化程度无法可靠估计") + + pct: float | None = None + if current_score is not None: + pct = degradation_pct(baseline_score, current_score) + if pct is not None and pct > thresholds.retire_pct: + reasons.append(f"较基线退化 {pct:.0%} (超过 {thresholds.retire_pct:.0%})") + elif pct is not None and pct > thresholds.watch_pct: + reasons.append(f"较基线退化 {pct:.0%} (超过 {thresholds.watch_pct:.0%})") + + hard_drought = False + if baseline_n_trades > 0: + drought_floor = max( + _TRADES_DROUGHT_ABS, int(baseline_n_trades * _TRADES_RATIO_HARD) + ) + if current_n_trades < drought_floor: + hard_drought = True + reasons.append( + f"交易数枯竭: 本轮 {current_n_trades} < 基线 {baseline_n_trades} 的硬底线 {drought_floor}" + ) + + if not hard_drought: + if current_score is not None and current_score < soft_floor: + reasons.append(f"{objective}={current_score:.3f} 击穿软底线 {soft_floor}") + if ( + worst_drawdown is not None + and worst_drawdown < thresholds.max_soft_drawdown + ): + reasons.append( + f"最差段回撤 {worst_drawdown:.1%} 差于 {thresholds.max_soft_drawdown:.0%}" + ) + if baseline_n_trades > 0: + soft_floor_trades = int(baseline_n_trades * _TRADES_RATIO_SOFT) + if current_n_trades < soft_floor_trades: + reasons.append( + f"交易数 {current_n_trades} 低于基线 {baseline_n_trades} 的 1/3" + ) + + if not reasons: + return GuardianVerdict("healthy", (), pct, incomplete_data) + if not incomplete_data and ( + hard_drought or (pct is not None and pct > thresholds.retire_pct) + ): + return GuardianVerdict("retired", tuple(reasons), pct, incomplete_data) + return GuardianVerdict("watch", tuple(reasons), pct, incomplete_data) + + +def plan_new_data_window( + partitions: list[date], + data_as_of: date, + *, + min_new_trading_days: int = MIN_NEW_TRADING_DAYS, +) -> tuple[date, date] | None: + """基线之后的新交易日窗口; 不足 min_new_trading_days 个则返回 None (跳过)。""" + fresh = [value for value in partitions if value > data_as_of] + if len(fresh) < min_new_trading_days: + return None + return fresh[0], fresh[-1] + + +def choose_k(start: date, end: date, k_max: int) -> int: + """窗口能切出的最大段数 (≥2), 切不出则抛 ValueError (调用方跳过该策略)。""" + if not K_SEGMENTS_MIN <= k_max <= K_SEGMENTS_MAX: + raise ValueError(f"k_max 必须在 {K_SEGMENTS_MIN}-{K_SEGMENTS_MAX} 之间") + span = (end - start).days + 1 + for k in range(min(k_max, K_SEGMENTS_MAX), K_SEGMENTS_MIN - 1, -1): + if span >= k * MIN_SEGMENT_DAYS: + return k + raise ValueError( + f"新数据自然日跨度 {span} 天切不出 ≥{MIN_SEGMENT_DAYS} 天的 {K_SEGMENTS_MIN} 段验证窗口" + ) + + +def split_windows(start: date, end: date, k: int) -> list[tuple[date, date]]: + """等分复验窗口 — 与稳健优化器 split_validation_windows 同算法。 + + 不直接复用是为了让纯逻辑模块不依赖服务层语义; 两者必须保持一致 + (见 tests 断言与 robust_optimizer 的对齐用例)。 + """ + span = (end - start).days + 1 + if span < k * MIN_SEGMENT_DAYS: + raise ValueError("区间太短, 切不出等长验证段") + windows: list[tuple[date, date]] = [] + base, rem = divmod(span, k) + cursor = start + for i in range(k): + length = base + (1 if i < rem else 0) + seg_end = cursor + timedelta(days=length - 1) + windows.append((cursor, seg_end)) + cursor = seg_end + timedelta(days=1) + return windows + + +def consecutive_watch(checks: list[Mapping[str, Any]]) -> int: + """末尾连续 watch 判定数 (连续 ≥2 次在 overview 标记告警)。""" + count = 0 + for check in reversed(checks): + if check.get("verdict") == "watch" and not check.get("error"): + count += 1 + else: + break + return count + + +@dataclass(frozen=True) +class CheckSummary: + """一次复验的归约结果 — runtime 计算后交给 evaluate_check 与落档。""" + + objective: str + segment_scores: list[float | None] + segment_trades: list[int | None] + robust: Mapping[str, Any] + n_trades_total: int + worst_drawdown: float | None + incomplete_data: bool = False + error: str | None = None diff --git a/backend/app/backtest/guardian_runtime.py b/backend/app/backtest/guardian_runtime.py new file mode 100644 index 00000000..23a960de --- /dev/null +++ b/backend/app/backtest/guardian_runtime.py @@ -0,0 +1,709 @@ +"""守护复验运行时: 发布/入护后的新数据上做 K 段真实回测并落判定。 + +进程模型与挖掘一致: 由 MiningJobManager 线程经 worker 子进程调用本函数。 +矩阵纪律镜像稳健优化器 — 全区间超集矩阵 + 每段只读视图复用; 与挖掘共用 +heavy 槽 (避免两份全市场矩阵并发)。 + +数据流纪律 (docs/auto-strategy-lab-plan.md §二): 本运行时的任何结果都 +不反馈进取参加权 — 复验只做验证与退役判定。 +""" +from __future__ import annotations + +import itertools +import logging +import math +import time +from collections.abc import Callable, Mapping +from datetime import date +from pathlib import Path +from typing import Any + +from app.backtest.guardian import ( + GUARDIAN_ALGORITHM_VERSION, + MIN_NEW_TRADING_DAYS, + CheckSummary, + GuardianThresholds, + choose_k, + evaluate_check, + objective_soft_floor, + plan_new_data_window, + split_windows, +) +from app.backtest.guards import validate_primary_objective +from app.backtest.robust_optimizer import robust_score +from app.services.guardian_store import ( + GuardianStore, + write_combinations, +) +from app.services.mining_preflight import enriched_partition_dates + +logger = logging.getLogger(__name__) + +ProgressCallback = Callable[[dict], None] +CancelCheck = Any # threading.Event / mp.Event 或返回 bool 的可调用 + +_GUARDIAN_MATRIX_CACHE_BYTES = 64 * 1024 * 1024 +# trailing_reference 基线标定用的尾随窗口长度 (交易日)。 +_CALIBRATE_TRADING_DAYS = 252 +# P2d 组合扫描: 相关性用近 _COMBINATION_WINDOW_TRADING_DAYS 个交易日的日收益, +# 共同日期不足 _COMBINATION_MIN_COMMON_DAYS 时该对不可判 (宁缺毋滥)。 +_COMBINATION_WINDOW_TRADING_DAYS = 126 +_COMBINATION_MIN_COMMON_DAYS = 30 +_COMBINATION_MAX_CORR = 0.70 +_SKIPPED_CAP = 200 # 结果里 skipped 明细上限, 防止极端数量策略撑爆事件载荷 + + + +class GuardianRuntimeCancelledError(RuntimeError): + pass + + +def _raise_if_cancelled(cancel_check: CancelCheck | None) -> None: + if cancel_check is None: + return + cancelled = cancel_check() if callable(cancel_check) else cancel_check.is_set() + if cancelled: + raise GuardianRuntimeCancelledError("guardian cancelled") + + +def _finite_or_none(value: Any) -> float | None: + if value is None: + return None + try: + number = float(value) + except (TypeError, ValueError): + return None + return number if math.isfinite(number) else None + + +def _result_policy(objective: str): + from app.backtest.strategy import BacktestResultPolicy + + return BacktestResultPolicy( + required_stats=frozenset({objective, "n_trades", "max_drawdown"}), + include_monte_carlo=str(objective).startswith("mc_maxdd_"), + include_curves=False, + include_trades=False, + include_per_symbol_stats=False, + include_return_distribution=False, + include_benchmark=False, + include_strategy_info=False, + ) + + +def run_guardian_runtime( + config: Mapping[str, Any], + *, + data_dir: Path, + service: Any, + strategy_engine: Any, + progress_cb: ProgressCallback | None = None, + cancel_check: CancelCheck | None = None, +) -> dict[str, Any]: + """遍历守护档案执行一轮复验; 单策略失败隔离, 不中断整体。""" + t0 = time.perf_counter() + raw_thresholds = config.get("thresholds") or {} + thresholds = GuardianThresholds( + **{ + key: raw_thresholds[key] + for key in ( + "watch_pct", + "retire_pct", + "max_soft_drawdown", + "k_max", + "min_new_trading_days", + ) + if key in raw_thresholds + } + ) + store = GuardianStore(data_dir) + if config.get("mode") == "combinations": + return _run_combination_scan( + config, data_dir=data_dir, service=service, + strategy_engine=strategy_engine, store=store, + cancel_check=cancel_check, t0=t0, + ) + selected = config.get("strategy_ids") + if selected is not None and not isinstance(selected, list): + raise ValueError("guardian strategy_ids must be a list when provided") + + profiles = store.list_profiles() + if selected is not None: + wanted = {str(item) for item in selected} + profiles = [p for p in profiles if str(p.get("strategy_id")) in wanted] + + # retired 档案不再自动复验 (设计: 退役不自动回场); 重新守护 = drop 后重新入护。 + runnable = [p for p in profiles if p.get("status") != "retired"] + skipped_retired = len(profiles) - len(runnable) + + results: list[dict[str, Any]] = [] + checked = skipped = errors = 0 + for index, profile in enumerate(runnable): + _raise_if_cancelled(cancel_check) + strategy_id = str(profile["strategy_id"]) + outcome = _check_one( + strategy_id, + profile, + store=store, + data_dir=data_dir, + service=service, + strategy_engine=strategy_engine, + thresholds=thresholds, + run_id=str(config.get("run_id") or ""), + cancel_check=cancel_check, + ) + if outcome["outcome"] == "checked": + checked += 1 + elif outcome["outcome"] == "skipped": + skipped += 1 + else: + errors += 1 + results.append({"strategy_id": strategy_id, **outcome}) + if progress_cb is not None: + progress_cb( + { + "type": "guardian_progress", + "done": index + 1, + "total": len(runnable), + "strategy_id": strategy_id, + "outcome": outcome["outcome"], + "verdict": outcome.get("verdict"), + } + ) + + return { + "status": "succeeded", + "algorithm_version": GUARDIAN_ALGORITHM_VERSION, + "checked": checked, + "skipped": skipped, + "errors": errors, + "skipped_retired": skipped_retired, + "results": results[:_SKIPPED_CAP], + "elapsed_ms": round((time.perf_counter() - t0) * 1000, 1), + } + + +def _run_combination_scan( + config: Mapping[str, Any], + *, + data_dir: Path, + service: Any, + strategy_engine: Any, + store: GuardianStore, + cancel_check: CancelCheck | None, + t0: float, +) -> dict[str, Any]: + """P2d: 健康守护冠军间的日收益相关性扫描, 低相关对写入组合建议。 + + 每策略一次近窗回测 (include_curves), 提取日收益做 Pearson 相关; + 只产出建议, 组合策略的创建始终由用户在页面上确认。 + """ + from app.backtest.strategy import StrategyBacktestConfig + from app.strategy import config as strategy_config + + _raise_if_cancelled(cancel_check) + healthy = [ + profile for profile in store.list_profiles() + if profile.get("status") == "healthy" + ] + if len(healthy) < 2: + return { + "status": "succeeded", "mode": "combinations", + "healthy_count": len(healthy), "pairs_evaluated": 0, + "suggestions": 0, "results": [], + "elapsed_ms": round((time.perf_counter() - t0) * 1000, 1), + } + + partitions = enriched_partition_dates(data_dir, "stock") + window = partitions[-_COMBINATION_WINDOW_TRADING_DAYS:] + if len(window) < _COMBINATION_MIN_COMMON_DAYS: + return { + "status": "succeeded", "mode": "combinations", + "healthy_count": len(healthy), "pairs_evaluated": 0, + "suggestions": 0, "results": [], + "error": f"enriched 数据不足 ({len(window)} 个交易日)", + "elapsed_ms": round((time.perf_counter() - t0) * 1000, 1), + } + + returns_by_strategy: dict[str, dict[str, float]] = {} + errors: list[dict[str, str]] = [] + for profile in healthy: + _raise_if_cancelled(cancel_check) + strategy_id = str(profile["strategy_id"]) + try: + strategy = strategy_engine.get(strategy_id) + except ValueError: + errors.append({"strategy_id": strategy_id, "error": "策略不可用"}) + continue + asset_type = str((strategy.meta.get("asset_types") or ["stock"])[0]) + overrides = strategy_config.load_override(data_dir, strategy_id) + params = strategy_engine.resolve_params(strategy, overrides=overrides) + result = service.run( + StrategyBacktestConfig( + strategy_id=strategy_id, + symbols=None, + start=window[0], + end=window[-1], + params=dict(params), + overrides=overrides, + asset_type=asset_type, + ), + cancel_event=cancel_check, + ) + series = _daily_returns(result) + if series is None: + errors.append({ + "strategy_id": strategy_id, + "error": result.error or "未产出可用净值曲线", + }) + continue + returns_by_strategy[strategy_id] = series + + suggestions: list[dict[str, Any]] = [] + pairs_evaluated = 0 + ids = sorted(returns_by_strategy) + for i, left in enumerate(ids): + for right in ids[i + 1:]: + corr = _pearson_on_common_dates( + returns_by_strategy[left], returns_by_strategy[right] + ) + pairs_evaluated += 1 + if corr is not None and corr <= _COMBINATION_MAX_CORR: + suggestions.append({ + "a": left, "b": right, + "corr": round(corr, 4), + "common_days": _common_day_count( + returns_by_strategy[left], returns_by_strategy[right] + ), + }) + suggestions.sort(key=lambda item: item["corr"]) + write_combinations( + data_dir, + window_start=window[0].isoformat(), + window_end=window[-1].isoformat(), + suggestions=suggestions, + pairs_evaluated=pairs_evaluated, + ) + return { + "status": "succeeded", "mode": "combinations", + "healthy_count": len(healthy), "pairs_evaluated": pairs_evaluated, + "suggestions": len(suggestions), + "results": errors[:20], + "elapsed_ms": round((time.perf_counter() - t0) * 1000, 1), + } + + +def _daily_returns(result: Any) -> dict[str, float] | None: + """从回测净值曲线提取日收益序列 {date: pct}; 缺曲线或点数不足返回 None。""" + curve = getattr(result, "equity_curve", None) or [] + points: list[tuple[str, float]] = [] + for item in curve: + if not isinstance(item, Mapping): + continue + day = item.get("date") + value = _finite_or_none(item.get("value")) + if isinstance(day, str) and value is not None and value > 0: + points.append((day[:10], value)) + if len(points) < _COMBINATION_MIN_COMMON_DAYS + 1: + return None + returns: dict[str, float] = {} + for (_, prev), (day, cur) in itertools.pairwise(points): + returns[day] = cur / prev - 1.0 + return returns if len(returns) >= _COMBINATION_MIN_COMMON_DAYS else None + + +def _pearson_on_common_dates( + left: dict[str, float], right: dict[str, float] +) -> float | None: + common = sorted(set(left) & set(right)) + if len(common) < _COMBINATION_MIN_COMMON_DAYS: + return None + xs = [left[day] for day in common] + ys = [right[day] for day in common] + n = len(common) + mx, my = sum(xs) / n, sum(ys) / n + cov = sum((x - mx) * (y - my) for x, y in zip(xs, ys, strict=True)) + vx = sum((x - mx) ** 2 for x in xs) + vy = sum((y - my) ** 2 for y in ys) + if vx <= 0.0 or vy <= 0.0: + return None # 常数序列无相关性可言 + return cov / (vx * vy) ** 0.5 + + +def _common_day_count(left: dict[str, float], right: dict[str, float]) -> int: + return len(set(left) & set(right)) + + +def _check_one( + strategy_id: str, + profile: Mapping[str, Any], + *, + store: GuardianStore, + data_dir: Path, + service: Any, + strategy_engine: Any, + thresholds: GuardianThresholds, + run_id: str, + cancel_check: CancelCheck | None, +) -> dict[str, Any]: + try: + strategy = strategy_engine.get(strategy_id) + except ValueError: + _record_simple_check( + store, strategy_id, run_id, error=f"策略不存在或不可加载: {strategy_id}" + ) + return {"outcome": "error", "error": "strategy_unavailable"} + + baseline = profile.get("baseline") or {} + objective = str(baseline.get("objective") or "sharpe") + try: + validate_primary_objective(objective) + objective_soft_floor(objective) + except ValueError as exc: + _record_simple_check(store, strategy_id, run_id, error=str(exc)) + return {"outcome": "error", "error": str(exc)} + + asset_type = str( + (strategy.meta.get("asset_types") or ["stock"])[0] + ) + partitions = enriched_partition_dates(data_dir, asset_type) + if not partitions: + _record_simple_check( + store, strategy_id, run_id, error=f"{asset_type} enriched 数据不可用" + ) + return {"outcome": "error", "error": "enriched_unavailable"} + data_as_of = date.fromisoformat(str(baseline["data_as_of"])) + + # trailing_reference 且尚无分数: 先用尾随窗口标定基线 (只此一次)。 + metrics = baseline.get("metrics") or {} + if baseline.get("kind") == "trailing_reference" and _finite_or_none( + metrics.get("score") + ) is None: + calibrate_error = _calibrate_baseline( + strategy_id, + strategy, + service=service, + strategy_engine=strategy_engine, + store=store, + data_dir=data_dir, + partitions=partitions, + objective=objective, + asset_type=asset_type, + cancel_check=cancel_check, + ) + if calibrate_error is not None: + _record_simple_check(store, strategy_id, run_id, error=calibrate_error) + return {"outcome": "error", "error": "calibration_failed"} + metrics = (store.get(strategy_id) or {}).get("baseline", {}).get("metrics") or {} + + baseline_score = _finite_or_none(metrics.get("score")) + baseline_n_trades = metrics.get("n_trades") + baseline_n_trades = ( + int(baseline_n_trades) + if isinstance(baseline_n_trades, (int, float)) and not isinstance(baseline_n_trades, bool) + else 0 + ) + if baseline_score is None: + _record_simple_check( + store, strategy_id, run_id, error="基线分数缺失, 无法比较 (mining_gate 基线不应出现)" + ) + return {"outcome": "error", "error": "baseline_missing"} + + window = plan_new_data_window( + partitions, data_as_of, min_new_trading_days=thresholds.min_new_trading_days + ) + if window is None: + fresh = sum(1 for value in partitions if value > data_as_of) + store.append_check( + strategy_id, + { + "checked_at": _now_iso(), + "run_id": run_id, + "skipped": True, + "reason": f"基线后新数据仅 {fresh} 个交易日 (需 ≥{thresholds.min_new_trading_days})", + }, + ) + return {"outcome": "skipped", "reason": "insufficient_new_data"} + + window_start, window_end = window + try: + k = choose_k(window_start, window_end, thresholds.k_max) + except ValueError as exc: + store.append_check( + strategy_id, + { + "checked_at": _now_iso(), + "run_id": run_id, + "skipped": True, + "reason": str(exc), + }, + ) + return {"outcome": "skipped", "reason": "window_too_short"} + + summary = _evaluate_windows( + strategy_id, + strategy, + service=service, + strategy_engine=strategy_engine, + data_dir=data_dir, + windows=split_windows(window_start, window_end, k), + objective=objective, + asset_type=asset_type, + cancel_check=cancel_check, + ) + missing = sum(1 for value in summary.segment_scores if value is None) + if missing == len(summary.segment_scores): + store.append_check( + strategy_id, + { + "checked_at": _now_iso(), + "run_id": run_id, + "objective": objective, + "window_start": window_start.isoformat(), + "window_end": window_end.isoformat(), + "k": k, + "error": summary.error or "全部验证段回测失败", + }, + ) + return {"outcome": "error", "error": summary.error or "all_segments_failed"} + + verdict = evaluate_check( + objective=objective, + baseline_score=baseline_score, + baseline_n_trades=baseline_n_trades, + current_score=_finite_or_none(summary.robust.get("score")), + current_n_trades=summary.n_trades_total, + worst_drawdown=summary.worst_drawdown, + thresholds=thresholds, + incomplete_data=summary.incomplete_data, + missing_segments=missing, + ) + store.append_check( + strategy_id, + { + "checked_at": _now_iso(), + "run_id": run_id, + "objective": objective, + "window_start": window_start.isoformat(), + "window_end": window_end.isoformat(), + "k": k, + "segment_scores": summary.segment_scores, + "segment_trades": summary.segment_trades, + "robust": _sanitize_robust(summary.robust), + # 残缺复验把首个段落级错误一并落档 (如"未产生买入信号"), 便于排查 + **({"error": summary.error} if summary.error else {}), + "n_trades_total": summary.n_trades_total, + "worst_drawdown": summary.worst_drawdown, + "degradation_pct": verdict.degradation_pct, + "verdict": verdict.status, + "reasons": list(verdict.reasons), + "incomplete": verdict.incomplete_data, + }, + ) + if verdict.status != "healthy" or profile.get("status") != verdict.status: + store.set_status( + strategy_id, + verdict.status, + reason="; ".join(verdict.reasons) or None, + ) + return { + "outcome": "checked", + "verdict": verdict.status, + "degradation_pct": verdict.degradation_pct, + } + + +def _evaluate_windows( + strategy_id: str, + strategy: Any, + *, + service: Any, + strategy_engine: Any, + data_dir: Path, + windows: list[tuple[date, date]], + objective: str, + asset_type: str, + cancel_check: CancelCheck | None, +) -> CheckSummary: + from app.backtest.strategy import StrategyBacktestConfig + from app.strategy import config as strategy_config + + overrides = strategy_config.load_override(data_dir, strategy_id) + params = strategy_engine.resolve_params(strategy, overrides=overrides) + policy = _result_policy(objective) + + def _config(start: date, end: date) -> Any: + return StrategyBacktestConfig( + strategy_id=strategy_id, + symbols=None, + start=start, + end=end, + params=dict(params), + overrides=overrides, + asset_type=asset_type, + ) + + full_start, full_end = windows[0][0], windows[-1][1] + prepared = None + seg_views: list[Any] = [] + if getattr(strategy, "execution_backend", "") == "matrix_native": + try: + prepared = service.prepare_matrix_optimization( + [_config(full_start, full_end)], + matrix_cache_max_bytes=_GUARDIAN_MATRIX_CACHE_BYTES, + ) + for seg_start, seg_end in windows: + try: + seg_views.append( + service.prepare_matrix_optimization( + [_config(seg_start, seg_end)], + matrix_cache_max_bytes=_GUARDIAN_MATRIX_CACHE_BYTES, + market_data_override=prepared.market_data, + ) + ) + except Exception: # noqa: BLE001 — 单段视图失败走通用路径 + logger.warning( + "守护复验 %s 段 %s 视图准备失败, 走通用路径", + strategy_id, + seg_start, + exc_info=True, + ) + seg_views.append(None) + except Exception as exc: # noqa: BLE001 — 矩阵准备失败整体走通用路径 + logger.warning("守护复验 %s 矩阵准备失败, 走通用路径: %r", strategy_id, exc) + prepared = None + seg_views = [] + + scores: list[float | None] = [] + trades: list[int | None] = [] + drawdowns: list[float | None] = [] + first_error: str | None = None + try: + for index, (seg_start, seg_end) in enumerate(windows): + _raise_if_cancelled(cancel_check) + config = _config(seg_start, seg_end) + kwargs: dict[str, Any] = {"result_policy": policy} + if prepared is not None and index < len(seg_views): + view = seg_views[index] + if view is not None: + kwargs["prepared"] = view + elif prepared is not None: + kwargs["prepared"] = prepared + result = service.run(config, cancel_event=cancel_check, **kwargs) + if result.error: + scores.append(None) + trades.append(None) + drawdowns.append(None) + first_error = first_error or str(result.error) + continue + stats = result.stats or {} + scores.append(_finite_or_none(stats.get(objective))) + n_trades = stats.get("n_trades") + trades.append( + int(n_trades) + if isinstance(n_trades, (int, float)) and not isinstance(n_trades, bool) + else None + ) + drawdowns.append(_finite_or_none(stats.get("max_drawdown"))) + finally: + for view in seg_views: + if view is not None: + view.compute_cache.close() + if prepared is not None: + prepared.compute_cache.close() + + valid_drawdowns = [value for value in drawdowns if value is not None] + n_total = sum(value for value in trades if value is not None) + return CheckSummary( + objective=objective, + segment_scores=scores, + segment_trades=trades, + robust=robust_score(scores), + n_trades_total=n_total, + worst_drawdown=min(valid_drawdowns) if valid_drawdowns else None, + incomplete_data=any(value is None for value in scores), + error=first_error, + ) + + +def _calibrate_baseline( + strategy_id: str, + strategy: Any, + *, + service: Any, + strategy_engine: Any, + store: GuardianStore, + data_dir: Path, + partitions: list[date], + objective: str, + asset_type: str, + cancel_check: CancelCheck | None, +) -> str | None: + """trailing_reference 首跑标定: 尾随窗口单次回测作为参考基线 (非 OOS, 仅供比较)。""" + from app.backtest.strategy import StrategyBacktestConfig + + _raise_if_cancelled(cancel_check) + window = partitions[-_CALIBRATE_TRADING_DAYS:] + if len(window) < MIN_NEW_TRADING_DAYS: + return f"尾随窗口交易日不足 ({len(window)})" + from app.strategy import config as strategy_config + + overrides = strategy_config.load_override(data_dir, strategy_id) + params = strategy_engine.resolve_params(strategy, overrides=overrides) + config = StrategyBacktestConfig( + strategy_id=strategy_id, + symbols=None, + start=window[0], + end=window[-1], + params=dict(params), + overrides=overrides, + asset_type=asset_type, + ) + try: + result = service.run( + config, cancel_event=cancel_check, result_policy=_result_policy(objective) + ) + except (OSError, ValueError, TypeError) as exc: + return f"基线标定回测失败: {exc}" + if result.error: + return f"基线标定回测失败: {result.error}" + score = _finite_or_none(result.stats.get(objective)) + if score is None: + return "基线标定回测未返回有限分数" + n_trades = result.stats.get("n_trades") + store.set_baseline_metrics( + strategy_id, + { + "score": score, + "n_trades": int(n_trades) + if isinstance(n_trades, (int, float)) and not isinstance(n_trades, bool) + else 0, + "max_drawdown": _finite_or_none(result.stats.get("max_drawdown")), + "window_start": window[0].isoformat(), + "window_end": window[-1].isoformat(), + }, + ) + return None + + +def _record_simple_check( + store: GuardianStore, + strategy_id: str, + run_id: str, + *, + error: str, +) -> None: + store.append_check( + strategy_id, + {"checked_at": _now_iso(), "run_id": run_id, "error": error[:500]}, + ) + + +def _sanitize_robust(robust: Mapping[str, Any]) -> dict[str, Any]: + return {key: _finite_or_none(value) for key, value in robust.items()} + + +def _now_iso() -> str: + from datetime import UTC, datetime + + return datetime.now(UTC).isoformat() diff --git a/backend/app/backtest/mining.py b/backend/app/backtest/mining.py index a4a23fba..bd74011a 100644 --- a/backend/app/backtest/mining.py +++ b/backend/app/backtest/mining.py @@ -203,8 +203,14 @@ def evaluate_candidate_gate( sharpe: float | None, max_drawdown: float | None, n_trades: int | None, + worst_fold_sharpe: float | None = None, ) -> CandidateGateResult: - """Check the documented promotion thresholds against real candidate evidence.""" + """Check the documented promotion thresholds against real candidate evidence. + + worst_fold_sharpe 是 mining-v3 起的稳健维度: strict 档 (confidence=high) 要求 + 最差外层折 Sharpe > 0, 防"均值好看、某段崩溃"的组合登顶。None 表示 v2 历史 + 工件无此列 — 历史候选按其产出时的口径放行, 不追溯执行新门槛。 + """ reasons: list[str] = [] if confidence == "low": reasons.append("exploratory results can only be saved as pending candidates") @@ -221,6 +227,14 @@ def evaluate_candidate_gate( ) if sharpe is None or sharpe < GATE_MIN_OOS_SHARPE: reasons.append(f"requires an OOS Sharpe of at least {GATE_MIN_OOS_SHARPE}") + if ( + confidence == "high" + and worst_fold_sharpe is not None + and worst_fold_sharpe <= 0.0 + ): + reasons.append( + f"strict profile requires a positive worst-fold Sharpe (got {worst_fold_sharpe:.3f})" + ) if max_drawdown is None or max_drawdown < GATE_MAX_DRAWDOWN: reasons.append( f"requires a max drawdown no worse than {abs(GATE_MAX_DRAWDOWN):.0%}" diff --git a/backend/app/backtest/mining_runtime.py b/backend/app/backtest/mining_runtime.py index 22ac457a..09d90622 100644 --- a/backend/app/backtest/mining_runtime.py +++ b/backend/app/backtest/mining_runtime.py @@ -1,6 +1,7 @@ """Production mining runtime executed only inside a spawned worker.""" from __future__ import annotations +import itertools import json import math import os @@ -37,12 +38,14 @@ from app.backtest.mining import ( MiningResult, MiningService, benchmark_candidate, + compute_candidate_signature, compute_rank_correlation, generate_nested_folds, required_outer_folds, required_trading_bars, validation_config_for_profile, ) +from app.backtest.robust_optimizer import robust_score from app.backtest.strategy import ( BacktestResultPolicy, ResolvedFeaturePlan, @@ -88,6 +91,15 @@ _REGIME_FILTERS: dict[str, dict[str, list[str]]] = { _SNAPSHOT_MAX_ATTEMPTS = 3 _SNAPSHOT_PUBLISH_WAIT_SECONDS = 5.0 +# ── P2b 冠军权重精调 (docs/auto-strategy-lab-plan.md §4.2) ────────────── +# 评分是截面百分位映射, 权重整体正缩放不改变排名 → 只搜比率: 固定首个因子 1.0, +# 其余在档位网格上取值。矩阵复用挖掘 run 的超集矩阵, 每个变体只是纯模拟成本。 +_WEIGHT_RUNGS: tuple[float, ...] = (0.5, 1.0, 1.5, 2.0) +_REFINE_TOP_N = 1 # 只精调最终冠军 +_REFINE_MAX_VARIANTS = 64 # 4^(n-1), n≤4 +_REFINE_MAX_TRIALS = 96 # 精调总回测预算 (与 MiningBudget.max_trials 同量级) +_REFINE_DEGRADATION_LIMIT = 0.2 # stage2 全窗退化超过 20% 放弃精调结果 + class MiningRuntimeCancelledError(RuntimeError): pass @@ -618,7 +630,7 @@ def run_mining_runtime( start_phase() artifact_started = time.perf_counter() emit({"phase": "artifacts", "label": "写入研究结果", "done": 0, "total": 4}) - artifact_frames = _build_artifacts( + artifact_frames, refinement_summary = _build_artifacts( panel, request, result, @@ -671,6 +683,7 @@ def run_mining_runtime( "data_as_of": request.end.isoformat(), "algorithm_version": MINING_ALGORITHM_VERSION, "methodology_version": FACTOR_METHODOLOGY_VERSION, + "refinement": refinement_summary, "proxy_trials_used": result.proxy_trials_used, "trials_used": result.trials_used + max(0, evaluator.backtest_count - result.trials_used), "panel_rows": panel.height, @@ -1014,7 +1027,7 @@ def _build_artifacts( metric_provider: TrainingMetricProvider, evaluator: MatcherCandidateEvaluator, cancel_check: CancelCheck | None, -) -> dict[str, pl.DataFrame]: +) -> tuple[dict[str, pl.DataFrame], dict[str, Any] | None]: nested = generate_nested_folds(_date_labels(panel), request.mining_request.validation) last_train = _panel_for_dates(panel, nested[-1].outer.train_labels) if "_target_date" in last_train.columns: @@ -1172,6 +1185,18 @@ def _build_artifacts( overall_rows, limit=request.max_finalists, ) + [benchmark_by_id[cid] for cid in sorted(benchmark_by_id)] + refined_rows, refinement_summary = _refine_champion_weights( + panel, + request, + result, + ranked_candidates, + evaluator, + nested, + cancel_check, + ) + refined_after: dict[str, list[dict[str, Any]]] = {} + if refined_rows and refinement_summary is not None: + refined_after[refinement_summary["candidate_signature"]] = refined_rows for candidate in ranked_candidates: rows = [row for row in overall_rows if row["candidate_signature"] == candidate.candidate_id] successful = [row for row in rows if not row["skipped"]] @@ -1200,9 +1225,10 @@ def _build_artifacts( separators=(",", ":"), ), "regime_state": "overall", - "score": _mean_or_none(sharpes), + "score": _robust_or_none(sharpes), "oos_return": _mean_or_none(returns), "oos_sharpe": _mean_or_none(sharpes), + "oos_worst_sharpe": min(sharpes) if sharpes else None, "oos_max_drawdown": min(drawdowns) if drawdowns else None, "oos_positive_fold_ratio": ( sum(value > 0 for value in returns) / len(returns) if returns else None @@ -1214,10 +1240,14 @@ def _build_artifacts( "promoted_candidate_id": None, "published_strategy_id": None, }) + # P2b: 原冠军行落盘后紧随其精调变体 (若产出); 冠军取自 ranked[:1], 必在循环内 + for refined_row in refined_after.pop(candidate.candidate_id, []): + candidate_rows.append(refined_row) candidates = pl.DataFrame(candidate_rows, schema_overrides={ "score": pl.Float64, "oos_return": pl.Float64, "oos_sharpe": pl.Float64, + "oos_worst_sharpe": pl.Float64, "oos_max_drawdown": pl.Float64, "oos_positive_fold_ratio": pl.Float64, "oos_n_trades": pl.Int64, @@ -1230,7 +1260,7 @@ def _build_artifacts( "correlation": correlation_frame, "candidates": candidates, "folds": folds, - } + }, refinement_summary def _factor_metric( @@ -1401,6 +1431,197 @@ def _regime_date_count( ) +def _refine_champion_weights( + panel: pl.DataFrame, + request: RuntimeRequest, + result: MiningResult, + ranked_candidates: Sequence[MiningCandidate], + evaluator: MatcherCandidateEvaluator, + nested: Sequence[Any], + cancel_check: CancelCheck | None, +) -> tuple[list[dict[str, Any]], dict[str, Any] | None]: + """P2b: 对最终冠军组合做权重比率精调, 产出额外的"权重精调"候选行。 + + 两段式: stage1 全部变体 × 各外层测试窗 (复用 run 超集矩阵) 按稳健分选优; + stage2 冠军变体与原权重全窗口复验, 退化超限则放弃 (宁缺毋滥)。 + 只产出新行, 不改写原始候选 — 原始结构仍在 folds 工件中留档。 + """ + champions = [ + candidate + for candidate in ranked_candidates[:_REFINE_TOP_N] + if candidate.kind == "factor_rank" and len(candidate.factor_names) >= 2 + ] + if not champions: + return [], None + champion = champions[0] + factors = list(champion.factor_names) + original_weights = [float(weight) for weight in champion.weights] + base = original_weights[0] + if base <= 0.0: + return [], None + original_ratios = tuple(round(weight / base, 6) for weight in original_weights) + + variants: list[tuple[float, ...]] = [] + seen = {original_ratios} + for combo in itertools.product(_WEIGHT_RUNGS, repeat=len(factors) - 1): + ratios = (1.0, *combo) + if ratios in seen: + continue + seen.add(ratios) + variants.append(ratios) + # 原权重(归一)始终作为对照基准参与评估 + all_variants = [original_ratios, *variants[: _REFINE_MAX_VARIANTS - 1]] + + test_label_sets = [fold.outer.test_labels for fold in nested] + n_trials = len(all_variants) * len(test_label_sets) + 2 # +2 = stage2 全窗两次 + # 预算纪律 (设计 §4.2): 精调只花本 run 搜索阶段剩下的真实回测额度, + # 不另开上限 — 搜索已耗尽预算 (balanced 96/strict 256) 时 4 因子冠军自然跳过。 + remaining = min( + _REFINE_MAX_TRIALS, + max(0, request.mining_request.budget.max_trials - result.trials_used), + ) + if n_trials > remaining: + return [], None + + fold_results: dict[tuple[float, ...], list[dict[str, Any] | None]] = {} + for ratios in all_variants: + _raise_if_cancelled(cancel_check) + definition = _weighted_definition(champion, ratios) + per_fold: list[dict[str, Any] | None] = [] + for labels in test_label_sets: + evaluation = evaluator.evaluate_test( + _panel_for_dates(panel, labels), definition + ) + per_fold.append( + None + if evaluation.error is not None + else { + key: _finite_or_none(evaluation.metrics.get(key)) + if key != "n_trades" + else evaluation.metrics.get("n_trades") + for key in ("total_return", "sharpe", "max_drawdown", "n_trades") + } + ) + fold_results[ratios] = per_fold + + def _variant_robust(ratios: tuple[float, ...]) -> float: + scores = [ + fold["sharpe"] if fold is not None else None + for fold in fold_results[ratios] + ] + value = robust_score(scores)["score"] + return float(value) + + best_ratios = max(all_variants, key=_variant_robust) + if best_ratios == original_ratios: + return [], None + + # stage2: 全窗口复验退化 (冠军变体 vs 原权重) + _raise_if_cancelled(cancel_check) + full_labels = _date_labels(panel) + refined_full = evaluator.evaluate_test( + _panel_for_dates(panel, full_labels), + _weighted_definition(champion, best_ratios), + ) + original_full = evaluator.evaluate_test( + _panel_for_dates(panel, full_labels), champion.definition() + ) + refined_full_sharpe = ( + _finite_or_none(refined_full.metrics.get("sharpe")) + if refined_full.error is None + else None + ) + original_full_sharpe = ( + _finite_or_none(original_full.metrics.get("sharpe")) + if original_full.error is None + else None + ) + summary: dict[str, Any] = { + "candidate_signature": champion.candidate_id, + "factor_names": factors, + "original_weights": original_ratios, + "refined_weights": best_ratios, + "robust_before": _variant_robust(original_ratios), + "robust_after": _variant_robust(best_ratios), + "full_window_sharpe_before": original_full_sharpe, + "full_window_sharpe_after": refined_full_sharpe, + } + if ( + refined_full_sharpe is None + or ( + original_full_sharpe is not None + and original_full_sharpe > 0 + and refined_full_sharpe + < (1.0 - _REFINE_DEGRADATION_LIMIT) * original_full_sharpe + ) + ): + summary["applied"] = False + summary["reason"] = "全窗口复验退化超过 20%, 放弃精调权重" + return [], summary + if refined_full_sharpe is None: + summary["applied"] = False + summary["reason"] = "精调变体全窗口回测失败" + return [], summary + + refined_definition = _weighted_definition(champion, best_ratios) + refined_signature = compute_candidate_signature(refined_definition) + if any(candidate.candidate_id == refined_signature for candidate in ranked_candidates): + summary["applied"] = False + summary["reason"] = "精调结果与既有候选重复" + return [], summary + + folds = fold_results[best_ratios] + successful = [fold for fold in folds if fold is not None] + returns = [fold["total_return"] for fold in successful if fold["total_return"] is not None] + sharpes = [fold["sharpe"] for fold in successful if fold["sharpe"] is not None] + drawdowns = [ + fold["max_drawdown"] for fold in successful if fold["max_drawdown"] is not None + ] + trades = [fold["n_trades"] for fold in successful if fold["n_trades"] is not None] + row = { + "signature": refined_signature, + "name": _candidate_name(champion) + " · 权重精调", + "kind": "factor_combination", + "factor_names_json": json.dumps(factors, ensure_ascii=False, separators=(",", ":")), + "strategy_id": None, + "definition_json": json.dumps( + refined_definition, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ), + "regime_state": "overall", + "score": _robust_or_none(sharpes), + "oos_return": _mean_or_none(returns), + "oos_sharpe": _mean_or_none(sharpes), + "oos_worst_sharpe": min(sharpes) if sharpes else None, + "oos_max_drawdown": min(drawdowns) if drawdowns else None, + "oos_positive_fold_ratio": ( + sum(value > 0 for value in returns) / len(returns) if returns else None + ), + "oos_n_trades": sum(int(value) for value in trades) if trades else None, + "confidence": _confidence(request.profile), + "valid_folds": len(successful), + "skipped_folds": len(folds) - len(successful), + "promoted_candidate_id": None, + "published_strategy_id": None, + } + summary["applied"] = True + return [row], summary + + +def _weighted_definition( + candidate: MiningCandidate, ratios: tuple[float, ...] +) -> dict[str, Any]: + definition = candidate.definition() + scoring = { + factor: float(ratio) + for factor, ratio in zip(candidate.factor_names, ratios, strict=True) + } + return {**definition, "scoring": scoring} + + def _rank_artifact_candidates( candidates: Sequence[MiningCandidate], overall_rows: Sequence[Mapping[str, Any]], @@ -1417,6 +1638,8 @@ def _candidate_artifact_rank( candidate: MiningCandidate, overall_rows: Sequence[Mapping[str, Any]], ) -> tuple[float, str]: + # mining-v3: 冠军按稳健分 (0.6×median + 0.4×worst) 排名, 单折运气不再能登顶; + # 均值只保留在 oos_sharpe 列作绝对门槛输入 (gate 阈值口径不变)。 sharpes = [ row.get("sharpe") for row in overall_rows @@ -1424,13 +1647,20 @@ def _candidate_artifact_rank( and not row.get("skipped") and row.get("sharpe") is not None ] - mean_sharpe = _mean_or_none(sharpes) + score = _robust_or_none(sharpes) return ( - -(mean_sharpe if mean_sharpe is not None else float("-inf")), + -(score if score is not None else float("-inf")), candidate.candidate_id, ) +def _robust_or_none(values: Sequence[float]) -> float | None: + if not values: + return None + score = robust_score(list(values))["score"] + return float(score) if score == score and abs(score) != float("inf") else None + + def _candidate_name(candidate: MiningCandidate) -> str: if candidate.kind == "existing_strategy": return f"已有策略 · {candidate.strategy_id}" diff --git a/backend/app/backtest/worker.py b/backend/app/backtest/worker.py index 75920060..213ce3e8 100644 --- a/backend/app/backtest/worker.py +++ b/backend/app/backtest/worker.py @@ -137,6 +137,10 @@ def make_worker_task(kind: str, data_dir: Path, config) -> dict[str, Any]: if not isinstance(config, dict): raise TypeError("mining worker config must be a dict") encoded = dict(config) + elif kind == "guardian": + if not isinstance(config, dict): + raise TypeError("guardian worker config must be a dict") + encoded = dict(config) else: raise ValueError(f"unsupported worker task kind: {kind}") return { @@ -231,6 +235,18 @@ def _worker_entry(task: dict[str, Any], event_queue, cancel_event) -> None: cancel_check=cancel_event, rss_sampler=sampler, ) + elif kind == "guardian": + from app.backtest.guardian_runtime import run_guardian_runtime + + payload = dict(task["config"]) + result = run_guardian_runtime( + {**payload.get("request", {}), "run_id": payload.get("run_id")}, + data_dir=data_dir, + service=service, + strategy_engine=strategy_engine, + progress_cb=_progress, + cancel_check=cancel_event, + ) else: raise ValueError(f"unsupported worker task kind: {kind}") diff --git a/backend/app/jobs/daily_pipeline.py b/backend/app/jobs/daily_pipeline.py index 9dc4ca00..c7c8e462 100644 --- a/backend/app/jobs/daily_pipeline.py +++ b/backend/app/jobs/daily_pipeline.py @@ -1029,16 +1029,18 @@ def _run_tracked(fn, job_label: str) -> bool: def _scheduled_pipeline_task(pipeline_fn) -> None: - """Run weekly mining only after the tracked daily pipeline has fully succeeded.""" + """Run the scheduled lab cycle (weekly mining + daily guardian) after pipeline success.""" if not _run_tracked(pipeline_fn, "daily_pipeline"): return try: - from app.services.mining_schedule import run_weekly_mining + from app.services.lab_schedule import run_scheduled_lab - result = run_weekly_mining(_get_app_state()) - logger.info("scheduled mining result: %s", result) + result = run_scheduled_lab(_get_app_state()) + logger.info("scheduled lab result: %s", result) except Exception: - logger.exception("scheduled mining enqueue failed; daily pipeline remains succeeded") + logger.exception( + "scheduled lab enqueue failed; daily pipeline remains succeeded" + ) # ================================================================ diff --git a/backend/app/main.py b/backend/app/main.py index 1517fb33..57730cfc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -23,9 +23,11 @@ from app.api import ( ext_pages, factors, financials, + guardian, indices, intraday, kline, + lab, lots, market_recap, mining, @@ -131,6 +133,7 @@ async def _application_lifespan(app: FastAPI): logger.info("custom factors loaded: %s", len(loaded_factors)) except Exception as exc: # noqa: BLE001 logger.warning("custom factors load failed: %s", exc) + from app.services.mining_jobs import MiningRunStore from app.services.mining_manager import MiningJobManager mining_manager = MiningJobManager(store.data_dir) @@ -138,6 +141,19 @@ async def _application_lifespan(app: FastAPI): app.state.mining_manager = mining_manager if recovered_mining_runs: logger.warning("recovered %d interrupted mining runs", recovered_mining_runs) + # 策略守护复验: 复用挖掘的任务编排 (线程 + spawn 子进程 + 持久 run store), + # 独立 namespace 存 run 记录; 与挖掘共用 heavy 槽 (避免双份全市场矩阵)。 + guardian_manager = MiningJobManager( + store.data_dir, + kind="guardian", + store=MiningRunStore(store.data_dir, namespace="guardian"), + ) + recovered_guardian_runs = guardian_manager.recover_interrupted() + app.state.guardian_manager = guardian_manager + if recovered_guardian_runs: + logger.warning( + "recovered %d interrupted guardian runs", recovered_guardian_runs + ) # 在接受回测请求前固定 managed generation,避免首批并发 worker 各自创建版本。 if settings.backtest_matrix_disk_cache_enabled: try: @@ -451,6 +467,9 @@ async def _application_lifespan(app: FastAPI): mmanager = getattr(app.state, "mining_manager", None) if mmanager: mmanager.shutdown() + gmanager = getattr(app.state, "guardian_manager", None) + if gmanager: + gmanager.shutdown() if app.state.scheduler: app.state.scheduler.shutdown(wait=False) ps = getattr(app.state, "pull_scheduler", None) @@ -606,6 +625,8 @@ app.include_router(screener.router) app.include_router(backtest.router) app.include_router(factors.router) app.include_router(mining.router) +app.include_router(guardian.router) +app.include_router(lab.router) app.include_router(intraday.router) app.include_router(indices.router) app.include_router(overview.router) diff --git a/backend/app/services/guardian_combinations.py b/backend/app/services/guardian_combinations.py new file mode 100644 index 00000000..8a99d397 --- /dev/null +++ b/backend/app/services/guardian_combinations.py @@ -0,0 +1,173 @@ +"""P2d 守护冠军组合: composite 策略生成 (用户确认后调用)。 + +只做"低相关健康冠军 → composite 策略文件 + 守护入护"这一件事; +候选对的发现由守护运行时的组合扫描产出 (guardian_runtime._run_combination_scan)。 +""" +from __future__ import annotations + +import hashlib +import logging +import os +import uuid +from pathlib import Path +from typing import Any + +from app.services.guardian_store import GuardianStore, read_combinations + +logger = logging.getLogger(__name__) + +_MAX_NAME = 60 +_GUARDIAN_COMBO_MAX = 50 # 累计生成的守护组合上限, 防无限膨胀 + + +def render_composite_source( + strategy_id: str, + name: str, + children: list[dict[str, Any]], +) -> str: + meta = { + "id": strategy_id, + "name": name, + "description": "守护冠军低相关组合 (自动生成, 来源见守护页)", + "asset_types": ["stock"], + "timeframes": ["1d"], + "params": [ + { + "id": "merge_mode", + "label": "合并模式", + "type": "select", + "options": ["union", "intersect"], + "default": "union", + }, + {"id": "min_confirm", "label": "交集最少确认数", "type": "int", + "default": 1, "min": 0}, + ], + "scoring": {}, + "order_by": "score", + "descending": True, + "limit": 100, + "children": children, + } + return ( + '"""守护组合策略 (自动生成, 请勿手改业务逻辑)。"""\n' + f"META = {meta!r}\n" + 'EXECUTION_BACKEND = "composite"\n' + ) + + +def create_guardian_composite( + data_dir: Path, + strategy_engine: Any, + *, + left: str, + right: str, + latest_enriched: Any, +) -> dict[str, Any]: + """按组合建议创建 composite 策略并纳入守护; 幂等 (已存在直接返回)。 + + fail-closed: 建议对必须在最近一次组合扫描结果中 (健康 + 相关性 ≤0.7)。 + """ + suggestions = read_combinations(data_dir) or {} + pair = next( + ( + item for item in suggestions.get("suggestions") or [] + if {item.get("a"), item.get("b")} == {left, right} + ), + None, + ) + if pair is None: + raise ValueError( + "该策略对不在最新组合建议中 — 请先运行组合扫描, 且双方需为健康守护策略" + ) + if latest_enriched is None: + raise ValueError("enriched 数据不可用, 无法建立守护基线") + # 与 _custom_strategy_path 同口径: 先 resolve 再比较, + # 避免 /tmp 等符号链接目录被误判为逃逸 data_dir + data_dir = Path(data_dir).resolve() + + strategy_id = _combo_strategy_id(left, right) + if strategy_engine.has(strategy_id): + _ensure_guardian_profile(data_dir, strategy_id, latest_enriched) + return {"ok": True, "strategy_id": strategy_id, "created": False} + + composite_dir = data_dir / "strategies" / "composite" + unresolved = composite_dir + unresolved.mkdir(parents=True, exist_ok=True) + if unresolved.is_symlink(): + raise ValueError("composite strategy directory must not be a symlink") + root = unresolved.resolve() + if not root.is_relative_to(data_dir): + raise ValueError("composite directory escapes data_dir") + existing = sorted(root.glob("guardian_combo_*.py")) + if len(existing) >= _GUARDIAN_COMBO_MAX: + raise ValueError( + f"守护组合数量已达上限 ({_GUARDIAN_COMBO_MAX}); 请删除不再使用的组合" + ) + path = (root / f"{strategy_id}.py").resolve(strict=False) + if path.parent != root: + raise ValueError("composite path escapes its directory") + + left_name = _strategy_display_name(strategy_engine, left) + right_name = _strategy_display_name(strategy_engine, right) + name = f"{left_name}+{right_name}"[:_MAX_NAME] + children = [ + {"strategy_id": left, "weight": 0.5}, + {"strategy_id": right, "weight": 0.5}, + ] + source = render_composite_source(strategy_id, name, children) + + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + try: + with temporary.open("x", encoding="utf-8", newline="\n") as stream: + stream.write(source) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + except OSError as exc: + temporary.unlink(missing_ok=True) + raise RuntimeError("failed to write composite strategy source") from exc + + strategy_engine.reload() + strategy = strategy_engine.get(strategy_id) + if strategy.execution_backend != "composite": + raise RuntimeError("生成的组合策略未通过引擎校验") + for child in children: + strategy_engine.get(str(child["strategy_id"])) + + _ensure_guardian_profile(data_dir, strategy_id, latest_enriched) + _invalidate_caches(data_dir) + return {"ok": True, "strategy_id": strategy_id, "created": True} + + +def _ensure_guardian_profile(data_dir: Path, strategy_id: str, latest: Any) -> None: + GuardianStore(data_dir).create_baseline( + strategy_id, + kind="trailing_reference", + data_as_of=latest.isoformat(), + objective="sharpe", + metrics={}, + provenance={"kind": "guardian_combination"}, + ) + + +def _invalidate_caches(data_dir: Path) -> None: + try: + from app.services.strategy_cache import clear_cache + + clear_cache(data_dir) + except Exception: # noqa: BLE001 — 缓存失效失败不阻断创建 + logger.warning("strategy cache invalidation failed", exc_info=True) + + +def _combo_strategy_id(left: str, right: str) -> str: + payload = "\\0".join(sorted((left, right))).encode("utf-8") + digest = hashlib.blake2b(payload, digest_size=8).hexdigest() + return f"guardian_combo_{digest}" + + +def _strategy_display_name(strategy_engine: Any, strategy_id: str) -> str: + try: + name = str(strategy_engine.get(strategy_id).meta.get("name") or "") + except ValueError: + name = "" + return name or strategy_id diff --git a/backend/app/services/guardian_schedule.py b/backend/app/services/guardian_schedule.py new file mode 100644 index 00000000..858063d4 --- /dev/null +++ b/backend/app/services/guardian_schedule.py @@ -0,0 +1,96 @@ +"""每日守护复验调度与确定性 claim。 + +镜像周度挖掘调度 (mining_schedule) 的纪律: 只做门槛检查与入队, 绝不同步执行 +复验; 同一北京交易日只 claim 一次, 重启/重复触发由 run_id 幂等吸收。 +挂在 daily_pipeline 成功之后 (数据刚更新, 正是复验的时机)。 +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import threading +from datetime import date, datetime +from pathlib import Path +from typing import Any + +from app.services import preferences +from app.services.guardian_store import GuardianStore +from app.services.mining_schedule import beijing_date + +logger = logging.getLogger(__name__) + +GUARDIAN_FINGERPRINT_VERSION = "guardian-data-v1" +_CLAIM_LOCK = threading.Lock() + + +def daily_claim(day: date) -> str: + return f"guardian-daily-{day.isoformat()}" + + +def guardian_request(config: dict[str, Any]) -> dict[str, Any]: + """调度/手动复验共用的 worker 请求体 (阈值来自守护偏好)。""" + return { + "strategy_ids": None, + "thresholds": { + "watch_pct": config["guardian_watch_pct"], + "retire_pct": config["guardian_retire_pct"], + "k_max": config["guardian_k_max"], + }, + } + + +def build_guardian_fingerprint(repo: Any) -> dict[str, Any]: + data_dir = Path(repo.store.data_dir) + profiles = [ + {"file": path.name, "mtime_ns": path.stat().st_mtime_ns} + for path in sorted((GuardianStore(data_dir).root).glob("*.json")) + ] + digest = hashlib.sha256( + json.dumps(profiles, ensure_ascii=False, sort_keys=True).encode("utf-8") + ).hexdigest() + return { + "version": GUARDIAN_FINGERPRINT_VERSION, + "latest_enriched_date": repo.latest_enriched_date("stock").isoformat() + if repo.latest_enriched_date("stock") is not None + else None, + "profiles": {"count": len(profiles), "digest": digest}, + } + + +def run_daily_guardian(app_state: Any, *, now: datetime | None = None) -> dict[str, Any]: + """Check the daily gate and enqueue a guardian pass; never run synchronously.""" + config = preferences.get_guardian_schedule() + if not config["guardian_schedule_enabled"]: + return {"status": "disabled"} + day = beijing_date(now) + if day.weekday() > 4: + return {"status": "weekday_mismatch"} + + manager = getattr(app_state, "guardian_manager", None) + repo = getattr(app_state, "repo", None) + if manager is None or repo is None: + raise RuntimeError("scheduled guardian dependencies are not initialized") + store = getattr(manager, "store", None) + if store is None: + raise RuntimeError("scheduled guardian manager has no run store") + + claim = daily_claim(day) + with _CLAIM_LOCK: + if store.get(claim) is not None: + return {"status": "already_claimed", "run_id": claim} + fingerprint = { + **build_guardian_fingerprint(repo), + "source": "scheduled", + "source_claim": claim, + } + run = manager.start( + guardian_request(config), + fingerprint, + force=False, + source="scheduled", + run_id=claim, + ) + run_id = run.get("run_id") if isinstance(run, dict) else None + return {"status": "enqueued", "run_id": run_id or claim} diff --git a/backend/app/services/guardian_store.py b/backend/app/services/guardian_store.py new file mode 100644 index 00000000..44a5d0aa --- /dev/null +++ b/backend/app/services/guardian_store.py @@ -0,0 +1,324 @@ +"""守护档案持久化: 每策略一份 JSON (基线 + 复验历史 + 状态)。 + +目录: data/user_data/strategy_guardian/{strategy_id}.json +设计见 docs/auto-strategy-lab-plan.md §3.1。档案文件由守护功能自身创建, +drop 只删除本目录内的档案 (fail-closed 路径校验), 不触碰策略源码。 +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import threading +import uuid +from collections.abc import Mapping +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from app.backtest.guardian import ( + BASELINE_KINDS, + GUARDIAN_STATUSES, + MAX_CHECKS, +) + +logger = logging.getLogger(__name__) + +_SCHEMA_VERSION = 1 +_SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$") +_LOCK = threading.RLock() + + +class GuardianStoreError(RuntimeError): + pass + + +# ── 组合建议 (P2d): 健康冠军间低相关可组合对, 由守护运行时扫描写入 ────── +_MAX_COMBINATION_SUGGESTIONS = 10 + + +def combination_path(data_dir: Path | str) -> Path: + root = Path(data_dir).resolve() / "user_data" / "strategy_guardian" + root.mkdir(parents=True, exist_ok=True) + return root / "combinations.json" + + +def read_combinations(data_dir: Path | str) -> dict[str, Any] | None: + path = combination_path(data_dir) + if not path.is_file(): + return None + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + return None + return value if isinstance(value, dict) else None + + +def write_combinations( + data_dir: Path | str, + *, + window_start: str, + window_end: str, + suggestions: list[dict[str, Any]], + pairs_evaluated: int, +) -> dict[str, Any]: + payload = { + "schema_version": 1, + "generated_at": _now_iso(), + "window_start": window_start, + "window_end": window_end, + "pairs_evaluated": pairs_evaluated, + "suggestions": suggestions[:_MAX_COMBINATION_SUGGESTIONS], + } + path = combination_path(data_dir) + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + try: + text = json.dumps(payload, ensure_ascii=False, allow_nan=False, indent=2) + "\n" + with temporary.open("w", encoding="utf-8", newline="\n") as stream: + stream.write(text) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + except OSError as exc: + temporary.unlink(missing_ok=True) + raise GuardianStoreError("failed to write combinations") from exc + return payload + + +class GuardianStoreValidationError(GuardianStoreError, ValueError): + pass + + +def _now_iso() -> str: + return datetime.now(UTC).isoformat() + + +class GuardianStore: + def __init__(self, data_dir: Path | str) -> None: + self.data_dir = Path(data_dir).resolve() + self.root = (self.data_dir / "user_data" / "strategy_guardian").resolve() + self.root.mkdir(parents=True, exist_ok=True) + + # ── 基线 ─────────────────────────────────────────────────────── + + def create_baseline( + self, + strategy_id: str, + *, + kind: str, + data_as_of: str, + objective: str, + metrics: Mapping[str, Any], + provenance: Mapping[str, Any] | None = None, + ) -> dict[str, Any]: + """创建守护档案; 档案已存在时幂等返回现有档案 (重复发布不覆盖历史)。""" + if kind not in BASELINE_KINDS: + raise GuardianStoreValidationError(f"unsupported baseline kind: {kind!r}") + _validate_iso_date(data_as_of, "data_as_of") + if not isinstance(objective, str) or not objective: + raise GuardianStoreValidationError("baseline objective must be a string") + with _LOCK: + existing = self.get(strategy_id) + if existing is not None: + return existing + profile = _new_profile(strategy_id) + profile["baseline"] = { + "kind": kind, + "data_as_of": data_as_of, + "objective": objective, + "metrics": _copy_json(metrics), + "provenance": _copy_json(provenance or {}), + "created_at": _now_iso(), + } + self._write(profile) + return profile + + # ── 读取 ─────────────────────────────────────────────────────── + + def get(self, strategy_id: str) -> dict[str, Any] | None: + path = self._profile_path(strategy_id) + if not path.is_file(): + return None + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise GuardianStoreError( + f"failed to read guardian profile: {strategy_id}" + ) from exc + if not isinstance(value, dict): + raise GuardianStoreError(f"invalid guardian profile: {strategy_id}") + return _normalize_profile(value, strategy_id) + + def list_profiles(self) -> list[dict[str, Any]]: + profiles: list[dict[str, Any]] = [] + for path in sorted(self.root.glob("*.json")): + # combinations.json 是 P2d 的组合建议文件, 不是策略档案 + if path.name == combination_path(self.data_dir).name: + continue + strategy_id = path.stem + try: + profile = self.get(strategy_id) + except GuardianStoreError: + logger.warning("skipping unreadable guardian profile: %s", path.name) + continue + if profile is not None: + profiles.append(profile) + profiles.sort(key=lambda item: str(item.get("updated_at") or "")) + return profiles + + # ── 复验与状态 ───────────────────────────────────────────────── + + def append_check(self, strategy_id: str, check: Mapping[str, Any]) -> dict[str, Any]: + """追加一条复验记录 (滚动保留最近 MAX_CHECKS 条) 并刷新 updated_at。""" + clean = _copy_json(check) + with _LOCK: + profile = self._required(strategy_id) + checks = list(profile.get("checks") or []) + checks.append(clean) + profile["checks"] = checks[-MAX_CHECKS:] + profile["updated_at"] = _now_iso() + self._write(profile) + return profile + + def set_status( + self, + strategy_id: str, + status: str, + *, + reason: str | None = None, + ) -> dict[str, Any]: + if status not in GUARDIAN_STATUSES: + raise GuardianStoreValidationError(f"unsupported guardian status: {status!r}") + with _LOCK: + profile = self._required(strategy_id) + if profile.get("status") != status: + profile["status"] = status + profile["status_reason"] = reason + profile["status_since"] = _now_iso() + profile["updated_at"] = _now_iso() + self._write(profile) + return profile + + def set_baseline_metrics( + self, + strategy_id: str, + metrics: Mapping[str, Any], + ) -> dict[str, Any]: + """trailing_reference 档案首跑标定后回填基线指标。""" + with _LOCK: + profile = self._required(strategy_id) + baseline = profile.get("baseline") + if not isinstance(baseline, dict): + raise GuardianStoreValidationError("profile has no baseline") + if baseline.get("kind") != "trailing_reference": + raise GuardianStoreValidationError( + "only trailing_reference baselines can be calibrated" + ) + baseline["metrics"] = _copy_json(metrics) + baseline["calibrated_at"] = _now_iso() + profile["updated_at"] = _now_iso() + self._write(profile) + return profile + + def drop(self, strategy_id: str) -> bool: + """删除守护档案 (仅本目录内文件)。返回是否实际删除。""" + with _LOCK: + path = self._profile_path(strategy_id) + if not path.exists(): + return False + path.unlink() + return True + + # ── 内部 ─────────────────────────────────────────────────────── + + def _required(self, strategy_id: str) -> dict[str, Any]: + profile = self.get(strategy_id) + if profile is None: + raise KeyError(strategy_id) + return profile + + def _profile_path(self, strategy_id: str) -> Path: + if not isinstance(strategy_id, str) or not _SAFE_ID.fullmatch(strategy_id): + raise GuardianStoreValidationError( + "strategy_id contains unsafe characters for guardian profiles" + ) + candidate = (self.root / f"{strategy_id}.json").resolve() + if not candidate.is_relative_to(self.root): + raise GuardianStoreValidationError("guardian profile path escapes root") + return candidate + + def _write(self, profile: dict[str, Any]) -> None: + path = self._profile_path(str(profile["strategy_id"])) + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + try: + text = json.dumps( + profile, ensure_ascii=False, allow_nan=False, indent=2 + ) + "\n" + with temporary.open("w", encoding="utf-8", newline="\n") as stream: + stream.write(text) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + except (OSError, TypeError, ValueError) as exc: + temporary.unlink(missing_ok=True) + raise GuardianStoreError(f"failed to write guardian profile: {path}") from exc + + +def _new_profile(strategy_id: str) -> dict[str, Any]: + now = _now_iso() + return { + "schema_version": _SCHEMA_VERSION, + "strategy_id": strategy_id, + "baseline": None, + "status": "pending", + "status_reason": None, + "status_since": now, + "checks": [], + "created_at": now, + "updated_at": now, + } + + +def _normalize_profile(value: dict[str, Any], strategy_id: str) -> dict[str, Any]: + status = value.get("status", "pending") + if status not in GUARDIAN_STATUSES: + raise GuardianStoreError(f"invalid status in guardian profile: {strategy_id}") + baseline = value.get("baseline") + if baseline is not None and ( + not isinstance(baseline, dict) + or baseline.get("kind") not in BASELINE_KINDS + ): + raise GuardianStoreError(f"invalid baseline in guardian profile: {strategy_id}") + checks = value.get("checks") + if not isinstance(checks, list): + checks = [] + normalized = dict(value) + normalized.update( + { + "schema_version": value.get("schema_version", _SCHEMA_VERSION), + "strategy_id": strategy_id, + "status": status, + "checks": checks[-MAX_CHECKS:], + } + ) + return normalized + + +def _copy_json(value: Mapping[str, Any]) -> dict[str, Any]: + try: + return json.loads(json.dumps(value, ensure_ascii=False, allow_nan=False)) + except (TypeError, ValueError) as exc: + raise GuardianStoreValidationError( + "guardian payload is not JSON serializable" + ) from exc + + +def _validate_iso_date(value: Any, field: str) -> None: + if not isinstance(value, str): + raise GuardianStoreValidationError(f"{field} must be an ISO date string") + try: + datetime.fromisoformat(value) + except ValueError as exc: + raise GuardianStoreValidationError(f"{field} is not a valid ISO date") from exc diff --git a/backend/app/services/lab_schedule.py b/backend/app/services/lab_schedule.py new file mode 100644 index 00000000..8ff2ad4e --- /dev/null +++ b/backend/app/services/lab_schedule.py @@ -0,0 +1,28 @@ +"""自动策略实验室调度单入口 (P3): 周度挖掘 + 每日守护一次挂载。 + +替代 daily_pipeline 直接调用两个调度函数的旧接线; 原入口 +(run_weekly_mining / run_daily_guardian) 保持独立可用, 便于单独触发与测试。 +""" +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +def run_scheduled_lab(app_state: Any) -> dict[str, Any]: + """收盘管道成功后依次触发周度挖掘与每日守护复验 (各自独立防重)。""" + from app.services.guardian_schedule import run_daily_guardian + from app.services.mining_schedule import run_weekly_mining + + results: dict[str, Any] = {"mining": None, "guardian": None} + try: + results["mining"] = run_weekly_mining(app_state) + except Exception: + logger.exception("scheduled lab mining enqueue failed") + try: + results["guardian"] = run_daily_guardian(app_state) + except Exception: + logger.exception("scheduled lab guardian enqueue failed") + return results diff --git a/backend/app/services/mining_candidates.py b/backend/app/services/mining_candidates.py index d425ebbd..2569ecd4 100644 --- a/backend/app/services/mining_candidates.py +++ b/backend/app/services/mining_candidates.py @@ -6,6 +6,7 @@ from __future__ import annotations import hashlib import json +import logging import math import os import re @@ -33,6 +34,8 @@ _FACTOR_IDS = frozenset(str(item["id"]) for item in FACTOR_COLUMNS) _FACTOR_DEFINITION_FIELDS = frozenset({"kind", "factor_names", "scoring", "directions"}) _EXISTING_DEFINITION_FIELDS = frozenset({"kind", "strategy_id"}) _BACKLINK_FIELDS = frozenset({"promoted_candidate_id", "published_strategy_id"}) +# mining-v3 新增列: v2 历史工件缺失时不拒读 (按产出时口径放行), 只在存在时校验类型 +_OPTIONAL_ARTIFACT_COLUMNS = frozenset({"oos_worst_sharpe"}) _PUBLISHED_PREFIX = "mined_factor_" _STRATEGY_ID_PATTERN = re.compile(r"^mined_factor_[A-Za-z0-9_-]{1,49}$") _ARTIFACT_SCHEMA = { @@ -46,6 +49,7 @@ _ARTIFACT_SCHEMA = { "score": pl.Float64, "oos_return": pl.Float64, "oos_sharpe": pl.Float64, + "oos_worst_sharpe": pl.Float64, "oos_max_drawdown": pl.Float64, "oos_positive_fold_ratio": pl.Float64, "oos_n_trades": pl.Int64, @@ -56,6 +60,7 @@ _ARTIFACT_SCHEMA = { "published_strategy_id": pl.String, } _LOCK = threading.RLock() +logger = logging.getLogger(__name__) class MiningCandidateService: @@ -121,12 +126,15 @@ class MiningCandidateService: sharpe=row.get("oos_sharpe"), max_drawdown=row.get("oos_max_drawdown"), n_trades=row.get("oos_n_trades"), + worst_fold_sharpe=row.get("oos_worst_sharpe"), ) if not gate.qualified: raise ValueError( "candidate does not meet the promotion gate: " + "; ".join(gate.reasons) ) + if definition["kind"] == "factor_rank": + self._enforce_mined_slot_limit() asset_type = self._asset_type(manifest) if definition["kind"] == "existing_strategy": published_id = str(definition["strategy_id"]) @@ -179,8 +187,84 @@ class MiningCandidateService: "published_strategy_id", published_id, ) + self._record_guardian_baseline(published_id, run_id, signature, summary, row) return {"ok": True, "strategy_id": published_id} + def _enforce_mined_slot_limit(self) -> None: + """P3 名额上限: 活跃 mined 策略满员时不再发布新冠军 (候选留 pending 等位)。 + + 退役/删除旧策略或调大上限 (守护页设置) 后可再发布; 0 = 不限。 + """ + from app.services import preferences + + limit = preferences.get_guardian_schedule()["lab_mined_slot_limit"] + if limit <= 0: + return + active = [ + meta.get("id") + for meta in self.strategy_engine.list_strategies() + if str(meta.get("id", "")).startswith(_PUBLISHED_PREFIX) + ] + if len(active) >= limit: + raise ValueError( + f"活跃挖掘策略名额已满 ({len(active)}/{limit}): " + "请退役或删除不再使用的 mined 策略, 或在守护页调大名额上限; " + "候选仍保留在研究候选库中等待发布" + ) + + def _record_guardian_baseline( + self, + strategy_id: str, + run_id: str, + signature: str, + summary: Mapping[str, Any], + row: Mapping[str, Any], + ) -> None: + """发布成功后落守护基线 (旁路: 失败只记日志, 不影响发布结果)。""" + try: + from app.services.guardian_store import GuardianStore + + metrics = self._candidate_metrics(row) + raw_score = row.get("score") + robust_baseline = ( + float(raw_score) + if raw_score is not None + and not isinstance(raw_score, bool) + and math.isfinite(float(raw_score)) + else None + ) + GuardianStore(self.data_dir).create_baseline( + strategy_id, + kind="mining_gate", + data_as_of=str(summary["data_as_of"]), + # 挖掘晋升门槛以 OOS Sharpe 为主目标, 基线目标随之固定; + # 手动入护的策略才可另选目标 (trailing_reference)。 + objective="sharpe", + metrics={ + # 基线分数取稳健分 (v3 起为 score 列), 与守护复验的 robust 分同口径; + # v2 历史工件无稳健分时退回均值 sharpe。 + "score": robust_baseline + if robust_baseline is not None + else metrics.get("oos_sharpe"), + "n_trades": metrics.get("oos_n_trades"), + "max_drawdown": metrics.get("oos_max_drawdown"), + "oos_return": metrics.get("oos_return"), + "oos_worst_sharpe": metrics.get("oos_worst_sharpe"), + "valid_folds": metrics.get("valid_folds"), + "oos_positive_fold_ratio": metrics.get("oos_positive_fold_ratio"), + "confidence": metrics.get("confidence"), + }, + provenance={ + "origin_run_id": run_id, + "candidate_signature": signature, + "algorithm_version": summary.get("algorithm_version"), + }, + ) + except Exception: # noqa: BLE001 — 旁路钩子: 任何失败都不阻断发布主流程 + logger.warning( + "guardian baseline recording failed for %s", strategy_id, exc_info=True + ) + def _load_candidate( self, run_id: str, @@ -253,12 +337,13 @@ class MiningCandidateService: raise ValueError( "mining candidates artifact exceeds its uncompressed size limit" ) - missing = sorted(set(_ARTIFACT_SCHEMA) - set(schema)) + required = set(_ARTIFACT_SCHEMA) - _OPTIONAL_ARTIFACT_COLUMNS + missing = sorted(required - set(schema)) if missing: raise ValueError(f"mining candidates artifact schema is invalid: {missing}") invalid_types = [ name for name, dtype in _ARTIFACT_SCHEMA.items() - if schema[name] != dtype + if name in schema and schema[name] != dtype ] if invalid_types: raise ValueError( @@ -266,7 +351,9 @@ class MiningCandidateService: f"{invalid_types}" ) try: - return pl.read_parquet(path, columns=list(_ARTIFACT_SCHEMA)) + return pl.read_parquet( + path, columns=[name for name in _ARTIFACT_SCHEMA if name in schema] + ) except Exception as exc: raise RuntimeError("failed to read mining candidates artifact") from exc @@ -446,6 +533,7 @@ class MiningCandidateService: def _candidate_metrics(row: Mapping[str, Any]) -> dict[str, Any]: fields = ( "oos_sharpe", + "oos_worst_sharpe", "oos_return", "oos_max_drawdown", "oos_positive_fold_ratio", diff --git a/backend/app/services/mining_jobs.py b/backend/app/services/mining_jobs.py index 343ab8a5..f8d1a730 100644 --- a/backend/app/services/mining_jobs.py +++ b/backend/app/services/mining_jobs.py @@ -48,6 +48,7 @@ MAX_EVENTS = 256 MAX_EVENT_PAYLOAD_BYTES = 16 * 1024 _SCHEMA_VERSION = 1 _RUN_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$") +_NAMESPACE_PATTERN = re.compile(r"^[a-z][a-z0-9_]{0,31}$") _STORE_LOCK = threading.RLock() _ALLOWED_TRANSITIONS: dict[str, frozenset[str]] = { @@ -123,12 +124,16 @@ def compute_run_signature(request: Mapping[str, Any], data_fingerprint: Any) -> class MiningRunStore: """Store one manifest, summary, artifact registry, and bounded event log per run.""" - def __init__(self, data_dir: Path | str | None = None) -> None: + def __init__(self, data_dir: Path | str | None = None, *, namespace: str = "mining") -> None: if data_dir is None: from app.config import settings data_dir = settings.data_dir - self.runs_root = (Path(data_dir).resolve() / "research" / "mining" / "runs").resolve() + if not _NAMESPACE_PATTERN.fullmatch(namespace): + raise MiningRunValidationError(f"unsupported run store namespace: {namespace!r}") + self.runs_root = ( + (Path(data_dir).resolve() / "research" / namespace / "runs").resolve() + ) self.runs_root.mkdir(parents=True, exist_ok=True) def create( diff --git a/backend/app/services/mining_manager.py b/backend/app/services/mining_manager.py index 0422ac3c..aabe0b64 100644 --- a/backend/app/services/mining_manager.py +++ b/backend/app/services/mining_manager.py @@ -40,9 +40,13 @@ class MiningJobManager: data_dir: Path | str, worker_runner: WorkerRunner = run_worker_task, task_factory: TaskFactory = make_worker_task, + *, + kind: str = "mining", + store: MiningRunStore | None = None, ) -> None: self._data_dir = Path(data_dir).resolve() - self._store = MiningRunStore(self._data_dir) + self._kind = kind + self._store = store if store is not None else MiningRunStore(self._data_dir) self._worker_runner = worker_runner self._task_factory = task_factory self._lock = threading.RLock() @@ -151,7 +155,7 @@ class MiningJobManager: thread = threading.Thread( target=self._run_job, args=(run_id, source, cancel_event), - name=f"mining-{run_id}", + name=f"{self._kind}-{run_id}", daemon=True, ) self._cancel_events[run_id] = cancel_event @@ -165,6 +169,8 @@ class MiningJobManager: cancel_event: threading.Event, ) -> None: try: + # 守护复验与挖掘共用 "mining" heavy 槽: 两者都是全市场级内存任务, + # 串行化避免双份大矩阵并发 (设计见 docs/auto-strategy-lab-plan.md §3.4)。 with shared_heavy_job_limiter.slot("mining", cancel_event=cancel_event): if not self._mark_running(run_id, cancel_event): return @@ -177,7 +183,7 @@ class MiningJobManager: "data_fingerprint": manifest["data_fingerprint"], "source": source, } - task = self._task_factory("mining", self._data_dir, payload) + task = self._task_factory(self._kind, self._data_dir, payload) result = self._worker_runner( task, lambda progress: self._record_progress(run_id, progress, cancel_event), diff --git a/backend/app/services/mining_schedule.py b/backend/app/services/mining_schedule.py index 6a6475d0..7213aab8 100644 --- a/backend/app/services/mining_schedule.py +++ b/backend/app/services/mining_schedule.py @@ -26,7 +26,7 @@ from app.services.regime_builder import load_regime_history, regime_path logger = logging.getLogger(__name__) BEIJING_TZ = ZoneInfo("Asia/Shanghai") -MINING_ALGORITHM_VERSION = "mining-v2" +MINING_ALGORITHM_VERSION = "mining-v3" FINGERPRINT_VERSION = "weekly-mining-data-v2" _PROFILES = frozenset({"balanced", "strict"}) _CLAIM_LOCK = threading.Lock() diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py index 2c178275..c4357b93 100644 --- a/backend/app/services/preferences.py +++ b/backend/app/services/preferences.py @@ -762,6 +762,75 @@ def set_mining_schedule(enabled: bool, weekday: int, profile: str) -> dict: return result +def get_guardian_schedule() -> dict: + """策略守护 (发布后持续复验) 配置。历史缺键时整体回退默认 (默认关闭)。""" + data = load() + enabled = data.get("guardian_schedule_enabled", False) + if not isinstance(enabled, bool): + enabled = False + watch = _guardian_pct(data.get("guardian_watch_pct"), 0.2) + retire = _guardian_pct(data.get("guardian_retire_pct"), 0.5) + if not 0.0 < watch < retire <= 1.0: + watch, retire = 0.2, 0.5 + k_max = data.get("guardian_k_max", 4) + if isinstance(k_max, bool) or not isinstance(k_max, int) or not 2 <= k_max <= 4: + k_max = 4 + slot_limit = data.get("lab_mined_slot_limit", 10) + if ( + isinstance(slot_limit, bool) + or not isinstance(slot_limit, int) + or not 0 <= slot_limit <= 50 + ): + slot_limit = 10 + return { + "guardian_schedule_enabled": enabled, + "guardian_watch_pct": watch, + "guardian_retire_pct": retire, + "guardian_k_max": k_max, + "lab_mined_slot_limit": slot_limit, + } + + +def set_guardian_schedule( + enabled: bool, + watch_pct: float, + retire_pct: float, + k_max: int, + lab_mined_slot_limit: int = 10, +) -> dict: + """校验并一次写入守护/实验室配置整组 (阈值语义与 backtest.guardian 一致)。""" + from app.backtest.guardian import GuardianThresholds + + thresholds = GuardianThresholds( + watch_pct=watch_pct, retire_pct=retire_pct, k_max=k_max + ) + if ( + isinstance(lab_mined_slot_limit, bool) + or not isinstance(lab_mined_slot_limit, int) + or not 0 <= lab_mined_slot_limit <= 50 + ): + raise ValueError("lab mined slot limit must be between 0 and 50 (0 = unlimited)") + result = { + "guardian_schedule_enabled": bool(enabled), + "guardian_watch_pct": thresholds.watch_pct, + "guardian_retire_pct": thresholds.retire_pct, + "guardian_k_max": thresholds.k_max, + "lab_mined_slot_limit": lab_mined_slot_limit, + } + save(result) + return result + + +def _guardian_pct(value, default: float) -> float: + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not 0.0 < float(value) <= 1.0 + ): + return default + return float(value) + + def get_review_push_channels() -> list[str]: """复盘推送渠道(多选) — 选定的外部工具列表, 复盘归档后逐个推送。 diff --git a/backend/tests/backtest/test_guardian.py b/backend/tests/backtest/test_guardian.py new file mode 100644 index 00000000..de0ef878 --- /dev/null +++ b/backend/tests/backtest/test_guardian.py @@ -0,0 +1,237 @@ +"""策略守护纯逻辑测试: 判定状态机、退化计算与窗口规划。""" +from __future__ import annotations + +from datetime import date, timedelta + +import pytest + +from app.backtest.guardian import ( + GuardianThresholds, + choose_k, + consecutive_watch, + degradation_pct, + evaluate_check, + objective_soft_floor, + plan_new_data_window, + split_windows, +) +from app.backtest.robust_optimizer import ( + split_validation_windows as robust_split_validation_windows, +) + + +class TestThresholds: + def test_defaults_valid(self): + t = GuardianThresholds() + assert t.watch_pct == 0.2 and t.retire_pct == 0.5 + + def test_watch_must_be_below_retire(self): + with pytest.raises(ValueError, match="watch_pct"): + GuardianThresholds(watch_pct=0.5, retire_pct=0.5) + with pytest.raises(ValueError, match="watch_pct"): + GuardianThresholds(watch_pct=0.6, retire_pct=0.5) + + def test_k_bounds(self): + with pytest.raises(ValueError): + GuardianThresholds(k_max=5) + with pytest.raises(ValueError): + GuardianThresholds(k_max=1) + + +class TestDegradation: + def test_improvement_is_zero(self): + assert degradation_pct(2.0, 3.0) == 0.0 + + def test_ratio(self): + assert degradation_pct(2.0, 1.0) == pytest.approx(0.5) + assert degradation_pct(2.0, 1.7) == pytest.approx(0.15) + + def test_nonpositive_baseline_returns_none(self): + assert degradation_pct(0.0, -1.0) is None + assert degradation_pct(-0.5, -2.0) is None + + +class TestSoftFloor: + def test_whitelist_enforced(self): + with pytest.raises(ValueError, match="白名单"): + objective_soft_floor("win_rate") + assert objective_soft_floor("sharpe") == 0.0 + assert objective_soft_floor("max_drawdown") == -0.30 + assert objective_soft_floor("mc_maxdd_p95") == -0.30 + + +class TestEvaluateCheck: + def _run(self, **overrides): + params = dict( + objective="sharpe", + baseline_score=2.0, + baseline_n_trades=300, + current_score=1.9, + current_n_trades=100, + worst_drawdown=-0.10, + thresholds=GuardianThresholds(), + ) + params.update(overrides) + return evaluate_check(**params) + + def test_healthy(self): + verdict = self._run() + assert verdict.status == "healthy" + assert verdict.reasons == () + assert verdict.degradation_pct == pytest.approx(0.05) + + def test_watch_on_moderate_degradation(self): + verdict = self._run(current_score=1.3) # 35% 退化 + assert verdict.status == "watch" + assert any("退化" in reason for reason in verdict.reasons) + + def test_retired_on_severe_degradation(self): + verdict = self._run(current_score=0.4) # 80% 退化 + assert verdict.status == "retired" + + def test_boundary_exactly_retire_pct_is_watch(self): + verdict = self._run(current_score=1.0) # 恰 50%, 未 "超过" + assert verdict.status == "watch" + + def test_retired_on_trade_drought(self): + verdict = self._run(current_n_trades=5) # 硬底线 max(3, 300*0.1)=30 + assert verdict.status == "retired" + assert any("枯竭" in reason for reason in verdict.reasons) + + def test_watch_on_soft_floor_sharpe(self): + # 基线 ≤0 时无相对退化 (pct=None), 软底线是唯一判据 + verdict = self._run(baseline_score=0.0, current_score=-0.2, current_n_trades=150) + assert verdict.status == "watch" + assert verdict.degradation_pct is None + assert any("软底线" in reason for reason in verdict.reasons) + + def test_watch_on_drawdown_floor(self): + verdict = self._run(worst_drawdown=-0.45) + assert verdict.status == "watch" + assert any("回撤" in reason for reason in verdict.reasons) + + def test_watch_on_third_trades_floor(self): + verdict = self._run(current_n_trades=80) # < 300/3=100, ≥ 硬底线 30 + assert verdict.status == "watch" + assert any("1/3" in reason for reason in verdict.reasons) + + def test_incomplete_data_caps_at_watch(self): + verdict = self._run(current_score=-10.0, incomplete_data=True) + assert verdict.status == "watch" + assert verdict.incomplete_data is True + + def test_incomplete_data_drought_caps_at_watch(self): + verdict = self._run(current_n_trades=0, incomplete_data=True) + assert verdict.status == "watch" + + def test_missing_segments_force_watch(self): + verdict = self._run(missing_segments=1) + assert verdict.status == "watch" + assert any("验证段回测失败" in reason for reason in verdict.reasons) + + def test_drawdown_objective_floor_uses_drawdown_scale(self): + verdict = evaluate_check( + objective="max_drawdown", + baseline_score=-0.10, + baseline_n_trades=100, + current_score=-0.12, # 退化 (0.10-(-0.12)... 基线<0 → pct=None) + current_n_trades=90, + worst_drawdown=-0.12, + thresholds=GuardianThresholds(), + ) + # 基线 -0.10 ≤ 0 → 无相对退化; -0.12 未击穿 -0.30 → 健康 + assert verdict.status == "healthy" + assert verdict.degradation_pct is None + + def test_nonpositive_baseline_uses_floors_only(self): + verdict = self._run(baseline_score=0.0, current_score=-0.5) + assert verdict.degradation_pct is None + assert verdict.status == "watch" + + +class TestWindowPlanning: + def _dates(self, count: int, first: date = date(2026, 1, 1)) -> list[date]: + return [first + timedelta(days=i) for i in range(count)] + + def test_insufficient_new_data_skips(self): + assert plan_new_data_window(self._dates(29), date(2025, 12, 31)) is None + + def test_sufficient_new_data(self): + dates = self._dates(40) + window = plan_new_data_window(dates, date(2025, 12, 31)) + assert window == (dates[0], dates[-1]) + + def test_as_of_date_excluded(self): + dates = self._dates(40) + # 基线当日及之前都不算新数据: 排除前 11 个 → 只剩 29 个 < 30 + window = plan_new_data_window(dates, dates[10]) + assert window is None + + def test_choose_k_prefers_max(self): + start, end = date(2026, 1, 1), date(2026, 1, 1) + timedelta(days=199) + assert choose_k(start, end, 4) == 4 + + def test_choose_k_falls_back(self): + start, end = date(2026, 1, 1), date(2026, 1, 1) + timedelta(days=39) + assert choose_k(start, end, 4) == 2 + + def test_choose_k_too_short_raises(self): + start, end = date(2026, 1, 1), date(2026, 1, 1) + timedelta(days=26) + with pytest.raises(ValueError): + choose_k(start, end, 2) + + def test_split_windows_matches_robust_optimizer(self): + start, end = date(2026, 1, 1), date(2026, 4, 30) + assert split_windows(start, end, 3) == robust_split_validation_windows(start, end, 3) + assert split_windows(start, end, 4) == robust_split_validation_windows(start, end, 4) + + +class TestConsecutiveWatch: + def test_counts_trailing_watch(self): + checks = [{"verdict": "healthy"}, {"verdict": "watch"}, {"verdict": "watch"}] + assert consecutive_watch(checks) == 2 + + def test_stops_at_error(self): + checks = [ + {"verdict": "watch"}, + {"verdict": "watch", "error": "boom"}, + {"verdict": "watch"}, + ] + assert consecutive_watch(checks) == 1 + + def test_empty(self): + assert consecutive_watch([]) == 0 + + +class TestGateWorstFold: + """P2a: strict 档 (confidence=high) 的 worst-fold 稳健门槛。""" + + def _gate(self, **overrides): + from app.backtest.mining import evaluate_candidate_gate + + params = dict( + confidence="high", + valid_folds=3, + positive_fold_ratio=1.0, + sharpe=1.2, + max_drawdown=-0.1, + n_trades=100, + worst_fold_sharpe=0.3, + ) + params.update(overrides) + return evaluate_candidate_gate(**params) + + def test_strict_positive_worst_passes(self): + assert self._gate().qualified + + def test_strict_nonpositive_worst_rejected(self): + result = self._gate(worst_fold_sharpe=0.0) + assert not result.qualified + assert any("worst-fold" in r for r in result.reasons) + assert not self._gate(worst_fold_sharpe=-0.5).qualified + + def test_legacy_artifact_without_worst_passes(self): + assert self._gate(worst_fold_sharpe=None).qualified + + def test_balanced_unaffected_by_worst(self): + assert self._gate(confidence="standard", worst_fold_sharpe=-2.0).qualified diff --git a/backend/tests/backtest/test_mining_runtime.py b/backend/tests/backtest/test_mining_runtime.py index 7c134492..ace7f04d 100644 --- a/backend/tests/backtest/test_mining_runtime.py +++ b/backend/tests/backtest/test_mining_runtime.py @@ -390,3 +390,229 @@ def test_validate_regime_availability_reports_coverage_gaps_in_fold_windows( with pytest.raises(ValueError, match="市场环境数据覆盖不完整"): _validate_regime_availability(panel, request, tmp_path) + + +# ── P2a 稳健选冠 (mining-v3) ────────────────────────────────────── + + +def _candidate(cid: str, factors: tuple[str, ...] = ("f1", "f2")) -> MiningCandidate: + return MiningCandidate( + candidate_id=cid, + kind="factor_rank", + factor_names=factors, + weights=(1.0,) * len(factors), + directions=(1,) * len(factors), + ) + + +def test_rank_prefers_robust_over_mean_champion() -> None: + """均值口径 A(1.67) > B(1.5); 稳健口径 A(1.0) < B(1.5) — v3 必须选 B。""" + rows = [ + {"candidate_signature": "a", "sharpe": 5.0, "skipped": False}, + {"candidate_signature": "a", "sharpe": 5.0, "skipped": False}, + {"candidate_signature": "a", "sharpe": -5.0, "skipped": False}, + {"candidate_signature": "b", "sharpe": 1.5, "skipped": False}, + {"candidate_signature": "b", "sharpe": 1.5, "skipped": False}, + {"candidate_signature": "b", "sharpe": 1.5, "skipped": False}, + ] + ranked = _rank_artifact_candidates( + [_candidate("a"), _candidate("b")], rows, limit=2 + ) + assert [item.candidate_id for item in ranked] == ["b", "a"] + + +def test_rank_skips_failed_folds_like_before() -> None: + rows = [ + {"candidate_signature": "a", "sharpe": 2.0, "skipped": True}, + {"candidate_signature": "a", "sharpe": 1.0, "skipped": False}, + {"candidate_signature": "b", "sharpe": 0.5, "skipped": False}, + ] + ranked = _rank_artifact_candidates( + [_candidate("a"), _candidate("b")], rows, limit=2 + ) + assert ranked[0].candidate_id == "a" + + +# ── P2b 冠军权重精调 ────────────────────────────────────────────── + + +class _RefineEvaluator: + """按 scoring 比率查表返回分数; 传入面板行数 == 全面板行数视为 stage2 全窗。""" + + FULL_ROWS = 8 + + def __init__(self, fold_scores, full_scores) -> None: + self.fold_scores = fold_scores # {ratios: [每折 sharpe]} + self.full_scores = full_scores # {ratios: sharpe} + self.fold_cursor: dict[tuple[float, ...], int] = {} + self.backtest_count = 0 + self.data_dir = None + + def evaluate_test(self, test, definition, *, regime_state="overall"): + from app.backtest.mining import CandidateEvaluation + + del regime_state + self.backtest_count += 1 + names = list(definition["factor_names"]) + base = definition["scoring"][names[0]] + ratios = tuple(round(definition["scoring"][n] / base, 6) for n in names) + if test.height >= self.FULL_ROWS: + sharpe = self.full_scores.get(ratios) + else: + scores = self.fold_scores.get(ratios) or [] + index = self.fold_cursor.get(ratios, 0) + self.fold_cursor[ratios] = index + 1 + sharpe = scores[index] if index < len(scores) else None + if sharpe is None: + return CandidateEvaluation(score=None, error="no scores for variant") + return CandidateEvaluation( + score=sharpe, + metrics={ + "sharpe": sharpe, + "total_return": 0.1 * sharpe, + "max_drawdown": -0.05, + "n_trades": 50, + }, + ) + + +class _Fold: + def __init__(self, labels) -> None: + self.outer = SimpleNamespace(test_labels=labels) + + +def _refine_request(profile="balanced"): + from app.backtest.mining import MiningBudget, MiningRequest + + budget = ( + MiningBudget.balanced() if profile == "balanced" else MiningBudget.strict() + ) + mining = MiningRequest(factor_names=("f1", "f2"), budget=budget, profile=profile) + return SimpleNamespace( + profile=profile, + mining_request=mining, + run_id="run", + max_finalists=8, + end=date(2026, 10, 23), + ) + + +def _run_refine(champion, evaluator, folds=3, trials_used=10, profile="balanced"): + from app.backtest.mining_runtime import _refine_champion_weights + + dates = [f"2026-01-0{i}" for i in range(1, 9)] + panel = pl.DataFrame({"date": dates}) + nested = tuple( + _Fold([dates[i], dates[i + 1]]) for i in range(0, folds * 2, 2) + ) # 每折两个标签, 与全窗 (8 行) 可区分 + result = SimpleNamespace(trials_used=trials_used) + return _refine_champion_weights( + panel, _refine_request(profile), result, [champion], evaluator, nested, None + ) + + +def _two_factor_champion(weights=(1.0, 1.0)): + scoring = {"f1": weights[0], "f2": weights[1]} + + class _C: + candidate_id = "sig-original" + kind = "factor_rank" + factor_names = ("f1", "f2") + + @staticmethod + def definition(): + return { + "kind": "factor_rank", + "factor_names": ["f1", "f2"], + "scoring": dict(scoring), + "directions": {"f1": "high", "f2": "high"}, + } + + instance = _C() + instance.weights = weights + instance.directions = (1, 1) + return instance + + +def test_refine_produces_refined_row_when_variant_wins(): + evaluator = _RefineEvaluator( + fold_scores={ + (1.0, 1.0): [1.0, 1.0, 1.0], + (1.0, 2.0): [2.0, 2.0, 2.0], + (1.0, 0.5): [0.5, 0.5, 0.5], + (1.0, 1.5): [1.5, 1.5, 1.5], + }, + full_scores={ + (1.0, 1.0): 1.0, + (1.0, 2.0): 1.8, + (1.0, 0.5): 0.4, + (1.0, 1.5): 1.4, + }, + ) + rows, summary = _run_refine(_two_factor_champion(), evaluator) + assert summary is not None and summary["applied"] is True + assert summary["refined_weights"] == (1.0, 2.0) + assert len(rows) == 1 + row = rows[0] + assert row["name"].endswith("· 权重精调") + assert row["score"] == 2.0 # 稳健分 = 各折一致 2.0 + assert row["oos_worst_sharpe"] == 2.0 + assert row["valid_folds"] == 3 + import json as _json + + definition = _json.loads(row["definition_json"]) + assert definition["scoring"] == {"f1": 1.0, "f2": 2.0} + # 调用预算: 4 变体 (原权重 + 3 个非重复档位) × 3 折 + 2 全窗 = 14 + assert evaluator.backtest_count == 14 + + +def test_refine_abandoned_on_full_window_degradation(): + evaluator = _RefineEvaluator( + fold_scores={ + (1.0, 1.0): [1.0, 1.0, 1.0], + (1.0, 2.0): [2.0, 2.0, 2.0], + (1.0, 0.5): [0.5, 0.5, 0.5], + (1.0, 1.5): [1.5, 1.5, 1.5], + }, + full_scores={(1.0, 1.0): 2.0, (1.0, 2.0): 0.5, (1.0, 0.5): 0.4, (1.0, 1.5): 1.4}, + ) + rows, summary = _run_refine(_two_factor_champion(), evaluator) + assert rows == [] + assert summary is not None and summary["applied"] is False + assert "退化" in summary["reason"] + + +def test_refine_no_improvement_returns_nothing(): + evaluator = _RefineEvaluator( + fold_scores={ + (1.0, 1.0): [1.0, 1.0, 1.0], + (1.0, 2.0): [0.9, 1.0, 0.8], + (1.0, 0.5): [0.5, 0.5, 0.5], + (1.0, 1.5): [0.8, 0.7, 0.9], + }, + full_scores={(1.0, 1.0): 1.0}, + ) + rows, summary = _run_refine(_two_factor_champion(), evaluator) + assert rows == [] and summary is None + + +def test_refine_skips_single_factor_champion(): + original = SimpleNamespace( + candidate_id="sig-1f", kind="factor_rank", + factor_names=("f1",), weights=(1.0,), directions=(1,), + definition=lambda: {}, + ) + rows, summary = _run_refine(original, _RefineEvaluator({}, {})) + assert rows == [] and summary is None + + +def test_refine_skips_when_budget_remaining_insufficient(): + original = SimpleNamespace( + candidate_id="sig-4f", kind="factor_rank", + factor_names=("f1", "f2", "f3", "f4"), + weights=(1.0, 1.0, 1.0, 1.0), directions=(1, 1, 1, 1), + definition=lambda: {}, + ) + # balanced 剩余额度 96-90=6 < 64 变体×3 折+2 → 跳过 + rows, summary = _run_refine(original, _RefineEvaluator({}, {}), trials_used=90) + assert rows == [] and summary is None diff --git a/backend/tests/test_guardian_api.py b/backend/tests/test_guardian_api.py new file mode 100644 index 00000000..b9051f59 --- /dev/null +++ b/backend/tests/test_guardian_api.py @@ -0,0 +1,223 @@ +"""守护 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 diff --git a/backend/tests/test_guardian_combinations.py b/backend/tests/test_guardian_combinations.py new file mode 100644 index 00000000..5b3e0cf4 --- /dev/null +++ b/backend/tests/test_guardian_combinations.py @@ -0,0 +1,377 @@ +"""P2d 组合扫描/创建 + P3 名额门槛与实验室战报测试。""" +from __future__ import annotations + +from datetime import date, timedelta +from pathlib import Path +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.backtest.guardian_runtime import run_guardian_runtime +from app.services.guardian_combinations import create_guardian_composite +from app.services.guardian_store import ( + GuardianStore, + read_combinations, + write_combinations, +) + + +def _write_partitions(data_dir: Path, dates: list[date]) -> None: + for value in dates: + partition = data_dir / "kline_daily_enriched" / f"date={value.isoformat()}" + partition.mkdir(parents=True, exist_ok=True) + (partition / "part.parquet").write_bytes(b"") + + +class _CurveService: + """按策略返回构造的日净值曲线; returns_patterns: {sid: [日收益循环模式]}。""" + + def __init__(self, patterns: dict[str, list[float]], dates: list[str]) -> None: + self.patterns = patterns + self.dates = dates + self.calls: list[str] = [] + + def run(self, config, cancel_event=None, prepared=None, result_policy=None): + self.calls.append(config.strategy_id) + pattern = self.patterns.get(config.strategy_id) + if pattern is None: + return SimpleNamespace(equity_curve=[], error=None, stats={}) + value = 100.0 + curve = [{"date": self.dates[0], "value": value}] + for i, day in enumerate(self.dates[1:]): + value *= 1.0 + pattern[i % len(pattern)] + curve.append({"date": day, "value": round(value, 4)}) + return SimpleNamespace(equity_curve=curve, error=None, stats={}) + + +class _Engine: + def __init__(self, known: set[str]) -> None: + self.known = known + + def get(self, strategy_id): + if strategy_id not in self.known: + raise ValueError(f"unknown strategy: {strategy_id}") + return SimpleNamespace( + meta={"asset_types": ["stock"], "name": strategy_id, "params": []}, + ) + + def resolve_params(self, strategy, params=None, overrides=None): + return {} + + +def _seed_healthy(store: GuardianStore, *strategy_ids: str) -> None: + for sid in strategy_ids: + store.create_baseline( + sid, + kind="mining_gate", + data_as_of="2026-01-01", + objective="sharpe", + metrics={"score": 1.0, "n_trades": 100, "max_drawdown": -0.1}, + ) + store.append_check(sid, {"verdict": "healthy"}) + store.set_status(sid, "healthy") + + +def test_combination_scan_finds_low_corr_pair(tmp_path): + dates = [date(2026, 1, 1) + timedelta(days=i) for i in range(130)] + _write_partitions(tmp_path, dates) + day_strs = [d.isoformat() for d in dates[-41:]] + store = GuardianStore(tmp_path) + _seed_healthy(store, "mined_a", "mined_b", "mined_c") + # a 与 b 完全正相关 (同模式), c 与 a 负相关 → 只建议 (a,c) / (b,c) + service = _CurveService( + { + "mined_a": [0.01, 0.02], + "mined_b": [0.01, 0.02], + "mined_c": [0.03, -0.01], + }, + day_strs, + ) + + result = run_guardian_runtime( + {"mode": "combinations", "run_id": "r1", "strategy_ids": None, + "thresholds": {}}, + data_dir=tmp_path, + service=service, + strategy_engine=_Engine({"mined_a", "mined_b", "mined_c"}), + ) + + assert result["mode"] == "combinations" + assert result["healthy_count"] == 3 + assert result["pairs_evaluated"] == 3 + suggestions = read_combinations(tmp_path)["suggestions"] + pairs = {frozenset((s["a"], s["b"])) for s in suggestions} + assert pairs == {frozenset(("mined_a", "mined_c")), + frozenset(("mined_b", "mined_c"))} + for item in suggestions: + assert item["corr"] <= 0.70 + assert item["common_days"] >= 30 + + +def test_combination_scan_needs_two_healthy(tmp_path): + dates = [date(2026, 1, 1) + timedelta(days=i) for i in range(40)] + _write_partitions(tmp_path, dates) + store = GuardianStore(tmp_path) + _seed_healthy(store, "mined_a") + + result = run_guardian_runtime( + {"mode": "combinations", "run_id": "r1", "strategy_ids": None, + "thresholds": {}}, + data_dir=tmp_path, service=_CurveService({}, []), strategy_engine=_Engine(set()), + ) + assert result["pairs_evaluated"] == 0 + assert read_combinations(tmp_path) is None + + +def test_create_composite_requires_known_suggestion(tmp_path): + from app.strategy.engine import StrategyEngine + + write_combinations( + tmp_path, window_start="2026-01-01", window_end="2026-06-01", + suggestions=[{"a": "x", "b": "y", "corr": 0.2, "common_days": 40}], + pairs_evaluated=1, + ) + engine = StrategyEngine(strategy_dirs=[tmp_path / "strategies" / "composite"]) + with pytest.raises(ValueError, match="不在最新组合建议中"): + create_guardian_composite( + tmp_path, engine, left="x", right="z", + latest_enriched=date(2026, 6, 1), + ) + with pytest.raises(ValueError): + create_guardian_composite( + tmp_path, engine, left="x", right="y", latest_enriched=None + ) + + +def test_create_composite_happy_path_and_idempotent(tmp_path): + from app.strategy.engine import StrategyEngine + + builtin = Path(__file__).resolve().parents[1] / "tests" / "fixtures" / "strategies" + engine = StrategyEngine( + strategy_dirs=[builtin, tmp_path / "strategies" / "custom", + tmp_path / "strategies" / "composite"] + ) + assert engine.has("ma_golden_cross") and engine.has("boll_breakout") + write_combinations( + tmp_path, window_start="2026-01-01", window_end="2026-06-01", + suggestions=[{"a": "ma_golden_cross", "b": "boll_breakout", + "corr": 0.15, "common_days": 60}], + pairs_evaluated=1, + ) + + result = create_guardian_composite( + tmp_path, engine, left="ma_golden_cross", right="boll_breakout", + latest_enriched=date(2026, 6, 1), + ) + assert result["created"] is True + combo_id = result["strategy_id"] + assert combo_id.startswith("guardian_combo_") + strategy = engine.get(combo_id) + assert strategy.execution_backend == "composite" + assert {c.strategy_id for c in strategy.composite.children} == { + "ma_golden_cross", "boll_breakout" + } + # 自动入护: trailing_reference 基线 + profile = GuardianStore(tmp_path).get(combo_id) + assert profile is not None + assert profile["baseline"]["kind"] == "trailing_reference" + assert profile["baseline"]["data_as_of"] == "2026-06-01" + + # 幂等: 已存在时不再写文件, 基线保留 + profile["baseline"]["metrics"] = {"score": 9.9} + GuardianStore(tmp_path)._write(profile) + again = create_guardian_composite( + tmp_path, engine, left="boll_breakout", right="ma_golden_cross", + latest_enriched=date(2026, 6, 2), + ) + assert again["created"] is False + refreshed = GuardianStore(tmp_path).get(combo_id) + assert refreshed["baseline"]["metrics"] == {"score": 9.9} + + +# ── P3: 名额门槛 + 实验室战报 ───────────────────────────────────── + + +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, 6, 1) + + +class _FakeEngine: + def __init__(self, ids: list[str]) -> None: + self.ids = ids + self.reloaded = 0 + + def list_strategies(self): + return [{"id": value} for value in self.ids] + + def get(self, strategy_id): + if strategy_id not in self.ids: + raise ValueError(f"unknown strategy: {strategy_id}") + return SimpleNamespace( + meta={"asset_types": ["stock"], "name": strategy_id, "params": []} + ) + + def has(self, strategy_id): + return strategy_id in self.ids + + def reload(self): + self.reloaded += 1 + + +def test_publish_slot_limit_blocks_when_full(tmp_path, monkeypatch): + """名额满: publish 拒绝并给出指引; 上限 0 = 不限。""" + from tests.test_mining_candidates import _create_run, _real_service + + store, run_id, signature = _create_run(tmp_path) + service, engine = _real_service(tmp_path, store) + 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": 1}, + ) + original_list = engine.list_strategies + engine.list_strategies = ( # type: ignore[method-assign] + lambda: [*original_list(), {"id": "mined_factor_occupied"}] + ) + with pytest.raises(ValueError, match="名额已满"): + service.publish(run_id, signature) + + 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": 0}, + ) + assert service.publish(run_id, signature)["ok"] is True + + +def test_lab_report_shape_and_slots(tmp_path, monkeypatch): + from app.api.lab import router as lab_router + from app.services import preferences + from app.services.mining_jobs import MiningRunStore + + 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, + "lab_mined_slot_limit": 2}, + ) + store = GuardianStore(tmp_path) + _seed_healthy(store, "mined_factor_a") + store.append_check("mined_factor_a", {"verdict": "watch"}) + store.set_status("mined_factor_a", "watch") + + app = FastAPI() + app.include_router(lab_router) + app.state.repo = _Repo(tmp_path) + app.state.strategy_engine = _FakeEngine( + ["mined_factor_a", "mined_factor_b", "other"] + ) + app.state.mining_manager = SimpleNamespace( + store=MiningRunStore(tmp_path, namespace="guardian") + ) + app.state.guardian_manager = SimpleNamespace( + store=MiningRunStore(tmp_path, namespace="guardian") + ) + + body = TestClient(app).get("/api/backtest/lab/report").json() + assert body["slots"] == { + "active_mined": 2, + "active_mined_ids": ["mined_factor_a", "mined_factor_b"], + "limit": 2, "full": True, + } + assert body["guardian"]["by_status"] == {"watch": 1} + assert body["guardian"]["profiles"] == 1 + assert body["combinations"]["suggestions"] == [] + assert body["mining"]["last_run"] is None + + +def test_guardian_api_combination_endpoints(tmp_path, monkeypatch): + from app.api.guardian import router as guardian_router + from app.services import preferences + from app.services.mining_jobs import MiningRunStore + + 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}, + ) + + class _Manager: + def __init__(self) -> None: + self.store = MiningRunStore(tmp_path, namespace="guardian") + self.starts = [] + + def start(self, request, fingerprint, force=False, source="manual", + run_id=None): + self.starts.append(request) + return self.store.create(request, fingerprint) + + app = FastAPI() + app.include_router(guardian_router) + app.state.repo = _Repo(tmp_path) + app.state.strategy_engine = _FakeEngine([]) + manager = _Manager() + app.state.guardian_manager = manager + client = TestClient(app) + + response = client.post("/api/backtest/guardian/combinations/scan") + assert response.status_code == 200 + assert manager.starts[-1]["mode"] == "combinations" + + # 无建议文件时创建 → 400 + response = client.post( + "/api/backtest/guardian/combinations/create", + json={"a": "x", "b": "y"}, + ) + assert response.status_code == 400 + assert "组合扫描" in response.json()["detail"] + + # overview 带组合建议 + write_combinations( + tmp_path, window_start="2026-01-01", window_end="2026-06-01", + suggestions=[{"a": "x", "b": "y", "corr": 0.1, "common_days": 40}], + pairs_evaluated=1, + ) + body = client.get("/api/backtest/guardian/overview").json() + assert body["combinations"]["suggestions"][0]["a"] == "x" + + +def test_create_composite_accepts_symlinked_data_dir(tmp_path): + """data_dir 含符号链接时 (如 macOS /tmp) 不能误判为路径逃逸。""" + from app.strategy.engine import StrategyEngine + + real = tmp_path / "real" + real.mkdir() + link = tmp_path / "link" + link.symlink_to(real, target_is_directory=True) + + builtin = Path(__file__).resolve().parents[1] / "tests" / "fixtures" / "strategies" + engine = StrategyEngine( + strategy_dirs=[builtin, link / "strategies" / "custom", + link / "strategies" / "composite"] + ) + write_combinations( + link, window_start="2026-01-01", window_end="2026-06-01", + suggestions=[{"a": "ma_golden_cross", "b": "boll_breakout", + "corr": 0.15, "common_days": 60}], + pairs_evaluated=1, + ) + result = create_guardian_composite( + link, engine, left="ma_golden_cross", right="boll_breakout", + latest_enriched=date(2026, 6, 1), + ) + assert result["created"] is True + assert (real / "strategies" / "composite" + / f"{result['strategy_id']}.py").is_file() diff --git a/backend/tests/test_guardian_runtime.py b/backend/tests/test_guardian_runtime.py new file mode 100644 index 00000000..93e7bef9 --- /dev/null +++ b/backend/tests/test_guardian_runtime.py @@ -0,0 +1,306 @@ +"""守护复验运行时测试: 用假回测服务驱动完整判定落档链路。""" +from __future__ import annotations + +import threading +from datetime import date, timedelta +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from app.backtest.guardian_runtime import ( + GuardianRuntimeCancelledError, + run_guardian_runtime, +) +from app.services.guardian_store import GuardianStore + +_AS_OF = date(2025, 12, 31) + + +def _write_partitions(data_dir: Path, dates: list[date], asset_type: str = "stock") -> None: + dirname = "kline_etf_enriched" if asset_type == "etf" else "kline_daily_enriched" + for value in dates: + partition = data_dir / dirname / f"date={value.isoformat()}" + partition.mkdir(parents=True, exist_ok=True) + (partition / "part.parquet").write_bytes(b"") + + +def _trading_dates(count: int, first: date = date(2025, 1, 1)) -> list[date]: + return [first + timedelta(days=index) for index in range(count)] + + +def _write_history(data_dir: Path) -> None: + """406 个连续分区: 2025-01-01..2026-02-10 — 基线 (2025-12-31) 之后还有 41 天新数据。 + + 41 个自然日只够切 2 段验证窗 (k=2: 01-01..01-21 与 01-22..02-10)。 + """ + _write_partitions(data_dir, _trading_dates(406)) + + +class _FakePrepared: + def __init__(self) -> None: + self.market_data = object() + self.compute_cache = SimpleNamespace(close=lambda: None) + + +class _FakeService: + """stats_by_start: {date.isoformat(): stats dict}; 默认调用返回 default。""" + + def __init__(self, default: dict, stats_by_start: dict | None = None, + errors_by_start: dict | None = None) -> None: + self.default = default + self.stats_by_start = stats_by_start or {} + self.errors_by_start = errors_by_start or {} + self.runs: list[date] = [] + self.prepared_calls = 0 + + def prepare_matrix_optimization(self, configs, matrix_cache_max_bytes=0, + market_data_override=None): + self.prepared_calls += 1 + return _FakePrepared() + + def run(self, config, cancel_event=None, prepared=None, result_policy=None): + self.runs.append(config.start) + key = config.start.isoformat() + if key in self.errors_by_start: + return SimpleNamespace(stats={}, error=self.errors_by_start[key]) + stats = dict(self.stats_by_start.get(key, self.default)) + if result_policy is not None and result_policy.required_stats is not None: + stats = {k: v for k, v in stats.items() if k in result_policy.required_stats} + return SimpleNamespace(stats=stats, error=None) + + +class _FakeEngine: + def __init__(self, known: set[str] | None = None) -> None: + self.known = known + + def get(self, strategy_id): + if self.known is not None and strategy_id not in self.known: + raise ValueError(f"unknown strategy: {strategy_id}") + return SimpleNamespace( + meta={"asset_types": ["stock"], "name": strategy_id, "params": []}, + execution_backend="matrix_native", + ) + + def resolve_params(self, strategy, params=None, overrides=None): + return {} + + +def _profile_store(tmp_path: Path) -> GuardianStore: + return GuardianStore(tmp_path) + + +def _seed_mining_profile(store: GuardianStore, strategy_id: str = "mined_factor_x", + *, score: float = 2.0, n_trades: int = 300) -> None: + store.create_baseline( + strategy_id, + kind="mining_gate", + data_as_of=_AS_OF.isoformat(), + objective="sharpe", + metrics={"score": score, "n_trades": n_trades, "max_drawdown": -0.10}, + ) + + +def _config(**overrides): + params = { + "run_id": "guardian-daily-test", + "strategy_ids": None, + "thresholds": {"watch_pct": 0.2, "retire_pct": 0.5, "k_max": 4}, + } + params.update(overrides) + return params + + +def test_healthy_check_transitions_pending_to_healthy(tmp_path): + _write_history(tmp_path) + store = _profile_store(tmp_path) + _seed_mining_profile(store) + service = _FakeService( + default={"sharpe": 1.9, "n_trades": 120, "max_drawdown": -0.08} + ) + + result = run_guardian_runtime( + _config(), data_dir=tmp_path, service=service, strategy_engine=_FakeEngine() + ) + + assert result["status"] == "succeeded" + assert result["checked"] == 1 and result["errors"] == 0 + profile = store.get("mined_factor_x") + assert profile["status"] == "healthy" + check = profile["checks"][-1] + assert check["verdict"] == "healthy" + assert check["k"] == 2 # 41 个新自然日只够切 2 段 + assert len(check["segment_scores"]) == 2 + assert check["degradation_pct"] == pytest.approx(0.05) + assert check["robust"]["score"] == pytest.approx(1.9) + assert check["n_trades_total"] == 240 + # 超集矩阵 + 每段视图: 1 (full) + 2 (segments) + assert service.prepared_calls == 3 + + +def test_severe_degradation_retires(tmp_path): + _write_history(tmp_path) + store = _profile_store(tmp_path) + _seed_mining_profile(store) + service = _FakeService(default={"sharpe": 0.2, "n_trades": 150, + "max_drawdown": -0.12}) + + run_guardian_runtime( + _config(), data_dir=tmp_path, service=service, strategy_engine=_FakeEngine() + ) + + profile = store.get("mined_factor_x") + assert profile["status"] == "retired" + assert "退化" in (profile["status_reason"] or "") + + +def test_retired_profile_not_rechecked(tmp_path): + _write_history(tmp_path) + store = _profile_store(tmp_path) + _seed_mining_profile(store) + store.set_status("mined_factor_x", "retired", reason="已退役") + service = _FakeService(default={"sharpe": 1.9, "n_trades": 100}) + + result = run_guardian_runtime( + _config(), data_dir=tmp_path, service=service, strategy_engine=_FakeEngine() + ) + + assert result["skipped_retired"] == 1 + assert service.runs == [] # 退役策略不再复验 + assert len(store.get("mined_factor_x")["checks"]) == 0 + + +def test_insufficient_new_data_skips_without_status_change(tmp_path): + # 数据只到基线后 10 天 + dates = _trading_dates(50)[:41] # 01-01..02-10; as_of=12-31 → 新数据 11 天 + _write_partitions(tmp_path, dates) + store = _profile_store(tmp_path) + _seed_mining_profile(store) + service = _FakeService(default={"sharpe": 1.0, "n_trades": 10}) + + result = run_guardian_runtime( + _config(), data_dir=tmp_path, service=service, strategy_engine=_FakeEngine() + ) + + assert result["skipped"] == 1 + profile = store.get("mined_factor_x") + assert profile["status"] == "pending" + assert profile["checks"][-1]["skipped"] is True + assert "新数据" in profile["checks"][-1]["reason"] + + +def test_single_segment_error_yields_watch_not_retired(tmp_path): + _write_history(tmp_path) + store = _profile_store(tmp_path) + _seed_mining_profile(store) + from app.backtest.guardian import split_windows + fresh_start, fresh_end = _AS_OF + timedelta(days=1), date(2026, 2, 10) + first_segment_start = split_windows(fresh_start, fresh_end, 2)[0][0] + service = _FakeService( + default={"sharpe": 0.05, "n_trades": 150, "max_drawdown": -0.05}, + errors_by_start={first_segment_start.isoformat(): "boom"}, + ) + + run_guardian_runtime( + _config(), data_dir=tmp_path, service=service, strategy_engine=_FakeEngine() + ) + + profile = store.get("mined_factor_x") + assert profile["status"] == "watch" + check = profile["checks"][-1] + assert check["verdict"] == "watch" + assert check["incomplete"] is True + assert any("验证段回测失败" in r for r in check["reasons"]) + # 残缺复验把首个段落级错误一并落档, 便于排查 + assert check["error"] == "boom" + + +def test_all_segments_error_records_error_and_keeps_status(tmp_path): + _write_history(tmp_path) + store = _profile_store(tmp_path) + _seed_mining_profile(store) + + class _AlwaysError(_FakeService): + def run(self, config, **kwargs): + return SimpleNamespace(stats={}, error="db down") + + result = run_guardian_runtime( + _config(), data_dir=tmp_path, service=_AlwaysError(default={}), + strategy_engine=_FakeEngine(), + ) + + assert result["errors"] == 1 + profile = store.get("mined_factor_x") + assert profile["status"] == "pending" + assert "db down" in (profile["checks"][-1]["error"] or "") + + +def test_missing_strategy_records_error(tmp_path): + _write_history(tmp_path) + store = _profile_store(tmp_path) + _seed_mining_profile(store, "ghost_strategy") + + result = run_guardian_runtime( + _config(), data_dir=tmp_path, service=_FakeService(default={}), + strategy_engine=_FakeEngine(known=set()), + ) + + assert result["errors"] == 1 + assert result["results"][0]["error"] == "strategy_unavailable" + assert store.get("ghost_strategy")["status"] == "pending" + + +def test_trailing_reference_calibrates_then_checks(tmp_path): + _write_history(tmp_path) + store = _profile_store(tmp_path) + store.create_baseline( + "my_manual_strategy", + kind="trailing_reference", + data_as_of=_AS_OF.isoformat(), + objective="sharpe", + metrics={}, + ) + service = _FakeService(default={"sharpe": 1.8, "n_trades": 90, + "max_drawdown": -0.09}) + + result = run_guardian_runtime( + _config(), data_dir=tmp_path, service=service, strategy_engine=_FakeEngine() + ) + + assert result["checked"] == 1 + profile = store.get("my_manual_strategy") + # 标定: 尾随 40 天窗口 (全部 100 天里最后 252→不足→全量 100... len=100≥30) + assert profile["baseline"]["metrics"]["score"] == 1.8 + assert profile["baseline"]["metrics"]["window_start"] is not None + assert profile["status"] == "healthy" + + +def test_cancel_raises(tmp_path): + _write_history(tmp_path) + store = _profile_store(tmp_path) + _seed_mining_profile(store) + cancel = threading.Event() + cancel.set() + + with pytest.raises(GuardianRuntimeCancelledError): + run_guardian_runtime( + _config(), data_dir=tmp_path, service=_FakeService(default={}), + strategy_engine=_FakeEngine(), cancel_check=cancel, + ) + + +def test_strategy_ids_filter(tmp_path): + _write_history(tmp_path) + store = _profile_store(tmp_path) + _seed_mining_profile(store, "mined_factor_a") + _seed_mining_profile(store, "mined_factor_b") + service = _FakeService(default={"sharpe": 1.9, "n_trades": 100}) + + result = run_guardian_runtime( + _config(strategy_ids=["mined_factor_b"]), data_dir=tmp_path, + service=service, strategy_engine=_FakeEngine(), + ) + + assert result["checked"] == 1 + assert store.get("mined_factor_a")["status"] == "pending" + assert store.get("mined_factor_b")["status"] == "healthy" diff --git a/backend/tests/test_guardian_schedule.py b/backend/tests/test_guardian_schedule.py new file mode 100644 index 00000000..ad513a16 --- /dev/null +++ b/backend/tests/test_guardian_schedule.py @@ -0,0 +1,250 @@ +"""守护每日调度与运行管理器参数化测试。""" +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 diff --git a/backend/tests/test_guardian_store.py b/backend/tests/test_guardian_store.py new file mode 100644 index 00000000..1e793cb4 --- /dev/null +++ b/backend/tests/test_guardian_store.py @@ -0,0 +1,166 @@ +"""守护档案存储测试: 幂等基线、复验滚动上限、状态转换与路径防护。""" +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"] diff --git a/backend/tests/test_mining_candidates.py b/backend/tests/test_mining_candidates.py index 801c536c..8adfa8fc 100644 --- a/backend/tests/test_mining_candidates.py +++ b/backend/tests/test_mining_candidates.py @@ -677,3 +677,28 @@ def test_promote_still_allowed_for_gated_candidates(tmp_path) -> None: result = service.promote(run_id, signature) assert result["status"] == "pending" + + +def test_publish_factor_records_guardian_baseline(tmp_path) -> None: + """发布成功自动落守护基线 (mining_gate), 重复发布不覆盖既有档案。""" + from app.services.guardian_store import GuardianStore + + store, run_id, signature = _create_run(tmp_path) + service, _engine = _real_service(tmp_path, store) + + result = service.publish(run_id, signature) + + guardian = GuardianStore(tmp_path) + profile = guardian.get(result["strategy_id"]) + assert profile is not None + assert profile["baseline"]["kind"] == "mining_gate" + assert profile["baseline"]["data_as_of"] == "2026-01-09" + assert profile["baseline"]["objective"] == "sharpe" + assert profile["baseline"]["metrics"]["score"] == 0.9 + assert profile["baseline"]["metrics"]["n_trades"] == 70 + assert profile["status"] == "pending" + + # 档案上积累的复验历史在重复发布 (幂等路径) 后必须保留 + guardian.append_check(result["strategy_id"], {"verdict": "healthy"}) + service.publish(run_id, signature) + assert len(guardian.get(result["strategy_id"])["checks"]) == 1 diff --git a/docs/auto-strategy-lab-plan.md b/docs/auto-strategy-lab-plan.md new file mode 100644 index 00000000..dcb00235 --- /dev/null +++ b/docs/auto-strategy-lab-plan.md @@ -0,0 +1,165 @@ +# 自动策略实验室设计(挖掘 → 组合 → 持续验证闭环) + +状态:**P1(策略守护)、P2a(稳健选冠)、P2b(权重精调)、P2d(策略级组合)、P3(闭环编排)均已实施并通过测试与真实数据 E2E 验证**;P2c(overlay 候选)按 §4.3 约定"按需启动,默认不做"暂不实施(需扩展可信发布契约,本轮收益/风险比不划算)。本文回答一个需求:**自动挖掘并验证各种因子,组合成好用的策略,并持续组合、持续验证**。 + +落地口径与设计稿的差异(均已在代码与测试中体现): + +- **P2b**:权重网格固定首个因子为 1.0(截面分位打分天然尺度不变),实际枚举 `{0.5, 1.0, 1.5, 2.0}^(n-1)`(≤64 变体);stage1 复用 run 已备矩阵跑外层测试窗口,stage2 全窗口复验退化 >20% 放弃;精调预算 = run 剩余额度(上限 96),预算不足自动跳过(4 因子冠军在 balanced 档默认跳过属预期)。精调结果作为**新候选行**追加(独立签名),不覆盖原冠军。 +- **P2d**:相关性采用**日收益 Pearson**(≥30 个共同交易日,样本更多);建议对相关性阈值 ≤0.70;生成 composite 需人工在前端确认(fail-closed:不在最新建议中的对直接拒绝),幂等(blake2b 签名),自动以 `trailing_reference` 基线入护。 +- **P3**:`lab_schedule.run_scheduled_lab` 为调度单入口(周度挖掘 + 日度守护,失败互不阻断);名额满时新冠军停在 pending 候选等位;retired 不自动删文件,腾名额走人工归档/删除既有路径。 + +结论先行:仓库已存在"因子池统计筛选 → 束搜索组合 → 嵌套样本外验证 → 晋升门槛 → 一键发布策略"的完整主链路(mining-v2,见 `docs/mining.md`)。本设计不重建挖掘引擎,而是补三块真正的缺口: + +1. **P1 策略守护**:发布后的策略在新数据上持续复验,退化自动告警/标记退役——"持续验证"目前完全不存在。 +2. **P2 组合进化**:冠军选择从"单点 OOS 均值"升级为稳健分(median+worst),并对冠军组合做权重精调(复用稳健优化器)——"组合"目前只有 1.0/2.0 离散权重的束搜索。 +3. **P3 闭环编排**:把周度挖掘与每日守护合并为一个有名额上限、有战报的总控状态机——"持续组合并验证"的自动循环。 + +--- + +## 一、现状盘点(均为已实现,设计以此为基础) + +| 能力 | 位置 | 说明 | +| --- | --- | --- | +| 因子池统计筛选 | `backend/app/services/auto_mining.py` `screen_all_factors()` | 按 \|IC\|×\|IR\| 选因子池 | +| 束搜索组合 | `backend/app/backtest/mining.py` `beam_search_factor_combinations()` (L610) | 组合 ≤4 因子,权重只试全 1.0 或单因子 2.0 | +| 嵌套样本外验证 | `mining.py` `generate_nested_folds()` (L752) | 外层 504/126/63,内层 252/63/63,purge 30 + embargo 5 | +| 真实回测评估 | `backend/app/backtest/mining_runtime.py` `MatcherCandidateEvaluator` (L142) | 候选转 `factor_rank_research` 策略真跑回测,score=外层 OOS Sharpe | +| 晋升门槛 | `mining.py` `evaluate_candidate_gate()` (L198) | 折数/正收益比/Sharpe≥0.5/回撤≥-25%/交易数≥60/confidence | +| 发布成策略 | `backend/app/services/mining_candidates.py` `publish()` (L112) | 渲染 `mined_factor_*.py`,**不记录发布基线** | +| 周度定时挖掘 | `backend/app/services/mining_schedule.py` `run_weekly_mining()` (L126) | 数据指纹 + claim 防重 | +| 进程隔离与预算 | `mining_runtime.py` + `worker.py` + `MiningRunStore` | spawn 子进程,峰值 RSS ≤1.5GiB(实测 ~1.35GiB/41s) | +| 稳健优化基建 | `robust_optimizer.py` / `walkforward.py` / `guards.py` | `robust_score`=0.6×median+0.4×worst、K 段切分、目标白名单 | +| 评估器注入点 | `mining.py` `CandidateEvaluator` Protocol (L426) | `MiningService.run(..., evaluator=)` 可整体替换评估语义 | + +**缺口**(本设计要解决的): + +- 发布即终点:`publish()` 之后策略与普通策略无异,因子失效无人知晓。 +- 选冠不稳健:外层折 Sharpe 取均值,单折运气可登顶;与稳健优化器的 median+worst 语义割裂。 +- 组合空间窄:权重 {1.0, 2.0}、无 overlay/风控维度、无策略级组合(composite 已有引擎但无自动流程)。 + +--- + +## 二、总体结构 + +```text + ┌─────────────── P3 总控(名额上限 + 战报)───────────────┐ + │ │ + 周度: screen_all_factors ──> MiningService 束搜索 ──> gate ──> publish ──┐ + (已有) (因子池筛选) (P2a: 稳健选冠) (P2b: 权重精调) │ + v + 每日: 策略守护档案 (基线 + 复验历史) + (P1 新建) healthy / watch / retired + │ + 退化 > 阈值 ──┘──> 告警 + 标记退役(腾名额) +``` + +数据流纪律(关键):**守护层(发布后数据)绝不反馈进取参加权**。P2 的精调只用挖掘窗口内的 K 段锚定;发布后数据只用于验证与退役判定。这是逐层 OOS:挖掘嵌套验证 → 晋升门槛 → 发布后纯 OOS 追踪。 + +--- + +## 三、P1 策略守护(持续验证) + +### 3.1 数据模型 + +- 守护档案:`data/user_data/strategy_guardian/{strategy_id}.json`,每策略一份: + - `baseline`:发布/入护时的指标快照(objective 分、max_drawdown、n_trades、valid_folds、`data_as_of`、definition 指纹)。 + - `baseline_kind`:`mining_gate`(发布时来自晋升门槛,天然 OOS)或 `trailing_reference`(手动入护策略,取入护日前的尾随窗口作参考——**不是 OOS**,仅作退化比较基准)。 + - `checks[]`:历次复验记录(≤52 条滚动),每条含窗口、各段分数、robust 分、退化百分比、判定。 + - `status`:`healthy | watch | retired`,及进入当前状态的时间与原因。 +- 复验 run 工件:`data/research/guardian/runs/{run_id}/`,沿用 `MiningRunStore` 的 manifest/summary/events 模式(重启可恢复、SSE 可续传)。 + +### 3.2 基线写入 + +- `MiningCandidateService.publish()` 渲染 `mined_factor_*` 成功后,同步写守护档案(baseline 来自 gate 指标,`baseline_kind=mining_gate`)。失败不影响发布本身(守护是旁路,fail-open 于发布、fail-closed 于判定)。 +- 手动入护:`POST /api/backtest/guardian/{strategy_id}/adopt`,任意策略可入护;首次先算尾随参考基线,标记 `trailing_reference`。 + +### 3.3 复验算法(每策略每次) + +1. 窗口:`baseline.data_as_of` 之后的全部新交易日,用 `robust_optimizer.split_validation_windows` 切 K 段(K=2..4,每段 ≥14 交易日);新增数据 <30 交易日则本轮跳过(记录 skipped)。 +2. 每段一次真实回测:`StrategyBacktestService.run` + `BacktestResultPolicy.optimizer_trial`(复用 `mining_runtime` 的矩阵共享与裁剪模式),objective 默认 `sharpe`(与挖掘 gate 同口径),必须属于 `guards.PRIMARY_OBJECTIVES`。 +3. 当前分 = `robust_optimizer.robust_score`(0.6×median + 0.4×worst)。 +4. 退化判定(方向感知,公式与 `robust_optimizer` stage2 的 degradation 同语义,min 类目标不误判): + - `healthy`:退化 ≤20% 且软底线未击穿(sharpe>0、回撤不差于 -30%、n_trades ≥ 基线 1/3)。 + - `watch`:退化 20%–50%,或击穿一项软底线;连续 2 次 watch 触发通知。 + - `retired`:退化 >50%,或硬底线击穿(n_trades 枯竭到接近零——因子失效最常见的形态是"不再触发")。 +5. 退役动作**只改档案状态 + 系统通知 + 前端置灰**,不自动删除/移动策略文件(用户数据安全,`CONTRIBUTING.md` §8);提供人工"归档"按钮,走策略删除的既有 fail-closed 校验路径。retired 不自动回场。 + +### 3.4 执行模型 + +- 新 worker kind `"guardian"`:`worker.py` `make_worker_task` (L129) 加一个分支,子进程内逐策略复验(全市场矩阵一次 `prepare_matrix_optimization`,各策略按需重建——策略 universe/评分不同,矩阵不能跨策略复用,但同策略的 K 段用 `market_data_override` 只读视图复用,模式同稳健优化器)。 +- 并发槽:与挖掘共用 `shared_heavy_job_limiter.slot("mining")`——两者都是全市场级内存任务,串行化避免双份 1.3GiB 矩阵。 +- 调度:`guardian_schedule.run_daily_guardian()`,镜像 `mining_schedule` 的 claim 模式(claim id `guardian-daily-{date}`,enriched 数据指纹无变化则跳过)。挂在主进程现有后台调度线程上。 +- `task_registry.TASK_KINDS` 增加 `"guardian"`(前端 `taskRegistry.ts` 同步)。 + +### 3.5 API 与前端 + +- `backend/app/api/guardian.py`:`GET /api/backtest/guardian/overview`(全部守护策略:状态、基线 vs 最近、退化、曲线端点)、`POST /{sid}/adopt|drop|rerun`、`GET|PATCH /config`(阈值、objective、K、开关走 preferences)。 +- 前端:回测页"稳健性"域新增子 tab **守护** → `frontend/src/pages/backtest/StrategyGuardian.tsx`(`RobustnessValidation.tsx` 加一个子 tab,路由不动)。表格 + 状态徽章 + 基线/近期对比 + 各段分数;rerun 走轮询(同 `miningTask` 模式),不需要 SSE 镜像。 + +--- + +## 四、P2 组合进化 + +### 4.1 稳健选冠(2a) + +- `mining_runtime` 内给 `MatcherCandidateEvaluator` 的外层聚合换语义:冠军排序分从"外层折 Sharpe 均值"改为 `robust_score(各外层折 OOS sharpe)`(median+worst),并把逐折分散度(worst/best)写入候选 meta。 +- 晋升门槛增加可选稳健维度:strict 档要求 worst-fold sharpe > 0(默认开);gate 的绝对阈值不变。 +- **版本纪律**:选冠语义变化会改变同请求的结果,落地时 `MINING_ALGORITHM_VERSION` 升为 `mining-v3`,并进数据指纹,防止升级后 claim 幂等复用旧口径结果。 + +### 4.2 冠军权重精调(2b) + +- 对束搜索最终冠军组合(≤4 因子),权重在 `{0.5, 1.0, 1.5, 2.0}^n`(≤256 组合)网格上做 K 段锚定验证(完全复用 `RobustOptimizeService` 的两段式:stage1 全网格分段,stage2 Top-N 全窗口复验退化),选出稳健权重写入发布策略的 `scoring`。 +- 矩阵复用挖掘 run 已准备的全区间超集矩阵(同策略/universe/区间,签名校验天然通过),精调增量成本 ≈ 一次稳健优化的 stage1。 +- 预算:精调 trial 计入 `MiningBudget`,不另开上限。 + +### 4.3 维度扩展(2c/2d,按需启动,默认不做) + +- 2c overlay 候选:对最终冠军试 ≤4 个预定义 overlay 模板(排除高位、排除低流动性等)。注意 overlay 改变 universe,每个变体需重建矩阵,成本 ×变体数,只在 strict 档开放。 +- 2d 策略级组合:守护档案中 ≥2 个 healthy 冠军且**日收益相关性 ≤0.7**(新增策略收益相关性计算,因子相关性剪枝 `compute_rank_correlation` 不直接适用)时,前端提示"可组合",人工确认后生成 composite 策略(引擎已支持 children ≤8)并纳入守护。不自动生成,防组合爆炸与过拟合叠加。 + +--- + +## 五、P3 闭环编排 + +- `lab_schedule.py` 统一状态机:`idle → screen → mine → gate → publish → watch → retire(腾名额) → idle`。两个 claim:`lab-weekly-{iso}`(挖掘+精调)、`lab-daily-{date}`(守护复验),替代原 `mining_schedule` 入口(保留旧入口兼容一个版本)。 +- 名额上限:活跃 mined 策略数 preference(默认 10);名额满时不发布新冠军,产出 pending 候选等位。 +- 战报:守护 + 挖掘 summary 聚合为前端总览卡(本周新挖掘 run、晋升/退役/在役数、平均退化、最弱在役策略)。 + +--- + +## 六、关键设计决策与风险 + +| 决策 | 理由 | +| --- | --- | +| 守护层数据绝不反馈进参数选择 | 逐层 OOS 纪律;否则"用未来调参",守护退化判定失去意义 | +| 退役不自动删策略文件 | 用户数据安全;删除/归档是高风险操作,必须人工确认 | +| 手动入护基线标 `trailing_reference` | 尾随窗口不是 OOS,语义必须显式区分,否则误判退化 | +| guardian 与 mining 共用 heavy 槽 | 两个全市场矩阵并发会破 RSS 门槛;串行化是唯一安全选项 | +| 2a 落地必升算法版本号 | 同请求不同口径的结果混用 claim 幂等缓存是数据事故 | +| objective 只取 `guards.PRIMARY_OBJECTIVES` | win_rate 等退化目标做主目标是已知反模式 | +| 复验窗口 <30 交易日跳过 | 段太短的 robust 分是噪声,判定只会制造假告警 | +| generation 快照 + 重试读面板 | 沿用挖掘运行时既有纪律,撞上 enriched 发布 fail-closed | + +剩余风险:守护判定阈值(20%/50%)是经验值,需在 P1 上线后按实际分布校准;策略收益相关性(2d)已定为日收益 Pearson(≥30 共同交易日)。 + +--- + +## 七、测试矩阵 + +| 改动 | 最低验证 | +| --- | --- | +| 判定状态机(纯函数) | healthy/watch/retired 边界、方向感知(min 目标)、n_trades 枯竭、退化百分比边界 | +| 基线写入 | publish 后档案存在且指标与 gate 一致;发布失败不写档案;手动入护标 trailing_reference | +| 调度 | claim 同日防重、数据指纹不变跳过、新增数据 <30 日跳过 | +| worker/API | kind 注册、overview/adopt/drop/rerun 契约与错误路径、generation fail-closed | +| 2a 选冠 | median+worst 与均值排序产生不同冠军的构造用例;版本号 bump 断言 | +| 2b 精调 | 网格 ≤256、与 robust_score 复用一致性、预算计入 | +| 前端 | `pnpm build`;守护页加载/空/错/watch/retired 状态 | +| 回归 | `test_mining_*.py` 全量 + 新增 `test_strategy_guardian*.py` | + +## 八、分期与验收 + +- **P1(先行)**:发布基线自动落档 + 每日复验 + 三态判定 + 守护页。验收:对一个已发布 mined 策略跑通"发布→复验→人工注入退化数据→watch/retired 告警"全链路(测试内构造),真实数据下守护页正常展示。✅ 已实施并通过真实数据 E2E。 +- **P2a+2b**:稳健选冠 + 权重精调 + 版本号升级。验收:同一历史 run 请求在新旧口径下冠军可不同且均有记录;精调后发布策略的 scoring 与档案一致。✅ 已实施(`mining-v3`,精调差异见文首落地口径)。 +- **P2d、P3**:✅ 已实施(组合建议扫描/人工确认生成 composite + 自动入护;lab 调度单入口 + 名额上限 + 战报聚合 `/api/backtest/lab/report` + 守护页战报卡)。 +- **P2c**:按需启动,默认不做(见文首说明)。 diff --git a/docs/features.md b/docs/features.md index e533ec06..0d705a25 100644 --- a/docs/features.md +++ b/docs/features.md @@ -64,7 +64,9 @@ **滚动样本外检验(验证 tab)**:每折在训练区间网格优化选参、紧邻测试区间做样本外(OOS)验证,滚动前移;核心产出是 OOS 拼接复利净值与 IS→OOS 退化(过拟合信号)。补强:每折 OOS 最小交易数门槛(不足标记跳过不混入复利)、运行前折预览(<3 折告警)、参数漂移轨迹(逐折最优值 + 稳定/漂移徽章)、「采用最新折参数」(确认框注明来源折,走策略配置 override)。 -**因子与策略挖掘**:复用已有日频因子和 matrix-native 策略,通过 T-1 市场环境、相关去重和嵌套样本外验证生成研究候选。任务在 spawn worker 中运行,支持持久 run ID、取消、刷新重连和显式发布;自动周度任务默认关闭且永不自动发布。完整口径见 [因子与策略挖掘](./mining.md)。 +**策略守护(验证 tab)**:挖掘发布的策略在发布时自动建立守护基线(晋升门槛指标,天然 OOS);此后每天收盘数据更新后自动在新数据上切 K 段做真实回测复验,按稳健分对比基线判定三态——健康(退化≤阈值)/观察(20%-50% 或击穿软底线,连续 2 次告警)/退役(退化>50% 或交易数枯竭)。退役只标记与置灰,不自动删策略文件;已退役策略不再自动复验(重新守护=移除后重新入护)。任意策略也可手动入护(尾随参考基线,非 OOS,判定只针对入护后新数据)。数据纪律:守护层结果绝不反馈进取参加权(逐层 OOS);段落回测失败时只降级到观察不直接退役;新数据不足 30 个交易日跳过本轮。守护页还提供**实验室战报**(守护/挖掘/名额聚合总览,`/api/backtest/lab/report`)与**组合建议**:扫描健康守护冠军间近 126 个交易日的日收益 Pearson 相关性,≤0.7 的对列出,人工确认后生成等权 composite 策略并自动以尾随参考基线入护(幂等,不在最新建议中的对拒绝生成);**挖掘策略名额上限**(偏好项,默认 10,0=不限)在发布门槛通过后校验,名额满时新冠军停在 pending 候选等位。设计见 [自动策略实验室设计](./auto-strategy-lab-plan.md)。 + +**因子与策略挖掘**:复用已有日频因子和 matrix-native 策略,通过 T-1 市场环境、相关去重和嵌套样本外验证生成研究候选。冠军排序按稳健分(0.6×中位+0.4×最差外层折),strict 档要求最差折 Sharpe>0;对冠军组合再做权重精调({0.5,1.0,1.5,2.0} 网格,消耗 run 剩余预算,退化>20% 放弃),精调结果作为独立候选行追加。任务在 spawn worker 中运行,支持持久 run ID、取消、刷新重连和显式发布;自动周度任务默认关闭且永不自动发布,周度挖掘与每日守护由调度单入口(`lab_schedule`)编排、失败互不阻断。完整口径见 [因子与策略挖掘](./mining.md)。 **策略设置统一**:回测页「高级策略设置」抽屉与策略页设置弹窗采用同一信息架构(策略条件 / 策略权重 / 风控触发 / 回测范围)。风控与持仓纪律(止损 · 止盈 · 移动止损 · 回撤止盈 · 最长持仓)以策略设置保存的值为默认,回测抽屉内可临时覆盖,也可「应用到策略」持久化;策略页保存的叠加条件随回测请求继承,与扫描 / 监控同一口径;回测范围(资产类型 / 股票池)为回测专属概念。 diff --git a/docs/mining.md b/docs/mining.md index 79f666e9..b6aa2c52 100644 --- a/docs/mining.md +++ b/docs/mining.md @@ -82,6 +82,12 @@ run 级的“有效折”统计只描述因子赛道:它按每折被选中的 门槛按 artifact 行指标在读取时计算,不改变历史 artifact 的 schema,因此旧运行的候选会得到相同的门槛判断。工作台会在候选上显示“达标/未达标”标记,未达标的候选“显式发布”按钮被禁用并展示原因。 +## 冠军排序与权重精调(mining-v3) + +选冠语义自 v3 起从"外层折 Sharpe 均值"改为**稳健分** `0.6×中位 + 0.4×最差`(复用 `robust_optimizer.robust_score`):artifact 的 `score` 列记录稳健分,`oos_sharpe` 仍为均值且**发布门槛阈值不变**;新增可选列 `oos_worst_sharpe`(最差外层折 Sharpe),v2 旧 artifact 缺该列仍可正常读取。strict 档(confidence=high)在列存在时额外要求最差折 Sharpe>0 才能发布。算法版本号升级进数据指纹,防止升级后周度 claim 幂等复用旧口径结果。 + +冠军组合再做**权重精调**:固定首个因子权重 1.0(截面分位打分天然尺度不变),其余因子在 `{0.5, 1.0, 1.5, 2.0}` 网格上枚举(≤64 变体);stage1 复用本 run 已备矩阵在外层测试窗评估,stage2 全窗口复验,较 stage1 退化 >20% 即放弃。精调消耗 run 剩余预算(上限 96 trial),预算不足自动跳过;精调产物作为**独立候选行**追加(自有签名,不覆盖原冠军),run summary 带 `refinement` 摘要。 + ## 置信度 三个预算档使用不同的训练窗口: diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 175c90fc..95d141f0 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -2361,6 +2361,114 @@ export interface UpgradeGuardStatus { pending_report?: UpgradeGuardReport | null } +// ===== 策略守护 (发布后持续复验) ===== +export interface GuardianConfig { + guardian_schedule_enabled: boolean + guardian_watch_pct: number + guardian_retire_pct: number + guardian_k_max: number + lab_mined_slot_limit: number +} + +export interface GuardianRun { + run_id: string + status: string + request: Record + created_at: string | null + updated_at: string | null + started_at: string | null + finished_at: string | null + error: string | null + progress: { done?: number; total?: number; strategy_id?: string; verdict?: string } | null +} + +export interface GuardianProfile { + strategy_id: string + name: string | null + status: 'pending' | 'healthy' | 'watch' | 'retired' + status_reason: string | null + status_since: string | null + baseline: { + kind: 'mining_gate' | 'trailing_reference' | null + data_as_of: string | null + objective: string | null + score: number | null + n_trades: number | null + max_drawdown: number | null + } + checks_count: number + consecutive_watch: number + alert: boolean + last_check: { + checked_at: string | null + verdict: 'healthy' | 'watch' | 'retired' | null + skipped: boolean + reason: string | null + error: string | null + degradation_pct: number | null + objective: string | null + window_start: string | null + window_end: string | null + k: number | null + reasons: string[] + } | null + updated_at: string | null +} + +export interface GuardianCombinationSuggestion { + a: string + b: string + corr: number + common_days: number +} + +export interface GuardianCombinations { + schema_version?: number + generated_at?: string + window_start?: string + window_end?: string + pairs_evaluated?: number + suggestions: GuardianCombinationSuggestion[] +} + +export interface GuardianOverview { + items: GuardianProfile[] + config: GuardianConfig + active_run: GuardianRun | null + combinations: GuardianCombinations | null +} + +export interface LabReport { + generated_at: string + mining: { + last_run: { + run_id: string + status: string + created_at: string | null + finished_at: string | null + algorithm_version?: string | null + confidence?: string | null + candidate_count?: number | null + valid_fold_count?: number | null + refinement?: Record | null + trials_used?: number | null + } | null + } + guardian: { + profiles: number + by_status: Record + alerts: number + last_run: { run_id: string; status: string; created_at: string | null; finished_at: string | null } | null + } + combinations: { generated_at?: string | null; suggestions: GuardianCombinationSuggestion[] } + slots: { + active_mined: number + active_mined_ids: string[] + limit: number + full: boolean + } +} + // ===== API surface ===== export const api = { health: () => request<{ status: string; version: string; mode: string }>('/health'), @@ -4108,6 +4216,55 @@ export const api = { })}`, ), + // ── 策略守护 (发布后持续复验) ────────────────────────────── + guardianOverview: () => + request('/api/backtest/guardian/overview'), + + guardianAdopt: (strategyId: string, objective: string) => + request<{ ok: boolean; strategy_id: string; run_id: string }>( + `/api/backtest/guardian/${encodeURIComponent(strategyId)}/adopt`, + { method: 'POST', body: JSON.stringify({ objective }) }, + ), + + guardianDrop: (strategyId: string) => + request<{ ok: boolean; strategy_id: string }>( + `/api/backtest/guardian/${encodeURIComponent(strategyId)}/drop`, + { method: 'POST' }, + ), + + guardianRerun: (strategyId: string) => + request<{ ok: boolean; strategy_id: string; run_id: string }>( + `/api/backtest/guardian/${encodeURIComponent(strategyId)}/rerun`, + { method: 'POST' }, + ), + + guardianRunAll: () => + request<{ ok: boolean; run_id: string }>('/api/backtest/guardian/run', { method: 'POST' }), + + guardianRuns: (limit = 20) => + request<{ items: GuardianRun[] }>(`/api/backtest/guardian/runs?limit=${limit}`), + + guardianConfig: () => request('/api/backtest/guardian/config'), + + guardianPatchConfig: ( + patch: Partial>, + ) => + request('/api/backtest/guardian/config', { + method: 'PATCH', + body: JSON.stringify(patch), + }), + + guardianScanCombinations: () => + request<{ ok: boolean; run_id: string }>('/api/backtest/guardian/combinations/scan', { method: 'POST' }), + + guardianCreateCombination: (a: string, b: string) => + request<{ ok: boolean; strategy_id: string; created: boolean }>('/api/backtest/guardian/combinations/create', { + method: 'POST', + body: JSON.stringify({ a, b }), + }), + + labReport: () => request('/api/backtest/lab/report'), + strategySaveConfig: (strategyId: string, overrides: Record) => request<{ ok: boolean }>('/api/strategies/config', { method: 'POST', diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 61c83e22..0a803d59 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -68,6 +68,9 @@ export const QK = { miningRun: (id: string) => ['backtest-mining-run', id] as const, miningResult: (id: string) => ['backtest-mining-result', id] as const, miningConfig: ['backtest-mining-config'] as const, + guardianOverview: ['backtest-guardian-overview'] as const, + guardianRuns: ['backtest-guardian-runs'] as const, + labReport: ['backtest-lab-report'] as const, researchCandidates: ['research-candidates'] as const, strategyLinkOptions: (assetType?: 'stock' | 'etf') => assetType ? ['strategy-link-options', assetType] as const diff --git a/frontend/src/pages/backtest/RobustnessValidation.tsx b/frontend/src/pages/backtest/RobustnessValidation.tsx index e8ea89cd..1cdf58d8 100644 --- a/frontend/src/pages/backtest/RobustnessValidation.tsx +++ b/frontend/src/pages/backtest/RobustnessValidation.tsx @@ -1,9 +1,10 @@ import { useState } from 'react' -import { SlidersHorizontal, Waypoints } from 'lucide-react' +import { ShieldCheck, SlidersHorizontal, Waypoints } from 'lucide-react' import { StrategyOptimizer } from './StrategyOptimizer' import { StrategyWalkForward } from './StrategyWalkForward' +import { StrategyGuardian } from './StrategyGuardian' -type Mode = 'sensitivity' | 'walkforward' +type Mode = 'sensitivity' | 'walkforward' | 'guardian' export function RobustnessValidation() { const [mode, setMode] = useState('sensitivity') @@ -14,6 +15,7 @@ export function RobustnessValidation() { {([ ['sensitivity', '稳健优化', SlidersHorizontal], ['walkforward', '滚动样本外检验', Waypoints], + ['guardian', '策略守护', ShieldCheck], ] as const).map(([value, label, Icon]) => ( + +
+ 手动入护用入护日前 252 个交易日的尾随表现作参考基线 (非样本外), 判定只针对此后新数据; 挖掘发布的策略自动入护 (门槛基线, 天然 OOS)。 +
+ {error &&
{error}
} + + ) +} + +/** P3 实验室战报: 挖掘/守护/名额聚合总览。 */ +function LabReportCard({ report }: { report: LabReport | undefined }) { + if (!report) return null + const mining = report.mining.last_run + const refinement = mining?.refinement as { applied?: boolean; refined_weights?: number[]; original_weights?: number[] } | null | undefined + return ( +
+
+
实验室战报
+
+ 守护 {report.guardian.profiles} 个 + {report.guardian.by_status.healthy ? ` · 健康 ${report.guardian.by_status.healthy}` : ''} + {report.guardian.by_status.watch ? ` · 观察 ${report.guardian.by_status.watch}` : ''} + {report.guardian.by_status.retired ? ` · 退役 ${report.guardian.by_status.retired}` : ''} + + {report.guardian.alerts > 0 && ( + + {report.guardian.alerts} 个连续观察告警 + + )} + + 挖掘名额 {report.slots.active_mined}/{report.slots.limit === 0 ? '∞' : report.slots.limit} + {report.slots.full ? ' (已满, 新候选等位)' : ''} + +
+
+
+ {mining + ? `最近挖掘 ${mining.status} · ${mining.algorithm_version ?? '—'} · 候选 ${mining.candidate_count ?? '—'} 个 · 有效折 ${mining.valid_fold_count ?? '—'}` + : '还没有挖掘运行'} + {refinement?.applied === true && ( + · 冠军权重已精调 {JSON.stringify(refinement.original_weights)} → {JSON.stringify(refinement.refined_weights)} + )} +
+
+ ) +} + +/** P2d 组合建议: 健康冠军低相关对, 确认后生成 composite 并入护。 */ +function CombinationsCard({ + combinations, + busy, + onScan, + onCreate, +}: { + combinations: GuardianCombinationSuggestion[] + generatedAt?: string | null + busy: boolean + onScan: () => void + onCreate: (a: string, b: string) => Promise +}) { + const [creating, setCreating] = useState(null) + return ( +
+
+
+ 组合建议 +
+ +
+ {combinations.length === 0 ? ( +
+ 暂无建议 — 扫描会计算健康守护冠军间近 126 个交易日的日收益相关性, 相关性 ≤0.7 的对才会出现在这里。 +
+ ) : ( +
+ {combinations.map(item => { + const key = `${item.a}+${item.b}` + return ( +
+ + {item.a} × {item.b} + 相关性 {item.corr.toFixed(2)} · {item.common_days} 日 + + +
+ ) + })} +
+ )} +
+ ) +} + +export function StrategyGuardian() { + const queryClient = useQueryClient() + const [error, setError] = useState(null) + const { data, isLoading, error: queryError } = useQuery({ + queryKey: QK.guardianOverview, + queryFn: () => api.guardianOverview(), + // 复验进行中每 2s 轮询; 平时按 TanStack 默认 (staleTime 到点后窗口聚焦刷新) + refetchInterval: query => (query.state.data?.active_run ? 2000 : false), + }) + const { data: labReport } = useQuery({ + queryKey: QK.labReport, + queryFn: () => api.labReport(), + refetchInterval: () => (data?.active_run ? 5000 : false), + }) + const refresh = () => { + queryClient.invalidateQueries({ queryKey: QK.guardianOverview }) + queryClient.invalidateQueries({ queryKey: QK.labReport }) + } + const runAll = useMutation({ + mutationFn: () => api.guardianRunAll(), + onSuccess: () => { setError(null); refresh() }, + onError: (e: Error) => setError(e.message), + }) + const rerun = useMutation({ + mutationFn: (sid: string) => api.guardianRerun(sid), + onSuccess: () => { setError(null); refresh() }, + onError: (e: Error) => setError(e.message), + }) + const drop = useMutation({ + mutationFn: (sid: string) => api.guardianDrop(sid), + onSuccess: () => { setError(null); refresh() }, + onError: (e: Error) => setError(e.message), + }) + const scanCombinations = useMutation({ + mutationFn: () => api.guardianScanCombinations(), + onSuccess: () => { setError(null); refresh() }, + onError: (e: Error) => setError(e.message), + }) + const createCombination = useMutation({ + mutationFn: ({ a, b }: { a: string; b: string }) => api.guardianCreateCombination(a, b), + onSuccess: () => { setError(null); refresh() }, + onError: (e: Error) => setError(e.message), + }) + + const items = data?.items ?? [] + const activeRun = data?.active_run ?? null + const progress = activeRun?.progress + + return ( +
+ {(error || queryError) && ( +
+ {error ?? (queryError instanceof Error ? queryError.message : '加载失败')} +
+ )} + + {activeRun && ( +
+
+ + + 复验进行中 {progress?.strategy_id ? `· ${progress.strategy_id}` : ''} + + {progress?.done ?? 0}/{progress?.total ?? '?'} +
+
+
+
+
+ )} + + + {data?.config && } + scanCombinations.mutate()} + onCreate={(a, b) => createCombination.mutateAsync({ a, b })} + /> + + +
+
守护中的策略 ({items.length})
+ +
+ + {isLoading ? ( +
加载中…
+ ) : items.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + + {items.map(p => { + const last = p.last_check + const lastText = last?.skipped + ? (last.reason ?? '跳过') + : last?.error + ? last.error + : last?.verdict + ? `${last.window_start ?? ''} ~ ${last.window_end ?? ''} · ${last.k ?? '—'} 段` + : '—' + return ( + + + + + + + + + + ) + })} + +
策略状态基线最近复验退化说明操作
+
{p.name ?? p.strategy_id}
+
{p.strategy_id}
+
+
+ + {p.alert && ( + + 连续 {p.consecutive_watch} 次观察 + + )} +
+
+
{BASELINE_KIND_LABEL[p.baseline.kind ?? ''] ?? '—'}
+
+ {p.baseline.objective ?? '—'} {fmtNum(p.baseline.score)} · {p.baseline.n_trades ?? '—'} 笔 · 截至 {p.baseline.data_as_of ?? '—'} +
+
+
{last?.checked_at ? new Date(last.checked_at).toLocaleString('zh-CN', { hour12: false }) : '—'}
+
{lastText}
+
0 ? '#f87171' : undefined }}> + {fmtDegradation(last?.degradation_pct)} + + {(last?.reasons?.length ? last.reasons : [p.status_reason]).filter(Boolean).join('; ') || '—'} + +
+ + +
+
+
+ )} +
+ ) +}