Files

403 lines
15 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.
"""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.114.7, 16.116.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.114.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.116.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.416.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