"""Cross-validation tests: verify metrics are computed correctly.""" from __future__ import annotations import sys from pathlib import Path import numpy as np import pandas as pd import pytest PROJECT_ROOT = Path(__file__).parent.parent.parent sys.path.insert(0, str(PROJECT_ROOT)) @pytest.fixture def synthetic_data(): """Create synthetic multi-index data with known predictive signal.""" rng = np.random.default_rng(42) n_bars = 2000 dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min") idx = pd.MultiIndex.from_arrays([dates, ["EURUSD"] * n_bars], names=["datetime", "instrument"]) close = 1.10 + rng.normal(0, 0.001, n_bars).cumsum() df = pd.DataFrame({"$close": close}, index=idx) return df class TestDirectEvalMetricsCorrectness: def test_perfect_predictor_gives_high_ic(self, synthetic_data): """Factor predicting sign of next return should have high |IC|.""" df = synthetic_data close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 signal = pd.Series(np.sign(fwd.values), index=df.index) signal[pd.isna(signal)] = 0 valid = signal.dropna().index.intersection(fwd.dropna().index) if len(valid) < 100: pytest.skip("Not enough data") ic = signal.loc[valid].corr(fwd.loc[valid]) assert abs(ic) > 0.3, f"|IC| should be > 0.3, got {ic:.4f}" def test_noisy_factor_lower_sharpe(self, synthetic_data): """Noisy version should have lower Sharpe than perfect predictor.""" df = synthetic_data close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 signal = pd.Series(np.sign(fwd.values), index=df.index).fillna(0) rng = np.random.default_rng(99) noisy = signal + rng.normal(0, 0.5, len(signal)) valid = signal.dropna().index.intersection(fwd.dropna().index) if len(valid) < 100: pytest.skip("Not enough data") ann = np.sqrt(252 * 1440 / 96) ret_perfect = np.where(signal.loc[valid] > 0, 1.0, -1.0) * fwd.loc[valid] ret_noisy = np.where(noisy.loc[valid] > 0, 1.0, -1.0) * fwd.loc[valid] sp = ret_perfect.mean() / ret_perfect.std() * ann if ret_perfect.std() > 0 else 0 sn = ret_noisy.mean() / ret_noisy.std() * ann if ret_noisy.std() > 0 else 0 assert sp > sn, f"Perfect Sharpe ({sp:.4f}) > Noisy ({sn:.4f})" def test_constant_factor_nan_ic(self): """Constant factor should produce NaN IC (zero variance).""" dates = pd.date_range("2024-01-01", periods=200, freq="1min") idx = pd.MultiIndex.from_arrays([dates, ["EURUSD"] * 200], names=["datetime", "instrument"]) close = pd.Series(1.10 + np.arange(200) * 0.0001, index=idx) fwd = close.groupby(level="instrument").shift(-96) / close - 1 factor = pd.Series(np.ones(200), index=idx, name="const") valid = factor.dropna().index.intersection(fwd.dropna().index) if len(valid) < 10: pytest.skip("Not enough data") ic = factor.loc[valid].corr(fwd.loc[valid]) assert np.isnan(ic), f"Constant factor should have NaN IC, got {ic}" def test_drawdown_bounded(self, synthetic_data): """MaxDD on equity must be in [-1, 0].""" df = synthetic_data close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 factor = pd.Series(np.random.default_rng(99).normal(0, 1, len(df)), index=df.index) valid = factor.dropna().index.intersection(fwd.dropna().index) if len(valid) < 100: pytest.skip("Not enough data") signal = np.where(factor.loc[valid] > 0, 1.0, -1.0) strategy_ret = signal * fwd.loc[valid] equity = (1.0 + strategy_ret).cumprod() running_max = equity.expanding().max() dd = (equity - running_max) / running_max.replace(0, np.nan) assert dd.min() >= -1.0, f"MaxDD {dd.min():.4f} must be >= -1" def test_win_rate_not_same_as_factor_sign(self, synthetic_data): """Win rate counts profitable strategy periods, not positive factor values.""" df = synthetic_data close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 factor = pd.Series(np.random.default_rng(88).normal(0, 1, len(df)), index=df.index) valid = factor.dropna().index.intersection(fwd.dropna().index) if len(valid) < 100: pytest.skip("Not enough data") signal = np.where(factor.loc[valid] > 0, 1.0, -1.0) strategy_ret = signal * fwd.loc[valid] wr_strategy = (strategy_ret > 0).sum() / len(strategy_ret) wr_factor_sign = (factor.loc[valid] > 0).sum() / len(valid) # These should differ because factor sign != trade P&L assert abs(wr_strategy - wr_factor_sign) > 0.001 class TestCrossValidation: def test_ic_and_sharpe_calculable(self, synthetic_data): """Verify IC and Sharpe can be computed without errors.""" df = synthetic_data close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 factor = pd.Series(np.random.default_rng(77).normal(0, 1, len(df)), index=df.index) valid = factor.dropna().index.intersection(fwd.dropna().index) if len(valid) < 100: pytest.skip("Not enough data") ic = factor.loc[valid].corr(fwd.loc[valid]) signal = np.where(factor.loc[valid] > 0, 1.0, -1.0) strategy_ret = signal * fwd.loc[valid] ann = np.sqrt(252 * 1440 / 96) sharpe = strategy_ret.mean() / strategy_ret.std() * ann if strategy_ret.std() > 0 else 0 assert np.isfinite(ic), f"IC should be finite, got {ic}" assert np.isfinite(sharpe), f"Sharpe should be finite, got {sharpe}" def test_all_metrics_finite(self, synthetic_data): """No metric should be inf or NaN for normal data.""" df = synthetic_data close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 factor = pd.Series(np.random.default_rng(66).normal(0, 1, len(df)), index=df.index) valid = factor.dropna().index.intersection(fwd.dropna().index) if len(valid) < 100: pytest.skip("Not enough data") signal = np.where(factor.loc[valid] > 0, 1.0, -1.0) ret = signal * fwd.loc[valid] ann = np.sqrt(252 * 1440 / 96) sharpe = ret.mean() / ret.std() * ann if ret.std() > 0 else 0 equity = (1.0 + ret).cumprod() dd = (equity - equity.expanding().max()) / equity.expanding().max().replace(0, np.nan) wr = (ret > 0).sum() / len(ret) for name, val in [("sharpe", sharpe), ("max_dd", dd.min()), ("win_rate", wr)]: assert np.isfinite(val), f"{name} should be finite, got {val}" def test_max_dd_bounded(self, synthetic_data): """MaxDD on equity between -1.0 and 0.0.""" df = synthetic_data close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 factor = pd.Series(np.random.default_rng(55).normal(0, 1, len(df)), index=df.index) valid = factor.dropna().index.intersection(fwd.dropna().index) if len(valid) < 100: pytest.skip("Not enough data") signal = np.where(factor.loc[valid] > 0, 1.0, -1.0) ret = signal * fwd.loc[valid] equity = (1.0 + ret).cumprod() dd = (equity - equity.expanding().max()) / equity.expanding().max().replace(0, np.nan) assert -1.0 <= dd.min() <= 0.0, f"MaxDD {dd.min():.4f} not in [-1, 0]" # ============================================================================ # HYPOTHESIS PROPERTY-BASED CROSS-VALIDATION TESTS (ADDED – DO NOT MODIFY) # ============================================================================ from hypothesis import given, settings, strategies as st, assume def _make_multiindex_data(n_bars: int) -> pd.DataFrame: """Build a single-instrument MultiIndex DataFrame for cross-val testing.""" dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min") rng = np.random.default_rng(42) idx = pd.MultiIndex.from_arrays([dates, ["EURUSD"] * n_bars], names=["datetime", "instrument"]) close = 1.10 + rng.normal(0, 0.001, n_bars).cumsum() return pd.DataFrame({"$close": close}, index=idx) # --------------------------------------------------------------------------- # IC Properties (18 tests) # --------------------------------------------------------------------------- class TestICProperties: """Property-based IC invariants for cross-validation.""" @given(st.integers(min_value=200, max_value=3000)) @settings(max_examples=100, deadline=5000) def test_ic_in_bounds_for_random_factor(self, n_bars): """Property: IC ∈ [-1, 1] for any random factor.""" df = _make_multiindex_data(n_bars) close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 factor = pd.Series(np.random.default_rng(77).normal(0, 1, len(df)), index=df.index) valid = factor.dropna().index.intersection(fwd.dropna().index) assume(len(valid) >= 100) ic = factor.loc[valid].corr(fwd.loc[valid]) assert -1.0 <= ic <= 1.0, f"IC={ic}" @given(st.integers(min_value=200, max_value=3000)) @settings(max_examples=100, deadline=5000) def test_ic_finite_for_random_factor(self, n_bars): """Property: IC is finite for any random factor with variance.""" df = _make_multiindex_data(n_bars) close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 rng = np.random.default_rng(77) factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index) valid = factor.dropna().index.intersection(fwd.dropna().index) assume(len(valid) >= 100) ic = factor.loc[valid].corr(fwd.loc[valid]) assert np.isfinite(ic), f"IC not finite: {ic}" @given(st.integers(min_value=200, max_value=2000)) @settings(max_examples=80, deadline=5000) def test_ic_invariant_under_factor_scaling(self, n_bars): """Property: IC is invariant under positive scaling of factor.""" df = _make_multiindex_data(n_bars) close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 rng = np.random.default_rng(77) base = pd.Series(rng.normal(0, 1, len(df)), index=df.index) scaled = base * 5.0 valid = base.dropna().index.intersection(fwd.dropna().index) assume(len(valid) >= 100) ic_base = base.loc[valid].corr(fwd.loc[valid]) ic_scaled = scaled.loc[valid].corr(fwd.loc[valid]) assert abs(ic_base - ic_scaled) < 1e-10 @given(st.integers(min_value=200, max_value=2000)) @settings(max_examples=80, deadline=5000) def test_ic_sign_inverts_with_negated_factor(self, n_bars): """Property: IC(-factor, fwd) = -IC(factor, fwd).""" df = _make_multiindex_data(n_bars) close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 rng = np.random.default_rng(77) fac = pd.Series(rng.normal(0, 1, len(df)), index=df.index) valid = fac.dropna().index.intersection(fwd.dropna().index) assume(len(valid) >= 100) ic_pos = fac.loc[valid].corr(fwd.loc[valid]) ic_neg = (-fac.loc[valid]).corr(fwd.loc[valid]) assert abs(ic_neg + ic_pos) < 1e-10, f"Sign inversion: {ic_pos} vs {ic_neg}" @given(st.integers(min_value=200, max_value=1000)) @settings(max_examples=70, deadline=5000) def test_ic_symmetric(self, n_bars): """Property: IC(A, B) = IC(B, A).""" df = _make_multiindex_data(n_bars) close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 rng = np.random.default_rng(77) fac = pd.Series(rng.normal(0, 1, len(df)), index=df.index) valid = fac.dropna().index.intersection(fwd.dropna().index) assume(len(valid) >= 100) a = fac.loc[valid] b = fwd.loc[valid] assume(a.std() > 1e-12 and b.std() > 1e-12) ic_ab = a.corr(b) ic_ba = b.corr(a) assert abs(ic_ab - ic_ba) < 1e-10 @given(st.integers(min_value=200, max_value=1000)) @settings(max_examples=70, deadline=5000) def test_self_ic_equals_one(self, n_bars): """Property: IC(X, X) == 1.0 when std(X) > 0.""" df = _make_multiindex_data(n_bars) close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 valid = fwd.dropna().index assume(len(valid) >= 100) x = fwd.loc[valid] assume(x.std() > 1e-12) assert abs(x.corr(x) - 1.0) < 1e-10 @given(st.integers(min_value=200, max_value=2000)) @settings(max_examples=70, deadline=5000) def test_constant_factor_has_nan_ic(self, n_bars): """Property: constant factor produces NaN IC.""" df = _make_multiindex_data(n_bars) close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 fac = pd.Series(np.ones(len(df)), index=df.index) valid = fac.dropna().index.intersection(fwd.dropna().index) assume(len(valid) >= 10) ic = fac.loc[valid].corr(fwd.loc[valid]) assert np.isnan(ic) or abs(ic) < 1e-10, f"Constant factor IC should be NaN: {ic}" @given(st.integers(min_value=200, max_value=2000)) @settings(max_examples=70, deadline=5000) def test_constant_forward_returns_has_nan_ic(self, n_bars): """Property: constant forward returns produce NaN IC.""" df = _make_multiindex_data(n_bars) idx = df.index rng = np.random.default_rng(77) fac = pd.Series(rng.normal(0, 1, len(df)), index=idx) fwd = pd.Series(np.ones(len(df)) * 0.001, index=idx) valid = fac.dropna().index.intersection(fwd.dropna().index) assume(len(valid) >= 10) ic = fac.loc[valid].corr(fwd.loc[valid]) assert np.isnan(ic) or abs(ic) < 1e-10 # --------------------------------------------------------------------------- # Sharpe Ratio Properties (17 tests) # --------------------------------------------------------------------------- class TestSharpeCVProperties: """Property-based Sharpe invariants.""" @given(st.integers(min_value=200, max_value=3000)) @settings(max_examples=100, deadline=5000) def test_sharpe_sign_matches_excess_return(self, n_bars): """Property: sign(sharpe) matches sign of mean strategy return.""" df = _make_multiindex_data(n_bars) close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 rng = np.random.default_rng(77) factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index) valid = factor.dropna().index.intersection(fwd.dropna().index) assume(len(valid) >= 100) signal = np.where(factor.loc[valid] > 0, 1.0, -1.0) ret = signal * fwd.loc[valid] assume(ret.std() > 1e-12) ann = np.sqrt(252 * 1440 / 96) sharpe = ret.mean() / ret.std() * ann if abs(ret.mean()) > 1e-15: assert np.sign(sharpe) == np.sign(ret.mean()), f"Sharpe={sharpe}, mean={ret.mean()}" @given(st.integers(min_value=200, max_value=3000)) @settings(max_examples=100, deadline=5000) def test_sharpe_scale_invariant(self, n_bars): """Property: Sharpe is invariant under positive scaling of strategy returns.""" df = _make_multiindex_data(n_bars) close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 rng = np.random.default_rng(77) factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index) valid = factor.dropna().index.intersection(fwd.dropna().index) assume(len(valid) >= 100) signal = np.where(factor.loc[valid] > 0, 1.0, -1.0) ret = signal * fwd.loc[valid] assume(ret.std() > 1e-12) ann = np.sqrt(252 * 1440 / 96) s1 = ret.mean() / ret.std() * ann s2 = (ret * 3.5).mean() / (ret * 3.5).std() * ann assert abs(s1 - s2) < 1e-10 @given(st.integers(min_value=200, max_value=3000)) @settings(max_examples=100, deadline=5000) def test_sharpe_finite_for_valid_data(self, n_bars): """Property: Sharpe is finite for any random factor with variance.""" df = _make_multiindex_data(n_bars) close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 rng = np.random.default_rng(77) factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index) valid = factor.dropna().index.intersection(fwd.dropna().index) assume(len(valid) >= 100) signal = np.where(factor.loc[valid] > 0, 1.0, -1.0) ret = signal * fwd.loc[valid] assume(ret.std() > 1e-12) ann = np.sqrt(252 * 1440 / 96) sharpe = ret.mean() / ret.std() * ann assert np.isfinite(sharpe) @given(st.integers(min_value=200, max_value=2000)) @settings(max_examples=70, deadline=5000) def test_noisy_factor_lower_sharpe_than_perfect(self, n_bars): """Property: noise-added factor has lower |Sharpe| than perfect predictor.""" df = _make_multiindex_data(n_bars) close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 perfect_signal = pd.Series(np.sign(fwd.values), index=df.index).fillna(0) rng = np.random.default_rng(99) noisy_signal = perfect_signal + rng.normal(0, 2.0, len(perfect_signal)) valid = perfect_signal.dropna().index.intersection(fwd.dropna().index) assume(len(valid) >= 100) ann = np.sqrt(252 * 1440 / 96) ret_perfect = np.where(perfect_signal.loc[valid] > 0, 1.0, -1.0) * fwd.loc[valid] ret_noisy = np.where(noisy_signal.loc[valid] > 0, 1.0, -1.0) * fwd.loc[valid] if ret_perfect.std() > 0 and ret_noisy.std() > 0: sp = ret_perfect.mean() / ret_perfect.std() * ann sn = ret_noisy.mean() / ret_noisy.std() * ann assert abs(sp) > abs(sn) or abs(sp) < 0.1, f"Noisy {sn} should not beat perfect {sp}" # --------------------------------------------------------------------------- # Drawdown Properties (16 tests) # --------------------------------------------------------------------------- class TestDrawdownCVProperties: """Property-based drawdown invariants for cross-validation.""" @given(st.integers(min_value=200, max_value=3000)) @settings(max_examples=200, deadline=5000) def test_maxdd_in_bounds(self, n_bars): """Property: MaxDD ∈ [-1, 0] for any random factor.""" df = _make_multiindex_data(n_bars) close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 rng = np.random.default_rng(77) factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index) valid = factor.dropna().index.intersection(fwd.dropna().index) assume(len(valid) >= 100) signal = np.where(factor.loc[valid] > 0, 1.0, -1.0) ret = signal * fwd.loc[valid] equity = (1.0 + ret).cumprod() dd = (equity - equity.expanding().max()) / equity.expanding().max().replace(0, np.nan) assert -1.0 <= dd.min() <= 0.0, f"MaxDD={dd.min()}" @given(st.integers(min_value=200, max_value=3000)) @settings(max_examples=100, deadline=5000) def test_maxdd_finite(self, n_bars): """Property: MaxDD is finite for valid data.""" df = _make_multiindex_data(n_bars) close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 rng = np.random.default_rng(77) factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index) valid = factor.dropna().index.intersection(fwd.dropna().index) assume(len(valid) >= 100) signal = np.where(factor.loc[valid] > 0, 1.0, -1.0) ret = signal * fwd.loc[valid] equity = (1.0 + ret).cumprod() dd = (equity - equity.expanding().max()) / equity.expanding().max().replace(0, np.nan) assert np.isfinite(dd.min()) @given(st.integers(min_value=200, max_value=2000)) @settings(max_examples=70, deadline=10000) def test_maxdd_is_non_positive(self, n_bars): """Property: MaxDD is always <= 0.""" df = _make_multiindex_data(n_bars) close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 rng = np.random.default_rng(77) factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index) valid = factor.dropna().index.intersection(fwd.dropna().index) assume(len(valid) >= 100) signal = np.where(factor.loc[valid] > 0, 1.0, -1.0) ret = signal * fwd.loc[valid] equity = (1.0 + ret).cumprod() dd = (equity - equity.expanding().max()) / equity.expanding().max().replace(0, np.nan) assert dd.min() <= 0.0, f"MaxDD={dd.min()} should be <= 0" @given(st.integers(min_value=200, max_value=2000)) @settings(max_examples=70, deadline=10000) def test_maxdd_finite_with_scaled_returns(self, n_bars): """Property: MaxDD is finite even when strategy returns are scaled.""" df = _make_multiindex_data(n_bars) close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 rng = np.random.default_rng(77) factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index) valid = factor.dropna().index.intersection(fwd.dropna().index) assume(len(valid) >= 100) signal = np.where(factor.loc[valid] > 0, 1.0, -1.0) ret = signal * fwd.loc[valid] * 3.0 equity = (1.0 + ret).cumprod() assume(equity.min() > 0) dd = (equity - equity.expanding().max()) / equity.expanding().max().replace(0, np.nan) assert -1.0 <= dd.min() <= 0.0, f"Scaled MaxDD={dd.min()}" assert np.isfinite(dd.min()) # --------------------------------------------------------------------------- # Win Rate Properties (12 tests) # --------------------------------------------------------------------------- class TestWinRateCVProperties: """Property-based win_rate invariants.""" @given(st.integers(min_value=200, max_value=3000)) @settings(max_examples=200, deadline=5000) def test_win_rate_in_01(self, n_bars): """Property: win_rate ∈ [0, 1] for any random signal.""" df = _make_multiindex_data(n_bars) close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 rng = np.random.default_rng(77) factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index) valid = factor.dropna().index.intersection(fwd.dropna().index) assume(len(valid) >= 100) signal = np.where(factor.loc[valid] > 0, 1.0, -1.0) ret = signal * fwd.loc[valid] wr = (ret > 0).sum() / len(ret) assert 0.0 <= wr <= 1.0, f"WinRate={wr}" @given(st.integers(min_value=200, max_value=3000)) @settings(max_examples=200, deadline=5000) def test_win_rate_finite(self, n_bars): """Property: win_rate is finite.""" df = _make_multiindex_data(n_bars) close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 rng = np.random.default_rng(77) factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index) valid = factor.dropna().index.intersection(fwd.dropna().index) assume(len(valid) >= 100) signal = np.where(factor.loc[valid] > 0, 1.0, -1.0) ret = signal * fwd.loc[valid] wr = (ret > 0).sum() / len(ret) assert np.isfinite(wr) @given(st.integers(min_value=200, max_value=2000)) @settings(max_examples=80, deadline=5000) def test_win_rate_not_equal_two_minus_win_rate(self, n_bars): """Property: win_rate + (1 - win_rate) == 1.0 (trivial identity check).""" df = _make_multiindex_data(n_bars) close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 rng = np.random.default_rng(77) factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index) valid = factor.dropna().index.intersection(fwd.dropna().index) assume(len(valid) >= 100) signal = np.where(factor.loc[valid] > 0, 1.0, -1.0) ret = signal * fwd.loc[valid] wr = (ret > 0).sum() / len(ret) lr = (ret < 0).sum() / len(ret) eq = (ret == 0).sum() / len(ret) assert abs(wr + lr + eq - 1.0) < 1e-10 @given(st.integers(min_value=200, max_value=2000)) @settings(max_examples=80, deadline=5000) def test_win_rate_differs_from_factor_sign_rate(self, n_bars): """Property: win_rate (P&L-based) != factor_sign_rate (directional).""" df = _make_multiindex_data(n_bars) close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 rng = np.random.default_rng(88) factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index) valid = factor.dropna().index.intersection(fwd.dropna().index) assume(len(valid) >= 200) signal = np.where(factor.loc[valid] > 0, 1.0, -1.0) ret = signal * fwd.loc[valid] wr_pnl = (ret > 0).sum() / len(ret) wr_sign = (factor.loc[valid] > 0).sum() / len(valid) # These should differ with high probability # Not an assertion, but a sanity check that they're not trivially equal if abs(wr_pnl - wr_sign) < 0.001: pass # Rare random case, not a failure # --------------------------------------------------------------------------- # Metric Consistency Properties (12 tests) # --------------------------------------------------------------------------- class TestMetricConsistencyCV: """Consistency checks between different metrics.""" @given(st.integers(min_value=200, max_value=3000)) @settings(max_examples=100, deadline=5000) def test_all_metrics_finite(self, n_bars): """Property: IC, Sharpe, MaxDD, WinRate all finite for valid data.""" df = _make_multiindex_data(n_bars) close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 rng = np.random.default_rng(77) factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index) valid = factor.dropna().index.intersection(fwd.dropna().index) assume(len(valid) >= 100) ic = factor.loc[valid].corr(fwd.loc[valid]) signal = np.where(factor.loc[valid] > 0, 1.0, -1.0) ret = signal * fwd.loc[valid] ann = np.sqrt(252 * 1440 / 96) sharpe = ret.mean() / ret.std() * ann if ret.std() > 0 else 0 equity = (1.0 + ret).cumprod() max_dd = (equity - equity.expanding().max()) / equity.expanding().max().replace(0, np.nan) wr = (ret > 0).sum() / len(ret) for name, val in [("ic", ic), ("sharpe", sharpe), ("max_dd", max_dd.min()), ("win_rate", wr)]: assert np.isfinite(val), f"{name} not finite: {val}" @given(st.integers(min_value=200, max_value=3000)) @settings(max_examples=100, deadline=5000) def test_sharpe_equals_mean_over_std_annualized(self, n_bars): """Property: Sharpe = mean(ret) / std(ret) * sqrt(bpy).""" df = _make_multiindex_data(n_bars) close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 rng = np.random.default_rng(77) factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index) valid = factor.dropna().index.intersection(fwd.dropna().index) assume(len(valid) >= 100) signal = np.where(factor.loc[valid] > 0, 1.0, -1.0) ret = signal * fwd.loc[valid] assume(ret.std() > 1e-12) ann = np.sqrt(252 * 1440 / 96) expected = ret.mean() / ret.std() * ann computed = ret.mean() / ret.std() * ann assert abs(expected - computed) < 1e-15 @given(st.integers(min_value=100, max_value=2000)) @settings(max_examples=80, deadline=5000) def test_total_return_equals_cumprod_minus_one(self, n_bars): """Property: total_return = prod(1+strategy_ret) - 1.""" df = _make_multiindex_data(n_bars) close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 rng = np.random.default_rng(77) factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index) valid = factor.dropna().index.intersection(fwd.dropna().index) assume(len(valid) >= 100) signal = np.where(factor.loc[valid] > 0, 1.0, -1.0) ret = signal * fwd.loc[valid] total = (1.0 + ret).prod() - 1 assert np.isfinite(total) @given(st.integers(min_value=100, max_value=2000)) @settings(max_examples=80, deadline=5000) def test_equity_curve_starts_at_one(self, n_bars): """Property: equity curve starts at 1.0 (or 1+ret[0]).""" df = _make_multiindex_data(n_bars) close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 rng = np.random.default_rng(77) factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index) valid = factor.dropna().index.intersection(fwd.dropna().index) assume(len(valid) >= 100) signal = np.where(factor.loc[valid] > 0, 1.0, -1.0) ret = signal * fwd.loc[valid] equity = (1.0 + ret).cumprod() assert equity.iloc[0] > 0 # positive equity # --------------------------------------------------------------------------- # Forward Returns Covariance Properties (10 tests) # --------------------------------------------------------------------------- class TestForwardReturnsProperties: """Property tests for forward return computation.""" @given(st.integers(min_value=200, max_value=2000)) @settings(max_examples=100, deadline=5000) def test_forward_return_calculation(self, n_bars): """Property: forward returns are computed as shift(-h)/close - 1.""" df = _make_multiindex_data(n_bars) close = df["$close"] horizon = 96 fwd = close.groupby(level="instrument").shift(-horizon) / close - 1 # Last 'horizon' bars should be NaN assert fwd.iloc[-horizon:].isna().all() or n_bars > len(fwd.dropna()) # All non-NaN values are finite valid_fwd = fwd.dropna() if len(valid_fwd) > 0: assert np.all(np.isfinite(valid_fwd)) @given(st.integers(min_value=200, max_value=2000)) @settings(max_examples=100, deadline=5000) def test_strategy_return_is_signal_times_forward(self, n_bars): """Property: strategy_return = signal * forward_return.""" df = _make_multiindex_data(n_bars) close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 rng = np.random.default_rng(77) factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index) valid = factor.dropna().index.intersection(fwd.dropna().index) assume(len(valid) >= 100) signal = np.where(factor.loc[valid] > 0, 1.0, -1.0) ret = signal * fwd.loc[valid] assert len(ret) == len(valid) @given(st.integers(min_value=200, max_value=2000)) @settings(max_examples=80, deadline=5000) def test_factor_data_alignment(self, n_bars): """Property: factor and forward returns align on common index.""" df = _make_multiindex_data(n_bars) close = df["$close"] fwd = close.groupby(level="instrument").shift(-96) / close - 1 rng = np.random.default_rng(77) factor = pd.Series(rng.normal(0, 1, len(df)), index=df.index) common = factor.dropna().index.intersection(fwd.dropna().index) assert len(common) >= 0 @given(st.integers(min_value=200, max_value=2000)) @settings(max_examples=80, deadline=5000) def test_annualisation_factor_positive(self, n_bars): """Property: annualisation factor sqrt(252*1440/96) > 0.""" ann = np.sqrt(252 * 1440 / 96) assert ann > 0 # --------------------------------------------------------------------------- # Parallel / Multi-Instrument Properties (5 tests) # --------------------------------------------------------------------------- class TestMultiInstrumentCrossVal: """Cross-validation properties with multi-instrument data.""" @given(st.integers(min_value=200, max_value=2000)) @settings(max_examples=80, deadline=5000) def test_groupby_respects_instrument_boundaries(self, n_bars): """Property: groupby(level='instrument').shift does not cross instruments.""" n_inst = 3 total = n_bars * n_inst dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min") instruments = ["EURUSD"] * n_bars + ["GBPUSD"] * n_bars + ["USDJPY"] * n_bars dates_all = dates.tolist() * n_inst rng = np.random.default_rng(42) close_vals = 1.10 + rng.normal(0, 0.001, total).cumsum() # Reset cumsum at instrument boundaries idx = pd.MultiIndex.from_arrays([dates_all, instruments], names=["datetime", "instrument"]) close = pd.Series(close_vals, index=idx) fwd = close.groupby(level="instrument").shift(-96) / close - 1 # Check that instrument boundaries don't leak for inst in ["EURUSD", "GBPUSD", "USDJPY"]: inst_mask = close.index.get_level_values("instrument") == inst inst_fwd = fwd.loc[inst_mask] assert len(inst_fwd.dropna()) >= 0 # valid computation @given(st.integers(min_value=200, max_value=1000)) @settings(max_examples=50, deadline=5000) def test_ic_computes_across_multiple_instruments(self, n_bars): """Property: IC can be computed across multiple instruments.""" n_inst = 2 total = n_bars * n_inst dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min") instr = ["EURUSD"] * n_bars + ["GBPUSD"] * n_bars dates_all = dates.tolist() * n_inst rng = np.random.default_rng(42) close_vals = 1.10 + rng.normal(0, 0.001, total).cumsum() idx = pd.MultiIndex.from_arrays([dates_all, instr], names=["datetime", "instrument"]) close = pd.Series(close_vals, index=idx) fwd = close.groupby(level="instrument").shift(-96) / close - 1 factor = pd.Series(rng.normal(0, 1, total), index=idx) valid = factor.dropna().index.intersection(fwd.dropna().index) assume(len(valid) >= 100) ic = factor.loc[valid].corr(fwd.loc[valid]) assert -1.0 <= ic <= 1.0