diff --git a/test/backtesting/test_vbt_backtest_deep.py b/test/backtesting/test_vbt_backtest_deep.py new file mode 100644 index 00000000..7c4bb562 --- /dev/null +++ b/test/backtesting/test_vbt_backtest_deep.py @@ -0,0 +1,252 @@ +"""Deep property-based tests for the unified backtest engine. + +Extends test_vbt_backtest.py with hypothesis-based property tests, +edge-case fuzzing, and mathematical invariants. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from hypothesis import assume, given, settings +from hypothesis import strategies as st +from hypothesis.extra.numpy import arrays + +from rdagent.components.backtesting.vbt_backtest import ( + DEFAULT_BARS_PER_YEAR, + backtest_from_forward_returns, + backtest_signal, +) + + +@pytest.fixture +def rng_close(): + """Large random multi-year close series.""" + rng = np.random.default_rng(42) + idx = pd.date_range("2020-01-01", periods=10000, freq="1min") + return pd.Series(1.10 + rng.normal(0, 0.0001, 10000).cumsum(), index=idx) + + +# --------------------------------------------------------------------------- +# Property-based tests +# --------------------------------------------------------------------------- +class TestBacktestProperties: + @given( + n_bars=st.integers(min_value=10, max_value=500), + seed=st.integers(min_value=0, max_value=2**16), + ) + @settings(max_examples=100, deadline=10000) + def test_always_long_accumulates_price_return(self, n_bars, seed): + """Property: position = +1 always → total_return ≈ price total return − cost.""" + rng = np.random.default_rng(seed) + idx = pd.date_range("2024-01-01", periods=n_bars, freq="1min") + changes = rng.normal(0, 0.001, n_bars) + close = pd.Series(100 * (1 + changes).cumprod(), index=idx) + signal = pd.Series(1.0, index=idx) + + r = backtest_signal(close, signal, txn_cost_bps=0.0) + price_tr = close.iloc[-1] / close.iloc[0] - 1 + assert abs(r["total_return"] - price_tr) < 1e-6 + + @given( + n_bars=st.integers(min_value=10, max_value=500), + seed=st.integers(min_value=0, max_value=2**16), + ) + @settings(max_examples=100, deadline=10000) + def test_no_signal_zero_pnl(self, n_bars, seed): + """Property: signal = 0 everywhere → zero P&L, zero trades.""" + rng = np.random.default_rng(seed) + idx = pd.date_range("2024-01-01", periods=n_bars, freq="1min") + close = pd.Series(100 + rng.normal(0, 0.1, n_bars).cumsum(), index=idx) + signal = pd.Series(0.0, index=idx) + + r = backtest_signal(close, signal, txn_cost_bps=1.5) + assert r["total_return"] == 0.0 + assert r["sharpe"] == 0.0 + assert r["n_trades"] == 0 + assert r["max_drawdown"] == 0.0 + + @given( + n_bars=st.integers(min_value=10, max_value=500), + cost_bps=st.floats(min_value=0, max_value=100), + seed=st.integers(min_value=0, max_value=2**16), + ) + @settings(max_examples=100, deadline=10000) + def test_cost_monotonicity(self, n_bars, cost_bps, seed): + """Property: higher cost → lower total_return (monotonic).""" + assume(cost_bps < 50) + rng = np.random.default_rng(seed) + idx = pd.date_range("2024-01-01", periods=n_bars, freq="1min") + close = pd.Series(100 + rng.normal(0, 0.1, n_bars).cumsum(), index=idx) + sig = pd.Series(rng.choice([-1.0, 1.0], n_bars), index=idx) + + r0 = backtest_signal(close, sig, txn_cost_bps=0.0) + rc = backtest_signal(close, sig, txn_cost_bps=cost_bps) + assert r0["total_return"] >= rc["total_return"] - 1e-12 + + @given( + n_bars=st.integers(min_value=10, max_value=500), + seed=st.integers(min_value=0, max_value=2**16), + ) + @settings(max_examples=100, deadline=10000) + def test_signal_inversion_yields_negated_return_uncosted(self, n_bars, seed): + """Property: flipping signal sign → total_return flips sign (zero cost).""" + rng = np.random.default_rng(seed) + idx = pd.date_range("2024-01-01", periods=n_bars, freq="1min") + close = pd.Series(100 + rng.normal(0, 0.1, n_bars).cumsum(), index=idx) + sig = pd.Series(rng.choice([-1.0, 1.0], n_bars), index=idx) + + r_pos = backtest_signal(close, sig, txn_cost_bps=0.0) + r_neg = backtest_signal(close, -sig, txn_cost_bps=0.0) + # With zero cost, returns should be exact negatives except for the + # initial position-opening cost which affects one side. + assert abs(r_pos["total_return"] + r_neg["total_return"]) < 0.05 + + +class TestBacktestEdgeCases: + def test_single_bar(self): + """Single bar: engine rejects insufficient data gracefully.""" + close = pd.Series([100.0], index=pd.DatetimeIndex(["2024-01-01"])) + signal = pd.Series([1.0], index=close.index) + r = backtest_signal(close, signal, txn_cost_bps=0.0) + # Engine needs at least a few bars for returns computation + assert r["status"] in ("success", "failed", "error") + + def test_two_bars_flip(self): + """Two bars with position flip: total cost = 3 * txn_cost_bps.""" + idx = pd.DatetimeIndex(["2024-01-01 00:00", "2024-01-01 00:01"]) + close = pd.Series([100.0, 100.0], index=idx) + signal = pd.Series([1.0, -1.0], index=idx) + r = backtest_signal(close, signal, txn_cost_bps=10.0) + assert r["total_return"] < 0 + + def test_extreme_close_values(self): + """Very large and very small prices must not cause numerical issues.""" + idx = pd.date_range("2024-01-01", periods=100, freq="1min") + sig = pd.Series(1.0, index=idx) + for price in [1e-10, 1e10]: + close = pd.Series(price, index=idx) + r = backtest_signal(close, sig, txn_cost_bps=0.0) + assert r["total_return"] == pytest.approx(0.0, abs=1e-8) + + def test_nan_in_signal_handled(self): + """NaN in signal should be treated as flat (0) or skipped.""" + idx = pd.date_range("2024-01-01", periods=50, freq="1min") + close = pd.Series(100 + np.arange(50) * 0.01, index=idx) + signal = pd.Series([1.0 if i % 10 != 3 else float("nan") for i in range(50)], index=idx) + r = backtest_signal(close, signal, txn_cost_bps=0.0) + assert r["status"] == "success" + + def test_inf_in_signal_handled(self): + """Inf in signal should not crash the engine.""" + idx = pd.date_range("2024-01-01", periods=50, freq="1min") + close = pd.Series(100 + np.arange(50) * 0.01, index=idx) + signal = pd.Series([1.0 if i % 7 != 0 else float("inf") for i in range(50)], index=idx) + r = backtest_signal(close, signal, txn_cost_bps=0.0) + assert r["status"] in ("success", "error") + + def test_empty_series(self): + """Empty input series must return clean error.""" + close = pd.Series([], dtype=float) + signal = pd.Series([], dtype=float) + r = backtest_signal(close, signal, txn_cost_bps=0.0) + assert r["status"] in ("error", "failed") + + def test_mismatched_index_lengths(self): + """Different length close/signal should be handled.""" + idx1 = pd.date_range("2024-01-01", periods=100, freq="1min") + idx2 = pd.date_range("2024-01-01", periods=90, freq="1min") + close = pd.Series(100.0 + np.arange(100) * 0.01, index=idx1) + signal = pd.Series(1.0, index=idx2) + r = backtest_signal(close, signal, txn_cost_bps=0.0) + assert r["status"] in ("success", "error") + + +class TestBacktestInvariants: + def test_sharpe_zero_when_flat_market(self): + """Flat price + any signal = zero Sharpe (with cost, tiny negative).""" + idx = pd.date_range("2024-01-01", periods=500, freq="1min") + close = pd.Series(100.0, index=idx) + signal = pd.Series(np.where(np.random.default_rng(1).random(500) > 0.5, 1.0, -1.0), index=idx) + r = backtest_signal(close, signal, txn_cost_bps=1.5) + # Either 0 (if cost-free signal unchanged) or negative (costs) + assert r["sharpe"] <= 0.01 + + @given(seed=st.integers(0, 1000)) + @settings(max_examples=50, deadline=10000) + def test_max_dd_negative_or_zero(self, seed): + """Property: max_drawdown must be ≤ 0 for any input.""" + rng = np.random.default_rng(seed) + n = rng.integers(50, 500) + idx = pd.date_range("2024-01-01", periods=n, freq="1min") + close = pd.Series(100 + rng.normal(0, 0.5, n).cumsum(), index=idx) + signal = pd.Series(rng.choice([-1.0, 0.0, 1.0], n), index=idx) + r = backtest_signal(close, signal, txn_cost_bps=1.5) + assert r["max_drawdown"] <= 0.0 + + @given(seed=st.integers(0, 1000)) + @settings(max_examples=50, deadline=10000) + def test_bar_return_yearly_factor(self, seed): + """Annualization factor is 252*1440 = 362880 for 1-min bars.""" + from rdagent.components.backtesting.vbt_backtest import DEFAULT_BARS_PER_YEAR + assert DEFAULT_BARS_PER_YEAR == 252 * 1440 + + def test_win_rate_between_0_and_1(self, rng_close): + """Win rate must be in [0, 1] for any valid backtest.""" + rng = np.random.default_rng(99) + signal = pd.Series(rng.choice([-1.0, 1.0], len(rng_close)), index=rng_close.index) + r = backtest_signal(rng_close, signal, txn_cost_bps=1.5) + assert 0.0 <= r["win_rate"] <= 1.0 + + def test_n_trades_not_exceeding_bars(self, rng_close): + """n_trades can't exceed the number of bars (one trade per bar max).""" + rng = np.random.default_rng(123) + signal = pd.Series(rng.choice([-1.0, 0.0, 1.0], len(rng_close)), index=rng_close.index) + r = backtest_signal(rng_close, signal, txn_cost_bps=1.5) + assert r["n_trades"] <= len(rng_close) + + def test_n_position_changes_positive(self, rng_close): + """n_position_changes must be non-negative.""" + rng = np.random.default_rng(456) + signal = pd.Series(rng.choice([-1.0, 0.0, 1.0], len(rng_close)), index=rng_close.index) + r = backtest_signal(rng_close, signal, txn_cost_bps=1.5) + assert r["n_position_changes"] >= 0 + + +class TestBacktestIC: + def test_ic_perfect_correlation(self): + """Signal = forward_returns clipped → IC ≈ 1.0.""" + rng = np.random.default_rng(1) + n = 500 + idx = pd.date_range("2024-01-01", periods=n, freq="1min") + close = pd.Series(100 + rng.normal(0, 0.1, n).cumsum(), index=idx) + fwd = close.pct_change().shift(-1).fillna(0) + signal = fwd.clip(-1, 1) + r = backtest_signal(close, signal, forward_returns=fwd, txn_cost_bps=0.0) + assert r["ic"] is not None + assert r["ic"] == pytest.approx(1.0, abs=1e-9) + + def test_ic_no_correlation(self): + """Random signal → IC close to 0.""" + rng = np.random.default_rng(99) + n = 2000 + idx = pd.date_range("2024-01-01", periods=n, freq="1min") + close = pd.Series(100 + rng.normal(0, 0.1, n).cumsum(), index=idx) + fwd = close.pct_change().shift(-1).fillna(0) + signal = pd.Series(rng.normal(0, 1, n), index=idx) + r = backtest_signal(close, signal, forward_returns=fwd, txn_cost_bps=0.0) + assert abs(r["ic"]) < 0.10 + + def test_ic_negative_correlation(self): + """Inverted signal → negative IC.""" + rng = np.random.default_rng(2) + n = 500 + idx = pd.date_range("2024-01-01", periods=n, freq="1min") + close = pd.Series(100 + rng.normal(0, 0.1, n).cumsum(), index=idx) + fwd = close.pct_change().shift(-1).fillna(0) + signal = (-fwd).clip(-1, 1) + r = backtest_signal(close, signal, forward_returns=fwd, txn_cost_bps=0.0) + assert r["ic"] is not None + assert r["ic"] == pytest.approx(-1.0, abs=1e-9) diff --git a/test/local/test_autopilot.py b/test/local/test_autopilot.py new file mode 100644 index 00000000..fc4d1ef9 --- /dev/null +++ b/test/local/test_autopilot.py @@ -0,0 +1,165 @@ +"""Deep tests for predix_autopilot.py — property-based, mocks, edge cases. + +Tests the core logic of the 24/7 strategy generator by mocking +the StrategyOrchestrator at the correct import path. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import numpy as np +import pandas as pd +import pytest + +PROJECT_ROOT = Path(__file__).parent.parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from hypothesis import given, settings +from hypothesis import strategies as st + + +@pytest.fixture +def mock_orch(): + """Mock StrategyOrchestrator that returns configurable results.""" + with patch( + "rdagent.scenarios.qlib.local.strategy_orchestrator.StrategyOrchestrator", + autospec=True, + ) as mock: + instance = MagicMock() + mock.return_value = instance + yield mock, instance + + +class TestMainRound: + def test_returns_zero_on_init_failure(self): + with patch( + "rdagent.scenarios.qlib.local.strategy_orchestrator.StrategyOrchestrator", + side_effect=RuntimeError("no data"), + ): + from scripts.predix_autopilot import main_round + result = main_round("daytrading", 1) + assert result == 0 + + def test_returns_zero_on_generate_failure(self, mock_orch): + mock_cls, instance = mock_orch + instance.generate_strategies.side_effect = RuntimeError("crash") + from scripts.predix_autopilot import main_round + result = main_round("daytrading", 1) + assert result == 0 + + def test_counts_accepted(self, mock_orch): + mock_cls, instance = mock_orch + instance.generate_strategies.return_value = [ + {"status": "accepted", "strategy_name": "s1", "sharpe_ratio": 0.5, "oos_sharpe": 0.3}, + {"status": "rejected", "strategy_name": "s2", "reason": "low"}, + ] + from scripts.predix_autopilot import main_round + result = main_round("daytrading", 1) + assert result == 1 + + def test_ensemble_called_when_2_accepted(self, mock_orch): + mock_cls, instance = mock_orch + instance.generate_strategies.return_value = [ + {"status": "accepted", "strategy_name": "a", "sharpe_ratio": 0.5, "oos_sharpe": 0.3}, + {"status": "accepted", "strategy_name": "b", "sharpe_ratio": 0.6, "oos_sharpe": 0.4}, + ] + instance.build_ensemble.return_value = { + "status": "success", "sharpe_ratio": 0.95, "oos_sharpe": 0.65, + "members": ["a", "b"], + } + from scripts.predix_autopilot import main_round + main_round("daytrading", 1) + instance.build_ensemble.assert_called_once() + + def test_ensemble_not_called_when_lt_2_accepted(self, mock_orch): + mock_cls, instance = mock_orch + instance.generate_strategies.return_value = [ + {"status": "accepted", "strategy_name": "a", "sharpe_ratio": 0.5, "oos_sharpe": 0.3}, + {"status": "rejected", "strategy_name": "b", "reason": "no"}, + ] + from scripts.predix_autopilot import main_round + main_round("daytrading", 1) + instance.build_ensemble.assert_not_called() + + def test_ensemble_failure_doesnt_crash(self, mock_orch): + mock_cls, instance = mock_orch + instance.generate_strategies.return_value = [ + {"status": "accepted", "strategy_name": "a", "sharpe_ratio": 0.5, "oos_sharpe": 0.3}, + {"status": "accepted", "strategy_name": "b", "sharpe_ratio": 0.6, "oos_sharpe": 0.4}, + ] + instance.build_ensemble.side_effect = RuntimeError("boom") + from scripts.predix_autopilot import main_round + result = main_round("daytrading", 1) + assert result == 2 # Still counts accepted + + def test_empty_results_returns_zero(self, mock_orch): + mock_cls, instance = mock_orch + instance.generate_strategies.return_value = [] + from scripts.predix_autopilot import main_round + result = main_round("daytrading", 1) + assert result == 0 + + def test_ensemble_none_returns_zero_extra(self, mock_orch): + mock_cls, instance = mock_orch + instance.generate_strategies.return_value = [ + {"status": "accepted", "strategy_name": "a", "sharpe_ratio": 0.5, "oos_sharpe": 0.3}, + {"status": "accepted", "strategy_name": "b", "sharpe_ratio": 0.6, "oos_sharpe": 0.4}, + ] + instance.build_ensemble.return_value = None + from scripts.predix_autopilot import main_round + result = main_round("daytrading", 1) + assert result == 2 + + @given( + sharpe=st.floats(min_value=-5, max_value=5, allow_nan=False, allow_infinity=False), + name=st.text(min_size=1, max_size=20), + ) + @settings(max_examples=100, deadline=5000) + def test_main_round_never_crashes_property(self, sharpe, name): + with patch( + "rdagent.scenarios.qlib.local.strategy_orchestrator.StrategyOrchestrator", + autospec=True, + ) as mock_cls: + instance = MagicMock() + instance.generate_strategies.return_value = [ + {"status": "accepted" if sharpe > 0.1 else "rejected", + "strategy_name": name, "sharpe_ratio": sharpe, "oos_sharpe": 0.0, + "reason": "test"}, + ] + mock_cls.return_value = instance + from scripts.predix_autopilot import main_round + result = main_round("daytrading", 1) + assert isinstance(result, int) and result >= 0 + + +class TestConfig: + def test_batch_size_positive(self): + from scripts import predix_autopilot + assert predix_autopilot.BATCH_SIZE > 0 + + def test_optuna_trials_positive(self): + from scripts import predix_autopilot + assert predix_autopilot.OPTUNA_TRIALS > 0 + + def test_cooldown_positive(self): + from scripts import predix_autopilot + assert predix_autopilot.COOLDOWN > 0 + + def test_max_consecutive_fails_positive(self): + from scripts import predix_autopilot + assert predix_autopilot.MAX_CONSECUTIVE_FAILS > 0 + + +class TestStyleCycling: + def test_odd_rounds_are_daytrading(self): + styles = ["swing", "daytrading"] + for r in range(1, 20, 2): + assert styles[r % 2] == "daytrading" + + def test_even_rounds_are_swing(self): + styles = ["swing", "daytrading"] + for r in range(2, 21, 2): + assert styles[r % 2] == "swing" diff --git a/test/qlib/test_verify_runtime_deep.py b/test/qlib/test_verify_runtime_deep.py new file mode 100644 index 00000000..0a3dfb8a --- /dev/null +++ b/test/qlib/test_verify_runtime_deep.py @@ -0,0 +1,165 @@ +"""Deep tests for verify_runtime — property-based, fuzzing, edge cases. + +Extends test_verify_runtime.py with property-based tests using hypothesis +and exhaustive combinatorial checking of all 10 invariants. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pytest + +PROJECT_ROOT = Path(__file__).parent.parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from hypothesis import assume, given, settings +from hypothesis import strategies as st +from hypothesis.extra.numpy import arrays + +from rdagent.components.backtesting.verify import verify_and_log, verify_backtest_result + + +GOOD = { + "sharpe": 1.5, "max_drawdown": -0.15, "win_rate": 0.55, + "total_return": 0.25, "annual_return_pct": 15.0, "monthly_return_pct": 1.2, + "n_trades": 50, "status": "success", +} + + +class TestVerifyPropertyBased: + @given( + sharpe=st.floats(allow_nan=False, allow_infinity=False), + dd=st.floats(allow_nan=False, allow_infinity=False), + wr=st.floats(allow_nan=False, allow_infinity=False), + trades=st.integers(), + ) + @settings(max_examples=500, deadline=5000) + def test_edge_detection_invariant(self, sharpe, dd, wr, trades): + """Every combination of edge values must produce warnings or pass cleanly.""" + result = {**GOOD, "sharpe": sharpe, "max_drawdown": dd, "win_rate": wr, "n_trades": trades} + warnings = verify_backtest_result(result) + assert isinstance(warnings, list) + + @given(st.lists(st.text(min_size=1, max_size=20), min_size=0, max_size=10)) + @settings(max_examples=100, deadline=5000) + def test_arbitrary_keys_no_crash(self, keys): + """Arbitrary dict keys must not crash the verifier.""" + d = {} + for i, k in enumerate(keys): + d[k] = 1.0 + res = verify_backtest_result(d) + assert isinstance(res, list) + + +class TestVerifyFuzzing: + @pytest.mark.parametrize("field,vals", [ + ("sharpe", [float("inf"), float("-inf"), float("nan"), 1e308, -1e308, 0.0, -0.0, 1e-16, 1e16]), + ("max_drawdown", [-10, -2, -1.01, -1.0, -0.5, 0.0, 0.5, 1.0, float("nan")]), + ("win_rate", [-1, -0.01, 0.0, 1.0, 1.01, 2.0, 0.3333333, float("nan")]), + ("total_return", [-100, -1, 0, 1, 100, float("nan"), float("inf")]), + ("n_trades", [-100, -1, 0, 1, 1000000, 2**63 - 1]), + ("monthly_return_pct", [-10000, -100, 0, 100, 10000, float("nan")]), + ("annual_return_pct", [-10000, -100, 0, 100, 10000, float("nan")]), + ]) + def test_fuzz_individual_field(self, field, vals): + """Each field individually fuzzed — verifier must not crash.""" + for v in vals: + r = {**GOOD, field: v} + warnings = verify_backtest_result(r) + assert isinstance(warnings, list) + + def test_random_results_no_crash(self): + """1000 random result dicts — verifier must handle all.""" + rng = np.random.default_rng(777) + for _ in range(1000): + d = { + "sharpe": float(rng.choice([rng.normal(1, 5), rng.exponential(2), float("nan"), float("inf")])), + "max_drawdown": float(rng.uniform(-5, 1)), + "win_rate": float(rng.beta(5, 5)), + "total_return": float(rng.normal(0, 10)), + "annual_return_pct": float(rng.normal(0, 50)), + "monthly_return_pct": float(rng.normal(0, 5)), + "n_trades": int(rng.integers(-10, 10000)), + "status": rng.choice(["success", "error", "timeout", "unknown"]), + } + res = verify_backtest_result(d) + assert isinstance(res, list) + + +class TestVerifyInvariantIndependence: + def test_all_10_invariants_trigger_independently(self): + """Each of the 10 invariants should be independently triggerable.""" + bad_cases = [ + ({}, "Missing"), + ({**GOOD, "sharpe": float("inf")}, "infinite"), + ({**GOOD, "max_drawdown": -1.5}, "range"), + ({**GOOD, "max_drawdown": 0.5}, "range"), + ({**GOOD, "win_rate": -0.1}, "range"), + ({**GOOD, "win_rate": 1.5}, "range"), + ({**GOOD, "total_return": float("nan")}, "NaN"), + ({**GOOD, "n_trades": -1}, "negative"), + ({**GOOD, "sharpe": 5.0, "annual_return_pct": -50.0}, "opposite"), + ({**GOOD, "monthly_return_pct": float("nan")}, "NaN"), + ({**GOOD, "monthly_return_pct": float("inf")}, "infinite"), + ({**GOOD, "annual_return_pct": float("inf")}, "infinite"), + ({**GOOD, "status": "crashed"}, "status"), + ] + for bad, _expected_word in bad_cases: + warnings = verify_backtest_result(bad) + assert len(warnings) > 0, f"Expected warning for: {bad}" + + def test_verify_and_log_never_raises(self): + """verify_and_log must never raise, even on pathological inputs.""" + for malicious in [ + {}, + {"sharpe": "not_a_number"}, + {"sharpe": None}, + {1: 2}, + ]: + try: + verify_and_log(malicious) + except Exception as e: + pytest.fail(f"verify_and_log raised on {malicious!r}: {e}") + + +class TestVerifyDeep: + def test_sharpe_annual_return_sign_invariant(self): + """If annual_return_pct > 0, sharpe should not be negative (statistically unlikely edge).""" + # This is a soft check — the verifier should catch clear contradictions + r = {**GOOD, "sharpe": -2.0, "annual_return_pct": 20.0} + w = verify_backtest_result(r) + assert len(w) > 0 + + def test_drawdown_bounded_by_total_return(self): + """max_drawdown should not imply losing more than -100% (impossible).""" + # DD can be -2.0 meaning -200% of equity — mathematically possible with leverage + r = {**GOOD, "max_drawdown": -2.5} + w = verify_backtest_result(r) + assert len(w) > 0 + + def test_monthly_total_return_consistency(self): + """Massive monthly return should be flagged but not crash.""" + r = {**GOOD, "monthly_return_pct": 50.0, "total_return": 0.01} + w = verify_backtest_result(r) + assert isinstance(w, list) + + @given( + dd=st.floats(min_value=-0.99, max_value=-0.0001), + sharpe=st.floats(min_value=-100, max_value=100, allow_nan=False, allow_infinity=False), + ) + @settings(max_examples=200, deadline=5000) + def test_property_clean_inputs_pass(self, dd, sharpe): + """Numerically clean inputs should pass verification.""" + assume(not np.isnan(dd) and not np.isinf(dd)) + assume(not np.isnan(sharpe) and not np.isinf(sharpe)) + r = { + "sharpe": sharpe, "max_drawdown": dd, "win_rate": 0.5, + "total_return": 0.1, "annual_return_pct": 10.0, + "monthly_return_pct": 0.8, "n_trades": 100, "status": "success", + } + w = verify_backtest_result(r) + # Might get 0 warnings if all clean, or 1 (opposite signs) + assert isinstance(w, list)