Files
tick-stock-panel/backend/tests/test_mining_candidates.py
T
shy3130 21279ecfa2 feat(lab): 自动策略实验室 — 守护复验 + 稳健选冠/权重精调 + 组合建议 + 闭环编排
在既有挖掘链路之上补齐「发布后持续验证与持续组合」闭环
(设计: docs/auto-strategy-lab-plan.md):

- P1 策略守护: 挖掘发布自动落 mining_gate 基线 (天然 OOS); 每日收盘
  数据更新后在新数据上切 K 段真实回测复验, 按稳健分对比基线判定
  健康/观察/退役三态 (段回测失败只降级观察不退役; 新数据 <30 交易日
  跳过本轮; retired 不再自动复验)。任意策略可手动入护 (尾随参考基线,
  判定只针对入护后新数据)。回测页稳健性验证新增「策略守护」子页。
- P2a 稳健选冠: 冠军排序分从外层折 Sharpe 均值改为 0.6×中位+0.4×最差
  (复用 robust_score); artifact 新增可选列 oos_worst_sharpe (v2 旧档
  兼容读取); strict 档发布额外要求最差折 Sharpe>0; 算法版本升
  mining-v3 并进数据指纹, 防止升级后 claim 幂等复用旧口径结果。
- P2b 权重精调: 冠军组合在 {0.5,1.0,1.5,2.0} 比率网格 (首因子固定
  1.0, 截面分位打分天然尺度不变) 两段式精调 — 外层测试窗选优 + 全窗
  复验退化>20% 放弃; 消耗 run 剩余预算 (不足自动跳过); 产物为独立
  签名候选行, run summary 带 refinement 摘要。
- P2d 组合建议: 守护运行时扫描健康冠军间近 126 个交易日日收益 Pearson
  相关性 (≥30 共同交易日, ≤0.7 入选); 前端人工确认后生成等权
  composite 策略 (fail-closed 建议对校验 + blake2b 幂等) 并自动以
  尾随参考基线入护。
- P3 闭环编排: lab_mined_slot_limit 偏好 (默认 10, 0=不限) 在发布
  门槛通过后校验, 名额满则新冠军停在 pending 等位;
  /api/backtest/lab/report 聚合守护/挖掘/名额战报 (前端总览卡);
  lab_schedule 单入口编排周度挖掘 + 日度守护 (失败互不阻断)。

测试: 新增 guardian 判定/运行时/存储/调度/API/组合与挖掘 v3 选冠/
精调用例; 后端全量 3224 通过, 前端 308 通过; 真实数据沙盒 E2E
(挖掘 v3 全链路 / 发布门槛 / 名额边界 / 复验语义 / 组合扫描 /
战报核对 / 性能延迟) 通过。P2c (overlay 候选) 按设计稿按需启动,
默认不做。

Signed-off-by: shy3130 <shy3130@qq.com>
2026-10-25 23:43:34 +08:00

705 lines
25 KiB
Python

