2026-05-04 21:06:53 +02:00
|
|
|
|
"""Robustness tests: slippage, latency, Monte-Carlo, OOS stress."""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import sys
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
import numpy as np
|
|
|
|
|
|
import pandas as pd
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
|
|
|
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def base_data():
|
|
|
|
|
|
n = 3000
|
|
|
|
|
|
dates = pd.date_range("2020-01-01", periods=n, freq="1min")
|
|
|
|
|
|
rng = np.random.default_rng(42)
|
|
|
|
|
|
close = pd.Series(1.10 * np.exp(np.cumsum(rng.normal(0, 0.0002, n))), index=dates)
|
|
|
|
|
|
signal = pd.Series(np.where(rng.normal(0, 1, n) > 0, 1.0, -1.0), index=dates)
|
|
|
|
|
|
return close, signal
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSlippageRobustness:
|
|
|
|
|
|
"""Sharpe should degrade gracefully with increasing slippage, not collapse."""
|
|
|
|
|
|
|
|
|
|
|
|
def test_zero_vs_one_pip(self, base_data):
|
|
|
|
|
|
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
|
|
|
|
|
close, signal = base_data
|
|
|
|
|
|
r0 = backtest_signal(close, signal, txn_cost_bps=0.0)
|
|
|
|
|
|
r1 = backtest_signal(close, signal, txn_cost_bps=1.7)
|
|
|
|
|
|
if r0["status"] == "success" and r1["status"] == "success":
|
|
|
|
|
|
# Slippage must not make metrics invalid
|
|
|
|
|
|
assert -1.0 <= r1["max_drawdown"] <= 0.0
|
|
|
|
|
|
assert np.isfinite(r1["sharpe"])
|
|
|
|
|
|
|
|
|
|
|
|
def test_two_pip_still_valid(self, base_data):
|
|
|
|
|
|
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
|
|
|
|
|
close, signal = base_data
|
|
|
|
|
|
r2 = backtest_signal(close, signal, txn_cost_bps=3.4)
|
|
|
|
|
|
if r2["status"] == "success":
|
|
|
|
|
|
assert -1.0 <= r2["max_drawdown"] <= 0.0
|
|
|
|
|
|
assert np.isfinite(r2["total_return"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestLatencyRobustness:
|
|
|
|
|
|
"""Signal delayed by N bars should produce similar (slightly degraded) results."""
|
|
|
|
|
|
|
|
|
|
|
|
def test_one_bar_latency(self, base_data):
|
|
|
|
|
|
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
|
|
|
|
|
close, signal = base_data
|
|
|
|
|
|
r_base = backtest_signal(close, signal, txn_cost_bps=2.14)
|
|
|
|
|
|
delayed = signal.shift(1).fillna(0)
|
|
|
|
|
|
r_delayed = backtest_signal(close, delayed, txn_cost_bps=2.14)
|
|
|
|
|
|
if r_base["status"] == "success" and r_delayed["status"] == "success":
|
|
|
|
|
|
# Same direction, slightly worse
|
|
|
|
|
|
assert np.sign(r_base["sharpe"]) == np.sign(r_delayed["sharpe"]) or (
|
|
|
|
|
|
abs(r_base["sharpe"]) < 0.1 and abs(r_delayed["sharpe"]) < 0.1
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_five_bar_latency(self, base_data):
|
|
|
|
|
|
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
|
|
|
|
|
close, signal = base_data
|
|
|
|
|
|
r_base = backtest_signal(close, signal, txn_cost_bps=2.14)
|
|
|
|
|
|
delayed = signal.shift(5).fillna(0)
|
|
|
|
|
|
r_delayed = backtest_signal(close, delayed, txn_cost_bps=2.14)
|
|
|
|
|
|
if r_base["status"] == "success" and r_delayed["status"] == "success":
|
|
|
|
|
|
# Should not crash, and metrics must be valid
|
|
|
|
|
|
assert -1.0 <= r_delayed["max_drawdown"] <= 0.0
|
|
|
|
|
|
assert 0.0 <= r_delayed["win_rate"] <= 1.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestMonteCarloRobustness:
|
|
|
|
|
|
"""Reshuffled returns must produce similar win_rate distribution."""
|
|
|
|
|
|
|
|
|
|
|
|
def test_reshuffle_preserves_win_rate_approximately(self, base_data):
|
|
|
|
|
|
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
|
|
|
|
|
close, signal = base_data
|
|
|
|
|
|
r_base = backtest_signal(close, signal, txn_cost_bps=0.0)
|
|
|
|
|
|
if r_base["status"] != "success":
|
|
|
|
|
|
pytest.skip("Base backtest failed")
|
|
|
|
|
|
|
|
|
|
|
|
# Reshuffle returns 100 times, compute win_rates
|
|
|
|
|
|
wr_base = r_base["win_rate"]
|
|
|
|
|
|
wr_shuffled = []
|
|
|
|
|
|
rng = np.random.default_rng(42)
|
|
|
|
|
|
returns = close.pct_change().fillna(0)
|
|
|
|
|
|
for _ in range(50):
|
|
|
|
|
|
shuffled = pd.Series(rng.permutation(returns.values), index=returns.index)
|
|
|
|
|
|
price_shuffled = (1 + shuffled).cumprod() * 1.10
|
|
|
|
|
|
r_s = backtest_signal(price_shuffled, signal, txn_cost_bps=0.0)
|
|
|
|
|
|
if r_s["status"] == "success":
|
|
|
|
|
|
wr_shuffled.append(r_s["win_rate"])
|
|
|
|
|
|
|
|
|
|
|
|
if wr_shuffled:
|
|
|
|
|
|
avg_wr = np.mean(wr_shuffled)
|
|
|
|
|
|
# Win rate shouldn't drop by more than 30pp from reshuffling
|
|
|
|
|
|
assert avg_wr > wr_base - 0.30 or wr_base < 0.40, (
|
|
|
|
|
|
f"Win rate not robust to reshuffle: base={wr_base:.1%}, shuffled_avg={avg_wr:.1%}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestOOSStress:
|
|
|
|
|
|
"""Out-of-sample must remain profitable, not just in-sample."""
|
|
|
|
|
|
|
|
|
|
|
|
def test_train_test_metrics_valid(self):
|
|
|
|
|
|
"""Train on first 70%, test on last 30% — OOS metrics must be valid."""
|
|
|
|
|
|
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
|
|
|
|
|
|
|
|
|
|
|
n = 5000
|
|
|
|
|
|
dates = pd.date_range("2020-01-01", periods=n, freq="1min")
|
|
|
|
|
|
rng = np.random.default_rng(42)
|
|
|
|
|
|
close = pd.Series(1.10 * np.exp(np.cumsum(rng.normal(0, 0.0002, n))), index=dates)
|
|
|
|
|
|
signal = pd.Series(np.where(rng.normal(0, 1, n) > 0, 1.0, -1.0), index=dates)
|
|
|
|
|
|
|
|
|
|
|
|
split = int(n * 0.7)
|
|
|
|
|
|
r_is = backtest_signal(close.iloc[:split], signal.iloc[:split], txn_cost_bps=0.0)
|
|
|
|
|
|
r_oos = backtest_signal(close.iloc[split:], signal.iloc[split:], txn_cost_bps=0.0)
|
|
|
|
|
|
|
|
|
|
|
|
if r_is["status"] == "success" and r_oos["status"] == "success":
|
|
|
|
|
|
assert -1.0 <= r_oos["max_drawdown"] <= 0.0
|
|
|
|
|
|
assert np.isfinite(r_oos["sharpe"])
|
|
|
|
|
|
|
|
|
|
|
|
def test_weekend_no_crash(self):
|
|
|
|
|
|
"""Data with weekend gaps must not crash."""
|
|
|
|
|
|
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
|
|
|
|
|
|
|
|
|
|
|
# Only weekdays
|
|
|
|
|
|
dates = pd.bdate_range("2024-01-01", periods=500, freq="1min")
|
|
|
|
|
|
close = pd.Series(1.10 + np.random.default_rng(42).normal(0, 0.0002, len(dates)).cumsum(), index=dates)
|
|
|
|
|
|
signal = pd.Series(np.where(np.random.default_rng(43).normal(0, 1, len(dates)) > 0, 1.0, -1.0), index=dates)
|
|
|
|
|
|
|
|
|
|
|
|
result = backtest_signal(close, signal)
|
|
|
|
|
|
assert result["status"] in ("success", "failed")
|
|
|
|
|
|
assert np.isfinite(result["sharpe"])
|
2026-05-10 23:42:46 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
# HYPOTHESIS PROPERTY-BASED ROBUSTNESS TESTS (ADDED – DO NOT MODIFY ABOVE)
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
from hypothesis import given, settings, strategies as st, assume
|
|
|
|
|
|
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
|
|
|
|
|
from rdagent.components.backtesting.vbt_backtest import backtest_from_forward_returns
|
|
|
|
|
|
from rdagent.components.backtesting.vbt_backtest import DEFAULT_BARS_PER_YEAR
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _price_signal(n: int, seed: int) -> tuple[pd.Series, pd.Series]:
|
|
|
|
|
|
dates = pd.date_range("2024-01-01", periods=n, freq="1min")
|
|
|
|
|
|
rng = np.random.default_rng(seed)
|
|
|
|
|
|
close = pd.Series(1.10 * np.exp(np.cumsum(rng.normal(0, 0.0002, n))), index=dates)
|
|
|
|
|
|
signal = pd.Series(np.where(rng.normal(0, 1, n) > 0, 1.0, -1.0), index=dates)
|
|
|
|
|
|
return close, signal
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Slippage Fuzzing (18 tests)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSlippageFuzzing:
|
|
|
|
|
|
"""Hypothesis-based slippage robustness."""
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=500, max_value=3000),
|
|
|
|
|
|
st.floats(min_value=0.0, max_value=100.0),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=150, deadline=5000)
|
|
|
|
|
|
def test_slippage_does_not_break_metrics(self, n_bars, cost):
|
|
|
|
|
|
"""Property: any slippage level leaves max_dd in [-1, 0]."""
|
|
|
|
|
|
close, signal = _price_signal(n_bars, seed=42)
|
|
|
|
|
|
result = backtest_signal(close, signal, txn_cost_bps=cost)
|
|
|
|
|
|
if result["status"] == "success":
|
|
|
|
|
|
assert -1.0 <= result["max_drawdown"] <= 0.0
|
|
|
|
|
|
assert np.isfinite(result["sharpe"])
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=1000, max_value=3000),
|
|
|
|
|
|
st.floats(min_value=0.0, max_value=5.0),
|
|
|
|
|
|
st.floats(min_value=0.0, max_value=5.0),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=100, deadline=5000)
|
|
|
|
|
|
def test_slippage_monotonic_sharpe_degradation(self, n_bars, cost_low, cost_high):
|
|
|
|
|
|
"""Property: higher cost never improves Sharpe (moderate costs only)."""
|
|
|
|
|
|
assume(cost_low <= cost_high)
|
|
|
|
|
|
assume(cost_high < 5.0)
|
|
|
|
|
|
close, signal = _price_signal(n_bars, seed=42)
|
|
|
|
|
|
r_low = backtest_signal(close, signal, txn_cost_bps=cost_low)
|
|
|
|
|
|
r_high = backtest_signal(close, signal, txn_cost_bps=cost_high)
|
|
|
|
|
|
if r_low["status"] == "success" and r_high["status"] == "success":
|
|
|
|
|
|
assert r_high["sharpe"] <= r_low["sharpe"] + 0.01
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=1000, max_value=3000),
|
|
|
|
|
|
st.floats(min_value=0.0, max_value=5.0),
|
|
|
|
|
|
st.floats(min_value=0.0, max_value=5.0),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=100, deadline=5000)
|
|
|
|
|
|
def test_slippage_monotonic_return_degradation(self, n_bars, cost_low, cost_high):
|
|
|
|
|
|
"""Property: higher cost never increases total_return (moderate costs)."""
|
|
|
|
|
|
assume(cost_low <= cost_high)
|
|
|
|
|
|
assume(cost_high < 5.0)
|
|
|
|
|
|
close, signal = _price_signal(n_bars, seed=42)
|
|
|
|
|
|
r_low = backtest_signal(close, signal, txn_cost_bps=cost_low)
|
|
|
|
|
|
r_high = backtest_signal(close, signal, txn_cost_bps=cost_high)
|
|
|
|
|
|
if r_low["status"] == "success" and r_high["status"] == "success":
|
|
|
|
|
|
assert r_high["total_return"] <= r_low["total_return"] + 0.001
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=1000, max_value=3000),
|
|
|
|
|
|
st.floats(min_value=0.0, max_value=100.0),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=100, deadline=5000)
|
|
|
|
|
|
def test_slippage_keeps_win_rate_in_bounds(self, n_bars, cost):
|
|
|
|
|
|
"""Property: win_rate ∈ [0, 1] regardless of slippage."""
|
|
|
|
|
|
close, signal = _price_signal(n_bars, seed=42)
|
|
|
|
|
|
result = backtest_signal(close, signal, txn_cost_bps=cost)
|
|
|
|
|
|
if result["status"] == "success":
|
|
|
|
|
|
assert 0.0 <= result["win_rate"] <= 1.0
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=1000, max_value=3000),
|
|
|
|
|
|
st.floats(min_value=0.0, max_value=20.0),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=100, deadline=5000)
|
|
|
|
|
|
def test_slippage_profit_factor_finite(self, n_bars, cost):
|
|
|
|
|
|
"""Property: profit_factor is finite with cost."""
|
|
|
|
|
|
close, signal = _price_signal(n_bars, seed=42)
|
|
|
|
|
|
result = backtest_signal(close, signal, txn_cost_bps=cost)
|
|
|
|
|
|
if result["status"] == "success" and result["n_trades"] > 0:
|
|
|
|
|
|
assert np.isfinite(result["profit_factor"]) or result["profit_factor"] == float("inf")
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.floats(min_value=0.0, max_value=10.0),
|
|
|
|
|
|
st.integers(min_value=1000, max_value=2000),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=70, deadline=5000)
|
|
|
|
|
|
def test_slippage_volatility_positive_or_zero(self, cost, n_bars):
|
|
|
|
|
|
"""Property: volatility >= 0."""
|
|
|
|
|
|
close, signal = _price_signal(n_bars, seed=42)
|
|
|
|
|
|
result = backtest_signal(close, signal, txn_cost_bps=cost)
|
|
|
|
|
|
if result["status"] == "success":
|
|
|
|
|
|
assert result["volatility"] >= 0
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.floats(min_value=0.0, max_value=100.0),
|
|
|
|
|
|
st.integers(min_value=1000, max_value=2000),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=100, deadline=5000)
|
|
|
|
|
|
def test_slippage_annual_return_finite(self, cost, n_bars):
|
|
|
|
|
|
"""Property: annualized_return is finite."""
|
|
|
|
|
|
close, signal = _price_signal(n_bars, seed=42)
|
|
|
|
|
|
result = backtest_signal(close, signal, txn_cost_bps=cost)
|
|
|
|
|
|
if result["status"] == "success":
|
|
|
|
|
|
assert np.isfinite(result["annualized_return"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Latency Fuzzing (15 tests)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestLatencyFuzzing:
|
|
|
|
|
|
"""Hypothesis-based latency robustness."""
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=1, max_value=20),
|
|
|
|
|
|
st.integers(min_value=1000, max_value=3000),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=100, deadline=5000)
|
|
|
|
|
|
def test_latency_keeps_metrics_valid(self, lag, n_bars):
|
|
|
|
|
|
"""Property: delayed signal by any lag still produces valid metrics."""
|
|
|
|
|
|
close, signal = _price_signal(n_bars, seed=42)
|
|
|
|
|
|
delayed = signal.shift(lag).fillna(0)
|
|
|
|
|
|
result = backtest_signal(close, delayed, txn_cost_bps=2.14)
|
|
|
|
|
|
if result["status"] == "success":
|
|
|
|
|
|
assert -1.0 <= result["max_drawdown"] <= 0.0
|
|
|
|
|
|
assert 0.0 <= result["win_rate"] <= 1.0
|
|
|
|
|
|
assert np.isfinite(result["sharpe"])
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=1, max_value=15),
|
|
|
|
|
|
st.integers(min_value=1000, max_value=3000),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=80, deadline=5000)
|
|
|
|
|
|
def test_latency_produces_valid_metrics(self, lag, n_bars):
|
|
|
|
|
|
"""Property: delayed signal always produces valid bounded metrics."""
|
|
|
|
|
|
close, signal = _price_signal(n_bars, seed=42)
|
|
|
|
|
|
r_base = backtest_signal(close, signal, txn_cost_bps=0.0)
|
|
|
|
|
|
delayed = signal.shift(lag).fillna(0)
|
|
|
|
|
|
r_delayed = backtest_signal(close, delayed, txn_cost_bps=0.0)
|
|
|
|
|
|
if r_base["status"] == "success" and r_delayed["status"] == "success":
|
|
|
|
|
|
assert -1.0 <= r_delayed["max_drawdown"] <= 0.0
|
|
|
|
|
|
assert 0.0 <= r_delayed["win_rate"] <= 1.0
|
|
|
|
|
|
assert np.isfinite(r_delayed["sharpe"])
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=1, max_value=10),
|
|
|
|
|
|
st.integers(min_value=1000, max_value=3000),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=80, deadline=5000)
|
|
|
|
|
|
def test_latency_preserves_signal_counts(self, lag, n_bars):
|
|
|
|
|
|
"""Property: signal_long + signal_short + signal_neutral == n_bars for delayed signal."""
|
|
|
|
|
|
close, signal = _price_signal(n_bars, seed=42)
|
|
|
|
|
|
delayed = signal.shift(lag).fillna(0)
|
|
|
|
|
|
result = backtest_signal(close, delayed, txn_cost_bps=0.0)
|
|
|
|
|
|
if result["status"] == "success":
|
|
|
|
|
|
total = result["signal_long"] + result["signal_short"] + result["signal_neutral"]
|
|
|
|
|
|
assert total == n_bars
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=1000, max_value=3000),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=50, deadline=5000)
|
|
|
|
|
|
def test_latency_zero_same_as_base(self, n_bars):
|
|
|
|
|
|
"""Property: 0-lag delayed signal = original signal result."""
|
|
|
|
|
|
close, signal = _price_signal(n_bars, seed=42)
|
|
|
|
|
|
r_orig = backtest_signal(close, signal, txn_cost_bps=0.0)
|
|
|
|
|
|
delayed = signal.shift(0).fillna(0)
|
|
|
|
|
|
r_delayed = backtest_signal(close, delayed, txn_cost_bps=0.0)
|
|
|
|
|
|
if r_orig["status"] == "success" and r_delayed["status"] == "success":
|
|
|
|
|
|
assert r_orig["total_return"] == r_delayed["total_return"]
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=5, max_value=30),
|
|
|
|
|
|
st.integers(min_value=2000, max_value=3000),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=40, deadline=5000)
|
|
|
|
|
|
def test_large_latency_does_not_crash(self, lag, n_bars):
|
|
|
|
|
|
"""Property: very large lag does not crash the backtest."""
|
|
|
|
|
|
close, signal = _price_signal(n_bars, seed=42)
|
|
|
|
|
|
delayed = signal.shift(lag).fillna(0)
|
|
|
|
|
|
result = backtest_signal(close, delayed, txn_cost_bps=2.14)
|
|
|
|
|
|
assert result["status"] in ("success", "failed")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Monte Carlo Fuzzing (12 tests)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestMonteCarloFuzzing:
|
|
|
|
|
|
"""Hypothesis-based Monte Carlo robustness."""
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=500, max_value=2000),
|
|
|
|
|
|
st.integers(min_value=10, max_value=50),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=50, deadline=5000)
|
|
|
|
|
|
def test_reshuffle_keeps_metrics_valid(self, n_bars, n_perm):
|
|
|
|
|
|
"""Property: all reshuffled runs produce valid metrics."""
|
|
|
|
|
|
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
|
|
|
|
|
close, signal = _price_signal(n_bars, seed=42)
|
|
|
|
|
|
returns = close.pct_change().fillna(0)
|
|
|
|
|
|
rng = np.random.default_rng(42)
|
|
|
|
|
|
for _ in range(n_perm):
|
|
|
|
|
|
shuffled = pd.Series(rng.permutation(returns.values), index=returns.index)
|
|
|
|
|
|
price_s = (1 + shuffled).cumprod() * 1.10
|
|
|
|
|
|
r = backtest_signal(price_s, signal, txn_cost_bps=0.0)
|
|
|
|
|
|
if r["status"] == "success":
|
|
|
|
|
|
assert -1.0 <= r["max_drawdown"] <= 0.0
|
|
|
|
|
|
assert 0.0 <= r["win_rate"] <= 1.0
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=500, max_value=2000),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=50, deadline=5000)
|
|
|
|
|
|
def test_reshuffle_win_rate_stable(self, n_bars):
|
|
|
|
|
|
"""Property: win_rate after reshuffle is always in [0, 1]."""
|
|
|
|
|
|
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
|
|
|
|
|
close, signal = _price_signal(n_bars, seed=42)
|
|
|
|
|
|
returns = close.pct_change().fillna(0)
|
|
|
|
|
|
rng = np.random.default_rng(42)
|
|
|
|
|
|
shuffled = pd.Series(rng.permutation(returns.values), index=returns.index)
|
|
|
|
|
|
price_s = (1 + shuffled).cumprod() * 1.10
|
|
|
|
|
|
r = backtest_signal(price_s, signal, txn_cost_bps=0.0)
|
|
|
|
|
|
if r["status"] == "success":
|
|
|
|
|
|
assert 0.0 <= r["win_rate"] <= 1.0
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=500, max_value=1500),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=50, deadline=5000)
|
|
|
|
|
|
def test_reshuffle_sharpe_finite(self, n_bars):
|
|
|
|
|
|
"""Property: Sharpe after reshuffle is finite."""
|
|
|
|
|
|
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
|
|
|
|
|
close, signal = _price_signal(n_bars, seed=42)
|
|
|
|
|
|
returns = close.pct_change().fillna(0)
|
|
|
|
|
|
rng = np.random.default_rng(42)
|
|
|
|
|
|
shuffled = pd.Series(rng.permutation(returns.values), index=returns.index)
|
|
|
|
|
|
price_s = (1 + shuffled).cumprod() * 1.10
|
|
|
|
|
|
r = backtest_signal(price_s, signal, txn_cost_bps=0.0)
|
|
|
|
|
|
if r["status"] == "success":
|
|
|
|
|
|
assert np.isfinite(r["sharpe"])
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=500, max_value=1500),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=50, deadline=5000)
|
|
|
|
|
|
def test_reshuffle_n_trades_unchanged(self, n_bars):
|
|
|
|
|
|
"""Property: n_trades unchanged by reshuffling (same signal pattern)."""
|
|
|
|
|
|
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
|
|
|
|
|
close, signal = _price_signal(n_bars, seed=42)
|
|
|
|
|
|
r_orig = backtest_signal(close, signal, txn_cost_bps=0.0)
|
|
|
|
|
|
returns = close.pct_change().fillna(0)
|
|
|
|
|
|
rng = np.random.default_rng(42)
|
|
|
|
|
|
shuffled = pd.Series(rng.permutation(returns.values), index=returns.index)
|
|
|
|
|
|
price_s = (1 + shuffled).cumprod() * 1.10
|
|
|
|
|
|
r_shuf = backtest_signal(price_s, signal, txn_cost_bps=0.0)
|
|
|
|
|
|
if r_orig["status"] == "success" and r_shuf["status"] == "success":
|
|
|
|
|
|
assert r_orig["n_trades"] == r_shuf["n_trades"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Random Market Data Fuzzing (20 tests)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestRandomMarketDataFuzzing:
|
|
|
|
|
|
"""Fuzz backtest_signal with completely random market data."""
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=100, max_value=5000),
|
|
|
|
|
|
st.floats(min_value=-0.1, max_value=0.1),
|
|
|
|
|
|
st.floats(min_value=0.00001, max_value=0.1),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=200, deadline=5000)
|
|
|
|
|
|
def test_random_prices_always_succeed(self, n_bars, drift, vol):
|
|
|
|
|
|
"""Property: backtesting with random geometric Brownian motion succeeds."""
|
|
|
|
|
|
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
|
|
|
|
|
rng = np.random.default_rng(42)
|
|
|
|
|
|
close = pd.Series(1.10 * np.exp(np.cumsum(rng.normal(drift, vol, n_bars))), index=dates)
|
|
|
|
|
|
signal = pd.Series(np.where(rng.normal(0, 1, n_bars) > 0, 1.0, -1.0), index=dates)
|
|
|
|
|
|
result = backtest_signal(close, signal, txn_cost_bps=0.0)
|
|
|
|
|
|
assert result["status"] in ("success", "failed")
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=100, max_value=3000),
|
|
|
|
|
|
st.floats(min_value=-0.01, max_value=0.01),
|
|
|
|
|
|
st.floats(min_value=0.0001, max_value=0.1),
|
|
|
|
|
|
st.floats(min_value=0.0, max_value=30.0),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=200, deadline=5000)
|
|
|
|
|
|
def test_random_data_all_metrics_finite(self, n_bars, drift, vol, cost):
|
|
|
|
|
|
"""Property: all key metrics are finite for random data."""
|
|
|
|
|
|
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
|
|
|
|
|
rng = np.random.default_rng(42)
|
|
|
|
|
|
close = pd.Series(1.10 * np.exp(np.cumsum(rng.normal(drift, vol, n_bars))), index=dates)
|
|
|
|
|
|
signal = pd.Series(np.where(rng.normal(0, 1, n_bars) > 0, 1.0, -1.0), index=dates)
|
|
|
|
|
|
result = backtest_signal(close, signal, txn_cost_bps=cost)
|
|
|
|
|
|
if result["status"] == "success":
|
|
|
|
|
|
for k in ["sharpe", "total_return", "max_drawdown"]:
|
|
|
|
|
|
assert np.isfinite(result[k]), f"{k} is not finite: {result[k]}"
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=100, max_value=3000),
|
|
|
|
|
|
st.floats(min_value=-0.01, max_value=0.01),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=200, deadline=5000)
|
|
|
|
|
|
def test_random_data_maxdd_in_bounds(self, n_bars, drift):
|
|
|
|
|
|
"""Property: max_drawdown ∈ [-1, 0] with random market data."""
|
|
|
|
|
|
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
|
|
|
|
|
rng = np.random.default_rng(42)
|
|
|
|
|
|
close = pd.Series(1.10 * np.exp(np.cumsum(rng.normal(drift, 0.001, n_bars))), index=dates)
|
|
|
|
|
|
signal = pd.Series(np.where(rng.normal(0, 1, n_bars) > 0, 1.0, -1.0), index=dates)
|
|
|
|
|
|
result = backtest_signal(close, signal, txn_cost_bps=0.0)
|
|
|
|
|
|
if result["status"] == "success":
|
|
|
|
|
|
assert -1.0 <= result["max_drawdown"] <= 0.0
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=100, max_value=3000),
|
|
|
|
|
|
st.floats(min_value=-0.01, max_value=0.01),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=200, deadline=5000)
|
|
|
|
|
|
def test_random_data_win_rate_in_bounds(self, n_bars, drift):
|
|
|
|
|
|
"""Property: win_rate ∈ [0, 1] with random market data."""
|
|
|
|
|
|
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
|
|
|
|
|
rng = np.random.default_rng(42)
|
|
|
|
|
|
close = pd.Series(1.10 * np.exp(np.cumsum(rng.normal(drift, 0.001, n_bars))), index=dates)
|
|
|
|
|
|
signal = pd.Series(np.where(rng.normal(0, 1, n_bars) > 0, 1.0, -1.0), index=dates)
|
|
|
|
|
|
result = backtest_signal(close, signal, txn_cost_bps=0.0)
|
|
|
|
|
|
if result["status"] == "success":
|
|
|
|
|
|
assert 0.0 <= result["win_rate"] <= 1.0
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=100, max_value=3000),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=100, deadline=5000)
|
|
|
|
|
|
def test_random_data_n_bars_matches_input(self, n_bars):
|
|
|
|
|
|
"""Property: n_bars in result equals input length."""
|
|
|
|
|
|
close, signal = _price_signal(n_bars, seed=42)
|
|
|
|
|
|
result = backtest_signal(close, signal, txn_cost_bps=0.0)
|
|
|
|
|
|
if result["status"] == "success":
|
|
|
|
|
|
assert result["n_bars"] == n_bars
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=100, max_value=3000),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=100, deadline=5000)
|
|
|
|
|
|
def test_random_data_signal_counts_sum_correctly(self, n_bars):
|
|
|
|
|
|
"""Property: signal_long + signal_short + signal_neutral == n_bars."""
|
|
|
|
|
|
close, signal = _price_signal(n_bars, seed=42)
|
|
|
|
|
|
result = backtest_signal(close, signal, txn_cost_bps=0.0)
|
|
|
|
|
|
if result["status"] == "success":
|
|
|
|
|
|
assert result["signal_long"] + result["signal_short"] + result["signal_neutral"] == n_bars
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=100, max_value=3000),
|
|
|
|
|
|
st.floats(min_value=1.0, max_value=500.0),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=100, deadline=5000)
|
|
|
|
|
|
def test_random_data_txn_cost_bps_preserved(self, n_bars, cost):
|
|
|
|
|
|
"""Property: txn_cost_bps reported matches input."""
|
|
|
|
|
|
close, signal = _price_signal(n_bars, seed=42)
|
|
|
|
|
|
result = backtest_signal(close, signal, txn_cost_bps=cost)
|
|
|
|
|
|
if result["status"] == "success":
|
|
|
|
|
|
assert abs(result["txn_cost_bps"] - cost) < 0.001
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# OOS Stress Fuzzing (10 tests)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestOOSStressFuzzing:
|
|
|
|
|
|
"""Hypothesis-based out-of-sample stress tests."""
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=1000, max_value=5000),
|
|
|
|
|
|
st.floats(min_value=0.3, max_value=0.8),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=100, deadline=5000)
|
|
|
|
|
|
def test_oos_metrics_valid(self, n_bars, split_fraction):
|
|
|
|
|
|
"""Property: OOS metrics remain valid for any split."""
|
|
|
|
|
|
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
|
|
|
|
|
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
|
|
|
|
|
rng = np.random.default_rng(42)
|
|
|
|
|
|
close = pd.Series(1.10 * np.exp(np.cumsum(rng.normal(0, 0.0002, n_bars))), index=dates)
|
|
|
|
|
|
signal = pd.Series(np.where(rng.normal(0, 1, n_bars) > 0, 1.0, -1.0), index=dates)
|
|
|
|
|
|
split = int(n_bars * split_fraction)
|
|
|
|
|
|
assume(split > 100)
|
|
|
|
|
|
assume(n_bars - split > 100)
|
|
|
|
|
|
r_oos = backtest_signal(close.iloc[split:], signal.iloc[split:], txn_cost_bps=0.0)
|
|
|
|
|
|
if r_oos["status"] == "success":
|
|
|
|
|
|
assert -1.0 <= r_oos["max_drawdown"] <= 0.0
|
|
|
|
|
|
assert np.isfinite(r_oos["sharpe"])
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=500, max_value=3000),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=80, deadline=5000)
|
|
|
|
|
|
def test_oos_sharpe_finite(self, n_bars):
|
|
|
|
|
|
"""Property: OOS Sharpe is always finite."""
|
|
|
|
|
|
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
|
|
|
|
|
close, signal = _price_signal(n_bars, seed=42)
|
|
|
|
|
|
split = n_bars // 2
|
|
|
|
|
|
assume(n_bars - split > 100)
|
|
|
|
|
|
r_oos = backtest_signal(close.iloc[split:], signal.iloc[split:], txn_cost_bps=0.0)
|
|
|
|
|
|
if r_oos["status"] == "success":
|
|
|
|
|
|
assert np.isfinite(r_oos["sharpe"])
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=1000, max_value=3000),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=80, deadline=5000)
|
|
|
|
|
|
def test_is_and_oos_both_produce_metrics(self, n_bars):
|
|
|
|
|
|
"""Property: both IS and OOS periods produce valid metrics."""
|
|
|
|
|
|
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
|
|
|
|
|
close, signal = _price_signal(n_bars, seed=42)
|
|
|
|
|
|
split = int(n_bars * 0.7)
|
|
|
|
|
|
assume(split > 100)
|
|
|
|
|
|
assume(n_bars - split > 100)
|
|
|
|
|
|
r_is = backtest_signal(close.iloc[:split], signal.iloc[:split], txn_cost_bps=0.0)
|
|
|
|
|
|
r_oos = backtest_signal(close.iloc[split:], signal.iloc[split:], txn_cost_bps=0.0)
|
|
|
|
|
|
if r_is["status"] == "success":
|
|
|
|
|
|
assert np.isfinite(r_is["sharpe"])
|
|
|
|
|
|
if r_oos["status"] == "success":
|
|
|
|
|
|
assert np.isfinite(r_oos["max_drawdown"])
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=500, max_value=2000),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=50, deadline=5000)
|
|
|
|
|
|
def test_oos_win_rate_in_bounds(self, n_bars):
|
|
|
|
|
|
"""Property: OOS win_rate ∈ [0, 1]."""
|
|
|
|
|
|
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
|
|
|
|
|
close, signal = _price_signal(n_bars, seed=42)
|
|
|
|
|
|
split = n_bars // 2
|
|
|
|
|
|
assume(n_bars - split > 100)
|
|
|
|
|
|
r_oos = backtest_signal(close.iloc[split:], signal.iloc[split:], txn_cost_bps=0.0)
|
|
|
|
|
|
if r_oos["status"] == "success":
|
|
|
|
|
|
assert 0.0 <= r_oos["win_rate"] <= 1.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Forward Returns Backtest Fuzzing (10 tests)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestForwardReturnsFuzzing:
|
|
|
|
|
|
"""Fuzz backtest_from_forward_returns with random factor and forward returns."""
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=30, max_value=500),
|
|
|
|
|
|
st.lists(st.floats(min_value=-10, max_value=10), min_size=30, max_size=500),
|
|
|
|
|
|
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=30, max_size=500),
|
|
|
|
|
|
st.floats(min_value=0.0, max_value=50.0),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=100, deadline=5000)
|
|
|
|
|
|
def test_forward_backtest_returns_all_keys(self, n, fac_raw, ret_raw, cost):
|
|
|
|
|
|
"""Property: backtest_from_forward_returns contains all expected keys."""
|
|
|
|
|
|
n = min(len(fac_raw), len(ret_raw))
|
|
|
|
|
|
factor = pd.Series(fac_raw[:n], dtype=float)
|
|
|
|
|
|
fwd = pd.Series(ret_raw[:n], dtype=float)
|
|
|
|
|
|
assume(factor.std() > 1e-12)
|
|
|
|
|
|
result = backtest_from_forward_returns(factor, fwd, txn_cost_bps=cost)
|
|
|
|
|
|
for k in ["status", "sharpe", "max_drawdown", "total_return", "win_rate",
|
|
|
|
|
|
"n_trades", "ic", "n_bars"]:
|
|
|
|
|
|
assert k in result, f"Missing key: {k}"
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=30, max_value=500),
|
|
|
|
|
|
st.lists(st.floats(min_value=-10, max_value=10), min_size=30, max_size=500),
|
|
|
|
|
|
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=30, max_size=500),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=100, deadline=5000)
|
|
|
|
|
|
def test_forward_backtest_maxdd_in_bounds(self, n, fac_raw, ret_raw):
|
|
|
|
|
|
"""Property: max_drawdown ∈ [-1, 0] from forward returns backtest."""
|
|
|
|
|
|
n = min(len(fac_raw), len(ret_raw))
|
|
|
|
|
|
factor = pd.Series(fac_raw[:n], dtype=float)
|
|
|
|
|
|
fwd = pd.Series(ret_raw[:n], dtype=float)
|
|
|
|
|
|
assume(factor.std() > 1e-12)
|
|
|
|
|
|
result = backtest_from_forward_returns(factor, fwd, txn_cost_bps=0.0)
|
|
|
|
|
|
if result["status"] == "success":
|
|
|
|
|
|
assert -1.0 <= result["max_drawdown"] <= 0.0
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=30, max_value=500),
|
|
|
|
|
|
st.lists(st.floats(min_value=-10, max_value=10), min_size=30, max_size=500),
|
|
|
|
|
|
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=30, max_size=500),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=100, deadline=5000)
|
|
|
|
|
|
def test_forward_backtest_ic_in_bounds(self, n, fac_raw, ret_raw):
|
|
|
|
|
|
"""Property: IC ∈ [-1, 1] from forward returns backtest."""
|
|
|
|
|
|
n = min(len(fac_raw), len(ret_raw))
|
|
|
|
|
|
factor = pd.Series(fac_raw[:n], dtype=float)
|
|
|
|
|
|
fwd = pd.Series(ret_raw[:n], dtype=float)
|
|
|
|
|
|
assume(factor.std() > 1e-12)
|
|
|
|
|
|
result = backtest_from_forward_returns(factor, fwd, txn_cost_bps=0.0)
|
|
|
|
|
|
if result["status"] == "success":
|
|
|
|
|
|
assert -1.0 <= result["ic"] <= 1.0, f"IC={result['ic']}"
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=30, max_value=500),
|
|
|
|
|
|
st.lists(st.floats(min_value=-10, max_value=10), min_size=30, max_size=500),
|
|
|
|
|
|
st.lists(st.floats(min_value=-0.5, max_value=0.5), min_size=30, max_size=500),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=100, deadline=5000)
|
|
|
|
|
|
def test_forward_backtest_win_rate_in_bounds(self, n, fac_raw, ret_raw):
|
|
|
|
|
|
"""Property: win_rate ∈ [0, 1] from forward returns backtest."""
|
|
|
|
|
|
n = min(len(fac_raw), len(ret_raw))
|
|
|
|
|
|
factor = pd.Series(fac_raw[:n], dtype=float)
|
|
|
|
|
|
fwd = pd.Series(ret_raw[:n], dtype=float)
|
|
|
|
|
|
assume(factor.std() > 1e-12)
|
|
|
|
|
|
result = backtest_from_forward_returns(factor, fwd, txn_cost_bps=0.0)
|
|
|
|
|
|
if result["status"] == "success":
|
|
|
|
|
|
assert 0.0 <= result["win_rate"] <= 1.0, f"WinRate={result['win_rate']}"
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=1, max_value=9),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=20, deadline=5000)
|
|
|
|
|
|
def test_forward_backtest_too_few_bars_fails(self, n):
|
|
|
|
|
|
"""Property: < 10 aligned bars fails."""
|
|
|
|
|
|
factor = pd.Series(np.arange(n, dtype=float))
|
|
|
|
|
|
fwd = pd.Series(np.arange(n, dtype=float))
|
|
|
|
|
|
result = backtest_from_forward_returns(factor, fwd)
|
|
|
|
|
|
assert result["status"] == "failed"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Edge Cases and Extreme Values Fuzzing (10 tests)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestEdgeCasesFuzzing:
|
|
|
|
|
|
"""Fuzzing with extreme/nonsense inputs."""
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=100, max_value=2000),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=70, deadline=5000)
|
|
|
|
|
|
def test_zero_price_initial_does_not_crash(self, n_bars):
|
|
|
|
|
|
"""Property: backtest handles near-zero initial prices."""
|
|
|
|
|
|
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
|
|
|
|
|
rng = np.random.default_rng(42)
|
|
|
|
|
|
close = pd.Series(0.000001 + abs(rng.normal(0, 0.0002, n_bars)).cumsum(), index=dates)
|
|
|
|
|
|
signal = pd.Series(np.where(rng.normal(0, 1, n_bars) > 0, 1.0, -1.0), index=dates)
|
|
|
|
|
|
result = backtest_signal(close, signal)
|
|
|
|
|
|
assert result["status"] in ("success", "failed")
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=100, max_value=2000),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=70, deadline=5000)
|
|
|
|
|
|
def test_very_large_price_does_not_crash(self, n_bars):
|
|
|
|
|
|
"""Property: backtest handles very large prices."""
|
|
|
|
|
|
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
|
|
|
|
|
rng = np.random.default_rng(42)
|
|
|
|
|
|
close = pd.Series(1e6 + rng.normal(0, 1, n_bars).cumsum(), index=dates)
|
|
|
|
|
|
signal = pd.Series(np.where(rng.normal(0, 1, n_bars) > 0, 1.0, -1.0), index=dates)
|
|
|
|
|
|
result = backtest_signal(close, signal)
|
|
|
|
|
|
assert result["status"] in ("success", "failed")
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=100, max_value=2000),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=70, deadline=5000)
|
|
|
|
|
|
def test_signal_all_nan_treated_as_flat(self, n_bars):
|
|
|
|
|
|
"""Property: signal full of NaN is treated as flat (win_rate=0, n_trades=0)."""
|
|
|
|
|
|
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
|
|
|
|
|
rng = np.random.default_rng(42)
|
|
|
|
|
|
close = pd.Series(1.10 + rng.normal(0, 0.0002, n_bars).cumsum(), index=dates)
|
|
|
|
|
|
signal = pd.Series([np.nan] * n_bars, index=dates)
|
|
|
|
|
|
result = backtest_signal(close, signal)
|
|
|
|
|
|
if result["status"] == "success":
|
|
|
|
|
|
assert result["n_trades"] == 0
|
|
|
|
|
|
assert result["win_rate"] == 0.0
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=1000, max_value=3000),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=70, deadline=5000)
|
|
|
|
|
|
def test_continuous_signal_produces_valid_metrics(self, n_bars):
|
|
|
|
|
|
"""Property: continuous signal in [-1, 1] produces valid metrics."""
|
|
|
|
|
|
dates = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
|
|
|
|
|
|
rng = np.random.default_rng(42)
|
|
|
|
|
|
close = pd.Series(1.10 * np.exp(np.cumsum(rng.normal(0, 0.0002, n_bars))), index=dates)
|
|
|
|
|
|
signal = pd.Series(rng.uniform(-1, 1, n_bars), index=dates)
|
|
|
|
|
|
result = backtest_signal(close, signal, txn_cost_bps=0.0)
|
|
|
|
|
|
if result["status"] == "success":
|
|
|
|
|
|
assert -1.0 <= result["max_drawdown"] <= 0.0
|
|
|
|
|
|
assert 0.0 <= result["win_rate"] <= 1.0
|
|
|
|
|
|
|
|
|
|
|
|
@given(
|
|
|
|
|
|
st.integers(min_value=500, max_value=2000),
|
|
|
|
|
|
)
|
|
|
|
|
|
@settings(max_examples=70, deadline=5000)
|
|
|
|
|
|
def test_weekend_gaps_produce_valid_metrics(self, n_bars):
|
|
|
|
|
|
"""Property: data with time gaps (weekends) produces valid metrics."""
|
|
|
|
|
|
dates = pd.bdate_range("2024-01-01", periods=n_bars, freq="1min")
|
|
|
|
|
|
rng = np.random.default_rng(42)
|
|
|
|
|
|
close = pd.Series(1.10 + rng.normal(0, 0.0002, len(dates)).cumsum(), index=dates)
|
|
|
|
|
|
signal = pd.Series(np.where(rng.normal(0, 1, len(dates)) > 0, 1.0, -1.0), index=dates)
|
|
|
|
|
|
result = backtest_signal(close, signal)
|
|
|
|
|
|
if result["status"] == "success":
|
|
|
|
|
|
assert np.isfinite(result["sharpe"])
|
|
|
|
|
|
assert -1.0 <= result["max_drawdown"] <= 0.0
|