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