File size: 11,347 Bytes
ef20ebe | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 | """
walk_forward.py tests. test_frozen_selection_is_unaffected_by_anything_
in_the_test_window is the single most important test in this file --
it's a direct, executable proof of the leakage-safety claim in that
module's docstring, not just a check that functions run without error.
"""
import numpy as np
import pandas as pd
import config as cfg
import walk_forward as wf
from features import compute_indicators
from fixtures import make_synthetic_ohlcv
from moirai_model import QuantileForecast
def test_chronological_split_preserves_time_order_and_proportions():
df = make_synthetic_ohlcv(n=1000, seed=70, freq="1h")
train, val, test = wf.chronological_split(df, cfg.WalkForwardConfig(train_frac=0.5, val_frac=0.3))
assert len(train) + len(val) + len(test) == len(df)
assert train.index[-1] < val.index[0] <= val.index[-1] < test.index[0]
assert abs(len(train) / len(df) - 0.5) < 0.01
assert abs(len(val) / len(df) - 0.3) < 0.01
def test_chronological_split_never_shuffles():
df = make_synthetic_ohlcv(n=500, seed=71, freq="1h")
train, val, test = wf.chronological_split(df)
for part in (train, val, test):
assert part.index.is_monotonic_increasing
def test_chronological_split_rejects_too_short_data():
df = make_synthetic_ohlcv(n=20, seed=72, freq="1h")
try:
wf.chronological_split(df)
raise AssertionError("expected ValueError for too-short data")
except ValueError:
pass
class _NoisyStub:
"""Raw probability is a noisy but genuinely informative function of
the actual forward return -- used to build a calibration dataset
that isn't pure garbage, without needing the real model."""
def __init__(self, seed=0):
self.rng = np.random.default_rng(seed)
def predict(self, context_df, target_col, past_covariate_cols, prediction_length, freq):
last_close = float(context_df[target_col].iloc[-1])
drift = self.rng.normal(0, 0.003)
p50 = last_close * (1 + drift)
spread = 0.004
prices = [p50 * (1 - spread), p50 * (1 - spread / 2), p50, p50 * (1 + spread / 2), p50 * (1 + spread)]
return QuantileForecast(horizon=prediction_length, quantile_levels=[0.1, 0.25, 0.5, 0.75, 0.9],
quantiles=np.array([prices]), samples=None,
context_length_used=len(context_df), prediction_length=prediction_length)
def test_frozen_selection_is_unaffected_by_anything_in_the_test_window():
"""THE core leakage-safety test: two frames identical through the
train/val boundary but with completely different (differently
seeded, differently distributed) data after it must produce an
IDENTICAL calibrator and frozen DecisionConfig from
select_frozen_config -- since that function is never given the test
split at all, this is a structural guarantee, and this test proves
it holds in practice, not just in the code's own claim about
itself."""
base = make_synthetic_ohlcv(n=900, seed=73, freq="1h")
# Loose grid values here are deliberate: this test isolates the
# leakage-safety question ("is the SELECTED config independent of
# test-region data") from the strategy-quality question (already
# covered by test_select_frozen_config_rejects_grid_points_below_
# min_trade_count) -- a stub with genuinely zero real skill gets
# correctly calibrated toward the base rate by design, so a
# realistic min_probability like 0.55-0.70 would legitimately find
# no acceptable config here, which is correct behavior but leaves
# nothing to compare across variants A/B.
wf_cfg = cfg.WalkForwardConfig(train_frac=0.5, val_frac=0.25,
min_probability_grid=(0.40, 0.50), buy_threshold_grid=(0.0003, 0.0006),
min_trades_for_calibration=10, min_trades_for_acceptance=1)
# Variant A: base data, unmodified.
feat_a = compute_indicators(base)
train_a, val_a, test_a = wf.chronological_split(feat_a, wf_cfg)
# Variant B: identical through train+val, then a totally different
# (differently-seeded) tail spliced in as the "test" region.
other_tail = make_synthetic_ohlcv(n=len(base), seed=999, freq="1h")
other_tail.index = base.index # align so concat/slicing stays simple
combined = base.copy()
val_end = int(len(base) * (wf_cfg.train_frac + wf_cfg.val_frac))
combined.iloc[val_end:] = other_tail.iloc[val_end:]
feat_b = compute_indicators(combined)
train_b, val_b, test_b = wf.chronological_split(feat_b, wf_cfg)
assert train_a.equals(train_b), "train split should be identical by construction"
assert val_a.equals(val_b), "val split should be identical by construction"
assert not test_a.equals(test_b), "test split MUST differ for this to be a meaningful test"
calib_a, dc_a, report_a = wf.select_frozen_config(
_NoisyStub(seed=5), train_a, val_a, timeframe="1h", asset_class="forex",
horizon=5, context_length=32, wf=wf_cfg)
calib_b, dc_b, report_b = wf.select_frozen_config(
_NoisyStub(seed=5), train_b, val_b, timeframe="1h", asset_class="forex",
horizon=5, context_length=32, wf=wf_cfg)
assert np.array_equal(calib_a.bin_edges, calib_b.bin_edges)
assert np.array_equal(calib_a.calibrated_values, calib_b.calibrated_values)
assert dc_a.min_probability == dc_b.min_probability
assert dc_a.buy_threshold == dc_b.buy_threshold
assert report_a["trials"] == report_b["trials"]
def test_select_frozen_config_grid_is_bounded_not_exhaustive_search():
"""Item 12: "do not search thousands of combinations" -- the number
of backtest runs select_frozen_config performs must equal exactly
len(min_probability_grid) * len(buy_threshold_grid), not something
open-ended."""
df = make_synthetic_ohlcv(n=700, seed=74, freq="1h")
feat = compute_indicators(df)
wf_cfg = cfg.WalkForwardConfig(train_frac=0.5, val_frac=0.3,
min_probability_grid=(0.55, 0.60, 0.65),
buy_threshold_grid=(0.0015, 0.0025),
min_trades_for_calibration=10, min_trades_for_acceptance=1)
train, val, _ = wf.chronological_split(feat, wf_cfg)
_, _, report = wf.select_frozen_config(_NoisyStub(seed=1), train, val, timeframe="1h",
asset_class="forex", horizon=5, context_length=32, wf=wf_cfg)
assert len(report["trials"]) == 3 * 2
def test_select_frozen_config_rejects_grid_points_below_min_trade_count():
"""Item 13: a combination profitable on a handful of trades must not
be selectable -- verified by setting min_trades_for_acceptance
absurdly high and confirming selection then fails outright rather
than quietly accepting a thin result."""
df = make_synthetic_ohlcv(n=700, seed=75, freq="1h")
feat = compute_indicators(df)
wf_cfg = cfg.WalkForwardConfig(train_frac=0.5, val_frac=0.3,
min_probability_grid=(0.55,), buy_threshold_grid=(0.0015,),
min_trades_for_calibration=10, min_trades_for_acceptance=100_000)
train, val, _ = wf.chronological_split(feat, wf_cfg)
try:
wf.select_frozen_config(_NoisyStub(seed=2), train, val, timeframe="1h",
asset_class="forex", horizon=5, context_length=32, wf=wf_cfg)
raise AssertionError("expected ValueError: no grid point can reach 100,000 trades")
except ValueError as e:
assert "no (min_probability, buy_threshold)" in str(e)
def test_baseline_forecasters_run_through_the_real_backtest_engine():
"""Both baselines must be usable exactly like the real forecaster --
same WalkForwardBacktester, same trading_summary -- for the
baseline comparison to mean anything."""
df = make_synthetic_ohlcv(n=300, seed=76, freq="1h")
feat = compute_indicators(df)
dc = cfg.DecisionConfig(min_probability=0.55, buy_threshold=0.0005, sell_threshold=-0.0005)
zero_summary = wf.run_baseline(wf.ZeroSkillForecaster, feat, "1h", "forex", 5, 32, dc)
assert zero_summary.get("num_trades", 0) == 0, "zero-skill baseline predicts no change -> never clears any edge threshold -> always HOLD"
momentum_summary = wf.run_baseline(wf.NaiveMomentumForecaster, feat, "1h", "forex", 5, 32, dc)
assert "num_trades" in momentum_summary # may or may not trade depending on synthetic noise; just must run cleanly
def test_check_acceptance_rejects_on_insufficient_trades():
summary = {"num_trades": 5, "net_pnl": 0.05, "profit_factor": 1.5, "max_drawdown": -0.02}
result = wf.check_acceptance(summary, {}, pd.Series([1.0, 1.01, 1.05]),
wf=cfg.WalkForwardConfig(min_trades_for_acceptance=30))
assert result["accepted"] is False
assert result["checks"]["sufficient_trade_count"] is False
def test_check_acceptance_rejects_negative_net_return_even_with_many_trades():
summary = {"num_trades": 100, "net_pnl": -0.05, "profit_factor": 0.8, "max_drawdown": -0.02}
result = wf.check_acceptance(summary, {}, pd.Series([1.0, 0.99, 0.95]),
wf=cfg.WalkForwardConfig(min_trades_for_acceptance=30))
assert result["accepted"] is False
assert result["checks"]["positive_net_return"] is False
assert result["checks"]["profit_factor_above_1"] is False
def test_check_acceptance_rejects_when_baseline_wins():
summary = {"num_trades": 50, "net_pnl": 0.01, "profit_factor": 1.1, "max_drawdown": -0.02}
baselines = {"momentum": {"net_pnl": 0.05}}
result = wf.check_acceptance(summary, baselines, pd.Series([1.0, 1.005, 1.01]),
wf=cfg.WalkForwardConfig(min_trades_for_acceptance=30))
assert result["checks"]["beats_baseline_momentum"] is False
assert result["accepted"] is False
def test_check_acceptance_can_accept_a_genuinely_good_result():
idx = pd.date_range("2024-01-01", periods=200, freq="1D") # ~6.7 months -> several distinct 30D bins
equity = pd.Series(np.linspace(1.0, 1.15, 200), index=idx) # smooth, steady growth -- no single-period dependency
summary = {"num_trades": 50, "net_pnl": 0.15, "profit_factor": 1.8, "max_drawdown": -0.03}
baselines = {"zero_skill": {"net_pnl": 0.0}, "momentum": {"net_pnl": 0.02}}
result = wf.check_acceptance(summary, baselines, equity, wf=cfg.WalkForwardConfig(min_trades_for_acceptance=30))
assert result["accepted"] is True, result["checks"]
def test_check_acceptance_rejects_when_one_period_explains_most_profit():
idx = pd.date_range("2024-01-01", periods=200, freq="1D") # ~6.7 months -> several distinct 30D bins
vals = np.ones(200)
vals[150:] = 1.30 # one sharp jump near the end, flat everywhere else
equity = pd.Series(vals, index=idx)
summary = {"num_trades": 50, "net_pnl": 0.30, "profit_factor": 5.0, "max_drawdown": -0.01}
result = wf.check_acceptance(summary, {}, equity, wf=cfg.WalkForwardConfig(min_trades_for_acceptance=30,
max_single_period_profit_share=0.70))
assert result["checks"]["not_single_period_dependent"] is False
assert result["accepted"] is False
|