"""Unit tests for v3 fractional Kelly sizing and regime-aware stops. Tests Kelly fraction computation, capacity cap enforcement, position minimum downgrade, stop/take-profit levels, and trailing stop monotonicity. Requirements validated: 14.1–14.7, 16.1–16.5 """ from __future__ import annotations import pytest from services.trading.position_sizer import ( KellySizingResult, compute_kelly_sizing, compute_reward_ratio, ) from services.trading.stop_loss_manager import ( TrailingStopResult, V3StopLevels, compute_trailing_stop, compute_v3_stops, ) # --------------------------------------------------------------------------- # Kelly sizing: negative edge → size = 0 (Req 14.7) # --------------------------------------------------------------------------- class TestKellyNegativeEdge: """Negative Kelly fraction forces zero sizing and downgrade.""" def test_p_win_03_b_2_negative_edge(self): """p_win=0.3, b=2.0 → f_kelly = (0.3*2 - 0.7)/2 = -0.05 → downgrade.""" result = compute_kelly_sizing( p_win=0.3, b=2.0, confidence=0.8, data_quality=0.9, contradiction=0.1, max_position_pct=0.10, available_caps={"sector_capacity": 0.10, "correlation_capacity": 0.10, "heat_capacity": 0.10}, ) assert isinstance(result, KellySizingResult) assert result.f_kelly == pytest.approx(-0.05, abs=1e-9) assert result.portfolio_pct == 0.0 assert result.downgrade is True assert result.downgrade_reason == "negative_edge" def test_p_win_05_b_1_zero_edge(self): """p_win=0.5, b=1.0 → f_kelly = (0.5*1 - 0.5)/1 = 0.0 → downgrade.""" result = compute_kelly_sizing( p_win=0.5, b=1.0, confidence=0.8, data_quality=0.9, contradiction=0.1, max_position_pct=0.10, available_caps={}, ) assert result.f_kelly == pytest.approx(0.0, abs=1e-9) assert result.portfolio_pct == 0.0 assert result.downgrade is True assert result.downgrade_reason == "negative_edge" # --------------------------------------------------------------------------- # Kelly sizing: positive edge → bounded size (Req 14.1–14.4) # --------------------------------------------------------------------------- class TestKellyPositiveEdge: """Positive Kelly fraction produces a sized position within caps.""" def test_p_win_07_b_2_positive_size(self): """p_win=0.7, b=2.0 → f_kelly = (1.4-0.3)/2 = 0.55 → positive size.""" confidence = 0.8 data_quality = 0.9 contradiction = 0.1 result = compute_kelly_sizing( p_win=0.7, b=2.0, confidence=confidence, data_quality=data_quality, contradiction=contradiction, max_position_pct=0.10, available_caps={"sector_capacity": 0.10, "correlation_capacity": 0.10, "heat_capacity": 0.10}, ) assert result.f_kelly == pytest.approx(0.55, abs=1e-9) # portfolio_pct = 0.55 * 0.25 * 0.8 * 0.9 * (1 - 0.1) = 0.55 * 0.25 * 0.8 * 0.9 * 0.9 expected_raw = 0.55 * 0.25 * confidence * data_quality * (1.0 - contradiction) # Clamped to max_position_pct = 0.10 expected_pct = min(expected_raw, 0.10) assert result.portfolio_pct == pytest.approx(expected_pct, abs=1e-9) assert result.portfolio_pct > 0.0 assert result.portfolio_pct <= 0.10 assert result.downgrade is False assert result.downgrade_reason == "" def test_high_confidence_respects_max_cap(self): """Even with strong edge, portfolio_pct cannot exceed max_position_pct.""" result = compute_kelly_sizing( p_win=0.9, b=3.0, confidence=1.0, data_quality=1.0, contradiction=0.0, max_position_pct=0.05, available_caps={}, ) assert result.portfolio_pct <= 0.05 # --------------------------------------------------------------------------- # Cap enforcement: sector, correlation, heat (Req 14.5) # --------------------------------------------------------------------------- class TestCapEnforcement: """Capacity constraints cap portfolio_pct.""" def test_sector_capacity_caps(self): """sector_capacity=0.02 caps portfolio_pct at 0.02.""" result = compute_kelly_sizing( p_win=0.7, b=2.0, confidence=0.8, data_quality=0.9, contradiction=0.1, max_position_pct=0.10, available_caps={"sector_capacity": 0.02, "correlation_capacity": 0.10, "heat_capacity": 0.10}, ) assert result.portfolio_pct <= 0.02 def test_correlation_capacity_zero_forces_zero(self): """correlation_capacity=0.0 (avg corr > 0.80) → portfolio_pct = 0.""" result = compute_kelly_sizing( p_win=0.7, b=2.0, confidence=0.8, data_quality=0.9, contradiction=0.1, max_position_pct=0.10, available_caps={"sector_capacity": 0.10, "correlation_capacity": 0.0, "heat_capacity": 0.10}, ) # correlation_capacity=0 → min(pct, 0) = 0 → below minimum → downgrade assert result.portfolio_pct == 0.0 assert result.downgrade is True assert result.downgrade_reason == "position_below_minimum" def test_heat_capacity_caps(self): """heat_capacity=0.01 caps portfolio_pct at 0.01.""" result = compute_kelly_sizing( p_win=0.7, b=2.0, confidence=0.8, data_quality=0.9, contradiction=0.1, max_position_pct=0.10, available_caps={"sector_capacity": 0.10, "correlation_capacity": 0.10, "heat_capacity": 0.01}, ) assert result.portfolio_pct <= 0.01 def test_minimum_of_all_caps(self): """portfolio_pct is min of all capacity constraints.""" result = compute_kelly_sizing( p_win=0.7, b=2.0, confidence=0.8, data_quality=0.9, contradiction=0.1, max_position_pct=0.10, available_caps={"sector_capacity": 0.03, "correlation_capacity": 0.05, "heat_capacity": 0.04}, ) # Minimum cap is sector_capacity=0.03 assert result.portfolio_pct <= 0.03 # --------------------------------------------------------------------------- # Position below minimum → downgrade (Req 14.6) # --------------------------------------------------------------------------- class TestPositionBelowMinimum: """Tiny positions below 0.005 trigger a downgrade.""" def test_tiny_position_downgrade(self): """Low data_quality and high contradiction → pct < 0.005 → downgrade.""" result = compute_kelly_sizing( p_win=0.55, b=1.5, confidence=0.3, data_quality=0.3, contradiction=0.7, max_position_pct=0.10, available_caps={}, ) # f_kelly = (0.55*1.5 - 0.45)/1.5 = (0.825-0.45)/1.5 = 0.25 # raw = 0.25 * 0.25 * 0.3 * 0.3 * 0.3 = 0.0016875 < 0.005 assert result.portfolio_pct == 0.0 assert result.downgrade is True assert result.downgrade_reason == "position_below_minimum" def test_just_above_minimum_no_downgrade(self): """Position at or above 0.005 is not downgraded.""" result = compute_kelly_sizing( p_win=0.7, b=2.0, confidence=0.8, data_quality=0.9, contradiction=0.1, max_position_pct=0.10, available_caps={}, ) # f_kelly=0.55, raw=0.55*0.25*0.8*0.9*0.9 = 0.0891 >> 0.005 assert result.portfolio_pct >= 0.005 assert result.downgrade is False # --------------------------------------------------------------------------- # Reward ratio computation (Req 14.2) # --------------------------------------------------------------------------- class TestRewardRatio: """Tests compute_reward_ratio clamping and formula.""" def test_reward_ratio_typical(self): """Typical values produce a ratio between 1.2 and 3.0.""" b = compute_reward_ratio(confidence=0.7, strength=0.5, contradiction=0.2) # raw = 1.2 + 2.0*0.7 + 1.0*0.5 - 0.2 = 1.2 + 1.4 + 0.5 - 0.2 = 2.9 assert b == pytest.approx(2.9, abs=1e-9) def test_reward_ratio_clamp_low(self): """Low confidence/strength and high contradiction → clamped to 1.2.""" b = compute_reward_ratio(confidence=0.0, strength=0.0, contradiction=1.0) # raw = 1.2 + 0 + 0 - 1.0 = 0.2 → clamped to 1.2 assert b == pytest.approx(1.2, abs=1e-9) def test_reward_ratio_clamp_high(self): """High confidence/strength → clamped to 3.0.""" b = compute_reward_ratio(confidence=1.0, strength=1.0, contradiction=0.0) # raw = 1.2 + 2.0 + 1.0 - 0 = 4.2 → clamped to 3.0 assert b == pytest.approx(3.0, abs=1e-9) # --------------------------------------------------------------------------- # Stop/TP computation with known inputs (Req 16.1–16.3) # --------------------------------------------------------------------------- class TestV3Stops: """Tests for regime-aware stop loss and take profit computation.""" def test_known_inputs(self): """entry=100, ATR_pct=0.02, regime_mult=2.0, sigma_h=0.04, b=2.0.""" result = compute_v3_stops( entry_price=100.0, atr_pct=0.02, regime_atr_mult=2.0, sigma_h=0.04, reward_ratio=2.0, ) assert isinstance(result, V3StopLevels) # stop_distance = max(0.02*2.0, 0.04*1.25, 0.005) = max(0.04, 0.05, 0.005) = 0.05 assert result.stop_distance_pct == pytest.approx(0.05, abs=1e-9) # stop = 100 * (1 - 0.05) = 95 assert result.stop_loss == pytest.approx(95.0, abs=1e-9) # TP = 100 * (1 + 2.0 * 0.05) = 110 assert result.take_profit == pytest.approx(110.0, abs=1e-9) assert result.reward_ratio == pytest.approx(2.0, abs=1e-9) def test_min_stop_distance_enforced(self): """Very low ATR and sigma → min stop distance of 0.005 is enforced.""" result = compute_v3_stops( entry_price=50.0, atr_pct=0.001, regime_atr_mult=1.0, sigma_h=0.001, reward_ratio=2.0, ) # max(0.001*1.0, 0.001*1.25, 0.005) = 0.005 assert result.stop_distance_pct == pytest.approx(0.005, abs=1e-9) assert result.stop_loss == pytest.approx(50.0 * (1 - 0.005), abs=1e-9) def test_atr_dominates(self): """High ATR*mult dominates stop distance.""" result = compute_v3_stops( entry_price=200.0, atr_pct=0.05, regime_atr_mult=2.5, sigma_h=0.03, reward_ratio=1.5, ) # max(0.05*2.5, 0.03*1.25, 0.005) = max(0.125, 0.0375, 0.005) = 0.125 assert result.stop_distance_pct == pytest.approx(0.125, abs=1e-9) assert result.stop_loss == pytest.approx(200.0 * (1 - 0.125), abs=1e-9) assert result.take_profit == pytest.approx(200.0 * (1 + 1.5 * 0.125), abs=1e-9) # --------------------------------------------------------------------------- # Trailing stop monotonicity (Req 16.4–16.5) # --------------------------------------------------------------------------- class TestTrailingStopMonotonicity: """Tests trailing stop activation and non-decreasing behavior.""" def test_not_activated_below_threshold(self): """Gain < 50% of TP distance → trailing not activated.""" result = compute_trailing_stop( existing_stop=95.0, current_price=101.0, # Gain = 1.0, TP distance = 10, 1.0 < 0.5*10 entry_price=100.0, take_profit=110.0, atr_pct=0.02, trailing_atr_mult=1.5, sigma_h=0.04, ) assert isinstance(result, TrailingStopResult) assert result.activated is False assert result.trailing_stop == 95.0 # Unchanged def test_activated_above_threshold(self): """Gain >= 50% of TP distance → trailing activated.""" result = compute_trailing_stop( existing_stop=95.0, current_price=105.0, # Gain = 5.0, TP distance = 10, 5.0 >= 0.5*10 entry_price=100.0, take_profit=110.0, atr_pct=0.02, trailing_atr_mult=1.5, sigma_h=0.04, ) assert result.activated is True # trailing_distance = max(0.02*1.5, 0.04*0.75) = max(0.03, 0.03) = 0.03 # candidate = 105 * (1 - 0.03) = 101.85 # max(95.0, 101.85) = 101.85 assert result.trailing_stop == pytest.approx(105.0 * (1 - 0.03), abs=1e-9) assert result.trailing_stop > 95.0 def test_monotonicity_over_price_sequence(self): """Trailing stop never decreases over a price sequence.""" # Setup: entry=100, TP=110, existing_stop=95 entry = 100.0 take_profit = 110.0 atr_pct = 0.02 trailing_atr_mult = 1.5 sigma_h = 0.04 prices = [102.0, 105.0, 103.0, 108.0] current_stop = 95.0 stops_recorded = [current_stop] for price in prices: result = compute_trailing_stop( existing_stop=current_stop, current_price=price, entry_price=entry, take_profit=take_profit, atr_pct=atr_pct, trailing_atr_mult=trailing_atr_mult, sigma_h=sigma_h, ) current_stop = result.trailing_stop stops_recorded.append(current_stop) # Verify monotonically non-decreasing for i in range(1, len(stops_recorded)): assert stops_recorded[i] >= stops_recorded[i - 1], ( f"Stop decreased at step {i}: {stops_recorded[i]} < {stops_recorded[i-1]}" ) def test_trailing_stop_never_below_existing(self): """Even with price drop, trailing stop stays at existing_stop.""" result = compute_trailing_stop( existing_stop=102.0, current_price=105.0, entry_price=100.0, take_profit=110.0, atr_pct=0.05, # Large ATR → candidate might be below existing trailing_atr_mult=2.0, sigma_h=0.08, ) # trailing_distance = max(0.05*2.0, 0.08*0.75) = max(0.10, 0.06) = 0.10 # candidate = 105 * (1 - 0.10) = 94.5 # max(102.0, 94.5) = 102.0 assert result.trailing_stop == pytest.approx(102.0, abs=1e-9) assert result.activated is True def test_exact_50pct_threshold_activates(self): """Gain exactly at 50% of TP distance activates trailing.""" # TP distance = 10, so gain must be >= 5.0 result = compute_trailing_stop( existing_stop=95.0, current_price=105.0, # Gain = 5.0 = 0.50 * 10 entry_price=100.0, take_profit=110.0, atr_pct=0.02, trailing_atr_mult=1.5, sigma_h=0.04, ) assert result.activated is True