test: deep property-based + fuzzing tests for backtest, verifier, and autopilot

- test_verify_runtime_deep: hypothesis property tests, fuzzing 1000 random results,
  invariant independence checking, edge cases (NaN, inf, negative trades)
- test_vbt_backtest_deep: property tests (cost monotonicity, signal inversion,
  max_dd invariants), edge cases (1 bar, empty, NaN, inf, mismatched lengths)
- test_autopilot: mocked orchestrator tests (failure recovery, counting logic,
  ensemble building, style cycling, hypothesis property tests)
This commit is contained in:
TPTBusiness
2026-05-08 23:12:33 +02:00
parent e029120090
commit 160ac96130
3 changed files with 582 additions and 0 deletions
+252
View File
@@ -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)
+165
View File
@@ -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"
+165
View File
@@ -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)