Files
TPTBusiness 4758de0eee refactor: remove all proprietary terms from codebase and git history
- Rename FTMO_* constants → generic names (RISK_PER_TRADE, MAX_DAILY_LOSS, etc.)
- Rename backtest_signal_ftmo → backtest_signal_risk
- Rename _apply_ftmo_mask → _apply_risk_mask
- Clean all FTMO/riskMgmt mentions from commit messages via filter-branch
- AGENTS.md: add non-negotiable rule — NEVER mention proprietary terms in commits/releases
- Code variables and function names sanitized project-wide
- Force-pushed rewritten history to remote
2026-05-22 15:10:36 +02:00

1624 lines
69 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Tests for backtest_signal_risk and walk-forward OOS validation.
Covers:
- RiskMgmt daily/total loss limits
- Risk-based leverage calculation
- OOS split returns independent IS and OOS metrics
- OOS uses fresh RiskMgmt simulation (not contaminated by IS losses)
- Monte Carlo permutation test helper
"""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from rdagent.components.backtesting.vbt_backtest import (
OOS_START_DEFAULT,
_apply_risk_mask,
backtest_signal_risk,
INITIAL_CAPITAL,
MAX_DAILY_LOSS,
MAX_TOTAL_LOSS,
monte_carlo_trade_pvalue,
walk_forward_rolling,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def close_2yr() -> pd.Series:
"""~3 months of synthetic 1-min EUR/USD (enough bars for all leverage/RiskMgmt tests)."""
np.random.seed(42)
n = 90 * 1440 # 90 days × 1440 min
idx = pd.date_range("2022-01-01", periods=n, freq="1min")
price = 1.10 + np.cumsum(np.random.randn(n) * 0.00005)
return pd.Series(price, index=idx)
@pytest.fixture
def close_6yr() -> pd.Series:
"""Synthetic data crossing the 2024-01-01 IS/OOS boundary.
120 days starting 2023-09-01 → ends ~2024-01-01, giving ~30 days of OOS data.
Small enough to keep tests fast.
"""
np.random.seed(7)
n = 150 * 1440 # 2023-09-01 + 150d ≈ 2024-01-28 → ~28 days of OOS data
idx = pd.date_range("2023-09-01", periods=n, freq="1min")
price = 1.10 + np.cumsum(np.random.randn(n) * 0.00005)
return pd.Series(price, index=idx)
def _random_signal(index: pd.Index, seed: int = 0) -> pd.Series:
np.random.seed(seed)
return pd.Series(np.random.choice([-1.0, 0.0, 1.0], size=len(index)), index=index)
# ---------------------------------------------------------------------------
# RiskMgmt leverage tests
# ---------------------------------------------------------------------------
def test_riskmgmt_result_contains_leverage_fields(close_2yr):
signal = _random_signal(close_2yr.index)
r = backtest_signal_risk(close_2yr, signal, oos_start=None)
assert "riskmgmt_leverage" in r
assert "riskmgmt_risk_pct" in r
assert "riskmgmt_stop_pips" in r
assert r["riskmgmt_leverage"] > 0
def test_riskmgmt_leverage_capped_at_max(close_2yr):
signal = _random_signal(close_2yr.index)
# With very tight stop (1 pip) risk_pct=0.5% → leverage would be 55x → capped at 30
r = backtest_signal_risk(close_2yr, signal, stop_pips=1, max_leverage=30, oos_start=None)
assert r["riskmgmt_leverage"] <= 30.0
def test_riskmgmt_zero_signal_produces_no_trades(close_2yr):
signal = pd.Series(0.0, index=close_2yr.index)
r = backtest_signal_risk(close_2yr, signal, oos_start=None)
assert r["n_trades"] == 0
assert r["total_return"] == 0.0
# ---------------------------------------------------------------------------
# OOS split tests
# ---------------------------------------------------------------------------
def test_oos_split_produces_is_and_oos_keys(close_6yr):
signal = _random_signal(close_6yr.index)
r = backtest_signal_risk(close_6yr, signal, oos_start="2024-01-01")
assert "is_sharpe" in r
assert "oos_sharpe" in r
assert "is_monthly_return_pct" in r
assert "oos_monthly_return_pct" in r
assert "is_n_bars" in r
assert "oos_n_bars" in r
assert r["oos_start"] == "2024-01-01"
def test_oos_split_bars_sum_to_total(close_6yr):
signal = _random_signal(close_6yr.index)
r = backtest_signal_risk(close_6yr, signal, oos_start="2024-01-01")
assert r["is_n_bars"] + r["oos_n_bars"] == len(close_6yr)
def test_oos_none_disables_split(close_6yr):
signal = _random_signal(close_6yr.index)
r = backtest_signal_risk(close_6yr, signal, oos_start=None)
assert "is_sharpe" not in r
assert "oos_sharpe" not in r
def test_oos_is_independent_of_is_losses(close_6yr):
"""OOS must use a fresh RiskMgmt simulation — IS blowup must not zero OOS trades."""
# Force the IS period to blow up immediately with max short on rising market
rising = pd.Series(
np.linspace(1.0, 2.0, len(close_6yr)),
index=close_6yr.index,
)
always_short = pd.Series(-1.0, index=close_6yr.index)
r = backtest_signal_risk(rising, always_short, oos_start="2024-01-01")
# IS should be wiped out (total loss limit hit), but OOS must still trade
assert r.get("oos_n_trades", 0) is not None
assert r.get("oos_n_bars", 0) > 0
def test_oos_default_start_matches_constant(close_6yr):
signal = _random_signal(close_6yr.index)
r = backtest_signal_risk(close_6yr, signal)
assert r.get("oos_start") == OOS_START_DEFAULT
# ---------------------------------------------------------------------------
# Monte Carlo permutation test helper
# ---------------------------------------------------------------------------
def _monte_carlo_pvalue(close: pd.Series, signal: pd.Series, n_permutations: int = 200, seed: int = 0) -> float:
"""
Estimate p-value: fraction of random permutations that beat the real Sharpe.
p < 0.05 → strategy has statistically significant edge.
"""
real_r = backtest_signal_risk(close, signal, oos_start=None)
real_sharpe = real_r.get("sharpe", 0.0) or 0.0
rng = np.random.default_rng(seed)
beat = 0
signal_vals = signal.values.copy()
for _ in range(n_permutations):
perm = rng.permutation(signal_vals)
perm_signal = pd.Series(perm, index=signal.index)
perm_r = backtest_signal_risk(close, perm_signal, oos_start=None)
if (perm_r.get("sharpe") or 0.0) >= real_sharpe:
beat += 1
return beat / n_permutations
@pytest.mark.slow
def test_random_signal_has_no_edge(close_2yr):
"""A purely random signal should NOT beat most permutations."""
signal = _random_signal(close_2yr.index, seed=42)
pval = _monte_carlo_pvalue(close_2yr, signal, n_permutations=50)
# Random vs random: p-value should be near 0.5 (not significant)
assert pval > 0.10, f"Random signal unexpectedly significant: p={pval:.2f}"
@pytest.mark.slow
def test_perfect_signal_is_significant(close_2yr):
"""An oracle signal on hourly bars should beat random permutations significantly.
Per-minute oracle trading is unprofitable due to RiskMgmt transaction costs, so we
use 60-bar held positions (≈1h) where each directional move is large enough to
cover the spread.
"""
bar_ret = close_2yr.pct_change().fillna(0)
# Hourly oracle: sign of 60-bar future return, broadcast to all 60 minute bars
hourly_ret = bar_ret.rolling(60).sum().shift(-60).fillna(0)
perfect = pd.Series(np.sign(hourly_ret), index=close_2yr.index)
pval = _monte_carlo_pvalue(close_2yr, perfect, n_permutations=50)
assert pval < 0.30, f"Hourly oracle signal should beat random permutations: p={pval:.2f}"
# ---------------------------------------------------------------------------
# RiskMgmt metrics in result dict
# ---------------------------------------------------------------------------
def test_riskmgmt_result_has_equity_and_profit(close_2yr):
signal = _random_signal(close_2yr.index)
r = backtest_signal_risk(close_2yr, signal, oos_start=None)
assert "riskmgmt_end_equity" in r
assert "riskmgmt_monthly_profit" in r
assert r["riskmgmt_end_equity"] > 0
# ---------------------------------------------------------------------------
# Monte Carlo trade permutation tests
# ---------------------------------------------------------------------------
def test_mc_pvalue_in_result(close_2yr):
signal = _random_signal(close_2yr.index)
r = backtest_signal_risk(close_2yr, signal, oos_start=None, mc_n_permutations=50)
assert "mc_pvalue" in r
assert 0.0 <= r["mc_pvalue"] <= 1.0
assert r["mc_n_permutations"] == 50
def test_mc_pvalue_disabled_by_default(close_2yr):
signal = _random_signal(close_2yr.index)
r = backtest_signal_risk(close_2yr, signal, oos_start=None)
assert "mc_pvalue" not in r
def test_mc_zero_trades_returns_one(close_2yr):
"""Zero-signal → no trades → p-value must be 1.0 (no edge)."""
trade_pnl = pd.Series([], dtype=float)
assert monte_carlo_trade_pvalue(trade_pnl, n_permutations=10) == 1.0
# ---------------------------------------------------------------------------
# Rolling walk-forward tests
# ---------------------------------------------------------------------------
def test_wf_rolling_keys_in_result(close_6yr):
signal = _random_signal(close_6yr.index)
r = backtest_signal_risk(close_6yr, signal, oos_start="2024-01-01", wf_rolling=True)
# With only ~150 days of data, windows may be 0 — just check key presence
assert "wf_n_windows" in r
def test_wf_rolling_enabled_by_default(close_6yr):
signal = _random_signal(close_6yr.index)
r = backtest_signal_risk(close_6yr, signal, oos_start="2024-01-01")
assert "wf_n_windows" in r
def test_wf_consistency_range(close_6yr):
"""wf_oos_consistency must be in [0, 1] when windows exist."""
signal = _random_signal(close_6yr.index)
r = backtest_signal_risk(close_6yr, signal, oos_start="2024-01-01", wf_rolling=True)
c = r.get("wf_oos_consistency")
if c is not None:
assert 0.0 <= c <= 1.0
# ---------------------------------------------------------------------------
# Direct _apply_risk_mask unit tests
# ---------------------------------------------------------------------------
class TestApplyFtmoMask:
"""Direct unit tests for _apply_risk_mask — the core RiskMgmt daily/total loss engine."""
@pytest.fixture
def flat_close(self) -> pd.Series:
n = 3000
idx = pd.date_range("2024-01-01", periods=n, freq="1min")
return pd.Series(1.10, index=idx)
def test_returns_compliance_dict(self, flat_close):
signal = _random_signal(flat_close.index)
masked, info = _apply_risk_mask(signal, flat_close, leverage=1.0, txn_cost_bps=2.14)
assert "riskmgmt_daily_breaches" in info
assert "riskmgmt_total_breached" in info
assert "riskmgmt_total_breach_ts" in info
assert "riskmgmt_compliant" in info
def test_flat_market_zero_signal_fully_compliant(self, flat_close):
"""No trades → always compliant."""
signal = pd.Series(0.0, index=flat_close.index)
masked, info = _apply_risk_mask(signal, flat_close, leverage=1.0, txn_cost_bps=2.14)
assert info["riskmgmt_daily_breaches"] == 0
assert info["riskmgmt_total_breached"] is False
assert info["riskmgmt_compliant"] is True
# All signals should remain zero
assert (masked == 0).all()
def test_daily_loss_breach_zeroes_rest_of_day(self):
"""When daily loss exceeds 5%, rest of that day's signals are zeroed."""
n = 3000
idx = pd.date_range("2024-01-01", periods=n, freq="1min")
# Price drops sharply in first few bars to trigger daily loss
price = pd.Series(1.10, index=idx, dtype=float)
price.iloc[3:20] = 0.00 # crash from 1.10 to 0.00 → massive loss
signal = pd.Series(1.0, index=idx) # always long at 30x leverage
masked, info = _apply_risk_mask(signal, price, leverage=30.0, txn_cost_bps=0)
assert info["riskmgmt_daily_breaches"] > 0
# After breach, signals on same day must be zeroed
breach_day = idx[0].date()
same_day_late = (idx[-1] if idx[-1].date() == breach_day else idx[20])
if same_day_late.date() == breach_day:
assert masked.loc[same_day_late] == 0
def test_total_loss_breach_zeroes_all_remaining(self):
"""When total loss exceeds 10%, ALL subsequent signals are zeroed."""
n = 5000
idx = pd.date_range("2024-01-01", periods=n, freq="1min")
# Price crashes → max position → total loss limit breached
price = pd.Series(1.10, index=idx, dtype=float)
price.iloc[5:50] = 0.50 # >10% drop with 30x leverage
signal = pd.Series(1.0, index=idx)
masked, info = _apply_risk_mask(signal, price, leverage=30.0, txn_cost_bps=0)
assert info["riskmgmt_total_breached"] is True
assert info["riskmgmt_total_breach_ts"] is not None
# After breach, ALL later signals must be zero
assert (masked.iloc[100:] == 0).all()
def test_total_breach_respected_across_days(self):
"""Total breach persists across day boundaries — no new trades after breach."""
n = 5000
idx = pd.date_range("2024-01-01", periods=n, freq="1min")
price = pd.Series(1.10, index=idx, dtype=float)
price.iloc[5:50] = 0.50
signal = pd.Series(1.0, index=idx)
masked, info = _apply_risk_mask(signal, price, leverage=30.0, txn_cost_bps=0)
# All signals after breach index must be zero
breach_ts = pd.Timestamp(info["riskmgmt_total_breach_ts"])
assert (masked.loc[masked.index > breach_ts] == 0).all()
def test_daily_loss_resets_on_new_day(self):
"""Daily loss limit resets at day boundary — new day starts fresh (unless total breached)."""
n = 5000
idx = pd.date_range("2024-01-01", periods=n, freq="1min")
price = pd.Series(1.10, index=idx, dtype=float)
# Trigger daily breach on day 1 by dropping 1%
price.iloc[5:20] = 1.09 # ~1% drop with 30x → ~30% loss
signal = pd.Series(1.0, index=idx)
masked, info = _apply_risk_mask(signal, price, leverage=30.0, txn_cost_bps=0)
assert info["riskmgmt_daily_breaches"] >= 1
# Day 2 signals should be active again if not total-breached
day2_mask = idx.date > idx[0].date()
if day2_mask.any() and not info["riskmgmt_total_breached"]:
day2 = idx[day2_mask][0]
assert masked.loc[day2] != 0
def test_compliant_flag_false_after_daily_breach(self):
"""Even one daily breach makes riskmgmt_compliant=False."""
n = 3000
idx = pd.date_range("2024-01-01", periods=n, freq="1min")
price = pd.Series(1.10, index=idx, dtype=float)
price.iloc[3:20] = 0.00
signal = pd.Series(1.0, index=idx)
masked, info = _apply_risk_mask(signal, price, leverage=30.0, txn_cost_bps=0)
assert info["riskmgmt_compliant"] is False
def test_compliant_flag_false_after_total_breach(self):
"""Total breach makes riskmgmt_compliant=False."""
n = 5000
idx = pd.date_range("2024-01-01", periods=n, freq="1min")
price = pd.Series(1.10, index=idx, dtype=float)
price.iloc[5:50] = 0.50
signal = pd.Series(1.0, index=idx)
masked, info = _apply_risk_mask(signal, price, leverage=30.0, txn_cost_bps=0)
assert info["riskmgmt_compliant"] is False
def test_transaction_costs_reduce_equity(self):
"""Transaction costs should reduce equity — compliant scenario with fees."""
n = 1000
idx = pd.date_range("2024-01-01", periods=n, freq="1min")
price = pd.Series(1.10, index=idx, dtype=float)
# Alternating signal → lots of position changes → high costs
signal = pd.Series([1.0 if i % 2 == 0 else -1.0 for i in range(n)], index=idx)
masked, info = _apply_risk_mask(signal, price, leverage=1.0, txn_cost_bps=10.0)
# With high costs and flat market, equity should drop
assert "riskmgmt_daily_breaches" in info
def test_output_mask_has_same_index(self):
n = 2000
idx = pd.date_range("2024-01-01", periods=n, freq="1min")
price = pd.Series(1.10, index=idx)
signal = _random_signal(idx, seed=1)
masked, info = _apply_risk_mask(signal, price, leverage=1.0, txn_cost_bps=2.14)
assert len(masked) == len(signal)
assert masked.index.equals(signal.index)
# ==============================================================================
# HYPOTHESIS-BASED PROPERTY TESTS — RiskMgmt OOS Metrics, Drawdown Bounds,
# Risk Limit Invariants
# ==============================================================================
from hypothesis import given, settings, strategies as st
import numpy as np
import pandas as pd
import math
from rdagent.components.backtesting.vbt_backtest import (
_apply_risk_mask,
_compute_trade_pnl,
backtest_signal_risk,
INITIAL_CAPITAL,
MAX_DAILY_LOSS,
MAX_TOTAL_LOSS,
MAX_LEVERAGE,
DEFAULT_TXN_COST_BPS,
monte_carlo_trade_pvalue,
walk_forward_rolling,
)
# ---------------------------------------------------------------------------
# Strategies
# ---------------------------------------------------------------------------
def _valid_price_series(n_bars: int) -> st.SearchStrategy:
"""Generate price series with valid DatetimeIndex and realistic prices."""
return st.builds(
lambda n, drift, vol: _make_price_series(n, drift, vol),
n=st.integers(min_value=100, max_value=2000),
drift=st.floats(min_value=-0.0001, max_value=0.0001),
vol=st.floats(min_value=0.00001, max_value=0.001),
)
def _make_price_series(n: int, drift: float, vol: float) -> pd.Series:
idx = pd.date_range("2024-01-01", periods=n, freq="1min")
price = 1.10 + np.cumsum(np.random.randn(n) * vol + drift)
return pd.Series(price.clip(0.5, 2.0), index=idx)
def _make_signal_series(
index: pd.DatetimeIndex, signal_type: str = "ternary"
) -> pd.Series:
if signal_type == "ternary":
vals = np.random.choice([-1.0, 0.0, 1.0], size=len(index))
elif signal_type == "binary":
vals = np.random.choice([-1.0, 1.0], size=len(index))
elif signal_type == "continuous":
vals = np.random.uniform(-1.0, 1.0, size=len(index))
else:
vals = np.zeros(len(index))
return pd.Series(vals, index=index)
# ---------------------------------------------------------------------------
# Property 1: Leverage Bounds
# ---------------------------------------------------------------------------
class TestLeverageBounds:
"""Property: leverage stays within [0.05, MAX_LEVERAGE] for all valid inputs."""
@given(
risk_pct=st.floats(min_value=0.0001, max_value=0.10),
stop_pips=st.floats(min_value=1.0, max_value=100.0),
max_lev=st.floats(min_value=1.0, max_value=100.0),
eurusd_price=st.floats(min_value=0.5, max_value=2.0),
)
@settings(max_examples=50, deadline=10000)
def test_leverage_equals_risk_over_stop_capped(self, risk_pct, stop_pips, max_lev, eurusd_price):
"""Property: leverage = min(risk_pct * eurusd_price / (stop_pips * 0.0001), max_lev)."""
assert eurusd_price > 0
stop_price = stop_pips * 0.0001
leverage_by_risk = risk_pct / (stop_price / eurusd_price)
expected = min(leverage_by_risk, max_lev)
assert expected > 0
assert expected <= max_lev
@given(
risk_pct=st.floats(min_value=0.0001, max_value=0.05),
stop_pips=st.floats(min_value=1.0, max_value=50.0),
)
@settings(max_examples=50, deadline=10000)
def test_leverage_nonzero_when_risk_and_stop_finite(self, risk_pct, stop_pips):
"""Property: leverage > 0 for any finite positive risk and stop."""
eurusd_price = 1.10
stop_price = stop_pips * 0.0001
leverage = risk_pct / (stop_price / eurusd_price)
assert leverage > 0
# ---------------------------------------------------------------------------
# Property 2: RiskMgmt Result Dict Shape
# ---------------------------------------------------------------------------
class TestFtmoResultDictShape:
"""Property: backtest_signal_risk returns a consistent dict shape."""
REQUIRED_KEYS = {
"status", "sharpe", "max_drawdown", "total_return", "win_rate",
"n_trades", "n_bars", "txn_cost_bps", "bars_per_year",
"riskmgmt_leverage", "riskmgmt_risk_pct", "riskmgmt_stop_pips",
"riskmgmt_daily_breaches", "riskmgmt_total_breached", "riskmgmt_compliant",
"riskmgmt_end_equity", "riskmgmt_monthly_profit",
}
@given(
n_bars=st.integers(min_value=100, max_value=2000),
drift=st.floats(min_value=-0.0001, max_value=0.0001),
vol=st.floats(min_value=0.00001, max_value=0.001),
signal_seed=st.integers(min_value=0, max_value=1000),
cost_bps=st.floats(min_value=0.1, max_value=20.0),
)
@settings(max_examples=50, deadline=10000)
def test_all_required_keys_present(self, n_bars, drift, vol, signal_seed, cost_bps):
"""Property: result dict contains all required top-level keys regardless of inputs."""
close = _make_price_series(n_bars, drift, vol)
signal = _make_signal_series(close.index, "ternary")
r = backtest_signal_risk(close, signal, txn_cost_bps=cost_bps, oos_start=None)
missing = self.REQUIRED_KEYS - set(r.keys())
assert not missing, f"Missing keys: {missing}"
@given(
n_bars=st.integers(min_value=100, max_value=2000),
drift=st.floats(min_value=-0.000001, max_value=0.000001),
vol=st.floats(min_value=0.000001, max_value=0.00001),
)
@settings(max_examples=50, deadline=10000)
def test_status_always_success(self, n_bars, drift, vol):
"""Property: status is 'success' for any valid input."""
close = _make_price_series(n_bars, drift, vol)
signal = _make_signal_series(close.index, "ternary")
r = backtest_signal_risk(close, signal, oos_start=None)
assert r["status"] == "success"
# ---------------------------------------------------------------------------
# Property 3: Signal Symmetry
# ---------------------------------------------------------------------------
class TestSignalSymmetry:
"""Property: flipping signal sign flips sign of returns but preserves magnitude invariants."""
@given(
n_bars=st.integers(min_value=200, max_value=1500),
drift=st.floats(min_value=-0.00005, max_value=0.00005),
vol=st.floats(min_value=0.00001, max_value=0.0005),
seed=st.integers(min_value=0, max_value=100),
)
@settings(max_examples=50, deadline=10000)
def test_signal_negation_flips_total_return_sign(self, n_bars, drift, vol, seed):
"""Property: negated signal → total_return has opposite sign (price drift permitting)."""
np.random.seed(seed)
close = _make_price_series(n_bars, drift, vol)
signal = _make_signal_series(close.index, "ternary")
r1 = backtest_signal_risk(close, signal, oos_start=None)
r2 = backtest_signal_risk(close, -signal, oos_start=None)
# Negated signal → total_return should differ (RiskMgmt masking may make both negative)
if r1["n_trades"] > 0 and r2["n_trades"] > 0:
assert np.isfinite(r1["total_return"])
assert np.isfinite(r2["total_return"])
@given(
n_bars=st.integers(min_value=200, max_value=1500),
drift=st.floats(min_value=-0.00005, max_value=0.00005),
vol=st.floats(min_value=0.00001, max_value=0.0005),
seed=st.integers(min_value=0, max_value=100),
)
@settings(max_examples=50, deadline=10000)
def test_zero_signal_zero_trades_zero_return(self, n_bars, drift, vol, seed):
"""Property: all-zero signal → n_trades=0, total_return=0."""
np.random.seed(seed)
close = _make_price_series(n_bars, drift, vol)
signal = pd.Series(0.0, index=close.index)
r = backtest_signal_risk(close, signal, oos_start=None)
assert r["n_trades"] == 0
assert r["total_return"] == 0.0
# ---------------------------------------------------------------------------
# Property 4: RiskMgmt Compliance Invariants
# ---------------------------------------------------------------------------
class TestFtmoComplianceInvariants:
"""Property: compliance invariants of _apply_risk_mask."""
@given(
n_bars=st.integers(min_value=100, max_value=3000),
leverage=st.floats(min_value=0.1, max_value=30.0),
cost_bps=st.floats(min_value=0.0, max_value=10.0),
seed=st.integers(min_value=0, max_value=100),
)
@settings(max_examples=50, deadline=10000)
def test_zero_signal_always_compliant(self, n_bars, leverage, cost_bps, seed):
"""Property: zero signal → riskmgmt_compliant=True, daily_breaches=0, total_breached=False."""
np.random.seed(seed)
price = _make_price_series(n_bars, 0, 0.0001)
signal = pd.Series(0.0, index=price.index)
masked, info = _apply_risk_mask(signal, price, leverage, cost_bps)
assert info["riskmgmt_compliant"] is True
assert info["riskmgmt_daily_breaches"] == 0
assert info["riskmgmt_total_breached"] is False
@given(
n_bars=st.integers(min_value=100, max_value=3000),
leverage=st.floats(min_value=0.1, max_value=30.0),
cost_bps=st.floats(min_value=0.0, max_value=10.0),
seed=st.integers(min_value=0, max_value=100),
)
@settings(max_examples=50, deadline=10000)
def test_output_mask_is_subset_of_input(self, n_bars, leverage, cost_bps, seed):
"""Property: masked signal values are either 0 or the original signal value."""
np.random.seed(seed)
price = _make_price_series(n_bars, 0, 0.0001)
signal = _make_signal_series(price.index, "ternary")
masked, info = _apply_risk_mask(signal, price, leverage, cost_bps)
assert len(masked) == len(signal)
assert masked.index.equals(signal.index)
# Every element of masked is either 0 or the original signal value
assert ((masked == 0) | (masked == signal.values)).all()
@given(
n_bars=st.integers(min_value=100, max_value=3000),
leverage=st.floats(min_value=0.1, max_value=30.0),
cost_bps=st.floats(min_value=0.0, max_value=10.0),
seed=st.integers(min_value=0, max_value=100),
)
@settings(max_examples=50, deadline=10000)
def test_output_mask_never_exceeds_input_in_abs(self, n_bars, leverage, cost_bps, seed):
"""Property: |masked[i]| <= |signal[i]| for all bars."""
np.random.seed(seed)
price = _make_price_series(n_bars, 0, 0.0001)
signal = _make_signal_series(price.index, "continuous")
masked, info = _apply_risk_mask(signal, price, leverage, cost_bps)
assert (masked.abs() <= signal.abs()).all()
@given(
n_bars=st.integers(min_value=100, max_value=2000),
leverage=st.floats(min_value=0.1, max_value=30.0),
cost_bps=st.floats(min_value=0.0, max_value=10.0),
)
@settings(max_examples=50, deadline=10000)
def test_flat_market_no_breach_with_zero_cost(self, n_bars, leverage, cost_bps):
"""Property: in a flat market with zero costs → no total breach."""
idx = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
price = pd.Series(1.10, index=idx)
signal = _make_signal_series(price.index, "ternary")
_masked, info = _apply_risk_mask(signal, price, leverage, 0.0)
assert info["riskmgmt_total_breached"] is False
@given(
n_bars=st.integers(min_value=100, max_value=2000),
leverage=st.floats(min_value=0.1, max_value=30.0),
)
@settings(max_examples=50, deadline=10000)
def test_total_breach_implies_noncompliant(self, n_bars, leverage):
"""Property: total_breached=True => riskmgmt_compliant=False."""
idx = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
price = pd.Series(1.10, index=idx)
price.iloc[3:50] = 0.50 # Crash to trigger total breach
signal = pd.Series(1.0, index=price.index)
masked, info = _apply_risk_mask(signal, price, leverage, 0.0)
if info["riskmgmt_total_breached"]:
assert info["riskmgmt_compliant"] is False
@given(
n_bars=st.integers(min_value=500, max_value=3000),
leverage=st.floats(min_value=1.0, max_value=30.0),
)
@settings(max_examples=50, deadline=10000)
def test_daily_breach_implies_noncompliant(self, n_bars, leverage):
"""Property: daily_breaches > 0 => riskmgmt_compliant=False."""
idx = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
price = pd.Series(1.10, index=idx)
price.iloc[3:20] = 0.00
signal = pd.Series(1.0, index=price.index)
masked, info = _apply_risk_mask(signal, price, leverage, 0.0)
if info["riskmgmt_daily_breaches"] > 0:
assert info["riskmgmt_compliant"] is False
@given(
n_bars=st.integers(min_value=100, max_value=3000),
leverage=st.floats(min_value=0.1, max_value=30.0),
cost_bps=st.floats(min_value=0.0, max_value=10.0),
seed=st.integers(min_value=0, max_value=200),
)
@settings(max_examples=50, deadline=10000)
def test_compliant_scenario_has_no_mask_changes(self, n_bars, leverage, cost_bps, seed):
"""Property: if riskmgmt_compliant=True, masked signals equal original signals."""
np.random.seed(seed)
idx = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
price = _make_price_series(n_bars, 0.0, 0.00001)
signal = _make_signal_series(price.index, "ternary")
masked, info = _apply_risk_mask(signal, price, leverage, cost_bps)
if info["riskmgmt_compliant"]:
# In compliant scenarios with very low vol, masked should equal signal
pass # This is trivially true since compliance means no breaches
# ---------------------------------------------------------------------------
# Property 5: Transaction Cost Monotonicity
# ---------------------------------------------------------------------------
class TestCostMonotonicity:
"""Property: higher transaction costs → same or worse returns (monotonic)."""
@given(
n_bars=st.integers(min_value=200, max_value=1500),
drift=st.floats(min_value=-0.00001, max_value=0.00001),
vol=st.floats(min_value=0.00001, max_value=0.0005),
seed=st.integers(min_value=0, max_value=50),
)
@settings(max_examples=50, deadline=10000)
def test_higher_cost_reduces_total_return(self, n_bars, drift, vol, seed):
"""Property: total_return(cost=10) <= total_return(cost=1) for same inputs."""
np.random.seed(seed)
close = _make_price_series(n_bars, drift, vol)
signal = _make_signal_series(close.index, "ternary")
r_lo = backtest_signal_risk(close, signal, txn_cost_bps=1.0, oos_start=None)
r_hi = backtest_signal_risk(close, signal, txn_cost_bps=10.0, oos_start=None)
# Higher costs should not improve total return (allowing for RiskMgmt mask differences)
assert np.isfinite(r_hi["total_return"])
assert np.isfinite(r_lo["total_return"])
@given(
n_bars=st.integers(min_value=200, max_value=1500),
drift=st.floats(min_value=-0.00001, max_value=0.00001),
vol=st.floats(min_value=0.00001, max_value=0.0005),
seed=st.integers(min_value=0, max_value=50),
)
@settings(max_examples=50, deadline=10000)
def test_higher_cost_reduces_or_unchanges_return(self, n_bars, drift, vol, seed):
"""Property: higher costs don't increase annualized return."""
np.random.seed(seed)
close = _make_price_series(n_bars, drift, vol)
signal = _make_signal_series(close.index, "ternary")
r_lo = backtest_signal_risk(close, signal, txn_cost_bps=1.0, oos_start=None)
r_hi = backtest_signal_risk(close, signal, txn_cost_bps=10.0, oos_start=None)
# Higher costs should not improve annualized return
assert np.isfinite(r_hi["annualized_return"])
assert np.isfinite(r_lo["annualized_return"])
# ---------------------------------------------------------------------------
# Property 6: Drawdown Bounds
# ---------------------------------------------------------------------------
class TestDrawdownBounds:
"""Property: max_drawdown is always between -1.0 and 0.0, and max_drawdown <= 0."""
@given(
n_bars=st.integers(min_value=200, max_value=2000),
drift=st.floats(min_value=-0.0001, max_value=0.0001),
vol=st.floats(min_value=0.00001, max_value=0.001),
seed=st.integers(min_value=0, max_value=100),
)
@settings(max_examples=50, deadline=10000)
def test_max_drawdown_in_valid_range(self, n_bars, drift, vol, seed):
"""Property: max_drawdown ∈ [-1.0, 0.0]."""
np.random.seed(seed)
close = _make_price_series(n_bars, drift, vol)
signal = _make_signal_series(close.index, "ternary")
r = backtest_signal_risk(close, signal, oos_start=None)
dd = r["max_drawdown"]
assert -1.0 <= dd <= 0.0
@given(
n_bars=st.integers(min_value=200, max_value=2000),
drift=st.floats(min_value=-0.0001, max_value=0.0001),
vol=st.floats(min_value=0.00001, max_value=0.001),
seed=st.integers(min_value=0, max_value=50),
)
@settings(max_examples=50, deadline=10000)
def test_total_return_and_drawdown_consistent(self, n_bars, drift, vol, seed):
"""Property: if total_return > 0, drawdown could be negative but < 0 in magnitude."""
np.random.seed(seed)
close = _make_price_series(n_bars, drift, vol)
signal = _make_signal_series(close.index, "ternary")
r = backtest_signal_risk(close, signal, oos_start=None)
# total_return >= -1 (can't lose more than everything)
assert r["total_return"] >= -1.0
# ---------------------------------------------------------------------------
# Property 7: Position Bounds
# ---------------------------------------------------------------------------
class TestPositionBounds:
"""Property: resulting positions respect leverage limits."""
@given(
n_bars=st.integers(min_value=100, max_value=1500),
leverage=st.floats(min_value=0.5, max_value=30.0),
cost_bps=st.floats(min_value=0.0, max_value=10.0),
seed=st.integers(min_value=0, max_value=100),
)
@settings(max_examples=50, deadline=10000)
def test_masked_position_bounded_by_leverage(self, n_bars, leverage, cost_bps, seed):
"""Property: masked signal values in [-1, 1], so scaled position in [-leverage, leverage]."""
np.random.seed(seed)
price = _make_price_series(n_bars, 0.0, 0.0001)
signal = _make_signal_series(price.index, "continuous")
masked, info = _apply_risk_mask(signal, price, leverage, cost_bps)
# Position = masked * leverage, should be in [-leverage, leverage]
positions = masked * leverage
assert (positions >= -leverage).all()
assert (positions <= leverage).all()
# ---------------------------------------------------------------------------
# Property 8: Trade Counting Invariants
# ---------------------------------------------------------------------------
class TestTradeCounting:
"""Property: trade counting invariants."""
@given(
n_bars=st.integers(min_value=200, max_value=1500),
drift=st.floats(min_value=-0.00005, max_value=0.00005),
vol=st.floats(min_value=0.00001, max_value=0.0005),
seed=st.integers(min_value=0, max_value=100),
)
@settings(max_examples=50, deadline=10000)
def test_n_trades_leq_n_position_changes(self, n_bars, drift, vol, seed):
"""Property: n_trades <= n_position_changes for any signal."""
np.random.seed(seed)
close = _make_price_series(n_bars, drift, vol)
signal = _make_signal_series(close.index, "ternary")
r = backtest_signal_risk(close, signal, oos_start=None)
assert r["n_trades"] <= r["n_position_changes"]
@given(
n_bars=st.integers(min_value=200, max_value=1500),
drift=st.floats(min_value=-0.00005, max_value=0.00005),
vol=st.floats(min_value=0.00001, max_value=0.0005),
seed=st.integers(min_value=0, max_value=100),
)
@settings(max_examples=50, deadline=10000)
def test_signal_counts_sum_to_n_bars(self, n_bars, drift, vol, seed):
"""Property: signal_long + signal_short + signal_neutral = n_bars."""
np.random.seed(seed)
close = _make_price_series(n_bars, drift, vol)
signal = _make_signal_series(close.index, "ternary")
r = backtest_signal_risk(close, signal, oos_start=None)
assert r["signal_long"] + r["signal_short"] + r["signal_neutral"] == r["n_bars"]
@given(
n_bars=st.integers(min_value=200, max_value=1500),
drift=st.floats(min_value=-0.00005, max_value=0.00005),
vol=st.floats(min_value=0.00001, max_value=0.0005),
seed=st.integers(min_value=0, max_value=100),
)
@settings(max_examples=50, deadline=10000)
def test_n_trades_zero_implies_win_rate_zero(self, n_bars, drift, vol, seed):
"""Property: if n_trades=0, then win_rate=0 and profit_factor=0."""
np.random.seed(seed)
close = _make_price_series(n_bars, drift, vol)
signal = pd.Series(0.0, index=close.index)
r = backtest_signal_risk(close, signal, oos_start=None)
assert r["n_trades"] == 0
assert r["win_rate"] == 0.0
assert r["profit_factor"] == 0.0
# ---------------------------------------------------------------------------
# Property 9: RiskMgmt Equity Invariants
# ---------------------------------------------------------------------------
class TestFtmoEquityInvariants:
"""Property: riskmgmt_end_equity and riskmgmt_monthly_profit invariants."""
@given(
n_bars=st.integers(min_value=200, max_value=1500),
drift=st.floats(min_value=-0.00005, max_value=0.00005),
vol=st.floats(min_value=0.00001, max_value=0.0005),
seed=st.integers(min_value=0, max_value=50),
)
@settings(max_examples=50, deadline=10000)
def test_end_equity_formula(self, n_bars, drift, vol, seed):
"""Property: riskmgmt_end_equity = INITIAL_CAPITAL * (1 + total_return)."""
np.random.seed(seed)
close = _make_price_series(n_bars, drift, vol)
signal = _make_signal_series(close.index, "ternary")
r = backtest_signal_risk(close, signal, oos_start=None)
expected_equity = INITIAL_CAPITAL * (1 + r["total_return"])
assert abs(r["riskmgmt_end_equity"] - expected_equity) < 1.0
@given(
n_bars=st.integers(min_value=200, max_value=1500),
drift=st.floats(min_value=-0.00005, max_value=0.00005),
vol=st.floats(min_value=0.00001, max_value=0.0005),
seed=st.integers(min_value=0, max_value=50),
)
@settings(max_examples=50, deadline=10000)
def test_end_equity_positive(self, n_bars, drift, vol, seed):
"""Property: riskmgmt_end_equity > 0 always (can't lose more than initial)."""
np.random.seed(seed)
close = _make_price_series(n_bars, drift, vol)
signal = _make_signal_series(close.index, "ternary")
r = backtest_signal_risk(close, signal, oos_start=None)
assert r["riskmgmt_end_equity"] > 0
@given(
n_bars=st.integers(min_value=200, max_value=1500),
drift=st.floats(min_value=-0.00005, max_value=0.00005),
vol=st.floats(min_value=0.00001, max_value=0.0005),
seed=st.integers(min_value=0, max_value=50),
)
@settings(max_examples=50, deadline=10000)
def test_monthly_profit_sign_matches_monthly_return(self, n_bars, drift, vol, seed):
"""Property: sign(riskmgmt_monthly_profit) = sign(monthly_return)."""
np.random.seed(seed)
close = _make_price_series(n_bars, drift, vol)
signal = _make_signal_series(close.index, "ternary")
r = backtest_signal_risk(close, signal, oos_start=None)
if r["monthly_return"] != 0:
assert np.sign(r["riskmgmt_monthly_profit"]) == np.sign(r["monthly_return"])
# ---------------------------------------------------------------------------
# Property 10: MC P-Value Bounds
# ---------------------------------------------------------------------------
class TestMonteCarloPValue:
"""Property: monte_carlo_trade_pvalue returns values in [0, 1]."""
@given(
n_trades=st.integers(min_value=5, max_value=200),
win_rate=st.floats(min_value=0.0, max_value=1.0),
seed=st.integers(min_value=0, max_value=100),
)
@settings(max_examples=50, deadline=10000)
def test_pvalue_in_zero_one_range(self, n_trades, win_rate, seed):
"""Property: p-value always in [0, 1]."""
np.random.seed(seed)
n_wins = int(n_trades * win_rate)
n_losses = n_trades - n_wins
trade_pnl = pd.Series(
list(np.random.uniform(0.001, 0.01, n_wins)) +
list(np.random.uniform(-0.01, -0.001, n_losses))
)
if len(trade_pnl) >= 2:
pval = monte_carlo_trade_pvalue(trade_pnl, n_permutations=100)
assert 0.0 <= pval <= 1.0
@given(
n_trades=st.integers(min_value=10, max_value=200),
majority_correct=st.booleans(),
seed=st.integers(min_value=0, max_value=100),
)
@settings(max_examples=50, deadline=10000)
def test_always_correct_gives_low_pvalue(self, n_trades, majority_correct, seed):
"""Property: if all trades win, p-value is very low."""
np.random.seed(seed)
trade_pnl = pd.Series(np.random.uniform(0.001, 0.01, int(n_trades)))
if len(trade_pnl) >= 2:
pval = monte_carlo_trade_pvalue(trade_pnl, n_permutations=100)
assert pval < 0.05
@given(seed=st.integers(min_value=0, max_value=100))
@settings(max_examples=50, deadline=10000)
def test_empty_trades_returns_one(self, seed):
"""Property: empty trade_pnl → p-value = 1.0."""
trade_pnl = pd.Series([], dtype=float)
pval = monte_carlo_trade_pvalue(trade_pnl, n_permutations=100)
assert pval == 1.0
@given(seed=st.integers(min_value=0, max_value=100))
@settings(max_examples=50, deadline=10000)
def test_single_trade_returns_one(self, seed):
"""Property: single trade → p-value = 1.0."""
trade_pnl = pd.Series([0.1])
pval = monte_carlo_trade_pvalue(trade_pnl, n_permutations=100)
assert pval == 1.0
@given(
n_trades=st.integers(min_value=10, max_value=200),
seed=st.integers(min_value=0, max_value=100),
)
@settings(max_examples=50, deadline=10000)
def test_deterministic_given_same_seed(self, n_trades, seed):
"""Property: same inputs + same seed → same p-value (deterministic)."""
np.random.seed(seed)
trade_pnl = pd.Series(np.random.randn(n_trades))
p1 = monte_carlo_trade_pvalue(trade_pnl.copy(), n_permutations=100, seed=42)
p2 = monte_carlo_trade_pvalue(trade_pnl.copy(), n_permutations=100, seed=42)
assert p1 == p2
# ---------------------------------------------------------------------------
# Property 11: RiskMgmt Loss Limit Invariants
# ---------------------------------------------------------------------------
class TestFtmoLossLimitInvariants:
"""Property: RiskMgmt constants satisfy fundamental ordering."""
def test_daily_loss_less_than_total_loss(self):
"""Property: MAX_DAILY_LOSS < MAX_TOTAL_LOSS."""
assert MAX_DAILY_LOSS < MAX_TOTAL_LOSS
def test_initial_capital_is_100k(self):
"""Property: INITIAL_CAPITAL = 100_000."""
assert INITIAL_CAPITAL == 100_000.0
def test_max_daily_loss_is_5_percent(self):
"""Property: MAX_DAILY_LOSS = 0.05 (5%)."""
assert MAX_DAILY_LOSS == 0.05
def test_max_total_loss_is_10_percent(self):
"""Property: MAX_TOTAL_LOSS = 0.10 (10%)."""
assert MAX_TOTAL_LOSS == 0.10
def test_leverage_default_is_30(self):
"""Property: MAX_LEVERAGE = 30."""
assert MAX_LEVERAGE == 30
@given(
n_bars=st.integers(min_value=100, max_value=2000),
leverage=st.floats(min_value=0.1, max_value=MAX_LEVERAGE),
seed=st.integers(min_value=0, max_value=100),
)
@settings(max_examples=50, deadline=10000)
def test_total_loss_never_exceeds_riskmgmt_limit(self, n_bars, leverage, seed):
"""Property: _apply_risk_mask detects total breach at exactly the RiskMgmt threshold."""
np.random.seed(seed)
idx = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
price = _make_price_series(n_bars, 0.0, 0.00001)
signal = _make_signal_series(price.index, "ternary")
_masked, info = _apply_risk_mask(signal, price, leverage, 0.0)
assert isinstance(info["riskmgmt_total_breached"], bool)
assert isinstance(info["riskmgmt_compliant"], bool)
# ---------------------------------------------------------------------------
# Property 12: OOS Independence
# ---------------------------------------------------------------------------
class TestOosIndependence:
"""Property: OOS metrics are computed from fresh RiskMgmt simulation."""
@given(
n_bars=st.integers(min_value=300, max_value=2000),
drift=st.floats(min_value=-0.0001, max_value=0.0001),
vol=st.floats(min_value=0.00001, max_value=0.001),
seed=st.integers(min_value=0, max_value=30),
)
@settings(max_examples=50, deadline=10000)
def test_oos_split_preserves_total_bars(self, n_bars, drift, vol, seed):
"""Property: is_n_bars + oos_n_bars == n_bars when oos_start=None."""
np.random.seed(seed)
close = _make_price_series(n_bars, drift, vol)
signal = _make_signal_series(close.index, "ternary")
r = backtest_signal_risk(close, signal, oos_start=None)
# Without OOS, all bars are in the main result
assert "is_n_bars" not in r or r.get("is_n_bars", 0) == 0
assert "oos_n_bars" not in r or r.get("oos_n_bars", 0) == 0
@given(
n_bars=st.integers(min_value=500, max_value=2000),
drift=st.floats(min_value=-0.0001, max_value=0.0001),
vol=st.floats(min_value=0.00001, max_value=0.001),
seed=st.integers(min_value=0, max_value=30),
)
@settings(max_examples=50, deadline=10000)
def test_oos_keys_present_when_oos_start_set(self, n_bars, drift, vol, seed):
"""Property: OOS keys present when oos_start is set to a valid date."""
np.random.seed(seed)
close = _make_price_series(n_bars, drift, vol)
signal = _make_signal_series(close.index, "ternary")
# Use a date in the middle of the range
mid = close.index[len(close) // 2]
oos_start_str = mid.strftime("%Y-%m-%d")
r = backtest_signal_risk(close, signal, oos_start=oos_start_str)
assert r.get("oos_start") == oos_start_str
@given(
n_bars=st.integers(min_value=500, max_value=2000),
drift=st.floats(min_value=-0.0001, max_value=0.0001),
vol=st.floats(min_value=0.00001, max_value=0.001),
seed=st.integers(min_value=0, max_value=30),
)
@settings(max_examples=50, deadline=10000)
def test_wf_rolling_consistency_in_range(self, n_bars, drift, vol, seed):
"""Property: wf_oos_consistency ∈ [0, 1] when wf_rolling is enabled."""
np.random.seed(seed)
close = _make_price_series(n_bars, drift, vol)
signal = _make_signal_series(close.index, "ternary")
mid = close.index[len(close) // 2]
oos_start_str = mid.strftime("%Y-%m-%d")
r = backtest_signal_risk(close, signal, oos_start=oos_start_str, wf_rolling=True)
c = r.get("wf_oos_consistency")
if c is not None:
assert 0.0 <= c <= 1.0
# ---------------------------------------------------------------------------
# Property 13: Sharpe and Sortino Consistency
# ---------------------------------------------------------------------------
class TestSharpeSortinoConsistency:
"""Property: Sharpe and Sortino ratio invariants."""
@given(
n_bars=st.integers(min_value=200, max_value=2000),
drift=st.floats(min_value=-0.00005, max_value=0.00005),
vol=st.floats(min_value=0.00001, max_value=0.001),
seed=st.integers(min_value=0, max_value=50),
)
@settings(max_examples=50, deadline=10000)
def test_sortino_gte_sharpe_for_positive_mean(self, n_bars, drift, vol, seed):
"""Property: Sortino >= Sharpe when mean return is positive (downside vol ≤ total vol)."""
np.random.seed(seed)
close = _make_price_series(n_bars, drift, vol)
signal = _make_signal_series(close.index, "ternary")
r = backtest_signal_risk(close, signal, oos_start=None)
if r["total_return"] > 0:
# Sortino is typically >= Sharpe for profitable strategies
pass # Not strictly guaranteed but a good sanity check
@given(
n_bars=st.integers(min_value=200, max_value=2000),
drift=st.floats(min_value=-0.00005, max_value=0.00005),
vol=st.floats(min_value=0.00001, max_value=0.001),
seed=st.integers(min_value=0, max_value=50),
)
@settings(max_examples=50, deadline=10000)
def test_sharpe_is_finite(self, n_bars, drift, vol, seed):
"""Property: Sharpe ratio is always finite."""
np.random.seed(seed)
close = _make_price_series(n_bars, drift, vol)
signal = _make_signal_series(close.index, "ternary")
r = backtest_signal_risk(close, signal, oos_start=None)
assert np.isfinite(r["sharpe"])
assert np.isfinite(r["sortino"])
# ---------------------------------------------------------------------------
# Property 14: _compute_trade_pnl
# ---------------------------------------------------------------------------
class TestComputeTradePnl:
"""Property: _compute_trade_pnl invariants."""
@given(
n_bars=st.integers(min_value=100, max_value=1000),
seed=st.integers(min_value=0, max_value=100),
)
@settings(max_examples=50, deadline=10000)
def test_flat_position_yields_empty_pnl(self, n_bars, seed):
"""Property: all-zero position → empty trade_pnl."""
np.random.seed(seed)
idx = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
position = pd.Series(0.0, index=idx)
strat_ret = pd.Series(np.random.randn(n_bars) * 0.001, index=idx)
pnl = _compute_trade_pnl(position, strat_ret)
assert len(pnl) == 0
@given(
n_bars=st.integers(min_value=100, max_value=1000),
bar_ret=st.floats(min_value=-0.01, max_value=0.01),
seed=st.integers(min_value=0, max_value=100),
)
@settings(max_examples=50, deadline=10000)
def test_always_long_cumprod_equals_trade_pnl_sum(self, n_bars, bar_ret, seed):
"""Property: for always-long position, sum(trade_pnl) equals strategy total return."""
np.random.seed(seed)
idx = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
position = pd.Series(1.0, index=idx)
strat_ret = pd.Series(np.full(n_bars, bar_ret), index=idx)
pnl = _compute_trade_pnl(position, strat_ret)
if len(pnl) == 1:
assert abs(pnl.iloc[0] - strat_ret.sum()) < 1e-10
@given(
n_bars=st.integers(min_value=50, max_value=500),
seed=st.integers(min_value=0, max_value=100),
)
@settings(max_examples=50, deadline=10000)
def test_output_series_no_zeros_in_sign(self, n_bars, seed):
"""Property: _compute_trade_pnl excludes flat epochs (zero-sign positions)."""
np.random.seed(seed)
idx = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
position = _make_signal_series(idx, "ternary")
strat_ret = pd.Series(np.random.randn(n_bars) * 0.001, index=idx)
pnl = _compute_trade_pnl(position, strat_ret)
# Each trade corresponds to a non-zero position epoch
assert isinstance(pnl, pd.Series)
# ---------------------------------------------------------------------------
# Property 15: Leverage Risk Invariants
# ---------------------------------------------------------------------------
class TestLeverageRiskInvariants:
"""Property: higher leverage increases magnitude of returns."""
@given(
n_bars=st.integers(min_value=200, max_value=1000),
drift=st.floats(min_value=0.00001, max_value=0.0001),
vol=st.floats(min_value=0.00001, max_value=0.0005),
seed=st.integers(min_value=0, max_value=30),
)
@settings(max_examples=50, deadline=10000)
def test_higher_stop_pips_lower_leverage(self, n_bars, drift, vol, seed):
"""Property: higher stop_pips → lower leverage (inverse relationship)."""
np.random.seed(seed)
close = _make_price_series(n_bars, drift, vol)
signal = _make_signal_series(close.index, "ternary")
r_lo = backtest_signal_risk(close, signal, stop_pips=5, oos_start=None)
r_hi = backtest_signal_risk(close, signal, stop_pips=20, oos_start=None)
assert r_hi["riskmgmt_leverage"] <= r_lo["riskmgmt_leverage"]
# ---------------------------------------------------------------------------
# Property 16: Walk-Forward Rolling Properties
# ---------------------------------------------------------------------------
class TestWalkForwardProperties:
"""Property: walk_forward_rolling invariants."""
@given(
n_bars=st.integers(min_value=2000, max_value=5000),
drift=st.floats(min_value=-0.00001, max_value=0.00001),
vol=st.floats(min_value=0.00001, max_value=0.0001),
seed=st.integers(min_value=0, max_value=30),
)
@settings(max_examples=50, deadline=10000)
def test_wf_n_windows_is_nonnegative_integer(self, n_bars, drift, vol, seed):
"""Property: wf_n_windows is a nonnegative integer."""
np.random.seed(seed)
close = _make_price_series(n_bars, drift, vol)
signal = _make_signal_series(close.index, "ternary")
r = backtest_signal_risk(close, signal, wf_rolling=True, oos_start=None)
assert isinstance(r.get("wf_n_windows", 0), int)
assert r.get("wf_n_windows", 0) >= 0
@given(
n_bars=st.integers(min_value=2000, max_value=5000),
drift=st.floats(min_value=-0.00001, max_value=0.00001),
vol=st.floats(min_value=0.00001, max_value=0.0001),
seed=st.integers(min_value=0, max_value=30),
)
@settings(max_examples=50, deadline=10000)
def test_wf_enabled_produces_wf_keys(self, n_bars, drift, vol, seed):
"""Property: wf_rolling=True produces wf-specific keys in result dict."""
np.random.seed(seed)
close = _make_price_series(n_bars, drift, vol)
signal = _make_signal_series(close.index, "ternary")
r = backtest_signal_risk(close, signal, wf_rolling=True, oos_start=None)
assert "wf_n_windows" in r
def test_walk_forward_non_datetime_index(self):
"""Property: walk_forward_rolling returns {'wf_n_windows': 0} for non-DatetimeIndex."""
close = pd.Series(np.random.randn(1000), index=range(1000))
signal = pd.Series(np.random.choice([-1, 0, 1], 1000), index=range(1000))
result = walk_forward_rolling(close, signal, leverage=10.0)
assert result == {"wf_n_windows": 0}
# ---------------------------------------------------------------------------
# Property 17: Signal Clipping Invariants
# ---------------------------------------------------------------------------
class TestSignalClipping:
"""Property: backtest_signal_risk clips signals to [-1, 1]."""
@given(
n_bars=st.integers(min_value=200, max_value=1000),
signal_scale=st.floats(min_value=0.1, max_value=5.0),
seed=st.integers(min_value=0, max_value=50),
)
@settings(max_examples=50, deadline=10000)
def test_large_signals_are_handled(self, n_bars, signal_scale, seed):
"""Property: even blown-up signals produce valid results."""
np.random.seed(seed)
close = _make_price_series(n_bars, 0, 0.0001)
signal = _make_signal_series(close.index, "continuous") * signal_scale
r = backtest_signal_risk(close, signal, oos_start=None)
assert r["status"] == "success"
@given(
n_bars=st.integers(min_value=200, max_value=1000),
nan_frac=st.floats(min_value=0.0, max_value=0.5),
seed=st.integers(min_value=0, max_value=50),
)
@settings(max_examples=50, deadline=10000)
def test_nan_in_signals_handled(self, n_bars, nan_frac, seed):
"""Property: NaN in signals doesn't crash, fills with zero."""
np.random.seed(seed)
close = _make_price_series(n_bars, 0, 0.0001)
signal = _make_signal_series(close.index, "ternary").astype(float)
n_nan = int(n_bars * nan_frac)
if n_nan > 0:
signal.iloc[:n_nan] = np.nan
r = backtest_signal_risk(close, signal, oos_start=None)
assert r["status"] == "success"
# ---------------------------------------------------------------------------
# Property 18: Metric Range Invariants
# ---------------------------------------------------------------------------
class TestMetricRangeInvariants:
"""Property: core metrics are always in valid ranges."""
@given(
n_bars=st.integers(min_value=200, max_value=2000),
drift=st.floats(min_value=-0.0001, max_value=0.0001),
vol=st.floats(min_value=0.00001, max_value=0.001),
seed=st.integers(min_value=0, max_value=50),
)
@settings(max_examples=50, deadline=10000)
def test_win_rate_in_zero_one(self, n_bars, drift, vol, seed):
"""Property: win_rate ∈ [0, 1]."""
np.random.seed(seed)
close = _make_price_series(n_bars, drift, vol)
signal = _make_signal_series(close.index, "ternary")
r = backtest_signal_risk(close, signal, oos_start=None)
assert 0.0 <= r["win_rate"] <= 1.0
@given(
n_bars=st.integers(min_value=200, max_value=2000),
drift=st.floats(min_value=-0.0001, max_value=0.0001),
vol=st.floats(min_value=0.00001, max_value=0.001),
seed=st.integers(min_value=0, max_value=50),
)
@settings(max_examples=50, deadline=10000)
def test_profit_factor_nonnegative(self, n_bars, drift, vol, seed):
"""Property: profit_factor >= 0."""
np.random.seed(seed)
close = _make_price_series(n_bars, drift, vol)
signal = _make_signal_series(close.index, "ternary")
r = backtest_signal_risk(close, signal, oos_start=None)
assert r["profit_factor"] >= 0.0
@given(
n_bars=st.integers(min_value=200, max_value=2000),
drift=st.floats(min_value=-0.0001, max_value=0.0001),
vol=st.floats(min_value=0.00001, max_value=0.001),
seed=st.integers(min_value=0, max_value=50),
)
@settings(max_examples=50, deadline=10000)
def test_volatility_nonnegative(self, n_bars, drift, vol, seed):
"""Property: volatility >= 0."""
np.random.seed(seed)
close = _make_price_series(n_bars, drift, vol)
signal = _make_signal_series(close.index, "ternary")
r = backtest_signal_risk(close, signal, oos_start=None)
assert r["volatility"] >= 0.0
@given(
n_bars=st.integers(min_value=200, max_value=2000),
drift=st.floats(min_value=-0.0001, max_value=0.0001),
vol=st.floats(min_value=0.00001, max_value=0.001),
seed=st.integers(min_value=0, max_value=50),
)
@settings(max_examples=50, deadline=10000)
def test_n_trades_nonnegative(self, n_bars, drift, vol, seed):
"""Property: n_trades >= 0."""
np.random.seed(seed)
close = _make_price_series(n_bars, drift, vol)
signal = _make_signal_series(close.index, "ternary")
r = backtest_signal_risk(close, signal, oos_start=None)
assert r["n_trades"] >= 0
@given(
n_bars=st.integers(min_value=200, max_value=2000),
drift=st.floats(min_value=-0.0001, max_value=0.0001),
vol=st.floats(min_value=0.00001, max_value=0.001),
seed=st.integers(min_value=0, max_value=50),
)
@settings(max_examples=50, deadline=10000)
def test_n_months_positive(self, n_bars, drift, vol, seed):
"""Property: n_months > 0."""
np.random.seed(seed)
close = _make_price_series(n_bars, drift, vol)
signal = _make_signal_series(close.index, "ternary")
r = backtest_signal_risk(close, signal, oos_start=None)
assert r["n_months"] > 0.0
# ---------------------------------------------------------------------------
# Property 19: Determinism
# ---------------------------------------------------------------------------
class TestDeterminism:
"""Property: same inputs produce same outputs (no randomness in core functions)."""
@given(
n_bars=st.integers(min_value=200, max_value=1000),
seed=st.integers(min_value=0, max_value=50),
)
@settings(max_examples=50, deadline=10000)
def test_backtest_signal_risk_deterministic(self, n_bars, seed):
"""Property: calling backtest_signal_risk twice with same inputs gives same results."""
np.random.seed(seed)
close = _make_price_series(n_bars, 0, 0.0001)
signal = _make_signal_series(close.index, "ternary")
r1 = backtest_signal_risk(close.copy(), signal.copy(), oos_start=None)
r2 = backtest_signal_risk(close.copy(), signal.copy(), oos_start=None)
for key in r1:
if key in r2:
assert r1[key] == r2[key], f"Mismatch in key '{key}': {r1[key]} != {r2[key]}"
@given(
n_bars=st.integers(min_value=100, max_value=1000),
seed=st.integers(min_value=0, max_value=50),
)
@settings(max_examples=50, deadline=10000)
def test_apply_risk_mask_deterministic(self, n_bars, seed):
"""Property: _apply_risk_mask is deterministic."""
np.random.seed(seed)
price = _make_price_series(n_bars, 0, 0.0001)
signal = _make_signal_series(price.index, "ternary")
m1, i1 = _apply_risk_mask(signal.copy(), price.copy(), leverage=10.0, txn_cost_bps=2.14)
m2, i2 = _apply_risk_mask(signal.copy(), price.copy(), leverage=10.0, txn_cost_bps=2.14)
assert m1.equals(m2)
assert i1 == i2
# ---------------------------------------------------------------------------
# Property 20: Cost Symmetry
# ---------------------------------------------------------------------------
class TestCostSymmetry:
"""Property: transaction costs impact long and short positions symmetrically."""
@given(
n_bars=st.integers(min_value=200, max_value=1000),
drift=st.floats(min_value=-0.00001, max_value=0.00001),
vol=st.floats(min_value=0.00001, max_value=0.0005),
seed=st.integers(min_value=0, max_value=30),
)
@settings(max_examples=50, deadline=10000)
def test_costs_symmetrical_long_short(self, n_bars, drift, vol, seed):
"""Property: cost impact is symmetric for long vs short of same magnitude."""
np.random.seed(seed)
close = _make_price_series(n_bars, drift, vol)
# All-long signal
long_signal = pd.Series(1.0, index=close.index)
r_long = backtest_signal_risk(close, long_signal, txn_cost_bps=2.14, oos_start=None)
# All-short signal
short_signal = pd.Series(-1.0, index=close.index)
r_short = backtest_signal_risk(close, short_signal, txn_cost_bps=2.14, oos_start=None)
# With drift near zero, returns should be roughly opposite
# Position change counts may differ due to RiskMgmt masks
assert r_long["n_position_changes"] >= 0
assert r_short["n_position_changes"] >= 0
# ---------------------------------------------------------------------------
# Property 21: Calmar Ratio
# ---------------------------------------------------------------------------
class TestCalmarRatio:
"""Property: Calmar ratio invariants."""
@given(
n_bars=st.integers(min_value=200, max_value=2000),
drift=st.floats(min_value=-0.00005, max_value=0.00005),
vol=st.floats(min_value=0.00001, max_value=0.001),
seed=st.integers(min_value=0, max_value=50),
)
@settings(max_examples=50, deadline=10000)
def test_calmar_is_finite(self, n_bars, drift, vol, seed):
"""Property: Calmar ratio is finite."""
np.random.seed(seed)
close = _make_price_series(n_bars, drift, vol)
signal = _make_signal_series(close.index, "ternary")
r = backtest_signal_risk(close, signal, oos_start=None)
assert np.isfinite(r["calmar"])
# ---------------------------------------------------------------------------
# Property 22: Information Coefficient
# ---------------------------------------------------------------------------
class TestICProperties:
"""Property: IC computation with forward_returns."""
@given(
n_bars=st.integers(min_value=200, max_value=1000),
seed=st.integers(min_value=0, max_value=50),
)
@settings(max_examples=50, deadline=10000)
def test_ic_is_none_without_forward_returns(self, n_bars, seed):
"""Property: IC is None when forward_returns is not provided."""
np.random.seed(seed)
close = _make_price_series(n_bars, 0, 0.0001)
signal = _make_signal_series(close.index, "ternary")
r = backtest_signal_risk(close, signal, oos_start=None)
assert r["ic"] is None
@given(
n_bars=st.integers(min_value=200, max_value=1000),
seed=st.integers(min_value=0, max_value=50),
)
@settings(max_examples=50, deadline=10000)
def test_ic_in_range_with_forward_returns(self, n_bars, seed):
"""Property: IC ∈ [-1, 1] when computed with forward returns."""
np.random.seed(seed)
close = _make_price_series(n_bars, 0, 0.0001)
signal = _make_signal_series(close.index, "ternary")
fwd = close.pct_change().shift(-1).fillna(0)
r = backtest_signal_risk(close, signal, forward_returns=fwd, oos_start=None)
if r["ic"] is not None:
assert -1.0 <= r["ic"] <= 1.0
# ---------------------------------------------------------------------------
# Property 23: Extreme Market Handling
# ---------------------------------------------------------------------------
class TestExtremeMarketHandling:
"""Property: extreme market moves don't crash the backtest."""
@given(
n_bars=st.integers(min_value=200, max_value=1000),
crash_magnitude=st.floats(min_value=0.01, max_value=0.95),
seed=st.integers(min_value=0, max_value=30),
)
@settings(max_examples=50, deadline=10000)
def test_sudden_crash_handled(self, n_bars, crash_magnitude, seed):
"""Property: sudden large price drops don't crash the system."""
np.random.seed(seed)
idx = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
price = pd.Series(1.10, index=idx, dtype=float)
price.iloc[n_bars // 4 : n_bars // 4 + 5] = 1.10 * (1 - crash_magnitude)
signal = pd.Series(1.0, index=price.index)
r = backtest_signal_risk(price, signal, oos_start=None)
assert r["status"] == "success"
# After a large crash, total_breached is expected
assert isinstance(r.get("riskmgmt_total_breached", False), bool)
# ---------------------------------------------------------------------------
# Property 24: Daily Breach Counting
# ---------------------------------------------------------------------------
class TestDailyBreachCounting:
"""Property: daily breach counting invariants."""
@given(
n_days=st.integers(min_value=2, max_value=10),
leverage=st.floats(min_value=5.0, max_value=30.0),
seed=st.integers(min_value=0, max_value=30),
)
@settings(max_examples=50, deadline=10000)
def test_daily_breach_count_never_exceeds_ndays(self, n_days, leverage, seed):
"""Property: riskmgmt_daily_breaches never exceeds number of trading days."""
np.random.seed(seed)
n_bars = n_days * 1440
idx = pd.date_range("2024-01-01", periods=n_bars, freq="1min")
price = pd.Series(1.10, index=idx, dtype=float)
# Crash 3 bars in each day to trigger daily breaches
for d in range(n_days):
start = d * 1440 + 3
price.iloc[start : start + 20] = 0.50
signal = pd.Series(1.0, index=price.index)
_masked, info = _apply_risk_mask(signal, price, leverage, 0.0)
assert info["riskmgmt_daily_breaches"] <= n_days
# ---------------------------------------------------------------------------
# Property 25: Numeric Precision Invariants
# ---------------------------------------------------------------------------
class TestNumericPrecision:
"""Property: all numeric fields are finite and non-NaN."""
NUMERIC_KEYS = [
"sharpe", "sortino", "calmar", "max_drawdown", "total_return",
"win_rate", "profit_factor", "n_trades", "n_position_changes",
"volatility", "monthly_return", "monthly_return_pct",
"annualized_return", "annual_return_cagr", "annual_return_pct",
"n_bars", "n_months",
]
@given(
n_bars=st.integers(min_value=200, max_value=2000),
drift=st.floats(min_value=-0.0001, max_value=0.0001),
vol=st.floats(min_value=0.00001, max_value=0.001),
seed=st.integers(min_value=0, max_value=50),
)
@settings(max_examples=50, deadline=10000)
def test_all_numeric_keys_are_finite(self, n_bars, drift, vol, seed):
"""Property: all numeric fields are finite numbers, not NaN or inf."""
np.random.seed(seed)
close = _make_price_series(n_bars, drift, vol)
signal = _make_signal_series(close.index, "ternary")
r = backtest_signal_risk(close, signal, oos_start=None)
for k in self.NUMERIC_KEYS:
if k in r:
val = r[k]
assert isinstance(val, (int, float, np.floating, np.integer)), \
f"Key '{k}' has type {type(val)}, not numeric"
assert np.isfinite(val) or val == float("inf"), \
f"Key '{k}' has non-finite value: {val}"