4ffde8cc06
- Database migration 018 with 13 tables for trading engine state - Trading engine service (services/trading/) with 12 pure computation modules: position sizer, stop-loss manager, reserve pool, circuit breaker, risk tier controller, correlation matrix, tax lots, trading window, gradual entry, notifications, micro-trading, backtester - Core TradingEngine with pre-trade evaluation pipeline and integration wiring - FastAPI HTTP service with 14 endpoints (health, config, decisions, metrics, backtest) - Performance tracker with Sharpe ratio, drawdown, profit factor computation - 194 Python tests (165 property-based + 29 integration) - Frontend: 13 TanStack Query hooks, 7 dashboard panels, tabbed Trading Engine page - Helm chart entry, network policy, nginx proxy, ingress for trading-engine - Shared infrastructure: enums, Redis keys, TradingConfig in AppConfig
423 lines
17 KiB
Python
423 lines
17 KiB
Python
"""Property-based tests for the Circuit Breaker.
|
|
|
|
Feature: autonomous-trading-engine
|
|
|
|
Tests properties 13 and 14 from the design specification,
|
|
covering circuit breaker activation triggers (daily loss, single position,
|
|
volatility) and cooldown expiry behaviour.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from hypothesis import given, settings, assume
|
|
from hypothesis import strategies as st
|
|
|
|
from services.trading.circuit_breaker import CircuitBreaker
|
|
from services.trading.models import CircuitBreakerState
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hypothesis strategies
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _circuit_breaker_strategy() -> st.SearchStrategy[CircuitBreaker]:
|
|
"""Generate CircuitBreaker instances with random but valid thresholds."""
|
|
return st.builds(
|
|
CircuitBreaker,
|
|
daily_loss_pct=st.floats(min_value=0.01, max_value=0.20, allow_nan=False, allow_infinity=False),
|
|
single_position_loss_pct=st.floats(min_value=0.05, max_value=0.50, allow_nan=False, allow_infinity=False),
|
|
ticker_cooldown_hours=st.integers(min_value=1, max_value=168),
|
|
volatility_pause_hours=st.integers(min_value=1, max_value=24),
|
|
stop_loss_hits_threshold=st.integers(min_value=2, max_value=10),
|
|
stop_loss_window_minutes=st.integers(min_value=5, max_value=120),
|
|
)
|
|
|
|
|
|
def _aware_datetime_strategy(
|
|
min_dt: datetime | None = None,
|
|
max_dt: datetime | None = None,
|
|
) -> st.SearchStrategy[datetime]:
|
|
"""Generate timezone-aware UTC datetimes."""
|
|
_min = min_dt or datetime(2024, 1, 1, tzinfo=timezone.utc)
|
|
_max = max_dt or datetime(2025, 12, 31, tzinfo=timezone.utc)
|
|
return st.datetimes(min_value=_min.replace(tzinfo=None), max_value=_max.replace(tzinfo=None)).map(
|
|
lambda dt: dt.replace(tzinfo=timezone.utc)
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Property 13: Circuit breaker activation
|
|
# **Validates: Requirements 6.1, 6.2, 6.3**
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestProperty13CircuitBreakerActivation:
|
|
"""Property 13: Circuit breaker activation.
|
|
|
|
**Validates: Requirements 6.1, 6.2, 6.3**
|
|
"""
|
|
|
|
# -- Daily loss trigger ------------------------------------------------
|
|
|
|
@settings(max_examples=100)
|
|
@given(
|
|
cb=_circuit_breaker_strategy(),
|
|
portfolio_value=st.floats(min_value=100.0, max_value=100000.0, allow_nan=False, allow_infinity=False),
|
|
excess_pct=st.floats(min_value=0.001, max_value=0.50, allow_nan=False, allow_infinity=False),
|
|
)
|
|
def test_daily_loss_triggers_when_loss_exceeds_threshold(
|
|
self, cb: CircuitBreaker, portfolio_value: float, excess_pct: float,
|
|
) -> None:
|
|
"""Daily loss circuit breaker triggers when abs(daily_pnl)/portfolio_value > daily_loss_pct."""
|
|
# Construct a daily_pnl that exceeds the threshold
|
|
loss_ratio = cb.daily_loss_pct + excess_pct
|
|
daily_pnl = -(loss_ratio * portfolio_value)
|
|
|
|
result = cb.check_daily_loss(daily_pnl=daily_pnl, portfolio_value=portfolio_value)
|
|
assert result is True, (
|
|
f"Expected daily_loss trigger: loss_ratio={loss_ratio:.4f} > threshold={cb.daily_loss_pct:.4f}"
|
|
)
|
|
|
|
@settings(max_examples=100)
|
|
@given(
|
|
cb=_circuit_breaker_strategy(),
|
|
portfolio_value=st.floats(min_value=100.0, max_value=100000.0, allow_nan=False, allow_infinity=False),
|
|
below_fraction=st.floats(min_value=0.0, max_value=0.99, allow_nan=False, allow_infinity=False),
|
|
)
|
|
def test_daily_loss_does_not_trigger_below_threshold(
|
|
self, cb: CircuitBreaker, portfolio_value: float, below_fraction: float,
|
|
) -> None:
|
|
"""Daily loss circuit breaker does NOT trigger when loss ratio <= threshold."""
|
|
# Loss ratio strictly below threshold
|
|
loss_ratio = cb.daily_loss_pct * below_fraction
|
|
daily_pnl = -(loss_ratio * portfolio_value)
|
|
|
|
result = cb.check_daily_loss(daily_pnl=daily_pnl, portfolio_value=portfolio_value)
|
|
assert result is False, (
|
|
f"Should not trigger: loss_ratio={loss_ratio:.4f} <= threshold={cb.daily_loss_pct:.4f}"
|
|
)
|
|
|
|
@settings(max_examples=100)
|
|
@given(
|
|
cb=_circuit_breaker_strategy(),
|
|
portfolio_value=st.floats(min_value=100.0, max_value=100000.0, allow_nan=False, allow_infinity=False),
|
|
profit=st.floats(min_value=0.01, max_value=10000.0, allow_nan=False, allow_infinity=False),
|
|
)
|
|
def test_daily_loss_does_not_trigger_on_positive_pnl(
|
|
self, cb: CircuitBreaker, portfolio_value: float, profit: float,
|
|
) -> None:
|
|
"""Daily loss circuit breaker never triggers on positive P&L."""
|
|
result = cb.check_daily_loss(daily_pnl=profit, portfolio_value=portfolio_value)
|
|
assert result is False, "Should not trigger on positive daily P&L"
|
|
|
|
# -- Single position trigger -------------------------------------------
|
|
|
|
@settings(max_examples=100)
|
|
@given(
|
|
cb=_circuit_breaker_strategy(),
|
|
excess=st.floats(min_value=0.001, max_value=0.50, allow_nan=False, allow_infinity=False),
|
|
)
|
|
def test_single_position_triggers_when_loss_exceeds_threshold(
|
|
self, cb: CircuitBreaker, excess: float,
|
|
) -> None:
|
|
"""Single position circuit breaker triggers when position_loss_pct > threshold."""
|
|
position_loss_pct = cb.single_position_loss_pct + excess
|
|
|
|
result = cb.check_single_position(position_loss_pct=position_loss_pct)
|
|
assert result is True, (
|
|
f"Expected single_position trigger: loss={position_loss_pct:.4f} > threshold={cb.single_position_loss_pct:.4f}"
|
|
)
|
|
|
|
@settings(max_examples=100)
|
|
@given(
|
|
cb=_circuit_breaker_strategy(),
|
|
below_fraction=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
|
|
)
|
|
def test_single_position_does_not_trigger_at_or_below_threshold(
|
|
self, cb: CircuitBreaker, below_fraction: float,
|
|
) -> None:
|
|
"""Single position circuit breaker does NOT trigger when loss <= threshold."""
|
|
position_loss_pct = cb.single_position_loss_pct * below_fraction
|
|
|
|
result = cb.check_single_position(position_loss_pct=position_loss_pct)
|
|
assert result is False, (
|
|
f"Should not trigger: loss={position_loss_pct:.4f} <= threshold={cb.single_position_loss_pct:.4f}"
|
|
)
|
|
|
|
# -- Volatility trigger ------------------------------------------------
|
|
|
|
@settings(max_examples=100)
|
|
@given(
|
|
cb=_circuit_breaker_strategy(),
|
|
base_time=_aware_datetime_strategy(),
|
|
)
|
|
def test_volatility_triggers_when_enough_stops_within_window(
|
|
self, cb: CircuitBreaker, base_time: datetime,
|
|
) -> None:
|
|
"""Volatility circuit breaker triggers when >= threshold stop-losses fire within the window."""
|
|
# Generate exactly threshold hits within the window
|
|
window_minutes = cb.stop_loss_window_minutes
|
|
n_hits = cb.stop_loss_hits_threshold
|
|
|
|
# Space hits evenly within the window
|
|
interval = timedelta(minutes=window_minutes / n_hits)
|
|
stop_loss_hits = [base_time + interval * i for i in range(n_hits)]
|
|
|
|
result = cb.check_volatility(stop_loss_hits=stop_loss_hits)
|
|
assert result is True, (
|
|
f"Expected volatility trigger: {n_hits} hits within {window_minutes}min window"
|
|
)
|
|
|
|
@settings(max_examples=100)
|
|
@given(
|
|
cb=_circuit_breaker_strategy(),
|
|
base_time=_aware_datetime_strategy(),
|
|
)
|
|
def test_volatility_does_not_trigger_when_too_few_stops(
|
|
self, cb: CircuitBreaker, base_time: datetime,
|
|
) -> None:
|
|
"""Volatility circuit breaker does NOT trigger with fewer than threshold hits."""
|
|
n_hits = cb.stop_loss_hits_threshold - 1
|
|
assume(n_hits >= 0)
|
|
|
|
# Even if all within the window, not enough hits
|
|
stop_loss_hits = [base_time + timedelta(minutes=i) for i in range(n_hits)]
|
|
|
|
result = cb.check_volatility(stop_loss_hits=stop_loss_hits)
|
|
assert result is False, (
|
|
f"Should not trigger: only {n_hits} hits < threshold {cb.stop_loss_hits_threshold}"
|
|
)
|
|
|
|
@settings(max_examples=100)
|
|
@given(
|
|
cb=_circuit_breaker_strategy(),
|
|
base_time=_aware_datetime_strategy(),
|
|
)
|
|
def test_volatility_does_not_trigger_when_stops_spread_outside_window(
|
|
self, cb: CircuitBreaker, base_time: datetime,
|
|
) -> None:
|
|
"""Volatility circuit breaker does NOT trigger when hits are spread beyond the window."""
|
|
n_hits = cb.stop_loss_hits_threshold
|
|
window_minutes = cb.stop_loss_window_minutes
|
|
|
|
# Space hits so that no contiguous sub-sequence of threshold size fits in the window
|
|
# Each hit is window_minutes + 1 apart, so any threshold-sized group spans
|
|
# (threshold - 1) * (window_minutes + 1) minutes > window_minutes
|
|
gap = timedelta(minutes=window_minutes + 1)
|
|
stop_loss_hits = [base_time + gap * i for i in range(n_hits)]
|
|
|
|
result = cb.check_volatility(stop_loss_hits=stop_loss_hits)
|
|
assert result is False, (
|
|
f"Should not trigger: hits spread {window_minutes + 1}min apart, "
|
|
f"window is {window_minutes}min"
|
|
)
|
|
|
|
@settings(max_examples=100)
|
|
@given(
|
|
cb=_circuit_breaker_strategy(),
|
|
)
|
|
def test_volatility_does_not_trigger_on_empty_hits(
|
|
self, cb: CircuitBreaker,
|
|
) -> None:
|
|
"""Volatility circuit breaker does NOT trigger with no stop-loss hits."""
|
|
result = cb.check_volatility(stop_loss_hits=[])
|
|
assert result is False, "Should not trigger on empty stop-loss hits list"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Property 14: Circuit breaker cooldown expiry
|
|
# **Validates: Requirements 6.5**
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestProperty14CircuitBreakerCooldownExpiry:
|
|
"""Property 14: Circuit breaker cooldown expiry.
|
|
|
|
**Validates: Requirements 6.5**
|
|
"""
|
|
|
|
# -- is_active cooldown expiry -----------------------------------------
|
|
|
|
@settings(max_examples=100)
|
|
@given(
|
|
cb=_circuit_breaker_strategy(),
|
|
triggered_at=_aware_datetime_strategy(),
|
|
extra_seconds=st.integers(min_value=1, max_value=86400),
|
|
)
|
|
def test_is_active_returns_false_after_cooldown_expires(
|
|
self, cb: CircuitBreaker, triggered_at: datetime, extra_seconds: int,
|
|
) -> None:
|
|
"""is_active returns False when current time > cooldown_expires."""
|
|
cooldown_expires = triggered_at + timedelta(hours=cb.volatility_pause_hours)
|
|
now = cooldown_expires + timedelta(seconds=extra_seconds)
|
|
|
|
state = CircuitBreakerState(
|
|
active=True,
|
|
trigger_type="daily_loss",
|
|
triggered_at=triggered_at,
|
|
cooldown_expires=cooldown_expires,
|
|
)
|
|
|
|
result = cb.is_active(state=state, now=now)
|
|
assert result is False, (
|
|
f"Expected inactive: now={now} > cooldown_expires={cooldown_expires}"
|
|
)
|
|
|
|
@settings(max_examples=100)
|
|
@given(
|
|
cb=_circuit_breaker_strategy(),
|
|
triggered_at=_aware_datetime_strategy(),
|
|
before_fraction=st.floats(min_value=0.0, max_value=0.99, allow_nan=False, allow_infinity=False),
|
|
)
|
|
def test_is_active_returns_true_before_cooldown_expires(
|
|
self, cb: CircuitBreaker, triggered_at: datetime, before_fraction: float,
|
|
) -> None:
|
|
"""is_active returns True when current time < cooldown_expires."""
|
|
cooldown_duration = timedelta(hours=cb.volatility_pause_hours)
|
|
cooldown_expires = triggered_at + cooldown_duration
|
|
# now is some fraction of the way through the cooldown (before expiry)
|
|
now = triggered_at + cooldown_duration * before_fraction
|
|
|
|
assume(now < cooldown_expires)
|
|
|
|
state = CircuitBreakerState(
|
|
active=True,
|
|
trigger_type="volatility",
|
|
triggered_at=triggered_at,
|
|
cooldown_expires=cooldown_expires,
|
|
)
|
|
|
|
result = cb.is_active(state=state, now=now)
|
|
assert result is True, (
|
|
f"Expected active: now={now} < cooldown_expires={cooldown_expires}"
|
|
)
|
|
|
|
@settings(max_examples=100)
|
|
@given(
|
|
cb=_circuit_breaker_strategy(),
|
|
triggered_at=_aware_datetime_strategy(),
|
|
)
|
|
def test_is_active_returns_false_when_state_not_active(
|
|
self, cb: CircuitBreaker, triggered_at: datetime,
|
|
) -> None:
|
|
"""is_active returns False when state.active is False regardless of time."""
|
|
state = CircuitBreakerState(
|
|
active=False,
|
|
trigger_type=None,
|
|
triggered_at=triggered_at,
|
|
cooldown_expires=triggered_at + timedelta(hours=24),
|
|
)
|
|
|
|
result = cb.is_active(state=state, now=triggered_at)
|
|
assert result is False, "Expected inactive when state.active is False"
|
|
|
|
# -- is_ticker_cooled_down expiry --------------------------------------
|
|
|
|
@settings(max_examples=100)
|
|
@given(
|
|
cb=_circuit_breaker_strategy(),
|
|
triggered_at=_aware_datetime_strategy(),
|
|
extra_seconds=st.integers(min_value=1, max_value=86400),
|
|
)
|
|
def test_ticker_cooldown_returns_false_after_expiry(
|
|
self, cb: CircuitBreaker, triggered_at: datetime, extra_seconds: int,
|
|
) -> None:
|
|
"""is_ticker_cooled_down returns False when cooldown has expired."""
|
|
cooldown_expires = triggered_at + timedelta(hours=cb.ticker_cooldown_hours)
|
|
now = cooldown_expires + timedelta(seconds=extra_seconds)
|
|
|
|
ticker_cooldowns = {"AAPL": cooldown_expires}
|
|
|
|
result = cb.is_ticker_cooled_down(
|
|
ticker="AAPL",
|
|
ticker_cooldowns=ticker_cooldowns,
|
|
now=now,
|
|
)
|
|
assert result is False, (
|
|
f"Expected ticker not cooled down: now={now} > expiry={cooldown_expires}"
|
|
)
|
|
|
|
@settings(max_examples=100)
|
|
@given(
|
|
cb=_circuit_breaker_strategy(),
|
|
triggered_at=_aware_datetime_strategy(),
|
|
before_fraction=st.floats(min_value=0.0, max_value=0.99, allow_nan=False, allow_infinity=False),
|
|
)
|
|
def test_ticker_cooldown_returns_true_during_cooldown(
|
|
self, cb: CircuitBreaker, triggered_at: datetime, before_fraction: float,
|
|
) -> None:
|
|
"""is_ticker_cooled_down returns True when still within cooldown period."""
|
|
cooldown_duration = timedelta(hours=cb.ticker_cooldown_hours)
|
|
cooldown_expires = triggered_at + cooldown_duration
|
|
now = triggered_at + cooldown_duration * before_fraction
|
|
|
|
assume(now < cooldown_expires)
|
|
|
|
ticker_cooldowns = {"TSLA": cooldown_expires}
|
|
|
|
result = cb.is_ticker_cooled_down(
|
|
ticker="TSLA",
|
|
ticker_cooldowns=ticker_cooldowns,
|
|
now=now,
|
|
)
|
|
assert result is True, (
|
|
f"Expected ticker cooled down: now={now} < expiry={cooldown_expires}"
|
|
)
|
|
|
|
@settings(max_examples=100)
|
|
@given(
|
|
cb=_circuit_breaker_strategy(),
|
|
now=_aware_datetime_strategy(),
|
|
)
|
|
def test_ticker_cooldown_returns_false_for_unknown_ticker(
|
|
self, cb: CircuitBreaker, now: datetime,
|
|
) -> None:
|
|
"""is_ticker_cooled_down returns False for a ticker with no cooldown entry."""
|
|
result = cb.is_ticker_cooled_down(
|
|
ticker="UNKNOWN",
|
|
ticker_cooldowns={},
|
|
now=now,
|
|
)
|
|
assert result is False, "Expected no cooldown for unknown ticker"
|
|
|
|
# -- compute_cooldown_expiry -------------------------------------------
|
|
|
|
@settings(max_examples=100)
|
|
@given(
|
|
cb=_circuit_breaker_strategy(),
|
|
triggered_at=_aware_datetime_strategy(),
|
|
)
|
|
def test_cooldown_expiry_single_position_uses_ticker_cooldown_hours(
|
|
self, cb: CircuitBreaker, triggered_at: datetime,
|
|
) -> None:
|
|
"""compute_cooldown_expiry for single_position uses ticker_cooldown_hours."""
|
|
expiry = cb.compute_cooldown_expiry(
|
|
trigger_type="single_position",
|
|
triggered_at=triggered_at,
|
|
)
|
|
expected = triggered_at + timedelta(hours=cb.ticker_cooldown_hours)
|
|
assert expiry == expected, (
|
|
f"single_position expiry {expiry} != expected {expected}"
|
|
)
|
|
|
|
@settings(max_examples=100)
|
|
@given(
|
|
cb=_circuit_breaker_strategy(),
|
|
triggered_at=_aware_datetime_strategy(),
|
|
trigger_type=st.sampled_from(["daily_loss", "volatility"]),
|
|
)
|
|
def test_cooldown_expiry_daily_loss_and_volatility_use_pause_hours(
|
|
self, cb: CircuitBreaker, triggered_at: datetime, trigger_type: str,
|
|
) -> None:
|
|
"""compute_cooldown_expiry for daily_loss/volatility uses volatility_pause_hours."""
|
|
expiry = cb.compute_cooldown_expiry(
|
|
trigger_type=trigger_type,
|
|
triggered_at=triggered_at,
|
|
)
|
|
expected = triggered_at + timedelta(hours=cb.volatility_pause_hours)
|
|
assert expiry == expected, (
|
|
f"{trigger_type} expiry {expiry} != expected {expected}"
|
|
)
|