Files
tick-stock-panel/backend/tests/test_mining_candidates.py
T
shy3130 918c7ade7e refactor(core): 策略与信号全面去内置化, 转为用户自定义定义
- 策略: 引擎只加载 data/strategies/{custom,ai,composite} 与研究模板目录,
  原 25 个内置策略一次性迁移为用户自定义文件(ID 不变, 引用零迁移);
  研究模板移至 app/strategy/research, 打包 spec 同步; 新装环境策略库为空
- 信号: 管线不再硬编码任何信号表达式; 涨跌停判定价成为运行时列
  limit_up_price/limit_down_price(生效价∓0.005 容差), 20 个信号转为
  data/user_data/custom_signals 定义(显式 column 沿用 signal_* 原列名);
  依赖解析/注入顺序/当日路径 prev-join 全部定义驱动; 语义统一为历史路径
  口径(顺带修正当日路径 BOLL 比较符与翘板/炸板空值两处既有偏差)
- 前端: 信号库单列表(全部可编辑), SignalPicker/监控选项/信号标签全走
  定义接口; 回测页新增免责声明(模拟性质/数据源准确性不作保证/不构成
  投资建议)
- 测试: 25 个策略夹具与 20 个信号种子入库 tests/fixtures, 受影响测试
  全部自包含化(不依赖运行目录用户数据); 77 万行 MA 死叉新旧表达式对拍
  逐行一致; 回测实跑 45 笔卖点信号出场验证矩阵链路
- 同批包含工作区既有进度: 策略结果 ext 列同日投影修复(含测试)、数据源插件、fuyao/auth/api_gateway/data_integrity 调整、README 信号库
  描述与 strategy.md 导入路径补充

验证: 后端 2592 passed(空数据目录 CI 模拟 2588 passed, 4 例为模拟环境
伪影复验排除); 前端 vitest 125 passed + tsc 0 错 + vite build 通过
2026-10-10 21:10:44 +08:00

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] / "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"