mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-08-09 13:00:56 +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']}"
|
||||
|
||||
@@ -487,3 +487,834 @@ class TestAddColumnIfNotExists:
|
||||
assert f"test_{col_type.lower()}" in cols
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HYPOTHESIS PROPERTY-BASED FUZZING TESTS (ADDED – DO NOT MODIFY ABOVE THIS LINE)
|
||||
# ============================================================================
|
||||
|
||||
from hypothesis import given, settings, strategies as st, assume, HealthCheck
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# add_factor Fuzzing (12 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorAddIdempotence:
|
||||
"""add_factor is idempotent: calling twice with same name returns same ID."""
|
||||
|
||||
@given(
|
||||
st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=122), min_size=1, max_size=50),
|
||||
st.text(min_size=1, max_size=20),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_add_factor_idempotent(self, name, ftype):
|
||||
"""Property: add_factor(name, type) always returns same ID for same name."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
id1 = db.add_factor(name, ftype)
|
||||
id2 = db.add_factor(name, ftype)
|
||||
assert id1 == id2, f"Idempotence violated: {id1} != {id2}"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.lists(
|
||||
st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=10),
|
||||
min_size=1, max_size=50, unique=True,
|
||||
),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_add_multiple_factors_all_unique_ids(self, names):
|
||||
"""Property: unique factor names produce unique IDs."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
ids = [db.add_factor(n, "test") for n in names]
|
||||
assert len(set(ids)) == len(names), "Unique names should yield unique IDs"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.text(min_size=1, max_size=30),
|
||||
st.integers(min_value=1, max_value=50),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_add_factor_always_positive_for_nonempty_name(self, name, repeat):
|
||||
"""Property: add_factor returns positive ID for any non-empty name."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
fid = db.add_factor(name, "t")
|
||||
assert fid > 0 or fid == -1, f"Unexpected id {fid}"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.text(min_size=1, max_size=30),
|
||||
st.text(min_size=1, max_size=20),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_add_factor_row_count_matches_calls(self, name, ftype):
|
||||
"""Property: after n calls with distinct names, factors table has exactly n rows."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
distinct_names = [f"{name}_{i}" for i in range(10)]
|
||||
for n in distinct_names:
|
||||
db.add_factor(n, ftype)
|
||||
c = db.conn.cursor()
|
||||
c.execute("SELECT COUNT(*) FROM factors")
|
||||
assert c.fetchone()[0] == 10
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# add_backtest Fuzzing (22 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAddBacktestFuzzing:
|
||||
"""Fuzz add_backtest with random metrics dictionaries."""
|
||||
|
||||
@given(
|
||||
st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=30),
|
||||
st.floats(min_value=-1.0, max_value=1.0),
|
||||
st.floats(min_value=-10.0, max_value=10.0),
|
||||
st.floats(min_value=-2.0, max_value=2.0),
|
||||
st.floats(min_value=-1.0, max_value=0.0),
|
||||
st.floats(min_value=0.0, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_add_backtest_with_random_metrics(self, name, ic, sharpe, ann_ret, dd, wr):
|
||||
"""Property: add_backtest always succeeds with random but valid metrics."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
bid = db.add_backtest(name, {
|
||||
"ic": ic, "sharpe_ratio": sharpe, "annualized_return": ann_ret,
|
||||
"max_drawdown": dd, "win_rate": wr,
|
||||
})
|
||||
assert bid > 0, f"add_backtest failed for name={name}"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=30),
|
||||
st.floats(min_value=-1.0, max_value=1.0),
|
||||
st.floats(min_value=-10.0, max_value=10.0),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_add_backtest_minimal_metrics(self, name, ic, sharpe):
|
||||
"""Property: add_backtest works with only ic and sharpe."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
bid = db.add_backtest(name, {"ic": ic, "sharpe_ratio": sharpe})
|
||||
assert bid > 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=30),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_add_backtest_empty_metrics(self, name):
|
||||
"""Property: add_backtest with empty dict still creates a record."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
bid = db.add_backtest(name, {})
|
||||
assert bid > 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=20),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_add_backtest_multiple_runs_sequential_ids(self, n_runs):
|
||||
"""Property: n runs for same factor produce n distinct monotonically increasing IDs."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
ids = []
|
||||
for i in range(n_runs):
|
||||
bid = db.add_backtest("MultiRun", {"ic": i / 100.0, "sharpe_ratio": 1.0})
|
||||
ids.append(bid)
|
||||
assert len(set(ids)) == n_runs, "IDs should be unique"
|
||||
assert sorted(ids) == ids, "IDs should be monotonically increasing"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.lists(
|
||||
st.tuples(
|
||||
st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=10),
|
||||
st.floats(min_value=-1.0, max_value=1.0),
|
||||
st.floats(min_value=-5.0, max_value=5.0),
|
||||
),
|
||||
min_size=5, max_size=30, unique_by=lambda t: t[0],
|
||||
),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_add_backtest_bulk_distinct_factors(self, entries):
|
||||
"""Property: adding backtests for distinct factors creates exactly that many rows."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
for name, ic_val, sh in entries:
|
||||
db.add_backtest(name, {"ic": ic_val, "sharpe_ratio": sh})
|
||||
c = db.conn.cursor()
|
||||
c.execute("SELECT COUNT(*) FROM backtest_runs")
|
||||
count = c.fetchone()[0]
|
||||
assert count == len(entries), f"Expected {len(entries)} runs, got {count}"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.floats(min_value=-100.0, max_value=100.0),
|
||||
st.floats(min_value=-100.0, max_value=100.0),
|
||||
st.floats(min_value=-100.0, max_value=100.0),
|
||||
st.floats(min_value=-100.0, max_value=100.0),
|
||||
st.floats(min_value=-100.0, max_value=100.0),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_add_backtest_extreme_values(self, ic, sharpe, ann_ret, dd, wr):
|
||||
"""Property: add_backtest handles extreme metric values without crashing."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
bid = db.add_backtest("ExtremeValues", {
|
||||
"ic": ic, "sharpe_ratio": sharpe, "annualized_return": ann_ret,
|
||||
"max_drawdown": dd, "win_rate": wr,
|
||||
})
|
||||
assert bid > 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.text(alphabet=st.characters(min_codepoint=32, max_codepoint=126), min_size=1, max_size=40),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_add_backtest_special_character_names(self, name):
|
||||
"""Property: add_backtest handles factor names with any printable characters."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
bid = db.add_backtest(name, {"ic": 0.05})
|
||||
c = db.conn.cursor()
|
||||
c.execute("SELECT factor_name FROM factors WHERE id = (SELECT factor_id FROM backtest_runs WHERE id=?)", (bid,))
|
||||
stored = c.fetchone()
|
||||
assert stored is not None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.floats(min_value=-1.0, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_add_backtest_with_raw_metrics(self, ic_val):
|
||||
"""Property: add_backtest survives raw_metrics key with various dict values."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
bid = db.add_backtest("RawMetricsTest", {
|
||||
"ic": ic_val,
|
||||
"raw_metrics": {"a": 1.0, "b": ic_val, "c": 100.0},
|
||||
})
|
||||
assert bid > 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# add_loop Fuzzing (10 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAddLoopFuzzing:
|
||||
"""Fuzz add_loop with random success/fail counts."""
|
||||
|
||||
@given(
|
||||
st.integers(min_value=0, max_value=100),
|
||||
st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_loop_success_rate_formula(self, success, fail):
|
||||
"""Property: success_rate = success / (success + fail) if total > 0 else 0."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
lid = db.add_loop(0, success, fail, None, "completed")
|
||||
c = db.conn.cursor()
|
||||
c.execute("SELECT success_rate FROM loop_results WHERE id=?", (lid,))
|
||||
rate = c.fetchone()[0]
|
||||
expected = success / (success + fail) if (success + fail) > 0 else 0.0
|
||||
assert abs(rate - expected) < 1e-10, f"Rate {rate} != expected {expected}"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.integers(min_value=0, max_value=50),
|
||||
st.integers(min_value=0, max_value=50),
|
||||
st.floats(min_value=-1.0, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_loop_best_ic_preserved(self, success, fail, best_ic):
|
||||
"""Property: best_ic value stored matches what was passed."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
lid = db.add_loop(42, success, fail, best_ic, "completed")
|
||||
c = db.conn.cursor()
|
||||
c.execute("SELECT best_ic FROM loop_results WHERE id=?", (lid,))
|
||||
stored = c.fetchone()[0]
|
||||
if best_ic is not None:
|
||||
assert abs(stored - best_ic) < 1e-10
|
||||
else:
|
||||
assert stored is None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.lists(st.integers(min_value=1, max_value=50), min_size=1, max_size=20, unique=True),
|
||||
st.integers(min_value=1, max_value=10),
|
||||
st.integers(min_value=1, max_value=10),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_loop_multiple_sequential_indices(self, indices, s, f):
|
||||
"""Property: multiple loops with distinct indices produce that many rows."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
for idx in indices:
|
||||
db.add_loop(idx, s, f, None, "completed")
|
||||
c = db.conn.cursor()
|
||||
c.execute("SELECT COUNT(*) FROM loop_results")
|
||||
assert c.fetchone()[0] == len(indices)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.integers(min_value=0, max_value=1000),
|
||||
st.integers(min_value=0, max_value=1000),
|
||||
st.text(min_size=1, max_size=20),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_loop_status_stored(self, success, fail, status):
|
||||
"""Property: status field reflects the passed value."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
lid = db.add_loop(99, success, fail, None, status)
|
||||
c = db.conn.cursor()
|
||||
c.execute("SELECT status FROM loop_results WHERE id=?", (lid,))
|
||||
assert c.fetchone()[0] == status
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_top_factors Properties (15 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetTopFactorsFuzzing:
|
||||
"""Property-based tests for get_top_factors."""
|
||||
|
||||
@given(
|
||||
st.lists(
|
||||
st.floats(min_value=-5.0, max_value=5.0),
|
||||
min_size=5, max_size=30,
|
||||
),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_top_factors_sorted_descending_by_sharpe(self, sharpes):
|
||||
"""Property: get_top_factors by sharpe returns strictly descending sharpe values."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
for i, sh in enumerate(sharpes):
|
||||
db.add_backtest(f"Factor_{i}", {"ic": 0.0, "sharpe_ratio": sh})
|
||||
df = db.get_top_factors(metric="sharpe", limit=len(sharpes))
|
||||
sh_vals = df["sharpe"].tolist()
|
||||
assert sh_vals == sorted(sh_vals, reverse=True), f"Not sorted: {sh_vals}"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.lists(
|
||||
st.floats(min_value=-1.0, max_value=1.0),
|
||||
min_size=5, max_size=30,
|
||||
),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_top_factors_by_ic_descending(self, ics):
|
||||
"""Property: get_top_factors by IC returns descending IC."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
for i, ic in enumerate(ics):
|
||||
db.add_backtest(f"Factor_{i}", {"ic": ic, "sharpe_ratio": 0.0})
|
||||
df = db.get_top_factors(metric="ic", limit=len(ics))
|
||||
ic_vals = df["ic"].tolist()
|
||||
assert ic_vals == sorted(ic_vals, reverse=True)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.integers(min_value=1, max_value=50),
|
||||
st.integers(min_value=1, max_value=200),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_top_factors_limit_respected(self, n_factors, limit):
|
||||
"""Property: result length <= limit and <= number of stored factors."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
for i in range(n_factors):
|
||||
db.add_backtest(f"Fac_{i}", {"ic": 0.0, "sharpe_ratio": 1.0})
|
||||
df = db.get_top_factors(metric="sharpe", limit=limit)
|
||||
assert len(df) <= limit
|
||||
assert len(df) <= n_factors
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.lists(
|
||||
st.floats(min_value=-5.0, max_value=5.0),
|
||||
min_size=10, max_size=40,
|
||||
),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_get_top_factors_all_columns_present(self, sharpes):
|
||||
"""Property: returned DataFrame always has expected columns."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
for i, sh in enumerate(sharpes):
|
||||
db.add_backtest(f"FC_{i}", {"ic": 0.0, "sharpe_ratio": sh})
|
||||
df = db.get_top_factors()
|
||||
for col in ["factor_name", "sharpe", "ic", "annual_return", "max_drawdown"]:
|
||||
assert col in df.columns, f"Missing column: {col}"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=10))
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_get_top_factors_empty_db_returns_empty(self, db_suffix):
|
||||
"""Property: querying empty database returns empty DataFrame."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, f"empty_{db_suffix}.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
df = db.get_top_factors(metric="sharpe", limit=10)
|
||||
assert len(df) == 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-5.0, max_value=5.0), min_size=5, max_size=30),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_get_top_factors_null_metrics_excluded(self, sharpes):
|
||||
"""Property: factors with NULL sharpe are excluded from top-by-sharpe."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
# Add factors with NULL sharpe
|
||||
for i in range(3):
|
||||
db.add_factor(f"NullFac_{i}", "type")
|
||||
for i, sh in enumerate(sharpes):
|
||||
db.add_backtest(f"RealFac_{i}", {"ic": 0.0, "sharpe_ratio": sh})
|
||||
df = db.get_top_factors(metric="sharpe", limit=100)
|
||||
assert len(df) <= len(sharpes)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_aggregate_stats Properties (8 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAggregateStatsProperties:
|
||||
"""Property tests for get_aggregate_stats."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-1.0, max_value=1.0), min_size=3, max_size=20),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_avg_ic_within_input_range(self, ics):
|
||||
"""Property: avg_ic lies between min and max of stored ICs."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
for i, ic in enumerate(ics):
|
||||
db.add_backtest(f"ICFactor_{i}", {"ic": ic, "sharpe_ratio": 1.0})
|
||||
stats = db.get_aggregate_stats()
|
||||
assert stats["avg_ic"] is not None
|
||||
assert min(ics) - 0.01 <= stats["avg_ic"] <= max(ics) + 0.01
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-10.0, max_value=10.0), min_size=3, max_size=20),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_max_sharpe_is_max(self, sharpes):
|
||||
"""Property: max_sharpe equals the maximum of stored sharpe values."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
for i, sh in enumerate(sharpes):
|
||||
db.add_backtest(f"SFactor_{i}", {"ic": 0.0, "sharpe_ratio": sh})
|
||||
stats = db.get_aggregate_stats()
|
||||
assert abs(stats["max_sharpe"] - max(sharpes)) < 1e-10
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-2.0, max_value=2.0), min_size=3, max_size=20),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_avg_return_within_range(self, returns):
|
||||
"""Property: avg_return is between min and max stored annualized_return."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
for i, r in enumerate(returns):
|
||||
db.add_backtest(f"RFactor_{i}", {"ic": 0.0, "annualized_return": r})
|
||||
stats = db.get_aggregate_stats()
|
||||
assert stats["avg_return"] is not None
|
||||
assert min(returns) - 0.01 <= stats["avg_return"] <= max(returns) + 0.01
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.integers(min_value=1, max_value=30),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_total_factors_counts_unique_names(self, n_factors):
|
||||
"""Property: total_factors counts unique factor names, not runs."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
distinct = n_factors // 2 + 1
|
||||
for i in range(distinct):
|
||||
db.add_backtest(f"UniqFac_{i}", {"ic": 0.01 * i})
|
||||
# Add second run for first factor
|
||||
db.add_backtest("UniqFac_0", {"ic": 0.99})
|
||||
stats = db.get_aggregate_stats()
|
||||
assert stats["total_factors"] == distinct
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema Migration Properties (8 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSchemaMigrationFuzzing:
|
||||
"""Property tests for _add_column_if_not_exists."""
|
||||
|
||||
@given(
|
||||
st.sampled_from(["REAL", "TEXT", "INTEGER", "BLOB"]),
|
||||
st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=20),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_add_column_idempotent(self, col_type, col_name):
|
||||
"""Property: adding the same column twice is safe (no-op second time)."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
db._add_column_if_not_exists("backtest_runs", col_name, col_type)
|
||||
db._add_column_if_not_exists("backtest_runs", col_name, col_type)
|
||||
c = db.conn.cursor()
|
||||
c.execute("PRAGMA table_info(backtest_runs)")
|
||||
cols = [row[1] for row in c.fetchall()]
|
||||
assert cols.count(col_name) == 1
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=15),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_column_added_to_all_tables(self, col_name):
|
||||
"""Property: column can be added to each allowed table."""
|
||||
for table in ["factors", "backtest_runs", "loop_results"]:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
db._add_column_if_not_exists(table, col_name, "REAL")
|
||||
c = db.conn.cursor()
|
||||
c.execute(f"PRAGMA table_info({table})")
|
||||
cols = [row[1] for row in c.fetchall()]
|
||||
assert col_name in cols, f"{col_name} not found in {table}"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.text(alphabet=st.characters(min_codepoint=32, max_codepoint=47), min_size=1, max_size=10),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_invalid_column_names_raise_value_error(self, bad_name):
|
||||
"""Property: non-alphanumeric (besides underscore) column names raise ValueError."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
with pytest.raises(ValueError):
|
||||
db._add_column_if_not_exists("backtest_runs", bad_name, "REAL")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(st.text(min_size=1, max_size=15))
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_invalid_table_name_raises(self, bad_table):
|
||||
"""Property: unknown table names raise ValueError."""
|
||||
assume(bad_table not in {"factors", "backtest_runs", "loop_results"})
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
with pytest.raises(ValueError):
|
||||
db._add_column_if_not_exists(bad_table, "col", "REAL")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data Integrity Properties (10 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDataIntegrityFuzzing:
|
||||
"""Property tests for data roundtrip and consistency."""
|
||||
|
||||
@given(
|
||||
st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=30),
|
||||
st.floats(min_value=-1.0, max_value=1.0),
|
||||
st.floats(min_value=-5.0, max_value=5.0),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_data_roundtrip_ic(self, name, ic, sharpe):
|
||||
"""Property: IC value retrieved matches what was stored."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db1 = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
bid = db1.add_backtest(name, {"ic": ic, "sharpe_ratio": sharpe})
|
||||
c = db1.conn.cursor()
|
||||
c.execute("SELECT ic FROM backtest_runs WHERE id=?", (bid,))
|
||||
stored = c.fetchone()[0]
|
||||
assert abs(stored - ic) < 1e-10
|
||||
finally:
|
||||
db1.close()
|
||||
|
||||
@given(
|
||||
st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=30),
|
||||
st.floats(min_value=-10.0, max_value=10.0),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_data_roundtrip_sharpe(self, name, sharpe):
|
||||
"""Property: Sharpe value retrieved matches stored."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
bid = db.add_backtest(name, {"ic": 0.0, "sharpe_ratio": sharpe})
|
||||
c = db.conn.cursor()
|
||||
c.execute("SELECT sharpe FROM backtest_runs WHERE id=?", (bid,))
|
||||
assert abs(c.fetchone()[0] - sharpe) < 1e-10
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=30),
|
||||
st.floats(min_value=-1.0, max_value=0.0),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_data_roundtrip_max_drawdown(self, name, dd):
|
||||
"""Property: max_drawdown roundtrip is exact."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
bid = db.add_backtest(name, {"ic": 0.0, "max_drawdown": dd, "sharpe_ratio": 1.0})
|
||||
c = db.conn.cursor()
|
||||
c.execute("SELECT max_drawdown FROM backtest_runs WHERE id=?", (bid,))
|
||||
assert abs(c.fetchone()[0] - dd) < 1e-10
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=30),
|
||||
st.floats(min_value=0.0, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_data_roundtrip_win_rate(self, name, wr):
|
||||
"""Property: win_rate roundtrip is exact."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
bid = db.add_backtest(name, {"ic": 0.0, "win_rate": wr, "sharpe_ratio": 1.0})
|
||||
c = db.conn.cursor()
|
||||
c.execute("SELECT win_rate FROM backtest_runs WHERE id=?", (bid,))
|
||||
assert abs(c.fetchone()[0] - wr) < 1e-10
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.lists(
|
||||
st.tuples(
|
||||
st.floats(min_value=-5.0, max_value=5.0),
|
||||
st.floats(min_value=-1.0, max_value=1.0),
|
||||
),
|
||||
min_size=5, max_size=30,
|
||||
),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000, suppress_health_check=[HealthCheck.filter_too_much])
|
||||
def test_multiple_runs_factor_count_consistent(self, pairs):
|
||||
"""Property: unique factor count between direct SQL and get_aggregate_stats matches."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
for i, (sh, ic) in enumerate(pairs):
|
||||
db.add_backtest(f"ConsistencyFac_{i}", {"ic": ic, "sharpe_ratio": sh})
|
||||
stats = db.get_aggregate_stats()
|
||||
c = db.conn.cursor()
|
||||
c.execute("SELECT COUNT(DISTINCT factor_name) FROM backtest_runs JOIN factors ON factor_id=factors.id")
|
||||
direct = c.fetchone()[0]
|
||||
assert stats["total_factors"] == direct
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(st.integers(min_value=1, max_value=50))
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_persistence_across_connections(self, n_factors):
|
||||
"""Property: data written in one connection is visible in a new connection."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db1 = ResultsDatabase(db_path=db_path)
|
||||
for i in range(n_factors):
|
||||
db1.add_backtest(f"Persist_{i}", {"ic": 0.01 * i, "sharpe_ratio": 1.0})
|
||||
db1.close()
|
||||
|
||||
db2 = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
c = db2.conn.cursor()
|
||||
c.execute("SELECT COUNT(*) FROM backtest_runs")
|
||||
assert c.fetchone()[0] == n_factors
|
||||
finally:
|
||||
db2.close()
|
||||
|
||||
@given(st.floats(min_value=-100.0, max_value=100.0))
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_nan_handled_in_metrics(self, nan_val):
|
||||
"""Property: NaN values in metrics do not crash."""
|
||||
assume(np.isnan(nan_val) or not np.isnan(nan_val)) # both branches tested
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
bid = db.add_backtest("NaNTest", {"ic": nan_val, "sharpe_ratio": 1.0})
|
||||
assert bid > 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_factor_history Properties (5 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetFactorHistoryFuzzing:
|
||||
"""Property tests for get_factor_history."""
|
||||
|
||||
@given(
|
||||
st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=20),
|
||||
st.integers(min_value=1, max_value=10),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_factor_history_returns_correct_count(self, name, n_runs):
|
||||
"""Property: get_factor_history returns exactly n rows for n backtest runs."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
for i in range(n_runs):
|
||||
db.add_backtest(name, {"ic": i * 0.01, "sharpe_ratio": 1.0})
|
||||
df = db.get_factor_history(name)
|
||||
assert len(df) == n_runs, f"Expected {n_runs}, got {len(df)}"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=90), min_size=1, max_size=20))
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_factor_history_empty_for_unknown(self, name):
|
||||
"""Property: get_factor_history for unknown factor returns empty DataFrame."""
|
||||
assume(len(name) > 0)
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
df = db.get_factor_history(name + "_unknown_suffix_xyz")
|
||||
assert len(df) == 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@given(
|
||||
st.floats(min_value=-1.0, max_value=1.0),
|
||||
st.floats(min_value=-5.0, max_value=5.0),
|
||||
)
|
||||
@settings(max_examples=10, deadline=5000)
|
||||
def test_factor_history_values_match(self, ic, sharpe):
|
||||
"""Property: get_factor_history returns the same values that were stored."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = os.path.join(td, "test.db")
|
||||
db = ResultsDatabase(db_path=db_path)
|
||||
try:
|
||||
db.add_backtest("HistoryCheck", {"ic": ic, "sharpe_ratio": sharpe})
|
||||
df = db.get_factor_history("HistoryCheck")
|
||||
assert len(df) > 0
|
||||
assert abs(df.iloc[0]["ic"] - ic) < 1e-10
|
||||
assert abs(df.iloc[0]["sharpe"] - sharpe) < 1e-10
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -481,3 +481,648 @@ class TestRiskManagementIntegration:
|
||||
from rdagent.components.backtesting.risk_management import (
|
||||
CorrelationAnalyzer, PortfolioOptimizer, AdvancedRiskManager
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HYPOTHESIS PROPERTY-BASED TESTS (ADDED – DO NOT MODIFY ABOVE THIS LINE)
|
||||
# ============================================================================
|
||||
|
||||
from hypothesis import given, settings, strategies as st, assume
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Correlation Matrix Properties (22 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCorrelationMatrixProperties:
|
||||
"""Property-based tests for correlation matrix invariants."""
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=15),
|
||||
st.integers(min_value=30, max_value=500),
|
||||
st.floats(min_value=0.001, max_value=0.1),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_corr_matrix_symmetric(self, n_assets, n_bars, noise):
|
||||
"""Property: correlation matrix is always symmetric."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="B")
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.normal(0, noise, (n_bars, n_assets))
|
||||
df = pd.DataFrame(data, columns=[f"A_{i}" for i in range(n_assets)], index=dates)
|
||||
analyzer = CorrelationAnalyzer()
|
||||
corr = analyzer.calculate_matrix(df)
|
||||
assert np.allclose(corr.values, corr.values.T, atol=1e-10)
|
||||
|
||||
@given(
|
||||
st.integers(min_value=1, max_value=20),
|
||||
st.integers(min_value=30, max_value=500),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000)
|
||||
def test_corr_diagonal_is_one(self, n_assets, n_bars):
|
||||
"""Property: all diagonal elements of correlation matrix equal 1.0."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="B")
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.normal(0, 0.02, (n_bars, n_assets))
|
||||
df = pd.DataFrame(data, columns=[f"A_{i}" for i in range(n_assets)], index=dates)
|
||||
analyzer = CorrelationAnalyzer()
|
||||
corr = analyzer.calculate_matrix(df)
|
||||
diag = np.diag(corr.values)
|
||||
assert np.allclose(diag, 1.0, atol=1e-10)
|
||||
|
||||
@given(
|
||||
st.integers(min_value=3, max_value=10),
|
||||
st.integers(min_value=50, max_value=300),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000)
|
||||
def test_corr_values_in_bounds(self, n_assets, n_bars):
|
||||
"""Property: all correlation values ∈ [-1, 1]."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="B")
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.normal(0, 0.02, (n_bars, n_assets))
|
||||
df = pd.DataFrame(data, columns=[f"A_{i}" for i in range(n_assets)], index=dates)
|
||||
analyzer = CorrelationAnalyzer()
|
||||
corr = analyzer.calculate_matrix(df)
|
||||
vals = corr.values.ravel()
|
||||
vals = vals[~np.isnan(vals)]
|
||||
assert np.all(vals >= -1.0)
|
||||
assert np.all(vals <= 1.0)
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=6),
|
||||
st.integers(min_value=30, max_value=500),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_corr_psd(self, n_assets, n_bars):
|
||||
"""Property: correlation matrix is positive semi-definite."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="B")
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.normal(0, 0.02, (n_bars, n_assets))
|
||||
df = pd.DataFrame(data, columns=[f"A_{i}" for i in range(n_assets)], index=dates)
|
||||
analyzer = CorrelationAnalyzer()
|
||||
corr = analyzer.calculate_matrix(df)
|
||||
vals = corr.values
|
||||
vals = np.nan_to_num(vals, nan=0)
|
||||
eigenvalues = np.linalg.eigvalsh(vals)
|
||||
assert np.all(eigenvalues >= -1e-10), f"Non-PSD: min eigenvalue={eigenvalues.min()}"
|
||||
|
||||
@given(st.integers(min_value=30, max_value=500))
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_single_asset_corr_is_one(self, n_bars):
|
||||
"""Property: correlation matrix of single asset is [[1.0]]."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="B")
|
||||
rng = np.random.default_rng(42)
|
||||
df = pd.DataFrame({"Only": rng.normal(0, 0.02, n_bars)}, index=dates)
|
||||
analyzer = CorrelationAnalyzer()
|
||||
corr = analyzer.calculate_matrix(df)
|
||||
assert corr.shape == (1, 1)
|
||||
assert corr.iloc[0, 0] == 1.0
|
||||
|
||||
@given(
|
||||
st.integers(min_value=3, max_value=10),
|
||||
st.integers(min_value=50, max_value=300),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_corr_equals_corr_from_pandas(self, n_assets, n_bars):
|
||||
"""Property: calculate_matrix matches pandas .corr()."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="B")
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.normal(0, 0.02, (n_bars, n_assets))
|
||||
df = pd.DataFrame(data, columns=[f"A_{i}" for i in range(n_assets)], index=dates)
|
||||
analyzer = CorrelationAnalyzer()
|
||||
result = analyzer.calculate_matrix(df)
|
||||
expected = df.dropna().corr()
|
||||
assert np.allclose(result.values, expected.values, atol=1e-10, equal_nan=True)
|
||||
|
||||
@given(
|
||||
st.floats(min_value=0.1, max_value=0.9),
|
||||
st.integers(min_value=50, max_value=200),
|
||||
)
|
||||
@settings(max_examples=40, deadline=5000)
|
||||
def test_corr_with_nans_still_symmetric(self, nan_fraction, n_bars):
|
||||
"""Property: correlation matrix stays symmetric even with NaN-contaminated data."""
|
||||
n_assets = 5
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="B")
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.normal(0, 0.02, (n_bars, n_assets))
|
||||
df = pd.DataFrame(data, columns=[f"A_{i}" for i in range(n_assets)], index=dates)
|
||||
for col in df.columns:
|
||||
n_nan = int(n_bars * nan_fraction * 0.3)
|
||||
df.loc[df.index[:n_nan], col] = np.nan
|
||||
analyzer = CorrelationAnalyzer()
|
||||
corr = analyzer.calculate_matrix(df)
|
||||
vals = np.nan_to_num(corr.values, nan=0)
|
||||
assert np.allclose(vals, vals.T, atol=1e-10)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# find_uncorrelated Properties (12 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFindUncorrelatedProperties:
|
||||
"""Property tests for find_uncorrelated."""
|
||||
|
||||
@given(
|
||||
st.integers(min_value=3, max_value=10),
|
||||
st.integers(min_value=100, max_value=500),
|
||||
st.floats(min_value=0.0, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_uncorrelated_count_bounded_by_n_assets(self, n_assets, n_bars, threshold):
|
||||
"""Property: number of uncorrelated factors <= n_assets."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="B")
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.normal(0, 0.02, (n_bars, n_assets))
|
||||
df = pd.DataFrame(data, columns=[f"A_{i}" for i in range(n_assets)], index=dates)
|
||||
analyzer = CorrelationAnalyzer()
|
||||
corr = analyzer.calculate_matrix(df)
|
||||
result = analyzer.find_uncorrelated(corr, threshold=threshold)
|
||||
assert len(result) <= n_assets
|
||||
|
||||
@given(
|
||||
st.integers(min_value=3, max_value=8),
|
||||
st.integers(min_value=100, max_value=400),
|
||||
st.floats(min_value=0.0, max_value=0.5),
|
||||
st.floats(min_value=0.5, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=70, deadline=5000)
|
||||
def test_threshold_monotonicity(self, n_assets, n_bars, t_low, t_high):
|
||||
"""Property: higher threshold => more or equal uncorrelated factors."""
|
||||
assume(t_low <= t_high)
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="B")
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.normal(0, 0.02, (n_bars, n_assets))
|
||||
df = pd.DataFrame(data, columns=[f"A_{i}" for i in range(n_assets)], index=dates)
|
||||
analyzer = CorrelationAnalyzer()
|
||||
corr = analyzer.calculate_matrix(df)
|
||||
r_low = analyzer.find_uncorrelated(corr, threshold=t_low)
|
||||
r_high = analyzer.find_uncorrelated(corr, threshold=t_high)
|
||||
assert len(r_high) >= len(r_low)
|
||||
|
||||
@given(
|
||||
st.integers(min_value=30, max_value=300),
|
||||
)
|
||||
@settings(max_examples=30, deadline=5000)
|
||||
def test_empty_matrix_returns_empty(self, n_bars):
|
||||
"""Property: find_uncorrelated on empty matrix returns []."""
|
||||
analyzer = CorrelationAnalyzer()
|
||||
assert analyzer.find_uncorrelated(pd.DataFrame()) == []
|
||||
|
||||
@given(
|
||||
st.integers(min_value=120, max_value=300),
|
||||
)
|
||||
@settings(max_examples=30, deadline=5000)
|
||||
def test_single_asset_is_uncorrelated(self, n_bars):
|
||||
"""Property: single-asset mean abs correlation to others is NaN → not found."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="B")
|
||||
rng = np.random.default_rng(42)
|
||||
df = pd.DataFrame({"Solo": rng.normal(0, 0.02, n_bars)}, index=dates)
|
||||
analyzer = CorrelationAnalyzer()
|
||||
corr = analyzer.calculate_matrix(df)
|
||||
result = analyzer.find_uncorrelated(corr, threshold=0.5)
|
||||
# Single asset has no "others" — abs().mean() returns NaN, which is not < threshold
|
||||
# So it should NOT be in result (or the list may be empty)
|
||||
assert isinstance(result, list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mean-Variance Properties (18 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMeanVarianceProperties:
|
||||
"""Property-based tests for mean_variance optimization."""
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=10),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_weights_sum_to_one(self, n_assets):
|
||||
"""Property: mean_variance weights always sum to 1."""
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
exp_ret = pd.Series(np.random.default_rng(42).uniform(0.01, 0.15, n_assets), index=names)
|
||||
cov_data = np.random.default_rng(43).uniform(0.01, 0.1, (n_assets, n_assets))
|
||||
cov_data = cov_data @ cov_data.T + np.eye(n_assets) * 0.01 # make PSD
|
||||
cov = pd.DataFrame(cov_data, index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w = opt.mean_variance(exp_ret, cov)
|
||||
assert abs(np.sum(w) - 1.0) < 1e-10
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=8),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_weights_are_numpy_array(self, n_assets):
|
||||
"""Property: mean_variance returns numpy array."""
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
exp_ret = pd.Series(np.random.default_rng(42).uniform(0.01, 0.15, n_assets), index=names)
|
||||
cov = pd.DataFrame(np.eye(n_assets) * 0.04, index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w = opt.mean_variance(exp_ret, cov)
|
||||
assert isinstance(w, np.ndarray)
|
||||
assert len(w) == n_assets
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=6),
|
||||
st.floats(min_value=0.001, max_value=0.2),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_equal_returns_different_vol_weights(self, n_assets, ret_val):
|
||||
"""Property: if all returns equal, lower-vol assets get higher weight."""
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
exp_ret = pd.Series([ret_val] * n_assets, index=names)
|
||||
# Increasing vol: A0 has 0.01, A1 has 0.04, ...
|
||||
diag = np.array([0.01 * (i + 1) for i in range(n_assets)])
|
||||
cov = pd.DataFrame(np.diag(diag), index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w = opt.mean_variance(exp_ret, cov)
|
||||
assert w[np.argmin(diag)] > w[np.argmax(diag)]
|
||||
|
||||
@given(
|
||||
st.integers(min_value=3, max_value=6),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_higher_return_gets_higher_weight_ceteris_paribus(self, n_assets):
|
||||
"""Property: among assets with equal risk, the one with highest return gets highest weight."""
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
rets = np.linspace(0.01, 0.20, n_assets)
|
||||
exp_ret = pd.Series(rets, index=names)
|
||||
cov = pd.DataFrame(np.eye(n_assets) * 0.04, index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w = opt.mean_variance(exp_ret, cov)
|
||||
assert np.argmax(w) == np.argmax(rets)
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=6),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_singular_cov_fallback_equal_weights(self, n_assets):
|
||||
"""Property: singular covariance produces equal weights (fallback)."""
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
exp_ret = pd.Series(np.random.default_rng(42).uniform(0.01, 0.15, n_assets), index=names)
|
||||
# Singular: all rows identical
|
||||
row = np.ones(n_assets) * 0.04
|
||||
cov = pd.DataFrame([row] * n_assets, index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w = opt.mean_variance(exp_ret, cov)
|
||||
expected = np.ones(n_assets) / n_assets
|
||||
assert np.allclose(w, expected, atol=0.01)
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=6),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_zero_cov_fallback_equal_weights(self, n_assets):
|
||||
"""Property: zero covariance matrix produces equal weights fallback."""
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
exp_ret = pd.Series(np.random.default_rng(42).uniform(0.01, 0.15, n_assets), index=names)
|
||||
cov = pd.DataFrame(np.zeros((n_assets, n_assets)), index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w = opt.mean_variance(exp_ret, cov)
|
||||
expected = np.ones(n_assets) / n_assets
|
||||
assert np.allclose(w, expected, atol=0.01)
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=8),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_negative_returns_still_sum_to_one(self, n_assets):
|
||||
"""Property: weights sum to 1 even when all expected returns are negative."""
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
exp_ret = pd.Series(np.random.default_rng(42).uniform(-0.20, -0.01, n_assets), index=names)
|
||||
cov = pd.DataFrame(np.eye(n_assets) * 0.04, index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w = opt.mean_variance(exp_ret, cov)
|
||||
assert abs(np.sum(w) - 1.0) < 1e-10
|
||||
|
||||
@given(
|
||||
st.floats(min_value=0.01, max_value=0.5),
|
||||
st.integers(min_value=2, max_value=6),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_weights_invariant_to_exp_ret_scale(self, scale, n_assets):
|
||||
"""Property: multiplying all expected returns by same factor doesn't change weights."""
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
rng = np.random.default_rng(42)
|
||||
base_rets = rng.uniform(0.01, 0.15, n_assets)
|
||||
exp_ret_1 = pd.Series(base_rets, index=names)
|
||||
exp_ret_2 = pd.Series(base_rets * scale, index=names)
|
||||
cov = pd.DataFrame(np.eye(n_assets) * 0.04, index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w1 = opt.mean_variance(exp_ret_1, cov)
|
||||
w2 = opt.mean_variance(exp_ret_2, cov)
|
||||
assert np.allclose(w1, w2, atol=1e-10), f"w1={w1}, w2={w2}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Risk-Parity Properties (16 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRiskParityProperties:
|
||||
"""Property-based tests for risk_parity optimization."""
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=8),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_weights_sum_to_one(self, n_assets):
|
||||
"""Property: risk_parity weights sum to 1."""
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.uniform(0.01, 0.1, (n_assets, n_assets))
|
||||
cov_data = data @ data.T + np.eye(n_assets) * 0.01
|
||||
cov = pd.DataFrame(cov_data, index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w = opt.risk_parity(cov)
|
||||
assert abs(np.sum(w) - 1.0) < 1e-10
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=8),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_weights_positive(self, n_assets):
|
||||
"""Property: risk_parity weights are all positive (long-only)."""
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.uniform(0.01, 0.1, (n_assets, n_assets))
|
||||
cov_data = data @ data.T + np.eye(n_assets) * 0.01
|
||||
cov = pd.DataFrame(cov_data, index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w = opt.risk_parity(cov)
|
||||
assert np.all(w > 0), f"Non-positive weight: {w}"
|
||||
|
||||
@given(st.integers(min_value=1, max_value=1))
|
||||
@settings(max_examples=20, deadline=5000)
|
||||
def test_single_asset_weight_is_one(self, _):
|
||||
"""Property: risk_parity with single asset returns [1.0]."""
|
||||
cov = pd.DataFrame([[0.04]], index=["A"], columns=["A"])
|
||||
opt = PortfolioOptimizer()
|
||||
w = opt.risk_parity(cov)
|
||||
assert len(w) == 1
|
||||
assert w[0] == 1.0
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=6),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_equal_vol_gives_equal_weights(self, n_assets):
|
||||
"""Property: diagonal covariance with equal variance => equal weights."""
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
cov = pd.DataFrame(np.eye(n_assets) * 0.04, index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w = opt.risk_parity(cov)
|
||||
expected = np.ones(n_assets) / n_assets
|
||||
assert np.allclose(w, expected, atol=0.01)
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=4),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_lower_vol_gets_higher_weight(self, n_assets):
|
||||
"""Property: asset with lower variance gets higher weight."""
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
diag = [0.01, 0.04, 0.09, 0.16][:n_assets]
|
||||
names = names[:n_assets]
|
||||
cov = pd.DataFrame(np.diag(diag), index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w = opt.risk_parity(cov)
|
||||
assert np.argmax(w) == 0 # lowest vol has idx 0
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=4),
|
||||
)
|
||||
@settings(max_examples=30, deadline=5000)
|
||||
def test_zero_variance_gives_equal_weights(self, n_assets):
|
||||
"""Property: zero covariance matrix falls back to equal weights."""
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
cov = pd.DataFrame(np.zeros((n_assets, n_assets)), index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w = opt.risk_parity(cov)
|
||||
expected = np.ones(n_assets) / n_assets
|
||||
assert np.allclose(w, expected, atol=0.01)
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=6),
|
||||
st.floats(min_value=0.5, max_value=5.0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_cov_scaling_invariance(self, n_assets, scale):
|
||||
"""Property: scaling covariance matrix by positive factor doesn't change RP weights."""
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.uniform(0.01, 0.1, (n_assets, n_assets))
|
||||
base = data @ data.T + np.eye(n_assets) * 0.01
|
||||
cov1 = pd.DataFrame(base, index=names, columns=names)
|
||||
cov2 = pd.DataFrame(base * scale, index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w1 = opt.risk_parity(cov1)
|
||||
w2 = opt.risk_parity(cov2)
|
||||
assert np.allclose(w1, w2, atol=1e-10)
|
||||
|
||||
@given(
|
||||
st.integers(min_value=2, max_value=6),
|
||||
st.integers(min_value=2, max_value=20),
|
||||
st.integers(min_value=50, max_value=200),
|
||||
)
|
||||
@settings(max_examples=30, deadline=5000)
|
||||
def test_more_iterations_similar_result(self, n_assets, few_iter, many_iter):
|
||||
"""Property: more iterations gives similar or equal result."""
|
||||
assume(few_iter <= many_iter)
|
||||
names = [f"A_{i}" for i in range(n_assets)]
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.uniform(0.01, 0.1, (n_assets, n_assets))
|
||||
cov_data = data @ data.T + np.eye(n_assets) * 0.01
|
||||
cov = pd.DataFrame(cov_data, index=names, columns=names)
|
||||
opt = PortfolioOptimizer()
|
||||
w1 = opt.risk_parity(cov, max_iter=few_iter)
|
||||
w2 = opt.risk_parity(cov, max_iter=many_iter)
|
||||
assert np.abs(np.sum(w1) - np.sum(w2)) < 0.01
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check_limits Properties (16 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCheckLimitsProperties:
|
||||
"""Property-based tests for check_limits."""
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=3, max_size=10),
|
||||
st.floats(min_value=0.01, max_value=0.5),
|
||||
st.floats(min_value=-0.5, max_value=-0.001),
|
||||
st.floats(min_value=0.01, max_value=1.0),
|
||||
st.floats(min_value=1.0, max_value=10.0),
|
||||
st.floats(min_value=0.01, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=200, deadline=5000)
|
||||
def test_all_checks_are_boolean(self, weights, vol, dd, max_pos, max_lev, max_dd):
|
||||
"""Property: all check_limits return values are boolean."""
|
||||
w = np.array(weights, dtype=float)
|
||||
mgr = AdvancedRiskManager(max_pos=max_pos, max_lev=max_lev, max_dd=max_dd)
|
||||
checks = mgr.check_limits(w, vol=vol, dd=dd)
|
||||
for k, v in checks.items():
|
||||
assert isinstance(v, (bool, np.bool_)), f"{k} is {type(v)}"
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=3, max_size=10),
|
||||
st.floats(min_value=-0.5, max_value=-0.001),
|
||||
st.floats(min_value=0.01, max_value=1.0),
|
||||
st.floats(min_value=1.0, max_value=10.0),
|
||||
st.floats(min_value=0.01, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=200, deadline=5000)
|
||||
def test_three_keys_present(self, weights, dd, max_pos, max_lev, max_dd):
|
||||
"""Property: check_limits returns exactly 3 keys."""
|
||||
w = np.array(weights, dtype=float)
|
||||
mgr = AdvancedRiskManager(max_pos=max_pos, max_lev=max_lev, max_dd=max_dd)
|
||||
checks = mgr.check_limits(w, vol=0.15, dd=dd)
|
||||
assert set(checks.keys()) == {"position_limit", "leverage_limit", "drawdown_limit"}
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=0.0, max_value=0.01), min_size=3, max_size=10),
|
||||
st.floats(min_value=-0.01, max_value=0),
|
||||
st.floats(min_value=0.1, max_value=1.0),
|
||||
st.floats(min_value=1.0, max_value=10.0),
|
||||
st.floats(min_value=0.1, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_tiny_weights_pass_all_limits(self, weights, dd, max_pos, max_lev, max_dd):
|
||||
"""Property: very small weights pass all limits."""
|
||||
w = np.array(weights, dtype=float)
|
||||
mgr = AdvancedRiskManager(max_pos=max_pos, max_lev=max_lev, max_dd=max_dd)
|
||||
checks = mgr.check_limits(w, vol=0.15, dd=dd)
|
||||
assert bool(checks["position_limit"]) is True
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=100.0, max_value=1000.0), min_size=1, max_size=5),
|
||||
st.floats(min_value=0.1, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_huge_weights_fail_position_limit(self, weights, max_pos):
|
||||
"""Property: weights much larger than max_pos fail position_limit."""
|
||||
w = np.array(weights, dtype=float)
|
||||
mgr = AdvancedRiskManager(max_pos=max_pos, max_lev=10000.0, max_dd=1.0)
|
||||
checks = mgr.check_limits(w, vol=0.15, dd=-0.01)
|
||||
assert bool(checks["position_limit"]) is False
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=50.0, max_value=500.0), min_size=3, max_size=10),
|
||||
st.floats(min_value=1.0, max_value=10.0),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_huge_weights_fail_leverage_limit(self, weights, max_lev):
|
||||
"""Property: sum(abs(weights)) > max_lev fails leverage_limit."""
|
||||
w = np.array(weights, dtype=float)
|
||||
mgr = AdvancedRiskManager(max_pos=1000.0, max_lev=max_lev, max_dd=1.0)
|
||||
checks = mgr.check_limits(w, vol=0.15, dd=-0.01)
|
||||
assert bool(checks["leverage_limit"]) is False
|
||||
|
||||
@given(
|
||||
st.floats(min_value=0.01, max_value=0.5),
|
||||
st.floats(min_value=-2.0, max_value=-0.01),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_big_drawdown_fails_drawdown_limit(self, max_dd, actual_dd):
|
||||
"""Property: |dd| > max_dd fails drawdown_limit."""
|
||||
w = np.array([0.1, 0.1, 0.1])
|
||||
mgr = AdvancedRiskManager(max_pos=1.0, max_lev=100.0, max_dd=max_dd)
|
||||
checks = mgr.check_limits(w, vol=0.15, dd=actual_dd)
|
||||
assume(abs(actual_dd) > max_dd)
|
||||
assert bool(checks["drawdown_limit"]) is False
|
||||
|
||||
@given(
|
||||
st.floats(min_value=0.01, max_value=0.5),
|
||||
st.floats(min_value=-0.001, max_value=0),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_small_drawdown_passes_drawdown_limit(self, max_dd, actual_dd):
|
||||
"""Property: small |dd| passes drawdown_limit."""
|
||||
w = np.array([0.1, 0.1, 0.1])
|
||||
mgr = AdvancedRiskManager(max_pos=1.0, max_lev=100.0, max_dd=max_dd)
|
||||
checks = mgr.check_limits(w, vol=0.15, dd=actual_dd)
|
||||
assert bool(checks["drawdown_limit"]) is True
|
||||
|
||||
@given(
|
||||
st.floats(min_value=0.01, max_value=1.0),
|
||||
st.floats(min_value=1.0, max_value=10.0),
|
||||
st.floats(min_value=0.01, max_value=1.0),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_zero_weights_pass_all(self, max_pos, max_lev, max_dd):
|
||||
"""Property: all-zero weights pass all limits."""
|
||||
w = np.zeros(5)
|
||||
mgr = AdvancedRiskManager(max_pos=max_pos, max_lev=max_lev, max_dd=max_dd)
|
||||
checks = mgr.check_limits(w, vol=0.15, dd=-0.01)
|
||||
assert all(checks.values())
|
||||
|
||||
@given(
|
||||
st.lists(st.floats(min_value=-2.0, max_value=2.0), min_size=2, max_size=8),
|
||||
)
|
||||
@settings(max_examples=100, deadline=5000)
|
||||
def test_position_limit_uses_abs_value(self, weights):
|
||||
"""Property: position_limit uses abs(weight) for both long and short."""
|
||||
w = np.array(weights, dtype=float)
|
||||
max_abs = np.max(np.abs(w))
|
||||
mgr = AdvancedRiskManager(max_pos=max_abs + 0.001, max_lev=1000.0, max_dd=1.0)
|
||||
checks = mgr.check_limits(w, vol=0.15, dd=-0.01)
|
||||
assert bool(checks["position_limit"]) is True
|
||||
|
||||
mgr2 = AdvancedRiskManager(max_pos=max_abs - 0.001, max_lev=1000.0, max_dd=1.0)
|
||||
checks2 = mgr2.check_limits(w, vol=0.15, dd=-0.01)
|
||||
if max_abs > 0.001:
|
||||
assert bool(checks2["position_limit"]) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Correlation + Risk Integration Properties (8 tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCorrelationRiskIntegration:
|
||||
"""Integration properties combining correlation analysis and risk checks."""
|
||||
|
||||
@given(
|
||||
st.integers(min_value=3, max_value=8),
|
||||
st.integers(min_value=100, max_value=500),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_uncorrelated_subset_weights_valid(self, n_assets, n_bars):
|
||||
"""Property: portfolio weights for uncorrelated subset pass basic validation."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="B")
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.normal(0, 0.02, (n_bars, n_assets))
|
||||
df = pd.DataFrame(data, columns=[f"A_{i}" for i in range(n_assets)], index=dates)
|
||||
analyzer = CorrelationAnalyzer()
|
||||
corr = analyzer.calculate_matrix(df)
|
||||
uncorr = analyzer.find_uncorrelated(corr, threshold=0.5)
|
||||
assume(len(uncorr) >= 2)
|
||||
|
||||
cov = df[uncorr].cov() * 252
|
||||
opt = PortfolioOptimizer()
|
||||
w = opt.risk_parity(cov)
|
||||
assert abs(np.sum(w) - 1.0) < 1e-10
|
||||
assert np.all(np.isfinite(w)), f"RP weights should be finite: {w}"
|
||||
|
||||
@given(
|
||||
st.integers(min_value=3, max_value=8),
|
||||
st.integers(min_value=100, max_value=300),
|
||||
)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_full_workflow_weight_sum_one(self, n_assets, n_bars):
|
||||
"""Property: full workflow (corr → uncorr → MV → risk check) runs end-to-end."""
|
||||
dates = pd.date_range("2024-01-01", periods=n_bars, freq="B")
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.normal(0, 0.02, (n_bars, n_assets))
|
||||
df = pd.DataFrame(data, columns=[f"A_{i}" for i in range(n_assets)], index=dates)
|
||||
analyzer = CorrelationAnalyzer()
|
||||
corr = analyzer.calculate_matrix(df)
|
||||
assume(corr.shape[0] >= 3)
|
||||
cov = df.cov()
|
||||
exp_ret = pd.Series(df.mean(), index=df.columns)
|
||||
opt = PortfolioOptimizer()
|
||||
mv = opt.mean_variance(exp_ret, cov)
|
||||
rp = opt.risk_parity(cov)
|
||||
assert abs(np.sum(mv) - 1.0) < 0.01
|
||||
assert abs(np.sum(rp) - 1.0) < 0.01
|
||||
|
||||
Reference in New Issue
Block a user