from __future__ import annotations
import json
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from types import SimpleNamespace
import polars as pl
import pytest
from app.backtest.candidates import CandidateStore, CandidateValidationError
from app.backtest.mining import compute_candidate_signature
from app.services.mining_candidates import (
MiningCandidateService,
_published_strategy_id,
)
from app.services.mining_jobs import MiningRunStore
from app.strategy import config as strategy_config
from app.strategy.engine import StrategyEngine
class _StrategyEngine:
def __init__(self) -> None:
self.strategies = {
"existing_daily": SimpleNamespace(
id="existing_daily",
execution_backend="matrix_native",
meta={
"id": "existing_daily",
"research_only": False,
"timeframes": ["1d"],
"asset_types": ["stock"],
},
)
}
def get(self, strategy_id: str):
if strategy_id not in self.strategies:
raise KeyError(strategy_id)
return self.strategies[strategy_id]
def list_strategies(self):
return [strategy.meta for strategy in self.strategies.values()]
def _factor_definition() -> dict:
return {
"kind": "factor_rank",
"factor_names": ["turnover_rate", "rsi_14"],
"scoring": {"turnover_rate": 1.0, "rsi_14": 2.0},
"directions": {"turnover_rate": "high", "rsi_14": "low"},
}
def _create_run(
tmp_path,
*,
definition: dict | None = None,
status: str = "succeeded",
run_id: str = "mining-run",
) -> tuple[MiningRunStore, str, str]:
definition = definition or _factor_definition()
signature = compute_candidate_signature(definition)
store = MiningRunStore(tmp_path)
store.create(
{
"factor_names": ["turnover_rate", "rsi_14"],
"strategy_ids": ["existing_daily", "ma_golden_cross"],
"asset_type": "stock",
"start": "2025-01-01",
"end": "2026-01-09",
"budget_profile": "exploratory",
"commission_pct": 0.0002,
"stamp_tax_pct": 0.0005,
"slippage_bps": 5.0,
},
{"generation": "test"},
run_id=run_id,
)
frame = pl.DataFrame({
"signature": [signature],
"name": ["因子组合候选"],
"kind": [
"existing_strategy"
if definition["kind"] == "existing_strategy"
else "factor_combination"
],
"factor_names_json": [json.dumps(definition.get("factor_names", []))],
"strategy_id": [definition.get("strategy_id")],
"definition_json": [json.dumps(definition, sort_keys=True)],
"regime_state": ["overall"],
"score": [0.9],
"oos_return": [0.08],
"oos_sharpe": [0.9],
"oos_max_drawdown": [-0.12],
"oos_positive_fold_ratio": [1.0],
"oos_n_trades": [70],
"confidence": ["standard"],
"valid_folds": [3],
"skipped_folds": [0],
"promoted_candidate_id": [None],
"published_strategy_id": [None],
}, schema_overrides={
"strategy_id": pl.String,
"promoted_candidate_id": pl.String,
"published_strategy_id": pl.String,
})
frame.write_parquet(store.artifact_path(run_id, "candidates"))
store.register_artifact(run_id, "candidates")
store.write_summary(run_id, {
"data_as_of": "2026-01-09",
"algorithm_version": "mining-v1",
"methodology_version": "factor_v2",
})
if status != "queued":
store.transition_status(run_id, "running")
store.transition_status(run_id, status)
return store, run_id, signature
def _service(tmp_path, store: MiningRunStore) -> MiningCandidateService:
return MiningCandidateService(
tmp_path,
store,
CandidateStore(tmp_path),
_StrategyEngine(),
strategy_cache_invalidator=lambda _data_dir: None,
)
def _real_service(
tmp_path,
store: MiningRunStore,
*,
cache_invalidator=None,
monitor_invalidator=None,
) -> tuple[MiningCandidateService, StrategyEngine]:
builtin_dir = Path(__file__).resolve().parents[1] / "tests" / "fixtures" / "strategies"
custom_dir = tmp_path / "strategies" / "custom"
engine = StrategyEngine(strategy_dirs=[builtin_dir, custom_dir])
service = MiningCandidateService(
tmp_path,
store,
CandidateStore(tmp_path),
engine,
strategy_cache_invalidator=cache_invalidator or (lambda _data_dir: None),
monitor_state_invalidator=monitor_invalidator,
)
return service, engine
def test_promote_rereads_artifact_and_repairs_backlink_idempotently(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path)
service = _service(tmp_path, store)
first = service.promote(run_id, signature)
second = service.promote(run_id, signature)
assert second == first
assert first["kind"] == "strategy"
assert first["source_id"].startswith("mined_factor_")
assert first["status"] == "pending"
assert first["config"]["origin_run_id"] == run_id
assert first["config"]["candidate_signature"] == signature
assert first["config"]["factor_names"] == ["turnover_rate", "rsi_14"]
assert first["config"]["directions"] == ["high", "low"]
assert first["config"]["weights"] == [1.0, 2.0]
assert first["metrics"]["oos_sharpe"] == pytest.approx(0.9)
persisted = pl.read_parquet(store.artifact_path(run_id, "candidates")).row(0, named=True)
assert persisted["promoted_candidate_id"] == first["id"]
assert len(CandidateStore(tmp_path).list()) == 1
def test_promote_repairs_backlink_after_partial_failure(tmp_path, monkeypatch) -> None:
store, run_id, signature = _create_run(tmp_path)
service = _service(tmp_path, store)
original = service._write_backlink
calls = 0
def fail_once(*args, **kwargs):
nonlocal calls
calls += 1
if calls == 1:
raise RuntimeError("injected backlink failure")
return original(*args, **kwargs)
monkeypatch.setattr(service, "_write_backlink", fail_once)
with pytest.raises(RuntimeError, match="injected"):
service.promote(run_id, signature)
created = CandidateStore(tmp_path).list()
assert len(created) == 1
recovered = service.promote(run_id, signature)
assert recovered["id"] == created[0]["id"]
persisted = pl.read_parquet(store.artifact_path(run_id, "candidates")).row(0, named=True)
assert persisted["promoted_candidate_id"] == created[0]["id"]
assert len(CandidateStore(tmp_path).list()) == 1
def test_promote_rejects_non_successful_run(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path, status="queued")
with pytest.raises(ValueError, match="successful"):
_service(tmp_path, store).promote(run_id, signature)
assert CandidateStore(tmp_path).list() == []
def test_promote_rejects_tampered_definition_before_candidate_write(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path)
path = store.artifact_path(run_id, "candidates")
frame = pl.read_parquet(path).with_columns(
pl.lit(json.dumps({
"kind": "factor_rank",
"factor_names": ["turnover_rate"],
"scoring": {"turnover_rate": 1.0},
"directions": {"turnover_rate": "high"},
})).alias("definition_json")
)
frame.write_parquet(path)
with pytest.raises(ValueError, match=r"signature|definition"):
_service(tmp_path, store).promote(run_id, signature)
assert CandidateStore(tmp_path).list() == []
def test_promote_rejects_factor_not_selected_in_origin_request(tmp_path) -> None:
definition = {
"kind": "factor_rank",
"factor_names": ["momentum_20d"],
"scoring": {"momentum_20d": 1.0},
"directions": {"momentum_20d": "high"},
}
store, run_id, signature = _create_run(tmp_path, definition=definition)
with pytest.raises(ValueError, match="origin request"):
_service(tmp_path, store).promote(run_id, signature)
def test_promote_existing_strategy_revalidates_current_engine_contract(tmp_path) -> None:
definition = {"kind": "existing_strategy", "strategy_id": "existing_daily"}
store, run_id, signature = _create_run(tmp_path, definition=definition)
promoted = _service(tmp_path, store).promote(run_id, signature)
assert promoted["source_id"] == "existing_daily"
assert promoted["config"]["strategy_id"] == "existing_daily"
def test_publish_existing_strategy_returns_verified_id_and_repairs_backlink(tmp_path) -> None:
definition = {"kind": "existing_strategy", "strategy_id": "existing_daily"}
store, run_id, signature = _create_run(tmp_path, definition=definition)
service = _service(tmp_path, store)
first = service.publish(run_id, signature)
second = service.publish(run_id, signature)
with pytest.raises(TypeError):
service.publish(run_id, signature, "existing_daily")
assert first == second == {"ok": True, "strategy_id": "existing_daily"}
persisted = pl.read_parquet(store.artifact_path(run_id, "candidates")).row(0, named=True)
assert persisted["published_strategy_id"] == "existing_daily"
assert CandidateStore(tmp_path).list() == []
def test_promote_requires_unique_artifact_signature(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path)
path = store.artifact_path(run_id, "candidates")
frame = pl.read_parquet(path)
pl.concat([frame, frame]).write_parquet(path)
with pytest.raises(ValueError, match="duplicate"):
_service(tmp_path, store).promote(run_id, signature)
def test_promote_concurrent_calls_create_one_authoritative_candidate(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path)
service = _service(tmp_path, store)
with ThreadPoolExecutor(max_workers=8) as executor:
items = list(executor.map(
lambda _index: service.promote(run_id, signature),
range(16),
))
assert len({item["id"] for item in items}) == 1
assert len(CandidateStore(tmp_path).list()) == 1
def test_promote_rejects_artifact_conflicting_with_existing_store_record(
tmp_path,
) -> None:
store, run_id, signature = _create_run(tmp_path)
service = _service(tmp_path, store)
original = service.promote(run_id, signature)
path = store.artifact_path(run_id, "candidates")
pl.read_parquet(path).with_columns(
pl.lit(1.1).alias("oos_sharpe"),
pl.lit(None, dtype=pl.String).alias("promoted_candidate_id"),
).write_parquet(path)
with pytest.raises(CandidateValidationError, match="冲突"):
service.promote(run_id, signature)
persisted = pl.read_parquet(path).row(0, named=True)
assert persisted["promoted_candidate_id"] is None
stored = CandidateStore(tmp_path).list()[0]
assert stored == original
assert stored["metrics"]["oos_sharpe"] == pytest.approx(0.9)
@pytest.mark.parametrize(
("definition", "message"),
[
(
{
"kind": "factor_rank",
"factor_names": ["turnover_rate"],
"scoring": {"turnover_rate": 0.0},
"directions": {"turnover_rate": "high"},
},
"positive",
),
(
{
"kind": "factor_rank",
"factor_names": ["turnover_rate"],
"scoring": {"turnover_rate": float("inf")},
"directions": {"turnover_rate": "high"},
},
"finite",
),
(
{
"kind": "factor_rank",
"factor_names": ["turnover_rate"],
"scoring": {"turnover_rate": 1.0},
"directions": {"turnover_rate": "sideways"},
},
"directions",
),
(
{
"kind": "factor_rank",
"factor_names": ["not_a_factor"],
"scoring": {"not_a_factor": 1.0},
"directions": {"not_a_factor": "high"},
},
"unknown factors",
),
(
{
"kind": "factor_rank",
"factor_names": [
"momentum_5d",
"momentum_10d",
"momentum_20d",
"momentum_30d",
"momentum_60d",
],
"scoring": {
"momentum_5d": 1.0,
"momentum_10d": 1.0,
"momentum_20d": 1.0,
"momentum_30d": 1.0,
"momentum_60d": 1.0,
},
"directions": {
"momentum_5d": "high",
"momentum_10d": "high",
"momentum_20d": "high",
"momentum_30d": "high",
"momentum_60d": "high",
},
},
"1 to 4",
),
],
)
def test_promote_rejects_invalid_factor_contract(tmp_path, definition, message) -> None:
store, run_id, signature = _create_run(tmp_path)
path = store.artifact_path(run_id, "candidates")
frame = pl.read_parquet(path).with_columns(
pl.lit(signature).alias("signature"),
pl.lit(json.dumps(definition)).alias("definition_json"),
pl.lit(json.dumps(definition["factor_names"])).alias("factor_names_json"),
)
frame.write_parquet(path)
with pytest.raises(ValueError, match=message):
_service(tmp_path, store).promote(run_id, signature)
@pytest.mark.parametrize("column", ["oos_sharpe", "oos_return", "score"])
def test_promote_rejects_nonfinite_artifact_values(tmp_path, column) -> None:
store, run_id, signature = _create_run(tmp_path)
path = store.artifact_path(run_id, "candidates")
pl.read_parquet(path).with_columns(pl.lit(float("nan")).alias(column)).write_parquet(path)
with pytest.raises(ValueError, match="finite"):
_service(tmp_path, store).promote(run_id, signature)
def test_promote_rejects_corrupt_or_missing_schema_artifact(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path)
path = store.artifact_path(run_id, "candidates")
path.write_bytes(b"not parquet")
with pytest.raises(RuntimeError, match="failed to read"):
_service(tmp_path, store).promote(run_id, signature)
path.unlink()
pl.DataFrame({"signature": [signature]}).write_parquet(path)
with pytest.raises(ValueError, match="schema"):
_service(tmp_path, store).promote(run_id, signature)
def test_publish_factor_discovers_public_strategy_and_repairs_runtime_state(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path)
invalidations: list[object] = []
service, engine = _real_service(
tmp_path,
store,
cache_invalidator=lambda data_dir: invalidations.append(data_dir),
monitor_invalidator=lambda: invalidations.append("monitor"),
)
strategy_config.save_override(
tmp_path,
"factor_rank_research",
{"params": {"entry_score": 99.0}},
)
result = service.publish(run_id, signature)
repeated = service.publish(run_id, signature)
assert repeated == result
strategy = engine.get(result["strategy_id"])
assert strategy.source == "custom"
assert strategy.execution_backend == "matrix_native"
assert strategy.meta["origin_run_id"] == run_id
assert strategy.meta["candidate_signature"] == signature
assert strategy.meta["research_only"] is False
assert strategy.meta["asset_types"] == ["stock"]
assert strategy.matrix_strategy._scoring == {"turnover_rate": 1.0, "rsi_14": 2.0}
assert strategy.matrix_strategy._directions == {
"turnover_rate": "high",
"rsi_14": "low",
}
assert result["strategy_id"] in {
meta["id"] for meta in engine.list_strategies()
}
assert strategy_config.load_override(
tmp_path, "factor_rank_research"
) == {"params": {"entry_score": 99.0}}
assert not (tmp_path / "user_data" / "strategy_overrides" / f"{result['strategy_id']}.json").exists()
persisted = pl.read_parquet(store.artifact_path(run_id, "candidates")).row(0, named=True)
assert persisted["published_strategy_id"] == result["strategy_id"]
assert invalidations == [tmp_path, "monitor", tmp_path, "monitor"]
def test_publish_factor_repairs_backlink_after_source_was_verified(
tmp_path,
monkeypatch,
) -> None:
store, run_id, signature = _create_run(tmp_path)
service, _engine = _real_service(tmp_path, store)
original = service._write_backlink
calls = 0
def fail_once(*args, **kwargs):
nonlocal calls
calls += 1
if calls == 1:
raise RuntimeError("injected publication backlink failure")
return original(*args, **kwargs)
monkeypatch.setattr(service, "_write_backlink", fail_once)
with pytest.raises(RuntimeError, match="injected publication"):
service.publish(run_id, signature)
result = service.publish(run_id, signature)
strategy_path = tmp_path / "strategies" / "custom" / f"{result['strategy_id']}.py"
assert strategy_path.is_file()
persisted = pl.read_parquet(store.artifact_path(run_id, "candidates")).row(0, named=True)
assert persisted["published_strategy_id"] == result["strategy_id"]
def test_publish_factor_uses_server_run_scoped_id_and_refuses_collision(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path)
service, _engine = _real_service(tmp_path, store)
expected_id = _published_strategy_id(run_id, signature)
with pytest.raises(TypeError):
service.publish(run_id, signature, "mined_factor_caller_selected")
target = tmp_path / "strategies" / "custom" / f"{expected_id}.py"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text("user owned", encoding="utf-8")
with pytest.raises(ValueError, match="collision"):
service.publish(run_id, signature)
assert target.read_text(encoding="utf-8") == "user owned"
def test_publish_factor_same_signature_in_different_runs_has_independent_ids(
tmp_path,
) -> None:
store, first_run_id, signature = _create_run(tmp_path, run_id="mining-run-one")
_other_store, second_run_id, second_signature = _create_run(
tmp_path,
run_id="mining-run-two",
)
service, engine = _real_service(tmp_path, store)
first = service.publish(first_run_id, signature)
second = service.publish(second_run_id, second_signature)
assert signature == second_signature
assert first["strategy_id"] == _published_strategy_id(first_run_id, signature)
assert second["strategy_id"] == _published_strategy_id(
second_run_id,
second_signature,
)
assert first["strategy_id"] != second["strategy_id"]
assert engine.has(first["strategy_id"])
assert engine.has(second["strategy_id"])
def test_publish_factor_rejects_inconsistent_backlink(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path)
path = store.artifact_path(run_id, "candidates")
pl.read_parquet(path).with_columns(
pl.lit("mined_factor_other").alias("published_strategy_id")
).write_parquet(path)
service, engine = _real_service(tmp_path, store)
with pytest.raises(ValueError, match="backlink is inconsistent"):
service.publish(run_id, signature)
expected_id = _published_strategy_id(run_id, signature)
assert not engine.has(expected_id)
assert not (tmp_path / "strategies" / "custom" / f"{expected_id}.py").exists()
def test_publish_factor_rolls_back_file_and_skips_invalidations_on_reload_failure(
tmp_path,
monkeypatch,
) -> None:
store, run_id, signature = _create_run(tmp_path)
invalidations: list[str] = []
service, engine = _real_service(
tmp_path,
store,
cache_invalidator=lambda _data_dir: invalidations.append("cache"),
monitor_invalidator=lambda: invalidations.append("monitor"),
)
real_reload = engine.reload
calls = 0
def fail_once() -> None:
nonlocal calls
calls += 1
if calls == 1:
raise ValueError("injected reload failure")
real_reload()
monkeypatch.setattr(engine, "reload", fail_once)
expected_id = _published_strategy_id(run_id, signature)
with pytest.raises(RuntimeError, match="injected reload failure"):
service.publish(run_id, signature)
assert not (tmp_path / "strategies" / "custom" / f"{expected_id}.py").exists()
assert not engine.has(expected_id)
assert invalidations == []
persisted = pl.read_parquet(store.artifact_path(run_id, "candidates")).row(0, named=True)
assert persisted["published_strategy_id"] is None
def test_publish_factor_rolls_back_on_runtime_invalidation_failure(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path)
invalidations: list[str] = []
def fail_monitor() -> None:
invalidations.append("monitor")
raise ValueError("injected monitor invalidation failure")
service, engine = _real_service(
tmp_path,
store,
cache_invalidator=lambda _data_dir: invalidations.append("cache"),
monitor_invalidator=fail_monitor,
)
expected_id = _published_strategy_id(run_id, signature)
with pytest.raises(RuntimeError, match="injected monitor invalidation failure"):
service.publish(run_id, signature)
assert invalidations == ["cache", "monitor"]
assert not (tmp_path / "strategies" / "custom" / f"{expected_id}.py").exists()
assert not engine.has(expected_id)
persisted = pl.read_parquet(store.artifact_path(run_id, "candidates")).row(0, named=True)
assert persisted["published_strategy_id"] is None
def test_publish_existing_does_not_reload_or_invalidate(tmp_path) -> None:
definition = {"kind": "existing_strategy", "strategy_id": "ma_golden_cross"}
store, run_id, signature = _create_run(tmp_path, definition=definition)
invalidations: list[str] = []
service, engine = _real_service(
tmp_path,
store,
cache_invalidator=lambda _data_dir: invalidations.append("cache"),
monitor_invalidator=lambda: invalidations.append("monitor"),
)
original = engine.get("ma_golden_cross")
result = service.publish(run_id, signature)
assert result == {"ok": True, "strategy_id": "ma_golden_cross"}
assert engine.get("ma_golden_cross") is original
assert invalidations == []
def _rewrite_candidate_metric(
store: MiningRunStore,
run_id: str,
column: str,
value,
) -> None:
path = store.artifact_path(run_id, "candidates")
frame = pl.read_parquet(path)
frame.with_columns(
pl.lit(value, dtype=frame.schema[column]).alias(column),
).write_parquet(path)
def test_publish_rejects_low_confidence_exploratory_candidate(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path)
_rewrite_candidate_metric(store, run_id, "confidence", "low")
service = _service(tmp_path, store)
with pytest.raises(ValueError, match="exploratory results can only be saved"):
service.publish(run_id, signature)
def test_publish_rejects_candidate_below_evidence_gate(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path)
_rewrite_candidate_metric(store, run_id, "oos_sharpe", 0.2)
service = _service(tmp_path, store)
with pytest.raises(ValueError, match=r"OOS Sharpe of at least 0.5"):
service.publish(run_id, signature)
def test_publish_rejects_candidate_with_insufficient_folds(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path)
_rewrite_candidate_metric(store, run_id, "valid_folds", 1)
service = _service(tmp_path, store)
with pytest.raises(ValueError, match="at least 2 valid outer folds"):
service.publish(run_id, signature)
def test_publish_rejects_candidate_missing_required_metrics(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path)
_rewrite_candidate_metric(store, run_id, "oos_n_trades", None)
service = _service(tmp_path, store)
with pytest.raises(ValueError, match="does not meet the promotion gate"):
service.publish(run_id, signature)
def test_promote_still_allowed_for_gated_candidates(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path)
_rewrite_candidate_metric(store, run_id, "confidence", "low")
_rewrite_candidate_metric(store, run_id, "oos_sharpe", 0.1)
service = _service(tmp_path, store)
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