""" tests/test_risk.py — Risk check pipeline tests. Covers: - Safe mode blocking - Daily loss limit - Max positions limit - 3 consecutive losses → pause + half size - Profit lock after daily target - Position sizing with ATR """ from __future__ import annotations import datetime from unittest.mock import patch import pytest import config from contracts import FinalScore from execution import risk as risk_module @pytest.fixture(autouse=True) def reset_risk_state(): """Reset risk module state before each test.""" risk_module._last_reset_date = None # force reset risk_module.reset_daily_state(equity=100_000.0) yield def _make_final(symbol: str, decision: str = "BUY", score: float = 0.7) -> FinalScore: return FinalScore( symbol=symbol, timestamp=datetime.datetime.now(datetime.timezone.utc), technical=score, sentiment=0.5, final=score, decision=decision, ) class TestDailyLossLimit: @patch("execution.broker.safe_mode_active", False) @patch("execution.broker.cancel_all_orders") @patch("execution.broker.close_all_positions") def test_blocks_after_max_loss(self, mock_close, mock_cancel): """After exceeding daily loss limit, all trades should be blocked.""" risk_module.update_pnl(realized=-(config.MAX_DAILY_LOSS_USD + 5.0), unrealized=0.0) allowed, reason = risk_module.run_risk_checks( symbol="AAPL", final_score=_make_final("AAPL"), open_position_count=0, open_positions={}, alert_callback=lambda *a, **kw: None, ) assert not allowed assert "daily_loss" in reason.lower() or "loss" in reason.lower() @patch("execution.broker.safe_mode_active", False) def test_allows_within_limit(self): risk_module.update_pnl(realized=-10.0, unrealized=0.0) allowed, reason = risk_module.run_risk_checks( symbol="AAPL", final_score=_make_final("AAPL"), open_position_count=0, open_positions={}, alert_callback=lambda *a, **kw: None, ) assert allowed class TestMaxPositions: @patch("execution.broker.safe_mode_active", False) def test_blocks_at_max(self): allowed, reason = risk_module.run_risk_checks( symbol="AAPL", final_score=_make_final("AAPL"), open_position_count=config.MAX_OPEN_POSITIONS, open_positions={}, alert_callback=lambda *a, **kw: None, ) assert not allowed @patch("execution.broker.safe_mode_active", False) def test_allows_below_max(self): allowed, reason = risk_module.run_risk_checks( symbol="AAPL", final_score=_make_final("AAPL"), open_position_count=2, open_positions={}, alert_callback=lambda *a, **kw: None, ) assert allowed class TestConsecutiveLosses: @patch("execution.broker.safe_mode_active", False) def test_pause_after_3_losses(self): """3 consecutive losses → 30 min pause.""" for _ in range(3): risk_module.record_trade_result(-100.0) allowed, reason = risk_module.run_risk_checks( symbol="AAPL", final_score=_make_final("AAPL"), open_position_count=0, open_positions={}, alert_callback=lambda *a, **kw: None, ) assert not allowed def test_half_size_after_3_losses(self): """After 3 losses, half_size_mode should be True.""" for _ in range(3): risk_module.record_trade_result(-100.0) # Trigger the risk check which sets half_size_mode with patch("execution.broker.safe_mode_active", False): risk_module.run_risk_checks( symbol="AAPL", final_score=_make_final("AAPL"), open_position_count=0, open_positions={}, alert_callback=lambda *a, **kw: None, ) assert risk_module.half_size_mode class TestProfitLock: @patch("execution.broker.safe_mode_active", False) def test_locks_after_target(self): """After hitting the configured daily target, lock profits.""" risk_module.update_pnl(realized=config.TARGET_DAILY_PROFIT_USD + 5.0, unrealized=0.0) allowed, reason = risk_module.run_risk_checks( symbol="AAPL", final_score=_make_final("AAPL"), open_position_count=0, open_positions={}, alert_callback=lambda *a, **kw: None, ) assert not allowed assert "profit" in reason.lower() or "target" in reason.lower() class TestPositionSizing: @patch("execution.broker.is_fractionable", return_value=True) def test_size_within_risk_budget(self, mock_frac): qty, stop_dist, is_frac = risk_module.compute_position_size( equity=100_000.0, atr_value=2.0, symbol="AAPL", ) # Risk per trade = 1% of $100k = $1000 # With ATR stop = 2.0 * 1.0 = $2.0 # Qty = 1000 / 2.0 = 500 assert qty > 0 assert stop_dist > 0 @patch("execution.broker.is_fractionable", return_value=True) def test_fractional_flag(self, mock_frac): qty, _, is_frac = risk_module.compute_position_size( equity=100.0, # tiny account → fractional atr_value=2.0, symbol="AAPL", ) if qty > 0 and qty < 1: assert is_frac class TestSafeMode: @patch("execution.broker.safe_mode_active", True) def test_safe_mode_blocks(self): allowed, reason = risk_module.run_risk_checks( symbol="AAPL", final_score=_make_final("AAPL"), open_position_count=0, open_positions={}, alert_callback=lambda *a, **kw: None, ) assert not allowed assert "safe" in reason.lower()