mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
- 市场环境: 新增情绪周期6阶段(冰点/启动/主升/高潮/退潮/修复, 连板梯队驱动, EMA平滑+2日确认+弱档否决, 平均段长9.7天)与概念/行业主线排名(涨停梯队聚合, 可配置宽基/风格标签过滤); 市场环境页重构, regime 透明加列, 与5档state并存 - 挖掘: 因子与策略挖掘全链路(API/worker/进程锁/候选库/前端工作台/文档), 周度调度默认关闭且永不自动发布 - 回测: 财务快照因子(点时口径), 批量回测预计算共享下期收益, 信号路径矩阵列依赖展开修复(consecutive_limit_ups 缺列报错) - 数据/性能: enriched 生成与预热治理, 重任务限流, 行情/K线缓存复用, 时区修复 - 测试: 后端全量 914 通过; GUI 黑盒验证截图存证 gui-test-screenshots/
680 lines
24 KiB
Python
680 lines
24 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] / "app" / "strategy" / "builtin"
|
|
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"
|