feat: math core v3 engine upgrade

This commit is contained in:
Celes Renata
2026-06-27 12:21:41 +00:00
parent 365bc5d4b7
commit b4bf0f2361
34 changed files with 11693 additions and 3 deletions
+403
View File
@@ -0,0 +1,403 @@
"""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