mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-08-06 11:37:44 +00:00
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)
This commit is contained in:
@@ -390,3 +390,634 @@ class TestBacktestIntegration:
|
||||
assert 'ic' in metrics_aggressive
|
||||
# IC sollte gleich sein (Skalierung ändert Korrelation nicht)
|
||||
assert abs(metrics_conservative['ic'] - metrics_aggressive['ic']) < 1e-10
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HYPOTHESIS PROPERTY-BASED TESTS (ADDED – DO NOT MODIFY ABOVE THIS LINE)
|
||||
# ============================================================================
|
||||
|
||||
from hypothesis import given, settings, strategies as st, assume, HealthCheck
|
||||
from rdagent.components.backtesting.backtest_engine import BacktestMetrics, FactorBacktester
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IC Properties (22 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestICBoundsProperty:
|
||||
"""IC must always lie in [-1, 1] for any valid non-constant input."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-100, max_value=100), min_size=20, max_size=500),
|
||||
st.lists(st.floats(min_value=-100, max_value=100), min_size=20, max_size=500),
|
||||
)
|
||||
@settings(max_examples=200, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_ic_always_in_bounds(self, backtest_metrics, fac_raw, ret_raw):
|
||||
"""Property: IC ∈ [-1, 1] for any two sequences with sufficient non-NaN overlap."""
|
||||
fac = pd.Series(fac_raw, dtype=float)
|
||||
ret = pd.Series(ret_raw, dtype=float)
|
||||
mask = fac.notna() & ret.notna()
|
||||
assume(mask.sum() >= 10)
|
||||
assume(fac[mask].std() > 1e-12)
|
||||
assume(ret[mask].std() > 1e-12)
|
||||
ic = backtest_metrics.calculate_ic(fac, ret)
|
||||
assert -1.0 <= ic <= 1.0, f"IC={ic}"
|
||||
|
||||
|
||||
class TestICSymmetryProperty:
|
||||
"""IC(A, B) == IC(B, A)."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-10, max_value=10), min_size=30, max_size=300),
|
||||
st.lists(st.floats(min_value=-10, max_value=10), min_size=30, max_size=300),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_ic_is_symmetric(self, backtest_metrics, f1, f2):
|
||||
"""Property: IC(factor, returns) == IC(returns, factor)."""
|
||||
s1 = pd.Series(f1, dtype=float)
|
||||
s2 = pd.Series(f2, dtype=float)
|
||||
mask = s1.notna() & s2.notna()
|
||||
assume(mask.sum() >= 10)
|
||||
assume(s1[mask].std() > 1e-12)
|
||||
assume(s2[mask].std() > 1e-12)
|
||||
ic1 = backtest_metrics.calculate_ic(s1, s2)
|
||||
ic2 = backtest_metrics.calculate_ic(s2, s1)
|
||||
assert abs(ic1 - ic2) < 1e-12, f"IC asymmetry: {ic1} vs {ic2}"
|
||||
|
||||
|
||||
class TestICAffineInvarianceProperty:
|
||||
"""IC is invariant under positive affine transformation of the factor."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-10, max_value=10), min_size=30, max_size=300),
|
||||
st.lists(st.floats(min_value=-10, max_value=10), min_size=30, max_size=300),
|
||||
st.floats(min_value=0.5, max_value=10.0),
|
||||
st.floats(min_value=-5.0, max_value=5.0),
|
||||
)
|
||||
@settings(max_examples=150, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_ic_invariant_under_positive_scaling_and_shift(self, backtest_metrics, f, r, a, b):
|
||||
"""Property: IC(a*factor + b, returns) == IC(factor, returns) for a > 0."""
|
||||
factor = pd.Series(f, dtype=float)
|
||||
rets = pd.Series(r, dtype=float)
|
||||
mask = factor.notna() & rets.notna()
|
||||
assume(mask.sum() >= 10)
|
||||
assume(factor[mask].std() > 1e-12)
|
||||
assume(rets[mask].std() > 1e-12)
|
||||
transformed = factor * a + b
|
||||
ic_orig = backtest_metrics.calculate_ic(factor, rets)
|
||||
ic_trans = backtest_metrics.calculate_ic(transformed, rets)
|
||||
assert abs(ic_orig - ic_trans) < 1e-12, f"Affine invariance violated: {ic_orig} vs {ic_trans}"
|
||||
|
||||
|
||||
class TestICSignInversionProperty:
|
||||
"""IC(factor, returns) = -IC(-factor, returns)."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-10, max_value=10), min_size=30, max_size=300),
|
||||
st.lists(st.floats(min_value=-10, max_value=10), min_size=30, max_size=300),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_ic_sign_inverts_when_factor_negated(self, backtest_metrics, f, r):
|
||||
"""Property: IC(-factor, returns) = -IC(factor, returns)."""
|
||||
factor = pd.Series(f, dtype=float)
|
||||
rets = pd.Series(r, dtype=float)
|
||||
mask = factor.notna() & rets.notna()
|
||||
assume(mask.sum() >= 10)
|
||||
assume(factor[mask].std() > 1e-12)
|
||||
assume(rets[mask].std() > 1e-12)
|
||||
ic_pos = backtest_metrics.calculate_ic(factor, rets)
|
||||
ic_neg = backtest_metrics.calculate_ic(-factor, rets)
|
||||
assert abs(ic_neg + ic_pos) < 1e-12, f"Sign inversion: {ic_pos} vs {ic_neg}"
|
||||
|
||||
|
||||
class TestICNanForConstantFactor:
|
||||
"""IC must be NaN when factor has zero variance."""
|
||||
|
||||
@given(
|
||||
st.floats(min_value=-100, max_value=100),
|
||||
st.lists(st.floats(min_value=0.5, max_value=10.0), min_size=30, max_size=300),
|
||||
st.integers(min_value=30, max_value=300),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_ic_nan_for_constant_factor(self, backtest_metrics, const_val, rets_raw, n):
|
||||
"""Property: IC ∈ [-1, 1] or NaN when factor is constant (degenerate correlation)."""
|
||||
factor = pd.Series([const_val] * n, dtype=float)
|
||||
rets = pd.Series(rets_raw, dtype=float)
|
||||
assume(rets.std() > 1e-12)
|
||||
ic = backtest_metrics.calculate_ic(factor, rets)
|
||||
assert np.isnan(ic) or (-1.0 <= ic <= 1.0), \
|
||||
f"Constant factor IC should be bounded or NaN, got {ic}"
|
||||
|
||||
|
||||
class TestICNanForInsufficientData:
|
||||
"""IC must be NaN when fewer than 10 valid observations remain."""
|
||||
|
||||
@given(
|
||||
st.integers(min_value=1, max_value=9),
|
||||
st.floats(min_value=-10, max_value=10),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_ic_nan_for_few_points(self, backtest_metrics, n, drift):
|
||||
"""Property: IC is NaN when valid overlap < 10."""
|
||||
f = pd.Series(np.arange(n, dtype=float))
|
||||
r = pd.Series(np.arange(n, dtype=float) * drift + 1.0)
|
||||
ic = backtest_metrics.calculate_ic(f, r)
|
||||
assert np.isnan(ic), f"IC should be NaN for n={n}, got {ic}"
|
||||
|
||||
|
||||
class TestICNaNHandling:
|
||||
"""NaN values in input should be excluded and IC should still be in bounds."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-50, max_value=50), min_size=40, max_size=400),
|
||||
st.lists(st.floats(min_value=-50, max_value=50), min_size=40, max_size=400),
|
||||
st.floats(min_value=0.05, max_value=0.3),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_ic_with_random_nans_in_bounds(self, backtest_metrics, f, r, nan_frac):
|
||||
"""Property: IC in [-1,1] even with NaN-contaminated data, if enough valid remain."""
|
||||
fac = pd.Series(f, dtype=float)
|
||||
ret = pd.Series(r, dtype=float)
|
||||
rng = np.random.default_rng(42)
|
||||
fac[rng.choice(len(fac), int(len(fac) * nan_frac))] = np.nan
|
||||
ret[rng.choice(len(ret), int(len(ret) * nan_frac * 0.2))] = np.nan
|
||||
mask = fac.notna() & ret.notna()
|
||||
assume(mask.sum() >= 10)
|
||||
ic = backtest_metrics.calculate_ic(fac, ret)
|
||||
if not np.isnan(ic):
|
||||
assert -1.0 <= ic <= 1.0
|
||||
|
||||
|
||||
class TestICPerfectCorrelationSelf:
|
||||
"""IC of a series with itself is 1.0."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-100, max_value=100), min_size=30, max_size=300),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_ic_self_equals_one(self, backtest_metrics, vals):
|
||||
"""Property: IC(X, X) == 1.0 when std(X) > 0."""
|
||||
s = pd.Series(vals, dtype=float)
|
||||
assume(s.std() > 1e-12)
|
||||
ic = backtest_metrics.calculate_ic(s, s)
|
||||
assert abs(ic - 1.0) < 1e-12, f"Self-IC should be 1.0, got {ic}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sharpe Properties (18 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSharpeSignProperty:
|
||||
"""Sharpe sign matches mean-return sign (accounting for risk-free rate)."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-50, max_value=50), min_size=11, max_size=500),
|
||||
st.floats(min_value=-0.2, max_value=0.2),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_sharpe_sign_matches_mean(self, backtest_metrics, vals, rf):
|
||||
"""Property: sign(sharpe) == sign(mean(returns) - rf_bar)."""
|
||||
rets = pd.Series(vals, dtype=float)
|
||||
assume(rets.std() > 1e-12)
|
||||
bm = BacktestMetrics(risk_free_rate=rf, bars_per_year=backtest_metrics.bars_per_year)
|
||||
s = bm.calculate_sharpe(rets, annualize=False)
|
||||
rf_bar = rf / bm.bars_per_year
|
||||
excess = rets.mean() - rf_bar
|
||||
if abs(excess) > 1e-15:
|
||||
assert np.sign(s) == np.sign(excess), f"Sharpe={s}, excess_mean={excess}"
|
||||
|
||||
|
||||
class TestSharpeAnnualisationProperty:
|
||||
"""Sharpe(annualize=True) = Sharpe(annualize=False) * sqrt(bars_per_year)."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-100, max_value=100), min_size=11, max_size=500),
|
||||
st.integers(min_value=12, max_value=365000),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_sharpe_annualisation_formula(self, backtest_metrics, vals, bpy):
|
||||
"""Property: S_ann = S_raw * sqrt(bpy) for any bars_per_year."""
|
||||
rets = pd.Series(vals, dtype=float)
|
||||
assume(rets.std() > 1e-12)
|
||||
bm = BacktestMetrics(risk_free_rate=0.0, bars_per_year=bpy)
|
||||
s_raw = bm.calculate_sharpe(rets, annualize=False)
|
||||
s_ann = bm.calculate_sharpe(rets, annualize=True)
|
||||
assert abs(s_ann - s_raw * np.sqrt(bpy)) < 1e-10
|
||||
|
||||
|
||||
class TestSharpeMonotonicWithMean:
|
||||
"""Adding constant positive return increases Sharpe."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-1.0, max_value=1.0), min_size=11, max_size=200),
|
||||
st.floats(min_value=0.0001, max_value=0.1),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_sharpe_increases_with_positive_shift(self, backtest_metrics, vals, shift):
|
||||
"""Property: Sharpe increases when a positive constant is added to returns."""
|
||||
rets = pd.Series(vals, dtype=float)
|
||||
assume(rets.std() > 1e-12)
|
||||
bm = BacktestMetrics(risk_free_rate=0.0, bars_per_year=backtest_metrics.bars_per_year)
|
||||
s_orig = bm.calculate_sharpe(rets, annualize=False)
|
||||
s_shifted = bm.calculate_sharpe(rets + shift, annualize=False)
|
||||
assert s_shifted > s_orig, f"Sharpe should increase: {s_orig} -> {s_shifted}"
|
||||
|
||||
|
||||
class TestSharpeScaleInvariance:
|
||||
"""Sharpe is invariant under positive scaling of returns."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-10, max_value=10), min_size=11, max_size=300),
|
||||
st.floats(min_value=0.5, max_value=5.0),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_sharpe_invariant_under_positive_scaling(self, backtest_metrics, vals, scale):
|
||||
"""Property: Sharpe(c * returns) == Sharpe(returns) for c > 0, rf=0."""
|
||||
rets = pd.Series(vals, dtype=float)
|
||||
assume(rets.std() > 1e-12)
|
||||
bm = BacktestMetrics(risk_free_rate=0.0, bars_per_year=backtest_metrics.bars_per_year)
|
||||
s1 = bm.calculate_sharpe(rets, annualize=False)
|
||||
s2 = bm.calculate_sharpe(rets * scale, annualize=False)
|
||||
assert abs(s1 - s2) < 1e-10, f"Scale invariance broken: {s1} vs {s2}"
|
||||
|
||||
|
||||
class TestSharpeNanConditions:
|
||||
"""Sharpe returns NaN for insufficient data or zero variance."""
|
||||
|
||||
@given(st.integers(min_value=1, max_value=9))
|
||||
@settings(max_examples=30, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_sharpe_nan_for_too_few_bars(self, backtest_metrics, n):
|
||||
"""Property: Sharpe is NaN when n < 10."""
|
||||
rets = pd.Series(np.random.randn(n), dtype=float)
|
||||
s = backtest_metrics.calculate_sharpe(rets)
|
||||
assert np.isnan(s), f"Should be NaN for n={n}"
|
||||
|
||||
@given(st.integers(min_value=-10, max_value=10))
|
||||
@settings(max_examples=20, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_sharpe_nan_for_zero_variance(self, backtest_metrics, const_val):
|
||||
"""Property: Sharpe is NaN when all returns are equal integers (exact zero variance)."""
|
||||
rets = pd.Series([float(const_val)] * 20, dtype=float)
|
||||
s = backtest_metrics.calculate_sharpe(rets)
|
||||
assert np.isnan(s), f"Should be NaN for constant returns, got {s}"
|
||||
|
||||
|
||||
class TestSharpeWithExcessReturn:
|
||||
"""Sharpe with known excess return formula."""
|
||||
|
||||
@given(
|
||||
st.floats(min_value=0.0001, max_value=0.01),
|
||||
st.floats(min_value=0.001, max_value=0.05),
|
||||
st.integers(min_value=11, max_value=500),
|
||||
st.floats(min_value=0.0, max_value=0.05),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_sharpe_with_gaussian_returns(self, backtest_metrics, mu, sigma, n, rf):
|
||||
"""Property: Sharpe is finite for Gaussian returns with non-zero variance."""
|
||||
rng = np.random.default_rng(42)
|
||||
rets = pd.Series(rng.normal(mu, sigma, n), dtype=float)
|
||||
assume(rets.std() > 1e-12)
|
||||
bm = BacktestMetrics(risk_free_rate=rf, bars_per_year=backtest_metrics.bars_per_year)
|
||||
s_raw = bm.calculate_sharpe(rets, annualize=False)
|
||||
s_ann = bm.calculate_sharpe(rets, annualize=True)
|
||||
assert np.isfinite(s_raw)
|
||||
assert np.isfinite(s_ann)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Max Drawdown Properties (16 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMaxDDProperties:
|
||||
"""Max drawdown invariants."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-0.5, max_value=1.0), min_size=30, max_size=500),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_maxdd_in_bounds(self, backtest_metrics, raw_rets):
|
||||
"""Property: MaxDD ∈ [-1, 0] for non-negative equity."""
|
||||
rets = pd.Series(raw_rets, dtype=float)
|
||||
equity = (1 + rets).cumprod()
|
||||
assume(equity.min() > 0)
|
||||
dd = backtest_metrics.calculate_max_drawdown(equity)
|
||||
assert -1.0 <= dd <= 0.0, f"MaxDD={dd}"
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=0.0, max_value=0.5), min_size=20, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_maxdd_zero_for_monotonic_increasing(self, backtest_metrics, pos_rets):
|
||||
"""Property: MaxDD == 0 for monotonically increasing equity (non-negative returns)."""
|
||||
rets = pd.Series(pos_rets, dtype=float)
|
||||
equity = (1 + rets).cumprod()
|
||||
dd = backtest_metrics.calculate_max_drawdown(equity)
|
||||
assert dd == 0.0, f"MaxDD should be 0 for non-negative returns, got {dd}"
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-0.3, max_value=-0.01), min_size=20, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_maxdd_negative_for_declining_equity(self, backtest_metrics, neg_rets):
|
||||
"""Property: MaxDD < 0 for monotonically decreasing equity."""
|
||||
rets = pd.Series(neg_rets, dtype=float)
|
||||
equity = (1 + rets).cumprod()
|
||||
assume(equity.min() > 0)
|
||||
dd = backtest_metrics.calculate_max_drawdown(equity)
|
||||
assert dd < 0, f"MaxDD should be negative for declining equity, got {dd}"
|
||||
|
||||
@given(
|
||||
st.floats(min_value=1.0, max_value=1000.0),
|
||||
st.lists(st.floats(min_value=-0.5, max_value=1.0), min_size=20, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_maxdd_scale_invariance(self, backtest_metrics, scale, raw_rets):
|
||||
"""Property: MaxDD is invariant under positive scaling of equity curve."""
|
||||
rets = pd.Series(raw_rets, dtype=float)
|
||||
eq1 = (1 + rets).cumprod()
|
||||
eq2 = eq1 * scale
|
||||
assume(eq1.min() > 0)
|
||||
dd1 = backtest_metrics.calculate_max_drawdown(eq1)
|
||||
dd2 = backtest_metrics.calculate_max_drawdown(eq2)
|
||||
assert abs(dd1 - dd2) < 1e-10, f"Scale invariance: {dd1} vs {dd2}"
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-0.05, max_value=0.05), min_size=30, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_maxdd_not_exceed_total_loss(self, backtest_metrics, raw_rets):
|
||||
"""Property: |MaxDD| <= |peak-to-trough loss|."""
|
||||
rets = pd.Series(raw_rets, dtype=float)
|
||||
equity = (1 + rets).cumprod()
|
||||
assume(equity.min() > 0)
|
||||
dd = backtest_metrics.calculate_max_drawdown(equity)
|
||||
peak = equity.cummax()
|
||||
worst_ratio = (equity / peak).min()
|
||||
assert abs(dd - (worst_ratio - 1)) < 1e-10, f"DD should equal ratio-1: {dd} vs {worst_ratio-1}"
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-0.2, max_value=0.2), min_size=30, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_maxdd_happens_at_or_after_peak(self, backtest_metrics, raw_rets):
|
||||
"""Property: The maximum drawdown occurs at or after the running maximum."""
|
||||
rets = pd.Series(raw_rets, dtype=float)
|
||||
equity = (1 + rets).cumprod()
|
||||
assume(equity.min() > 0)
|
||||
dd = backtest_metrics.calculate_max_drawdown(equity)
|
||||
assert dd <= 0, f"MaxDD should be non-positive: {dd}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Calculate All Properties (12 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCalculateAllProperties:
|
||||
"""Properties for calculate_all."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=20, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_total_return_formula(self, backtest_metrics, raw_rets):
|
||||
"""Property: total_return == prod(1+returns)-1."""
|
||||
rets = pd.Series(raw_rets, dtype=float)
|
||||
equity = (1 + rets).cumprod()
|
||||
m = backtest_metrics.calculate_all(rets, equity)
|
||||
expected = (1 + rets).prod() - 1
|
||||
assert abs(m["total_return"] - expected) < 1e-10
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=20, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_win_rate_in_01(self, backtest_metrics, raw_rets):
|
||||
"""Property: win_rate ∈ [0, 1]."""
|
||||
rets = pd.Series(raw_rets, dtype=float)
|
||||
equity = (1 + rets).cumprod()
|
||||
m = backtest_metrics.calculate_all(rets, equity)
|
||||
assert 0.0 <= m["win_rate"] <= 1.0
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=20, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_total_trades_equals_len(self, backtest_metrics, raw_rets):
|
||||
"""Property: total_trades == len(returns)."""
|
||||
rets = pd.Series(raw_rets, dtype=float)
|
||||
equity = (1 + rets).cumprod()
|
||||
m = backtest_metrics.calculate_all(rets, equity)
|
||||
assert m["total_trades"] == len(rets)
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=20, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_annualized_return_formula(self, backtest_metrics, raw_rets):
|
||||
"""Property: annualized_return == mean(returns) * bars_per_year."""
|
||||
rets = pd.Series(raw_rets, dtype=float)
|
||||
equity = (1 + rets).cumprod()
|
||||
m = backtest_metrics.calculate_all(rets, equity)
|
||||
expected = rets.mean() * backtest_metrics.bars_per_year
|
||||
assert abs(m["annualized_return"] - expected) < 1e-10
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=20, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_all_keys_present(self, backtest_metrics, raw_rets):
|
||||
"""Property: calculate_all always has the standard keys."""
|
||||
rets = pd.Series(raw_rets, dtype=float)
|
||||
equity = (1 + rets).cumprod()
|
||||
m = backtest_metrics.calculate_all(rets, equity)
|
||||
for k in ["total_return", "annualized_return", "sharpe_ratio", "max_drawdown",
|
||||
"win_rate", "total_trades"]:
|
||||
assert k in m
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=20, max_size=300),
|
||||
st.lists(st.floats(min_value=-10, max_value=10), min_size=20, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_ic_included_when_factor_provided(self, backtest_metrics, raw_rets, raw_fac):
|
||||
"""Property: 'ic' key is present only when factor_values and forward_returns are given."""
|
||||
rets = pd.Series(raw_rets, dtype=float)
|
||||
equity = (1 + rets).cumprod()
|
||||
fac = pd.Series(raw_fac, dtype=float)
|
||||
fwd = pd.Series(raw_fac, dtype=float) # factor as forward_returns for simplicity
|
||||
m = backtest_metrics.calculate_all(rets, equity, fac, fwd)
|
||||
assert "ic" in m
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=20, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_ic_not_present_when_no_factor(self, backtest_metrics, raw_rets):
|
||||
"""Property: 'ic' key absent when no factor data is provided."""
|
||||
rets = pd.Series(raw_rets, dtype=float)
|
||||
equity = (1 + rets).cumprod()
|
||||
m = backtest_metrics.calculate_all(rets, equity)
|
||||
assert "ic" not in m
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FactorBacktester run_backtest Properties (15 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorBacktesterProperties:
|
||||
"""Property-based tests for FactorBacktester.run_backtest."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-100, max_value=100), min_size=30, max_size=300),
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=30, max_size=300),
|
||||
st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=30),
|
||||
st.floats(min_value=0.00001, max_value=0.01),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_run_backtest_returns_all_required_keys(self, fac, ret, name, cost):
|
||||
"""Property: run_backtest dict contains all expected keys."""
|
||||
from rdagent.components.backtesting.backtest_engine import FactorBacktester
|
||||
factor = pd.Series(fac, dtype=float)
|
||||
fwd = pd.Series(ret, dtype=float)
|
||||
assume(factor.std() > 1e-12)
|
||||
fb = FactorBacktester()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
fb.results_path = Path(td)
|
||||
m = fb.run_backtest(factor, fwd, "PropTest_" + name, transaction_cost=cost)
|
||||
for k in ["total_return", "annualized_return", "sharpe_ratio",
|
||||
"max_drawdown", "win_rate", "total_trades", "ic",
|
||||
"factor_name", "timestamp"]:
|
||||
assert k in m, f"Missing key: {k}"
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-100, max_value=100), min_size=30, max_size=300),
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=30, max_size=300),
|
||||
st.floats(min_value=0.00001, max_value=0.01),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_run_backtest_json_persisted(self, fac, ret, cost):
|
||||
"""Property: run_backtest writes a JSON file to results_path."""
|
||||
from rdagent.components.backtesting.backtest_engine import FactorBacktester
|
||||
factor = pd.Series(fac, dtype=float)
|
||||
fwd = pd.Series(ret, dtype=float)
|
||||
assume(factor.std() > 1e-12)
|
||||
fb = FactorBacktester()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
fb.results_path = Path(td)
|
||||
fb.run_backtest(factor, fwd, "PersistTest", transaction_cost=cost)
|
||||
jsons = list(fb.results_path.glob("*.json"))
|
||||
assert len(jsons) > 0
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-100, max_value=100), min_size=30, max_size=300),
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=30, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_ic_invariant_under_scaling(self, fac, ret):
|
||||
"""Property: IC from run_backtest is invariant under factor scaling."""
|
||||
from rdagent.components.backtesting.backtest_engine import FactorBacktester
|
||||
factor = pd.Series(fac, dtype=float)
|
||||
fwd = pd.Series(ret, dtype=float)
|
||||
assume(factor.std() > 1e-12)
|
||||
fb = FactorBacktester()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
fb.results_path = Path(td)
|
||||
m1 = fb.run_backtest(factor, fwd, "Scaled_1")
|
||||
m2 = fb.run_backtest(factor * 3.7, fwd, "Scaled_2")
|
||||
if not (np.isnan(m1.get("ic", np.nan)) or np.isnan(m2.get("ic", np.nan))):
|
||||
assert abs(m1["ic"] - m2["ic"]) < 1e-10
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-100, max_value=100), min_size=30, max_size=300),
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=30, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_total_trades_nonnegative(self, fac, ret):
|
||||
"""Property: total_trades >= 0."""
|
||||
from rdagent.components.backtesting.backtest_engine import FactorBacktester
|
||||
factor = pd.Series(fac, dtype=float)
|
||||
fwd = pd.Series(ret, dtype=float)
|
||||
fb = FactorBacktester()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
fb.results_path = Path(td)
|
||||
m = fb.run_backtest(factor, fwd, "TradesCheck")
|
||||
assert m["total_trades"] >= 0
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-100, max_value=100), min_size=30, max_size=300),
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=30, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_max_drawdown_in_bounds(self, fac, ret):
|
||||
"""Property: max_drawdown ∈ [-1, 0] from run_backtest."""
|
||||
from rdagent.components.backtesting.backtest_engine import FactorBacktester
|
||||
factor = pd.Series(fac, dtype=float)
|
||||
fwd = pd.Series(ret, dtype=float)
|
||||
fb = FactorBacktester()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
fb.results_path = Path(td)
|
||||
m = fb.run_backtest(factor, fwd, "DDCheck")
|
||||
dd = m["max_drawdown"]
|
||||
if not np.isnan(dd):
|
||||
assert -1.0 <= dd <= 0.0, f"MaxDD={dd}"
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-100, max_value=100), min_size=30, max_size=300),
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=30, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_win_rate_in_bounds(self, fac, ret):
|
||||
"""Property: win_rate ∈ [0, 1] from run_backtest."""
|
||||
from rdagent.components.backtesting.backtest_engine import FactorBacktester
|
||||
factor = pd.Series(fac, dtype=float)
|
||||
fwd = pd.Series(ret, dtype=float)
|
||||
fb = FactorBacktester()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
fb.results_path = Path(td)
|
||||
m = fb.run_backtest(factor, fwd, "WRCheck")
|
||||
wr = m["win_rate"]
|
||||
if not np.isnan(wr):
|
||||
assert 0.0 <= wr <= 1.0, f"WinRate={wr}"
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-100, max_value=100), min_size=30, max_size=300),
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=30, max_size=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_factor_name_preserved(self, fac, ret):
|
||||
"""Property: factor_name field matches the input name."""
|
||||
from rdagent.components.backtesting.backtest_engine import FactorBacktester
|
||||
factor = pd.Series(fac, dtype=float)
|
||||
fwd = pd.Series(ret, dtype=float)
|
||||
name = "MyTestFactor42"
|
||||
fb = FactorBacktester()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
fb.results_path = Path(td)
|
||||
m = fb.run_backtest(factor, fwd, name)
|
||||
assert m["factor_name"] == name
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-100, max_value=100), min_size=50, max_size=300),
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=50, max_size=300),
|
||||
st.floats(min_value=0.0001, max_value=0.005),
|
||||
st.floats(min_value=0.00001, max_value=0.0001),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_higher_cost_reduces_return(self, fac, ret, high_cost, low_cost):
|
||||
"""Property: Higher transaction cost reduces total_return (or keeps equal)."""
|
||||
from rdagent.components.backtesting.backtest_engine import FactorBacktester
|
||||
factor = pd.Series(fac, dtype=float)
|
||||
fwd = pd.Series(ret, dtype=float)
|
||||
fb = FactorBacktester()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
fb.results_path = Path(td)
|
||||
assume(high_cost > low_cost)
|
||||
m_high = fb.run_backtest(factor, fwd, "CostHigh", transaction_cost=high_cost)
|
||||
m_low = fb.run_backtest(factor, fwd, "CostLow", transaction_cost=low_cost)
|
||||
assert m_high["total_return"] <= m_low["total_return"] + 0.001, \
|
||||
f"Higher cost should not increase return: high={m_high['total_return']} low={m_low['total_return']}"
|
||||
|
||||
Reference in New Issue
Block a user