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