Files
tick-stock-panel/backend/tests/backtest/test_mining.py
T
shy3130 697c27bb02 feat(v0.2): 市场阶段与主线识别 + 因子挖掘全链路 + 数据层完善
- 市场环境: 新增情绪周期6阶段(冰点/启动/主升/高潮/退潮/修复, 连板梯队驱动,
  EMA平滑+2日确认+弱档否决, 平均段长9.7天)与概念/行业主线排名(涨停梯队聚合,
  可配置宽基/风格标签过滤); 市场环境页重构, regime 透明加列, 与5档state并存
- 挖掘: 因子与策略挖掘全链路(API/worker/进程锁/候选库/前端工作台/文档),
  周度调度默认关闭且永不自动发布
- 回测: 财务快照因子(点时口径), 批量回测预计算共享下期收益,
  信号路径矩阵列依赖展开修复(consecutive_limit_ups 缺列报错)
- 数据/性能: enriched 生成与预热治理, 重任务限流, 行情/K线缓存复用, 时区修复
- 测试: 后端全量 914 通过; GUI 黑盒验证截图存证 gui-test-screenshots/
2026-08-16 23:39:07 +08:00

942 lines
31 KiB
Python

from __future__ import annotations
import json
from dataclasses import asdict, replace
from datetime import date, timedelta
import numpy as np
import polars as pl
import pytest
import app.backtest.mining as mining_module
from app.backtest.mining import (
CandidateEvaluation,
CorrelationResult,
FactorMetric,
MiningBudget,
MiningRequest,
MiningService,
NestedValidationConfig,
_searchable_factors,
beam_search_factor_combinations,
compute_rank_correlation,
generate_nested_folds,
nested_fold_count,
prune_correlated_factors,
required_outer_folds,
required_trading_bars,
validation_config_for_profile,
)
def _panel(days: int = 12, assets: int = 6) -> pl.DataFrame:
rows = []
start = date(2024, 1, 2)
for day_id in range(days):
current = start + timedelta(days=day_id * 2)
for asset_id in range(assets):
target = float(asset_id + (day_id % 2) * 0.1)
rows.append({
"symbol": f"{asset_id:06d}.SZ",
"date": current,
"good": target,
"inverse": -target,
"copy": target * 10.0,
"noise": float((asset_id * 7 + day_id * 3) % assets),
"_next_return": target,
})
return pl.DataFrame(rows)
def _metrics() -> tuple[FactorMetric, ...]:
return (
FactorMetric("good", 1.0, 1.0, 1.0, 0.2, 1.0),
FactorMetric("copy", 0.9, 0.9, 1.0, 0.1, 1.0),
FactorMetric("inverse", 0.8, 0.8, 1.0, 0.1, -1.0),
FactorMetric("noise", 0.2, 0.1, 0.9, 0.5, 0.0),
)
def test_profiles_enforce_hard_limits_and_are_json_serializable():
request = MiningRequest.for_profile("exploratory", ["good", "noise"])
assert request.budget.beam_width == 8
assert request.validation.outer_train_bars == 126
assert request.validation.outer_test_bars == 63
assert NestedValidationConfig.balanced().outer_train_bars == 504
assert NestedValidationConfig.strict().outer_train_bars == 756
assert json.loads(request.to_json())["factor_names"] == ["good", "noise"]
assert asdict(request)["validation"]["purge_bars"] == 30
with pytest.raises(ValueError, match="max_factors"):
MiningBudget(max_factors=49)
with pytest.raises(ValueError, match="beam_width"):
MiningBudget(beam_width=33)
with pytest.raises(ValueError, match="max_trials"):
MiningBudget(max_trials=257)
with pytest.raises(ValueError, match="existing strategy count"):
MiningRequest(
factor_names=("good",),
existing_strategy_ids=tuple(str(index) for index in range(9)),
)
@pytest.mark.parametrize(
("profile", "required_bars", "folds"),
[
("exploratory", 219, 1),
("balanced", 786, 3),
("strict", 1164, 3),
],
)
def test_profile_trading_bar_requirements(profile, required_bars, folds):
config = validation_config_for_profile(profile)
assert required_outer_folds(profile) == folds
assert required_trading_bars(config, folds) == required_bars
assert nested_fold_count(required_bars - 1, config) == folds - 1
assert nested_fold_count(required_bars, config) == folds
def test_rank_correlation_is_pairwise_finite_symmetric_and_average_ranked():
panel = pl.DataFrame({
"date": [date(2024, 1, 2)] * 5 + [date(2024, 1, 3)] * 5,
"a": [1.0, 1.0, 3.0, np.nan, 5.0, 5.0, 4.0, 3.0, 2.0, 1.0],
"b": [2.0, 2.0, 6.0, 8.0, np.inf, 1.0, 2.0, 3.0, 4.0, 5.0],
"c": [1.0, None, 2.0, 3.0, 4.0, None, None, None, None, None],
"constant": [7.0] * 10,
})
result = compute_rank_correlation(
panel,
["a", "b", "c", "constant"],
date(2024, 1, 2),
date(2024, 1, 3),
)
matrix = np.asarray(result.matrix)
counts = np.asarray(result.pair_counts)
np.testing.assert_allclose(matrix, matrix.T)
np.testing.assert_array_equal(np.diag(matrix), np.ones(4))
assert counts[0, 1] == 2
assert counts[0, 2] == 1
assert counts[2, 2] == 1
assert counts[3, 3] == 2
assert counts[0, 3] == 0
assert np.isnan(matrix[0, 3])
assert result.n_dates == 2
assert set(result.timing_ms) == {"filter", "rank_accumulate", "finalize", "total"}
daily_correlations = []
for daily in panel.partition_by("date"):
valid = daily.filter(pl.col("a").is_finite() & pl.col("b").is_finite())
daily_correlations.append(np.corrcoef(
valid["a"].rank(method="average").to_numpy(),
valid["b"].rank(method="average").to_numpy(),
)[0, 1])
assert daily_correlations == pytest.approx([1.0, -1.0])
assert matrix[0, 1] == pytest.approx(np.mean(daily_correlations))
def test_rank_correlation_reranks_after_pairwise_finite_intersection():
panel = pl.DataFrame({
"date": [date(2024, 1, 2)] * 4,
"a": [1.0, 2.0, 3.0, 4.0],
"b": [10.0, None, 20.0, 30.0],
"c": [None, 10.0, 20.0, 30.0],
})
result = compute_rank_correlation(panel, ["a", "b", "c"])
matrix = np.asarray(result.matrix)
assert matrix[0, 1] == pytest.approx(1.0)
assert matrix[0, 2] == pytest.approx(1.0)
assert matrix[1, 2] == pytest.approx(1.0)
def test_pruning_uses_deterministic_metric_order_and_reports_representative():
correlation = CorrelationResult(
factor_names=("a", "b", "c"),
matrix=((1.0, 0.9, 0.1), (0.9, 1.0, 0.2), (0.1, 0.2, 1.0)),
pair_counts=((10, 10, 10), (10, 10, 10), (10, 10, 10)),
elapsed_ms=1.0,
n_dates=2,
n_rows=10,
)
metrics = (
FactorMetric("b", 1.0, 2.0, 0.8, 0.1),
FactorMetric("c", 0.5, 1.0, 1.0, 0.1),
FactorMetric("a", 1.0, 2.0, 0.8, 0.2),
)
result = prune_correlated_factors(metrics, correlation, 0.8)
assert result.selected == ("b", "c")
assert result.excluded[0].factor_id == "a"
assert result.excluded[0].representative == "b"
assert result.excluded[0].rho == pytest.approx(0.9)
def test_pruning_ignores_unestimable_factor_pairs():
correlation = CorrelationResult(
factor_names=("a", "b"),
matrix=((1.0, float("nan")), (float("nan"), 1.0)),
pair_counts=((10, 0), (0, 10)),
elapsed_ms=1.0,
n_dates=2,
n_rows=10,
)
metrics = (
FactorMetric("a", 1.0, 1.0, 1.0, 0.1),
FactorMetric("b", 0.9, 0.9, 1.0, 0.1),
)
result = prune_correlated_factors(metrics, correlation, 0.8)
assert result.selected == ("a", "b")
assert result.excluded == ()
def test_beam_search_learns_direction_from_train_and_honors_real_proxy_budget():
panel = _panel()
first = beam_search_factor_combinations(
panel,
["noise", "inverse", "good"],
max_combination_size=4,
beam_width=32,
max_trials=7,
)
second = beam_search_factor_combinations(
panel,
["good", "noise", "inverse"],
max_combination_size=4,
beam_width=32,
max_trials=7,
)
reversed_rows = beam_search_factor_combinations(
panel.reverse(),
["good", "noise", "inverse"],
max_combination_size=4,
beam_width=32,
max_trials=7,
)
assert first.trials_used == 7
assert first.budget_exhausted is True
assert first.candidates == second.candidates == reversed_rows.candidates
inverse = next(
candidate
for candidate in first.candidates
if candidate.factor_names == ("inverse",)
)
assert inverse.directions == (-1,)
assert all(len(candidate.factor_names) <= 4 for candidate in first.candidates)
assert all(
set(candidate.weights) <= {1.0, 2.0}
and sum(weight == 2.0 for weight in candidate.weights) <= 1
for candidate in first.candidates
)
def test_beam_search_does_not_access_rows_outside_explicit_train_range():
panel = _panel(days=10)
train_end = sorted(panel["date"].unique().to_list())[5]
changed = panel.with_columns(
pl.when(pl.col("date") > train_end)
.then(-pl.col("_next_return") * 1_000.0)
.otherwise(pl.col("_next_return"))
.alias("_next_return")
)
original_result = beam_search_factor_combinations(
panel,
["good", "inverse", "noise"],
train_end=train_end,
max_trials=30,
)
changed_result = beam_search_factor_combinations(
changed,
["good", "inverse", "noise"],
train_end=train_end,
max_trials=30,
)
assert original_result.candidates == changed_result.candidates
def test_nested_folds_use_trading_labels_with_explicit_purge_and_embargo():
labels = [f"T{index:02d}" for index in range(22)]
config = NestedValidationConfig(
outer_train_bars=12,
outer_test_bars=4,
outer_step_bars=4,
inner_train_bars=5,
inner_test_bars=2,
inner_step_bars=2,
purge_bars=1,
embargo_bars=2,
min_train_bars=5,
)
folds = generate_nested_folds(labels, config)
assert folds[0].outer.train_labels == tuple(labels[:12])
assert folds[0].outer.purge_labels == ("T12",)
assert folds[0].outer.test_labels == tuple(labels[13:17])
assert folds[0].outer.embargo_labels == ("T17", "T18")
assert folds[0].inner[0].train_labels == tuple(labels[:5])
assert folds[0].inner[0].purge_labels == ("T05",)
assert folds[0].inner[0].test_labels == ("T06", "T07")
assert set(folds[0].inner[-1].test_labels).issubset(folds[0].outer.train_labels)
with pytest.raises(ValueError, match="insufficient trading bars"):
generate_nested_folds(labels[:10], config)
def _frame_labels(frame) -> tuple[str, ...]:
return tuple(
frame.select(pl.col("date").cast(pl.Utf8).str.slice(0, 10).unique().sort())
.to_series()
.to_list()
)
class _Evaluator:
def __init__(self) -> None:
self.calls: list[tuple[tuple[str, ...], tuple[str, ...], dict]] = []
def evaluate_candidate(self, train, test, definition):
train_labels = _frame_labels(train)
test_labels = _frame_labels(test)
self.calls.append((train_labels, test_labels, dict(definition)))
return {"score": len(definition.get("factor_names", ())) or 0.1}
class _AlternatingWinnerEvaluator(_Evaluator):
"""Prefer the good factor only in windows before 2024-01-25; every later window prefers the runner-up.
With a 20-day panel (dates spaced two calendar days apart) and a
non-overlapping outer step, every inner and outer window of the first
fold falls in January while every window of the second fold falls after
the boundary, so the per-fold winner flips and cross-fold evaluation
has two distinct definitions to score.
"""
def evaluate_candidate(self, train, test, definition):
self.calls.append((_frame_labels(train), _frame_labels(test), dict(definition)))
prefer_good = _frame_labels(test)[0] < "2024-01-25"
names = definition.get("factor_names") or ()
is_good = bool(names) and names[0] == "good"
return {"score": 2.0 if is_good == prefer_good else 1.0}
class _LabelEvaluator(_Evaluator):
def __init__(self) -> None:
super().__init__()
self.label_calls: list[tuple[tuple[str, ...], tuple[str, ...], dict]] = []
def evaluate_candidate(self, train, test, definition):
raise AssertionError("label evaluator must not receive fold DataFrames")
def evaluate_candidate_labels(self, train_labels, test_labels, definition):
self.label_calls.append((
tuple(train_labels),
tuple(test_labels),
dict(definition),
))
return {"score": len(definition.get("factor_names", ())) or 0.1}
@pytest.mark.parametrize("evaluator_type", [_Evaluator, _LabelEvaluator])
def test_mining_service_reselects_per_inner_fold_and_keeps_tests_out_of_selection(
evaluator_type,
):
panel = _panel(days=10)
validation = NestedValidationConfig(
outer_train_bars=8,
outer_test_bars=2,
outer_step_bars=2,
inner_train_bars=4,
inner_test_bars=2,
inner_step_bars=2,
purge_bars=0,
embargo_bars=0,
min_train_bars=4,
)
request = MiningRequest(
factor_names=("good", "copy", "inverse", "noise"),
correlation_threshold=0.95,
budget=MiningBudget(
max_combination_size=2,
beam_width=4,
max_proxy_trials=18,
max_trials=3,
),
validation=validation,
profile="exploratory",
)
folds = generate_nested_folds(
sorted(str(value) for value in panel["date"].unique().to_list()),
validation,
)
assert len(folds) == 1
metric_calls: list[tuple[str, ...]] = []
def metric_provider(train, factor_names):
assert tuple(factor_names) == request.factor_names
labels = tuple(
train.select(pl.col("date").cast(pl.Utf8).str.slice(0, 10).unique().sort())
.to_series()
.to_list()
)
metric_calls.append(labels)
return _metrics()
evaluator = evaluator_type()
result = MiningService().run(
panel,
request,
metric_provider=metric_provider,
evaluator=evaluator,
)
nested = folds[0]
expected_selection_labels = [
*(inner.train_labels for inner in nested.inner),
nested.outer.train_labels,
]
assert metric_calls == expected_selection_labels
assert result.trials_used == request.budget.max_trials
assert result.proxy_trials_used <= request.budget.max_proxy_trials
evaluator_calls = (
evaluator.label_calls
if isinstance(evaluator, _LabelEvaluator)
else evaluator.calls
)
assert len(evaluator_calls) == len(nested.inner) + 1
for call, inner in zip(evaluator_calls[:-1], nested.inner, strict=True):
train_labels, test_labels, _ = call
assert train_labels == inner.train_labels
assert test_labels == inner.test_labels
assert not set(nested.outer.test_labels).intersection(train_labels + test_labels)
outer_train, outer_test, outer_definition = evaluator_calls[-1]
assert outer_train == nested.outer.train_labels
assert outer_test == nested.outer.test_labels
assert result.folds[0].selected_candidate_id is not None
selected = next(
candidate
for candidate in result.folds[0].candidates
if candidate.candidate_id == result.folds[0].selected_candidate_id
)
assert outer_definition == selected.definition()
changed = panel.with_columns(
pl.when(
pl.col("date")
.cast(pl.Utf8)
.str.slice(0, 10)
.is_in(nested.outer.test_labels)
)
.then(-pl.col("good") * 999.0)
.otherwise(pl.col("good"))
.alias("good")
)
without_evaluation = MiningService().run(
panel,
request,
factor_metrics=_metrics(),
)
changed_result = MiningService().run(
changed,
request,
factor_metrics=_metrics(),
)
assert without_evaluation.folds[0].selected_factors == (
changed_result.folds[0].selected_factors
)
assert without_evaluation.folds[0].candidates == changed_result.folds[0].candidates
with pytest.raises(ValueError, match="requires at least 3 outer folds"):
MiningService().run(
panel,
replace(request, profile="balanced"),
factor_metrics=_metrics(),
)
def test_mining_service_requires_fold_local_metrics_with_evaluator():
panel = _panel(days=10)
request = MiningRequest(
factor_names=("good",),
budget=MiningBudget(max_combination_size=1, max_proxy_trials=12, max_trials=3),
validation=NestedValidationConfig(
outer_train_bars=8,
outer_test_bars=2,
outer_step_bars=2,
inner_train_bars=4,
inner_test_bars=2,
inner_step_bars=2,
purge_bars=0,
embargo_bars=0,
min_train_bars=4,
),
profile="exploratory",
)
with pytest.raises(ValueError, match="metric_provider is required"):
MiningService().run(panel, request, evaluator=_Evaluator())
with pytest.raises(ValueError, match="factor_metrics must not be supplied"):
MiningService().run(
panel,
request,
factor_metrics=(FactorMetric("good", 1.0, 1.0, 1.0, 0.1),),
metric_provider=lambda _train, _names: (
FactorMetric("good", 1.0, 1.0, 1.0, 0.1),
),
evaluator=_Evaluator(),
)
def test_target_endpoint_is_removed_from_every_train_phase_but_not_tests(monkeypatch):
panel = _panel(days=10)
labels = sorted(panel["date"].unique().to_list())
target_dates = pl.DataFrame({
"date": labels,
"_target_date": [*labels[1:], labels[-1] + timedelta(days=2)],
})
panel = panel.join(target_dates, on="date", how="left")
validation = NestedValidationConfig(
outer_train_bars=8,
outer_test_bars=2,
outer_step_bars=2,
inner_train_bars=4,
inner_test_bars=2,
inner_step_bars=2,
purge_bars=0,
embargo_bars=0,
min_train_bars=4,
)
request = MiningRequest(
factor_names=("good",),
budget=MiningBudget(max_combination_size=1, max_proxy_trials=12, max_trials=3),
validation=validation,
profile="exploratory",
)
nested = generate_nested_folds([str(label) for label in labels], validation)[0]
train_ends = [*(inner.train_end for inner in nested.inner), nested.outer.train_end]
metric_frames = []
correlation_frames = []
beam_frames = []
def assert_target_bounded(frame, train_end):
assert frame.filter(
pl.col("_target_date").cast(pl.Utf8).str.slice(0, 10) > train_end
).is_empty()
def metric_provider(train, factor_names):
train_end = train_ends[len(metric_frames)]
assert tuple(factor_names) == request.factor_names
assert_target_bounded(train, train_end)
metric_frames.append(train)
return (FactorMetric("good", 1.0, 1.0, 1.0, 0.1),)
original_correlation = mining_module.compute_rank_correlation
original_beam = mining_module.beam_search_factor_combinations
def checked_correlation(frame, *args, **kwargs):
train_end = train_ends[len(correlation_frames)]
assert_target_bounded(frame, train_end)
correlation_frames.append(frame)
return original_correlation(frame, *args, **kwargs)
def checked_beam(frame, *args, **kwargs):
train_end = train_ends[len(beam_frames)]
assert_target_bounded(frame, train_end)
beam_frames.append(frame)
return original_beam(frame, *args, **kwargs)
class EndpointEvaluator(_Evaluator):
def __init__(self):
super().__init__()
self.test_frames = []
def evaluate_candidate(self, train, test, definition):
self.test_frames.append(test)
return super().evaluate_candidate(train, test, definition)
monkeypatch.setattr(mining_module, "compute_rank_correlation", checked_correlation)
monkeypatch.setattr(mining_module, "beam_search_factor_combinations", checked_beam)
evaluator = EndpointEvaluator()
result = MiningService().run(
panel,
request,
metric_provider=metric_provider,
evaluator=evaluator,
)
assert result.folds[0].error is None
assert len(metric_frames) == len(correlation_frames) == len(beam_frames) == 3
evaluation_folds = [*nested.inner, nested.outer]
assert len(evaluator.calls) == len(evaluation_folds)
for (train_labels, test_labels, _), test_frame, fold in zip(
evaluator.calls,
evaluator.test_frames,
evaluation_folds,
strict=True,
):
assert train_labels == fold.train_labels[:-1]
assert test_labels == fold.test_labels
assert test_frame.height == len(fold.test_labels) * 6
assert not test_frame.filter(
pl.col("_target_date").cast(pl.Utf8).str.slice(0, 10) > fold.test_end
).is_empty()
def test_outer_refit_fails_when_selected_factor_structure_is_missing():
panel = _panel(days=10)
request = MiningRequest(
factor_names=("good", "copy"),
correlation_threshold=0.8,
budget=MiningBudget(max_combination_size=1, max_proxy_trials=24, max_trials=8),
validation=NestedValidationConfig(
outer_train_bars=8,
outer_test_bars=2,
outer_step_bars=2,
inner_train_bars=4,
inner_test_bars=2,
inner_step_bars=2,
purge_bars=0,
embargo_bars=0,
min_train_bars=4,
),
profile="exploratory",
)
def metric_provider(train, _factor_names):
if train["date"].n_unique() < 8:
return (
FactorMetric("good", 1.0, 1.0, 1.0, 0.1),
FactorMetric("copy", 0.5, 0.5, 1.0, 0.1),
)
return (
FactorMetric("good", 0.5, 0.5, 1.0, 0.1),
FactorMetric("copy", 1.0, 1.0, 1.0, 0.1),
)
evaluator = _Evaluator()
result = MiningService().run(
panel,
request,
metric_provider=metric_provider,
evaluator=evaluator,
)
fold = result.folds[0]
assert len(evaluator.calls) == 2
assert fold.selected_candidate_id is None
assert fold.outer_evaluation is None
assert fold.error == "outer retraining did not reproduce selected candidate structure"
assert {candidate.factor_names for candidate in fold.candidates} == {("copy",)}
def test_candidate_evaluation_dataclass_score_must_be_finite():
panel = _panel(days=10)
request = MiningRequest(
factor_names=("good",),
budget=MiningBudget(max_combination_size=1, max_proxy_trials=12, max_trials=8),
validation=NestedValidationConfig(
outer_train_bars=8,
outer_test_bars=2,
outer_step_bars=2,
inner_train_bars=4,
inner_test_bars=2,
inner_step_bars=2,
purge_bars=0,
embargo_bars=0,
min_train_bars=4,
),
profile="exploratory",
)
class NonFiniteEvaluator:
def evaluate_candidate(self, train, test, definition):
return CandidateEvaluation(score=float("nan"), metrics={"source": "test"})
result = MiningService().run(
panel,
request,
metric_provider=lambda _train, _names: (
FactorMetric("good", 1.0, 1.0, 1.0, 0.1),
),
evaluator=NonFiniteEvaluator(),
)
assert result.folds[0].selected_candidate_id is None
assert result.folds[0].error == "no candidate completed inner validation within budget"
def test_beam_search_skips_factors_without_sufficient_valid_observations():
panel = _panel().with_columns(
pl.lit(None).cast(pl.Float64).alias("all_null"),
pl.lit(7.0).alias("constant"),
pl.col("inverse").alias("valid_inverse"),
pl.when(pl.col("symbol").is_in(["000000.SZ", "000001.SZ"]))
.then(pl.col("good"))
.otherwise(None)
.alias("sparse"),
)
result = beam_search_factor_combinations(
panel,
["all_null", "constant", "good", "sparse", "valid_inverse"],
max_combination_size=2,
max_trials=30,
)
invalid_only = beam_search_factor_combinations(
panel,
["all_null", "constant", "sparse"],
max_combination_size=2,
max_trials=30,
)
assert result.candidates
assert all(
set(candidate.factor_names) <= {"good", "valid_inverse"}
for candidate in result.candidates
)
assert {candidate.factor_names for candidate in result.candidates} >= {
("good",),
("valid_inverse",),
("good", "valid_inverse"),
}
assert all(candidate.dates > 0 and candidate.observations >= 3 for candidate in result.candidates)
assert invalid_only.candidates == ()
def test_small_real_allowance_evaluates_factor_and_finalists_stay_capped():
panel = _panel(days=10)
request = MiningRequest(
factor_names=("good", "noise"),
existing_strategy_ids=tuple(f"existing-{index}" for index in range(8)),
budget=MiningBudget(max_combination_size=1, max_proxy_trials=12, max_trials=1),
validation=NestedValidationConfig(
outer_train_bars=8,
outer_test_bars=2,
outer_step_bars=2,
inner_train_bars=4,
inner_test_bars=2,
inner_step_bars=2,
purge_bars=0,
embargo_bars=0,
min_train_bars=4,
),
profile="exploratory",
)
evaluator = _Evaluator()
result = MiningService().run(
panel,
request,
metric_provider=lambda _train, _names: (
FactorMetric("good", 1.0, 1.0, 1.0, 0.1),
FactorMetric("noise", 0.5, 0.5, 1.0, 0.1),
),
evaluator=evaluator,
)
assert len(evaluator.calls) == 1
assert evaluator.calls[0][2]["kind"] == "factor_rank"
finalists = result.folds[0].candidates
assert all(candidate.kind == "factor_rank" for candidate in finalists)
assert len(finalists) == 2
assert result.folds[0].selected_candidate_id is not None
def test_existing_strategies_are_benchmarked_on_every_outer_fold():
panel = _panel(days=18)
request = MiningRequest(
factor_names=("good",),
existing_strategy_ids=("alpha", "beta"),
budget=MiningBudget(max_combination_size=1, max_proxy_trials=24, max_trials=64),
validation=NestedValidationConfig(
outer_train_bars=8,
outer_test_bars=2,
outer_step_bars=4,
inner_train_bars=4,
inner_test_bars=2,
inner_step_bars=2,
purge_bars=0,
embargo_bars=0,
min_train_bars=4,
),
profile="exploratory",
)
evaluator = _Evaluator()
result = MiningService().run(
panel,
request,
metric_provider=lambda _train, _names: (FactorMetric("good", 1.0, 1.0, 1.0, 0.1),),
evaluator=evaluator,
)
assert len(result.folds) == 3
for fold in result.folds:
assert all(candidate.kind == "factor_rank" for candidate in fold.candidates)
signatures = [candidate_id for candidate_id, _ in fold.benchmark_evaluations]
assert signatures == ["strategy:alpha", "strategy:beta"]
for _, evaluation in fold.benchmark_evaluations:
assert evaluation.error is None
evaluated_strategy_kinds = [
definition["kind"]
for *_, definition in evaluator.calls
if definition["kind"] == "existing_strategy"
]
assert len(evaluated_strategy_kinds) == 6
def test_winner_definitions_are_cross_evaluated_on_all_outer_folds():
panel = _panel(days=20)
request = MiningRequest(
factor_names=("good", "noise"),
budget=MiningBudget(max_combination_size=1, max_proxy_trials=24, max_trials=64),
validation=NestedValidationConfig(
outer_train_bars=8,
outer_test_bars=2,
outer_step_bars=10,
inner_train_bars=4,
inner_test_bars=2,
inner_step_bars=2,
purge_bars=0,
embargo_bars=0,
min_train_bars=4,
),
profile="exploratory",
)
evaluator = _AlternatingWinnerEvaluator()
result = MiningService().run(
panel,
request,
metric_provider=lambda _train, _names: (
FactorMetric("good", 1.0, 1.0, 1.0, 0.1),
FactorMetric("noise", 0.9, 0.9, 1.0, 0.1),
),
evaluator=evaluator,
)
assert len(result.folds) == 2
winners = {fold.selected_candidate_id for fold in result.folds}
assert len(winners) == 2
for fold in result.folds:
cross_ids = [candidate_id for candidate_id, _ in fold.cross_evaluations]
assert cross_ids == sorted(winners - {fold.selected_candidate_id})
for _, evaluation in fold.cross_evaluations:
assert evaluation.error is None
def test_benchmarks_run_even_when_factor_track_fails_on_a_fold():
panel = _panel(days=18)
request = MiningRequest(
factor_names=("good",),
existing_strategy_ids=("alpha",),
budget=MiningBudget(max_combination_size=1, max_proxy_trials=24, max_trials=64),
validation=NestedValidationConfig(
outer_train_bars=8,
outer_test_bars=2,
outer_step_bars=4,
inner_train_bars=4,
inner_test_bars=2,
inner_step_bars=2,
purge_bars=0,
embargo_bars=0,
min_train_bars=4,
),
profile="exploratory",
)
class _FailingEvaluator(_Evaluator):
def evaluate_candidate(self, train, test, definition):
raise RuntimeError("backtest exploded")
result = MiningService().run(
panel,
request,
metric_provider=lambda _train, _names: (FactorMetric("good", 1.0, 1.0, 1.0, 0.1),),
evaluator=_FailingEvaluator(),
)
assert len(result.folds) == 1
fold = result.folds[0]
assert fold.error == "no candidate completed inner validation within budget"
assert fold.selected_candidate_id is None
assert [candidate_id for candidate_id, _ in fold.benchmark_evaluations] == [
"strategy:alpha"
]
benchmark = fold.benchmark_evaluations[0][1]
assert benchmark.error == "backtest exploded"
def test_searchable_factors_are_capped_by_beam_width():
budget = MiningBudget(beam_width=3)
assert _searchable_factors(("a", "b", "c", "d", "e"), budget) == ("a", "b", "c")
assert _searchable_factors(("a",), MiningBudget(beam_width=8)) == ("a",)
def test_beam_search_only_receives_capped_factor_inputs():
names = tuple(f"factor_{index:02d}" for index in range(20))
rows = []
start = date(2024, 1, 2)
for day_id in range(12):
for asset_id in range(6):
row = {
"symbol": f"{asset_id:06d}.SZ",
"date": start + timedelta(days=day_id),
"_next_return": float(asset_id),
}
for factor_id, name in enumerate(names):
row[name] = float((asset_id * (factor_id + 1)) % 7) + factor_id * 0.5
rows.append(row)
panel = pl.DataFrame(rows)
request = MiningRequest(
factor_names=names,
correlation_threshold=1.0,
budget=MiningBudget(
max_combination_size=2,
beam_width=4,
max_proxy_trials=96,
max_trials=8,
),
validation=NestedValidationConfig(
outer_train_bars=8,
outer_test_bars=2,
outer_step_bars=4,
inner_train_bars=4,
inner_test_bars=2,
inner_step_bars=2,
purge_bars=0,
embargo_bars=0,
min_train_bars=4,
),
profile="exploratory",
)
evaluator = _Evaluator()
result = MiningService().run(
panel,
request,
metric_provider=lambda _train, factor_names: tuple(
FactorMetric(name, 1.0 - index * 0.01, 1.0, 1.0, 0.1)
for index, name in enumerate(factor_names)
),
evaluator=evaluator,
)
searched = {
factor_name
for candidate in result.folds[0].candidates
for factor_name in candidate.factor_names
}
assert searched <= set(names[:4])
assert any(len(candidate.factor_names) == 2 for candidate in result.folds[0].candidates)