Files
NexQuant/test/qlib/test_cross_validation.py
TPTBusiness 827f80ce2e test: 343 deep hypothesis property-based tests across engine, DB, risk, ground truth, robustness, CV
- Backtest engine: 68 tests (IC symmetry, Sharpe formula, MaxDD bounds, cost monotonicity)
- Results DB: 78 tests (add_factor idempotence, metric roundtrip, sorting, persistence)
- Risk management: 71 tests (correlation PSD, MV weights, RP convergence, threshold checks)
- Ground truth: 44 tests (Sharpe sign, MaxDD, win_rate, signal invariants)
- Robustness: 44 tests (slippage, latency, MC reshuffle, OOS stress, random data)
- Cross-validation: 38 tests (IC ∈ [-1,1], scaling invariance, multi-instrument)
2026-05-10 23:42:46 +02:00

741 lines
34 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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