Files
tick-stock-panel/backend/tests/backtest/test_mining_runtime.py
T
shy3130 de51929a44 fix(mining): 市场环境数据缺失时挖掘启动即失败并前置提示
用户在未计算市场环境的数据目录跑挖掘: 搜索阶段的 regime 评估被
evaluator 软捕获成 error 字符串, 整轮嵌套搜索照常跑完后, artifacts
阶段的 _regime_date_count 硬抛 ValueError, 计算全部浪费且只在最后
给出指引。

- mining_runtime: require_regime 时在因子面板就绪后立即用与
  artifacts 相同的 fold 口径预构建一次 regime mask (required 区间取
  全部外层 fold 测试窗并集), 数据为空/覆盖不完整即刻带指引消息终止
- MiningWorkbench: 启动前查询 /api/regime/latest, 未计算时侧栏显示
  警告横幅(直达数据页)并在开始时 toast 阻止, 避免产生必败的运行记录
- 测试: 缺数据 fail-fast / 覆盖充足通过 / fold 窗口内缺口报错 三例
2026-08-26 12:55:38 +08:00

393 lines
12 KiB
Python

from __future__ import annotations
from datetime import date, timedelta
from types import SimpleNamespace
import polars as pl
import pytest
from app.backtest.mining import (
MiningCandidate,
NestedValidationConfig,
generate_nested_folds,
)
from app.backtest.mining_runtime import (
TrainingMetricProvider,
_decode_runtime_request,
_load_compact_factor_panel,
_prepare_base_market,
_rank_artifact_candidates,
_regime_date_count,
_validate_regime_availability,
attach_single_forward_return,
)
from app.services import regime_builder
def test_runtime_rejects_insufficient_balanced_range_before_loading_panel(
tmp_path,
) -> None:
first = date(2023, 10, 13)
dates = [first + timedelta(days=offset) for offset in range(690)]
for value in dates:
partition = tmp_path / "kline_daily_enriched" / f"date={value.isoformat()}"
partition.mkdir(parents=True)
(partition / "part.parquet").touch()
payload = {
"run_id": "insufficient-balanced",
"request": {
"factor_names": ["turnover_rate"],
"strategy_ids": [],
"asset_type": "stock",
"budget_profile": "balanced",
"start": dates[0].isoformat(),
"end": dates[-1].isoformat(),
},
}
with pytest.raises(
ValueError,
match=(
r"balanced mining requires at least 786 enriched trading bars for "
r"3 outer folds; effective range .* has 690"
),
):
_decode_runtime_request(payload, tmp_path, SimpleNamespace())
def test_single_forward_label_uses_global_trading_axis_without_jump() -> None:
first = date(2024, 1, 2)
missing = first + timedelta(days=1)
resumed = first + timedelta(days=2)
panel = pl.DataFrame({
"symbol": ["000001.SZ", "000001.SZ"],
"date": [first, resumed],
"close": [10.0, 12.0],
"turnover_rate": [1.0, 2.0],
"unused_factor": [9.0, 10.0],
})
result = attach_single_forward_return(
panel,
start=first,
end=resumed,
horizon=1,
trading_dates=[first, missing, resumed],
factor_names=["turnover_rate"],
)
first_row = result.filter(pl.col("date") == first).row(0, named=True)
assert first_row["_target_date"] == missing
assert first_row["_next_return"] is None
assert "_forward_return_1d" not in result.columns
assert "close" not in result.columns
assert "unused_factor" not in result.columns
assert result.columns == [
"symbol",
"date",
"turnover_rate",
"_next_return",
"_target_date",
]
assert result.schema["_next_return"] == pl.Float32
assert result.schema["turnover_rate"] == pl.Float32
fast = attach_single_forward_return(
panel.sort(["date", "symbol"]),
start=first,
end=resumed,
horizon=1,
trading_dates=[first, missing, resumed],
factor_names=["turnover_rate"],
assume_unique_symbol_date=True,
)
assert fast.equals(result)
def test_compact_factor_panel_matches_full_symbol_independent_calculation(
monkeypatch,
) -> None:
first = date(2024, 1, 2)
rows = []
for symbol, offset in (("a", 0.0), ("b", 2.0), ("c", 4.0)):
for day in range(70):
close = 10.0 + offset + day * 0.1
rows.append({
"symbol": symbol,
"date": first + timedelta(days=day),
"open": close - 0.1,
"high": close + 0.2,
"low": close - 0.2,
"close": close,
"volume": 1000.0 + day,
"amount": close * (1000.0 + day),
"turnover_rate": 1.0 + day / 100.0,
})
raw = pl.DataFrame(rows).sort(["symbol", "date"])
class Engine:
def load_panel(self, *_args, **_kwargs):
return raw
engine = Engine()
from app.backtest.factor import FactorBacktestService
factor_service = FactorBacktestService(engine)
config = SimpleNamespace(
symbols=None,
start=first,
end=first + timedelta(days=69),
asset_type="stock",
)
names = ("momentum_20d", "rsi_14", "ma20_bias")
full = factor_service._compute_missing_factors(
raw,
set(names),
assume_sorted=True,
).select(["symbol", "date", "close", *names]).with_columns([
pl.col(name).cast(pl.Float32) for name in names
]).sort(["date", "symbol"])
monkeypatch.setattr("app.backtest.mining_runtime._SYMBOL_BATCH_SIZE", 1)
compact = _load_compact_factor_panel(
factor_service,
config,
names,
expected_generation="generation",
cancel_check=None,
)
assert compact.equals(full)
def test_compact_factor_panel_rejects_noncanonical_symbol_date_keys() -> None:
first = date(2024, 1, 2)
canonical = pl.DataFrame({
"symbol": ["a", "a", "b"],
"date": [first, first + timedelta(days=1), first],
"open": [1.0, 1.0, 1.0],
"high": [1.0, 1.0, 1.0],
"low": [1.0, 1.0, 1.0],
"close": [1.0, 1.0, 1.0],
"volume": [1.0, 1.0, 1.0],
"amount": [1.0, 1.0, 1.0],
"turnover_rate": [1.0, 1.0, 1.0],
})
config = SimpleNamespace(
symbols=None,
start=first,
end=first + timedelta(days=1),
asset_type="stock",
)
class Engine:
def __init__(self, panel):
self.panel = panel
def load_panel(self, *_args, **_kwargs):
return self.panel
from app.backtest.factor import FactorBacktestService
for invalid in (
canonical.with_columns(pl.Series(
"date",
[first + timedelta(days=1), first, first],
)),
pl.concat([canonical.slice(0, 1), canonical]),
):
with pytest.raises(ValueError, match="unique symbol/date"):
_load_compact_factor_panel(
FactorBacktestService(Engine(invalid)),
config,
("turnover_rate",),
expected_generation="generation",
cancel_check=None,
)
def test_artifact_finalists_are_truncated_by_oos_sharpe_before_signature() -> None:
low = MiningCandidate(candidate_id="a-low", kind="existing_strategy", strategy_id="low")
high = MiningCandidate(candidate_id="z-high", kind="existing_strategy", strategy_id="high")
rows = [
{"candidate_signature": "a-low", "sharpe": 0.2, "skipped": False},
{"candidate_signature": "z-high", "sharpe": 1.4, "skipped": False},
]
assert _rank_artifact_candidates([low, high], rows, limit=1) == [high]
def test_prepare_base_market_forwards_cancel_event(monkeypatch, tmp_path) -> None:
cancel_event = object()
captured = {}
plan = SimpleNamespace(
base_columns=frozenset(),
intermediate_columns=frozenset(),
indicator_columns=frozenset(),
signal_columns=frozenset(),
matrix_columns=frozenset(),
instrument_columns=frozenset(),
warmup_bars=1,
full_feature_fallback=False,
execution_backend="matrix_native",
fundamental_columns=frozenset(),
)
research = SimpleNamespace(entry_signals=[], exit_signals=[])
strategy_engine = SimpleNamespace(get=lambda _strategy_id: research)
service = SimpleNamespace(
_effective_basic_filter=lambda *_args: {},
engine=SimpleNamespace(),
)
request = SimpleNamespace(
factor_names=("turnover_rate",),
strategy_ids=(),
asset_type="stock",
forward_horizon=1,
start=date(2024, 1, 2),
end=date(2024, 1, 3),
symbols=None,
)
monkeypatch.setattr(
"app.backtest.mining_runtime.StrategyDependencyResolver.resolve",
lambda *_args, **_kwargs: plan,
)
monkeypatch.setattr(
"app.backtest.mining_runtime.build_matrix_cache_profile",
lambda *_args, **_kwargs: SimpleNamespace(),
)
def load_matrix(*_args, **kwargs):
captured.update(kwargs)
return "market"
service.engine.load_market_data_matrix_for_backtest = load_matrix
result = _prepare_base_market(
service,
strategy_engine,
tmp_path,
request,
expected_generation="generation",
cancel_check=cancel_event,
)
assert result == "market"
assert captured["cancel_event"] is cancel_event
def test_training_metric_provider_uses_only_supplied_fold() -> None:
start = date(2024, 1, 2)
rows = []
for day_offset in range(3):
for asset_id in range(4):
rows.append({
"symbol": f"{asset_id:06d}.SZ",
"date": start + timedelta(days=day_offset),
"factor": float(asset_id),
"_next_return": (
float(asset_id) if day_offset < 2 else float(-asset_id)
),
})
panel = pl.DataFrame(rows)
train = panel.filter(pl.col("date") < start + timedelta(days=2))
provider = TrainingMetricProvider("_next_return")
metric = provider(train, ["factor"])[0]
assert metric.rank_ic == pytest.approx(1.0)
assert metric.coverage == pytest.approx(1.0)
assert provider.calls[0]["end"] == (start + timedelta(days=1)).isoformat()
assert provider.calls[0]["rows"] == 8
def test_regime_date_count_uses_t_minus_one_market_labels(tmp_path) -> None:
labels = [date(2024, 1, 2) + timedelta(days=offset) for offset in range(4)]
panel = pl.DataFrame({"date": labels})
regime_builder.upsert_regime_history(tmp_path, pl.DataFrame({
"date": labels[:3],
"state": ["weak", "strong", "lean_strong"],
"score": [20, 80, 70],
}))
fold = SimpleNamespace(
test_start=labels[1].isoformat(),
test_end=labels[3].isoformat(),
)
assert _regime_date_count(panel, fold, "strong", tmp_path) == 2
assert _regime_date_count(panel, fold, "weak", tmp_path) == 1
def _small_validation() -> NestedValidationConfig:
return NestedValidationConfig(
outer_train_bars=10,
outer_test_bars=3,
outer_step_bars=5,
inner_train_bars=5,
inner_test_bars=2,
inner_step_bars=3,
purge_bars=1,
embargo_bars=1,
min_train_bars=3,
)
def _regime_panel(n: int = 20) -> tuple[list[date], pl.DataFrame]:
labels = [date(2024, 1, 2) + timedelta(days=offset) for offset in range(n)]
return labels, pl.DataFrame({"date": labels})
def _upsert_regime(tmp_path, labels, states: list[str]) -> None:
regime_builder.upsert_regime_history(tmp_path, pl.DataFrame({
"date": labels,
"state": states,
"score": [50] * len(labels),
}))
def test_validate_regime_availability_fails_fast_when_regime_data_missing(
tmp_path,
) -> None:
labels, panel = _regime_panel()
request = SimpleNamespace(
mining_request=SimpleNamespace(validation=_small_validation()),
)
with pytest.raises(ValueError, match="市场环境数据为空"):
_validate_regime_availability(panel, request, tmp_path)
def test_validate_regime_availability_passes_when_regime_covers_fold_windows(
tmp_path,
) -> None:
labels, panel = _regime_panel()
_upsert_regime(
tmp_path,
labels[:-1],
["range"] * (len(labels) - 1),
)
request = SimpleNamespace(
mining_request=SimpleNamespace(validation=_small_validation()),
)
_validate_regime_availability(panel, request, tmp_path)
def test_validate_regime_availability_reports_coverage_gaps_in_fold_windows(
tmp_path,
) -> None:
labels, panel = _regime_panel()
nested = generate_nested_folds(
[value.isoformat() for value in labels], _small_validation()
)
gap_date = date.fromisoformat(nested[0].outer.test_start) + timedelta(days=1)
covered = [value for value in labels if value != gap_date]
_upsert_regime(tmp_path, covered, ["range"] * len(covered))
request = SimpleNamespace(
mining_request=SimpleNamespace(validation=_small_validation()),
)
with pytest.raises(ValueError, match="市场环境数据覆盖不完整"):
_validate_regime_availability(panel, request, tmp_path)