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
926 lines
37 KiB
Python
926 lines
37 KiB
Python
"""Property-based tests for the Position Sizer.
|
||
|
||
Feature: autonomous-trading-engine
|
||
|
||
Tests properties 1–5, 7, 19, and 24 from the design specification,
|
||
covering position sizing formula, correlation adjustment, sector exposure,
|
||
diversification bonus, Active Pool computation, earnings proximity,
|
||
portfolio heat, and Active Pool minimum enforcement.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from datetime import datetime, timedelta
|
||
|
||
from hypothesis import given, settings, assume
|
||
from hypothesis import strategies as st
|
||
|
||
from services.trading.models import (
|
||
OpenPosition,
|
||
PortfolioState,
|
||
PositionSizeResult,
|
||
RiskTierConfig,
|
||
)
|
||
from services.trading.position_sizer import PositionSizer
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Hypothesis strategies
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _risk_tier_config_strategy() -> st.SearchStrategy[RiskTierConfig]:
|
||
"""Generate random RiskTierConfig objects with valid parameter ranges."""
|
||
return st.builds(
|
||
RiskTierConfig,
|
||
name=st.sampled_from(["conservative", "moderate", "aggressive"]),
|
||
min_confidence=st.floats(min_value=0.10, max_value=0.95, allow_nan=False, allow_infinity=False),
|
||
max_position_pct=st.floats(min_value=0.02, max_value=0.50, allow_nan=False, allow_infinity=False),
|
||
stop_loss_atr_multiplier=st.floats(min_value=0.5, max_value=5.0, allow_nan=False, allow_infinity=False),
|
||
reward_risk_ratio=st.floats(min_value=0.5, max_value=5.0, allow_nan=False, allow_infinity=False),
|
||
max_sector_pct=st.floats(min_value=0.05, max_value=0.60, allow_nan=False, allow_infinity=False),
|
||
max_portfolio_heat=st.floats(min_value=0.05, max_value=0.50, allow_nan=False, allow_infinity=False),
|
||
)
|
||
|
||
|
||
def _open_position_strategy(
|
||
sector: st.SearchStrategy[str] | None = None,
|
||
) -> st.SearchStrategy[OpenPosition]:
|
||
"""Generate random OpenPosition objects."""
|
||
sector_st = sector if sector is not None else st.sampled_from(
|
||
["Technology", "Healthcare", "Energy", "Financials", "Consumer"]
|
||
)
|
||
return st.builds(
|
||
OpenPosition,
|
||
ticker=st.from_regex(r"[A-Z]{3,5}", fullmatch=True),
|
||
quantity=st.integers(min_value=1, max_value=100),
|
||
entry_price=st.floats(min_value=5.0, max_value=500.0, allow_nan=False, allow_infinity=False),
|
||
current_price=st.floats(min_value=5.0, max_value=500.0, allow_nan=False, allow_infinity=False),
|
||
unrealized_pnl=st.floats(min_value=-1000.0, max_value=1000.0, allow_nan=False, allow_infinity=False),
|
||
market_value=st.floats(min_value=10.0, max_value=5000.0, allow_nan=False, allow_infinity=False),
|
||
sector=sector_st,
|
||
stop_loss_price=st.floats(min_value=1.0, max_value=400.0, allow_nan=False, allow_infinity=False),
|
||
take_profit_price=st.floats(min_value=10.0, max_value=1000.0, allow_nan=False, allow_infinity=False),
|
||
signal_confidence=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
|
||
is_micro_trade=st.just(False),
|
||
)
|
||
|
||
|
||
def _portfolio_state_strategy(
|
||
positions: st.SearchStrategy[list] | None = None,
|
||
sector_exposure: st.SearchStrategy[dict] | None = None,
|
||
active_pool: st.SearchStrategy[float] | None = None,
|
||
portfolio_heat: st.SearchStrategy[float] | None = None,
|
||
) -> st.SearchStrategy[PortfolioState]:
|
||
"""Generate random PortfolioState objects."""
|
||
return st.builds(
|
||
PortfolioState,
|
||
positions=positions if positions is not None else st.just([]),
|
||
total_value=st.floats(min_value=100.0, max_value=20000.0, allow_nan=False, allow_infinity=False),
|
||
cash=st.floats(min_value=0.0, max_value=10000.0, allow_nan=False, allow_infinity=False),
|
||
active_pool=active_pool if active_pool is not None else st.floats(
|
||
min_value=100.0, max_value=10000.0, allow_nan=False, allow_infinity=False
|
||
),
|
||
reserve_pool=st.floats(min_value=0.0, max_value=5000.0, allow_nan=False, allow_infinity=False),
|
||
sector_exposure=sector_exposure if sector_exposure is not None else st.just({}),
|
||
portfolio_heat=portfolio_heat if portfolio_heat is not None else st.just(0.0),
|
||
open_position_count=st.integers(min_value=0, max_value=10),
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Property 1: Position sizing formula and invariants
|
||
# **Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.7**
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestProperty1PositionSizingFormula:
|
||
"""Property 1: Position sizing formula and invariants.
|
||
|
||
**Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.7**
|
||
"""
|
||
|
||
sizer = PositionSizer()
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
confidence=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
|
||
active_pool=st.floats(min_value=100.0, max_value=10000.0, allow_nan=False, allow_infinity=False),
|
||
price=st.floats(min_value=1.0, max_value=500.0, allow_nan=False, allow_infinity=False),
|
||
risk_tier=_risk_tier_config_strategy(),
|
||
absolute_cap=st.floats(min_value=10.0, max_value=500.0, allow_nan=False, allow_infinity=False),
|
||
)
|
||
def test_zero_allocation_below_min_confidence(
|
||
self, confidence: float, active_pool: float, price: float,
|
||
risk_tier: RiskTierConfig, absolute_cap: float,
|
||
) -> None:
|
||
"""Confidence below min_confidence yields zero allocation."""
|
||
assume(confidence < risk_tier.min_confidence)
|
||
|
||
portfolio = PortfolioState(active_pool=active_pool)
|
||
result = self.sizer.compute(
|
||
confidence=confidence,
|
||
ticker="TEST",
|
||
sector="Technology",
|
||
current_price=price,
|
||
active_pool=active_pool,
|
||
risk_tier=risk_tier,
|
||
portfolio_state=portfolio,
|
||
correlation_matrix={},
|
||
earnings_calendar={},
|
||
absolute_position_cap=absolute_cap,
|
||
)
|
||
assert result.rejected is True
|
||
assert result.dollar_amount == 0.0
|
||
assert result.share_quantity == 0
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
active_pool=st.floats(min_value=100.0, max_value=10000.0, allow_nan=False, allow_infinity=False),
|
||
price=st.floats(min_value=1.0, max_value=500.0, allow_nan=False, allow_infinity=False),
|
||
risk_tier=_risk_tier_config_strategy(),
|
||
absolute_cap=st.floats(min_value=10.0, max_value=500.0, allow_nan=False, allow_infinity=False),
|
||
)
|
||
def test_allocation_never_exceeds_max_position_pct_or_cap(
|
||
self, active_pool: float, price: float,
|
||
risk_tier: RiskTierConfig, absolute_cap: float,
|
||
) -> None:
|
||
"""Allocation never exceeds max_position_pct * active_pool or absolute cap."""
|
||
# Use confidence well above threshold to get a non-rejected result
|
||
confidence = min(risk_tier.min_confidence + 0.3, 1.0)
|
||
|
||
portfolio = PortfolioState(active_pool=active_pool)
|
||
result = self.sizer.compute(
|
||
confidence=confidence,
|
||
ticker="TEST",
|
||
sector="Technology",
|
||
current_price=price,
|
||
active_pool=active_pool,
|
||
risk_tier=risk_tier,
|
||
portfolio_state=portfolio,
|
||
correlation_matrix={},
|
||
earnings_calendar={},
|
||
absolute_position_cap=absolute_cap,
|
||
)
|
||
if not result.rejected:
|
||
max_allowed = risk_tier.max_position_pct * active_pool
|
||
# Dollar amount (based on whole shares) should not exceed the cap or max_position_pct
|
||
assert result.dollar_amount <= max_allowed + 0.01, (
|
||
f"dollar_amount {result.dollar_amount} > max_allowed {max_allowed}"
|
||
)
|
||
assert result.dollar_amount <= absolute_cap + 0.01, (
|
||
f"dollar_amount {result.dollar_amount} > absolute_cap {absolute_cap}"
|
||
)
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
active_pool=st.floats(min_value=200.0, max_value=10000.0, allow_nan=False, allow_infinity=False),
|
||
price=st.floats(min_value=1.0, max_value=50.0, allow_nan=False, allow_infinity=False),
|
||
risk_tier=_risk_tier_config_strategy(),
|
||
absolute_cap=st.floats(min_value=50.0, max_value=500.0, allow_nan=False, allow_infinity=False),
|
||
)
|
||
def test_share_quantity_rounded_down(
|
||
self, active_pool: float, price: float,
|
||
risk_tier: RiskTierConfig, absolute_cap: float,
|
||
) -> None:
|
||
"""Share quantity is always rounded down to whole shares (math.floor)."""
|
||
confidence = min(risk_tier.min_confidence + 0.3, 1.0)
|
||
|
||
portfolio = PortfolioState(active_pool=active_pool)
|
||
result = self.sizer.compute(
|
||
confidence=confidence,
|
||
ticker="TEST",
|
||
sector="Technology",
|
||
current_price=price,
|
||
active_pool=active_pool,
|
||
risk_tier=risk_tier,
|
||
portfolio_state=portfolio,
|
||
correlation_matrix={},
|
||
earnings_calendar={},
|
||
absolute_position_cap=absolute_cap,
|
||
)
|
||
if not result.rejected:
|
||
assert result.share_quantity == int(result.share_quantity)
|
||
assert result.share_quantity >= 1
|
||
# Verify it's floor: share_quantity * price should be <= the pre-rounding dollar amount
|
||
assert result.dollar_amount == result.share_quantity * price
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
active_pool=st.floats(min_value=100.0, max_value=500.0, allow_nan=False, allow_infinity=False),
|
||
risk_tier=_risk_tier_config_strategy(),
|
||
)
|
||
def test_rejection_when_zero_shares(
|
||
self, active_pool: float, risk_tier: RiskTierConfig,
|
||
) -> None:
|
||
"""Trade rejected when rounded share quantity is zero (price too high)."""
|
||
confidence = min(risk_tier.min_confidence + 0.1, 1.0)
|
||
# Use a very high price so dollar_amount / price < 1
|
||
price = active_pool * 10.0
|
||
|
||
portfolio = PortfolioState(active_pool=active_pool)
|
||
result = self.sizer.compute(
|
||
confidence=confidence,
|
||
ticker="TEST",
|
||
sector="Technology",
|
||
current_price=price,
|
||
active_pool=active_pool,
|
||
risk_tier=risk_tier,
|
||
portfolio_state=portfolio,
|
||
correlation_matrix={},
|
||
earnings_calendar={},
|
||
absolute_position_cap=50.0,
|
||
)
|
||
assert result.rejected is True
|
||
assert result.share_quantity == 0
|
||
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Property 2: Correlation-based allocation adjustment
|
||
# **Validates: Requirements 2.5, 9.2, 9.3**
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestProperty2CorrelationAdjustment:
|
||
"""Property 2: Correlation-based allocation adjustment.
|
||
|
||
**Validates: Requirements 2.5, 9.2, 9.3**
|
||
"""
|
||
|
||
sizer = PositionSizer()
|
||
|
||
def _make_portfolio_with_position(
|
||
self, ticker: str = "EXIST", sector: str = "Technology",
|
||
market_value: float = 500.0, active_pool: float = 5000.0,
|
||
) -> PortfolioState:
|
||
"""Create a portfolio with one existing position."""
|
||
pos = OpenPosition(
|
||
ticker=ticker, quantity=10, entry_price=50.0,
|
||
current_price=50.0, unrealized_pnl=0.0,
|
||
market_value=market_value, sector=sector,
|
||
stop_loss_price=45.0, take_profit_price=60.0,
|
||
signal_confidence=0.7,
|
||
)
|
||
return PortfolioState(
|
||
positions=[pos],
|
||
total_value=active_pool + 500.0,
|
||
cash=active_pool - market_value,
|
||
active_pool=active_pool,
|
||
reserve_pool=500.0,
|
||
sector_exposure={sector: market_value},
|
||
portfolio_heat=0.0,
|
||
open_position_count=1,
|
||
)
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
corr=st.floats(min_value=0.51, max_value=0.79, allow_nan=False, allow_infinity=False),
|
||
)
|
||
def test_allocation_reduced_when_correlation_above_half(self, corr: float) -> None:
|
||
"""Allocation reduced when weighted avg correlation > 0.5."""
|
||
portfolio = self._make_portfolio_with_position(active_pool=5000.0)
|
||
risk_tier = RiskTierConfig(
|
||
name="moderate", min_confidence=0.5, max_position_pct=0.10,
|
||
stop_loss_atr_multiplier=2.0, reward_risk_ratio=1.5,
|
||
max_sector_pct=0.50, max_portfolio_heat=0.50,
|
||
)
|
||
corr_matrix = {("NEW", "EXIST"): corr}
|
||
|
||
result_with_corr = self.sizer.compute(
|
||
confidence=0.8, ticker="NEW", sector="Healthcare",
|
||
current_price=10.0, active_pool=5000.0,
|
||
risk_tier=risk_tier, portfolio_state=portfolio,
|
||
correlation_matrix=corr_matrix, earnings_calendar={},
|
||
absolute_position_cap=500.0,
|
||
)
|
||
|
||
result_no_corr = self.sizer.compute(
|
||
confidence=0.8, ticker="NEW", sector="Healthcare",
|
||
current_price=10.0, active_pool=5000.0,
|
||
risk_tier=risk_tier, portfolio_state=portfolio,
|
||
correlation_matrix={("NEW", "EXIST"): 0.0}, earnings_calendar={},
|
||
absolute_position_cap=500.0,
|
||
)
|
||
|
||
if not result_with_corr.rejected and not result_no_corr.rejected:
|
||
assert result_with_corr.dollar_amount <= result_no_corr.dollar_amount
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
corr=st.floats(min_value=0.81, max_value=1.0, allow_nan=False, allow_infinity=False),
|
||
)
|
||
def test_trade_rejected_when_correlation_above_0_8(self, corr: float) -> None:
|
||
"""Trade rejected when weighted avg correlation > 0.8."""
|
||
portfolio = self._make_portfolio_with_position(active_pool=5000.0)
|
||
risk_tier = RiskTierConfig(
|
||
name="moderate", min_confidence=0.5, max_position_pct=0.10,
|
||
stop_loss_atr_multiplier=2.0, reward_risk_ratio=1.5,
|
||
max_sector_pct=0.50, max_portfolio_heat=0.50,
|
||
)
|
||
corr_matrix = {("NEW", "EXIST"): corr}
|
||
|
||
result = self.sizer.compute(
|
||
confidence=0.8, ticker="NEW", sector="Healthcare",
|
||
current_price=10.0, active_pool=5000.0,
|
||
risk_tier=risk_tier, portfolio_state=portfolio,
|
||
correlation_matrix=corr_matrix, earnings_calendar={},
|
||
absolute_position_cap=500.0,
|
||
)
|
||
assert result.rejected is True
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
corr=st.floats(min_value=-1.0, max_value=0.5, allow_nan=False, allow_infinity=False),
|
||
)
|
||
def test_allocation_unchanged_when_correlation_at_or_below_half(self, corr: float) -> None:
|
||
"""Allocation unchanged when weighted avg correlation <= 0.5."""
|
||
portfolio = self._make_portfolio_with_position(active_pool=5000.0)
|
||
risk_tier = RiskTierConfig(
|
||
name="moderate", min_confidence=0.5, max_position_pct=0.10,
|
||
stop_loss_atr_multiplier=2.0, reward_risk_ratio=1.5,
|
||
max_sector_pct=0.50, max_portfolio_heat=0.50,
|
||
)
|
||
corr_matrix_with = {("NEW", "EXIST"): corr}
|
||
corr_matrix_zero = {("NEW", "EXIST"): 0.0}
|
||
|
||
result_with = self.sizer.compute(
|
||
confidence=0.8, ticker="NEW", sector="Healthcare",
|
||
current_price=10.0, active_pool=5000.0,
|
||
risk_tier=risk_tier, portfolio_state=portfolio,
|
||
correlation_matrix=corr_matrix_with, earnings_calendar={},
|
||
absolute_position_cap=500.0,
|
||
)
|
||
result_zero = self.sizer.compute(
|
||
confidence=0.8, ticker="NEW", sector="Healthcare",
|
||
current_price=10.0, active_pool=5000.0,
|
||
risk_tier=risk_tier, portfolio_state=portfolio,
|
||
correlation_matrix=corr_matrix_zero, earnings_calendar={},
|
||
absolute_position_cap=500.0,
|
||
)
|
||
|
||
# Both should produce the same dollar amount (correlation <= 0.5 has no effect)
|
||
if not result_with.rejected and not result_zero.rejected:
|
||
assert result_with.dollar_amount == result_zero.dollar_amount
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
corr_low=st.floats(min_value=0.51, max_value=0.79, allow_nan=False, allow_infinity=False),
|
||
corr_high=st.floats(min_value=0.51, max_value=0.79, allow_nan=False, allow_infinity=False),
|
||
)
|
||
def test_monotonic_non_increase_with_higher_correlation(
|
||
self, corr_low: float, corr_high: float,
|
||
) -> None:
|
||
"""Higher correlation produces lower or equal allocation (monotonic non-increase)."""
|
||
assume(corr_low < corr_high)
|
||
|
||
portfolio = self._make_portfolio_with_position(active_pool=5000.0)
|
||
risk_tier = RiskTierConfig(
|
||
name="moderate", min_confidence=0.5, max_position_pct=0.10,
|
||
stop_loss_atr_multiplier=2.0, reward_risk_ratio=1.5,
|
||
max_sector_pct=0.50, max_portfolio_heat=0.50,
|
||
)
|
||
|
||
result_low = self.sizer.compute(
|
||
confidence=0.8, ticker="NEW", sector="Healthcare",
|
||
current_price=10.0, active_pool=5000.0,
|
||
risk_tier=risk_tier, portfolio_state=portfolio,
|
||
correlation_matrix={("NEW", "EXIST"): corr_low},
|
||
earnings_calendar={}, absolute_position_cap=500.0,
|
||
)
|
||
result_high = self.sizer.compute(
|
||
confidence=0.8, ticker="NEW", sector="Healthcare",
|
||
current_price=10.0, active_pool=5000.0,
|
||
risk_tier=risk_tier, portfolio_state=portfolio,
|
||
correlation_matrix={("NEW", "EXIST"): corr_high},
|
||
earnings_calendar={}, absolute_position_cap=500.0,
|
||
)
|
||
|
||
# Higher correlation → lower or equal allocation
|
||
low_amount = result_low.dollar_amount if not result_low.rejected else 0.0
|
||
high_amount = result_high.dollar_amount if not result_high.rejected else 0.0
|
||
assert high_amount <= low_amount + 0.01
|
||
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Property 3: Sector exposure computation and enforcement
|
||
# **Validates: Requirements 2.6, 9.4**
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestProperty3SectorExposure:
|
||
"""Property 3: Sector exposure computation and enforcement.
|
||
|
||
**Validates: Requirements 2.6, 9.4**
|
||
"""
|
||
|
||
sizer = PositionSizer()
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
max_sector_pct=st.floats(min_value=0.10, max_value=0.50, allow_nan=False, allow_infinity=False),
|
||
active_pool=st.floats(min_value=1000.0, max_value=10000.0, allow_nan=False, allow_infinity=False),
|
||
exposure_frac=st.floats(min_value=0.51, max_value=0.99, allow_nan=False, allow_infinity=False),
|
||
)
|
||
def test_allocation_reduced_when_exceeding_sector_limit(
|
||
self, max_sector_pct: float, active_pool: float, exposure_frac: float,
|
||
) -> None:
|
||
"""Allocation reduced when adding position would exceed max_sector_pct."""
|
||
max_sector_dollars = max_sector_pct * active_pool
|
||
# Derive existing_exposure directly from the sector limit so it's always valid
|
||
existing_exposure = max_sector_dollars * exposure_frac
|
||
|
||
risk_tier = RiskTierConfig(
|
||
name="moderate", min_confidence=0.5, max_position_pct=0.15,
|
||
stop_loss_atr_multiplier=2.0, reward_risk_ratio=1.5,
|
||
max_sector_pct=max_sector_pct, max_portfolio_heat=0.50,
|
||
)
|
||
|
||
pos = OpenPosition(
|
||
ticker="EXIST", quantity=10, entry_price=50.0,
|
||
current_price=50.0, unrealized_pnl=0.0,
|
||
market_value=existing_exposure, sector="Technology",
|
||
stop_loss_price=45.0, take_profit_price=60.0,
|
||
signal_confidence=0.7,
|
||
)
|
||
portfolio = PortfolioState(
|
||
positions=[pos],
|
||
total_value=active_pool + 500.0,
|
||
cash=active_pool - existing_exposure,
|
||
active_pool=active_pool,
|
||
reserve_pool=500.0,
|
||
sector_exposure={"Technology": existing_exposure},
|
||
portfolio_heat=0.0,
|
||
open_position_count=1,
|
||
)
|
||
|
||
result = self.sizer.compute(
|
||
confidence=0.9, ticker="NEW", sector="Technology",
|
||
current_price=5.0, active_pool=active_pool,
|
||
risk_tier=risk_tier, portfolio_state=portfolio,
|
||
correlation_matrix={}, earnings_calendar={},
|
||
absolute_position_cap=max_sector_dollars,
|
||
)
|
||
|
||
if not result.rejected:
|
||
# The resulting position + existing should not exceed the sector limit
|
||
assert existing_exposure + result.dollar_amount <= max_sector_dollars + 0.01
|
||
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Property 4: Diversification bonus for under-represented sectors
|
||
# **Validates: Requirements 9.5**
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestProperty4DiversificationBonus:
|
||
"""Property 4: Diversification bonus for under-represented sectors.
|
||
|
||
**Validates: Requirements 9.5**
|
||
"""
|
||
|
||
sizer = PositionSizer()
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
num_existing_sectors=st.integers(min_value=0, max_value=2),
|
||
)
|
||
def test_bonus_applied_when_fewer_than_3_sectors_and_new_sector(
|
||
self, num_existing_sectors: int,
|
||
) -> None:
|
||
"""1.2x bonus applied when portfolio has < 3 sectors and trade is in new sector."""
|
||
existing_sectors = ["Technology", "Healthcare", "Energy"][:num_existing_sectors]
|
||
sector_exposure = {s: 200.0 for s in existing_sectors}
|
||
positions = [
|
||
OpenPosition(
|
||
ticker=f"T{i}", quantity=5, entry_price=40.0,
|
||
current_price=40.0, unrealized_pnl=0.0,
|
||
market_value=200.0, sector=s,
|
||
stop_loss_price=35.0, take_profit_price=50.0,
|
||
signal_confidence=0.7,
|
||
)
|
||
for i, s in enumerate(existing_sectors)
|
||
]
|
||
|
||
active_pool = 5000.0
|
||
risk_tier = RiskTierConfig(
|
||
name="moderate", min_confidence=0.5, max_position_pct=0.15,
|
||
stop_loss_atr_multiplier=2.0, reward_risk_ratio=1.5,
|
||
max_sector_pct=0.50, max_portfolio_heat=0.50,
|
||
)
|
||
|
||
portfolio = PortfolioState(
|
||
positions=positions,
|
||
total_value=active_pool + 500.0,
|
||
cash=active_pool - sum(200.0 for _ in existing_sectors),
|
||
active_pool=active_pool,
|
||
reserve_pool=500.0,
|
||
sector_exposure=sector_exposure,
|
||
portfolio_heat=0.0,
|
||
open_position_count=len(positions),
|
||
)
|
||
|
||
# Trade in a new sector not in existing sectors
|
||
new_sector = "Financials"
|
||
|
||
result_new_sector = self.sizer.compute(
|
||
confidence=0.7, ticker="NEW", sector=new_sector,
|
||
current_price=5.0, active_pool=active_pool,
|
||
risk_tier=risk_tier, portfolio_state=portfolio,
|
||
correlation_matrix={}, earnings_calendar={},
|
||
absolute_position_cap=500.0,
|
||
)
|
||
|
||
# Trade in an existing sector (no bonus)
|
||
if existing_sectors:
|
||
result_existing_sector = self.sizer.compute(
|
||
confidence=0.7, ticker="NEW2", sector=existing_sectors[0],
|
||
current_price=5.0, active_pool=active_pool,
|
||
risk_tier=risk_tier, portfolio_state=portfolio,
|
||
correlation_matrix={}, earnings_calendar={},
|
||
absolute_position_cap=500.0,
|
||
)
|
||
|
||
if not result_new_sector.rejected and not result_existing_sector.rejected:
|
||
# New sector should get a bonus (higher allocation)
|
||
assert result_new_sector.dollar_amount >= result_existing_sector.dollar_amount
|
||
else:
|
||
# With 0 existing sectors, new sector should still get the bonus
|
||
if not result_new_sector.rejected:
|
||
assert result_new_sector.dollar_amount > 0
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
active_pool=st.floats(min_value=2000.0, max_value=10000.0, allow_nan=False, allow_infinity=False),
|
||
)
|
||
def test_no_bonus_when_3_or_more_sectors(self, active_pool: float) -> None:
|
||
"""No bonus when portfolio has >= 3 sectors."""
|
||
existing_sectors = ["Technology", "Healthcare", "Energy"]
|
||
sector_exposure = {s: 200.0 for s in existing_sectors}
|
||
positions = [
|
||
OpenPosition(
|
||
ticker=f"T{i}", quantity=5, entry_price=40.0,
|
||
current_price=40.0, unrealized_pnl=0.0,
|
||
market_value=200.0, sector=s,
|
||
stop_loss_price=35.0, take_profit_price=50.0,
|
||
signal_confidence=0.7,
|
||
)
|
||
for i, s in enumerate(existing_sectors)
|
||
]
|
||
|
||
risk_tier = RiskTierConfig(
|
||
name="moderate", min_confidence=0.5, max_position_pct=0.15,
|
||
stop_loss_atr_multiplier=2.0, reward_risk_ratio=1.5,
|
||
max_sector_pct=0.50, max_portfolio_heat=0.50,
|
||
)
|
||
|
||
portfolio = PortfolioState(
|
||
positions=positions,
|
||
total_value=active_pool + 500.0,
|
||
cash=active_pool - 600.0,
|
||
active_pool=active_pool,
|
||
reserve_pool=500.0,
|
||
sector_exposure=sector_exposure,
|
||
portfolio_heat=0.0,
|
||
open_position_count=3,
|
||
)
|
||
|
||
# Trade in a new sector — should NOT get bonus since we already have 3 sectors
|
||
result_new = self.sizer.compute(
|
||
confidence=0.7, ticker="NEW", sector="Financials",
|
||
current_price=5.0, active_pool=active_pool,
|
||
risk_tier=risk_tier, portfolio_state=portfolio,
|
||
correlation_matrix={}, earnings_calendar={},
|
||
absolute_position_cap=500.0,
|
||
)
|
||
|
||
# Trade in an existing sector
|
||
result_existing = self.sizer.compute(
|
||
confidence=0.7, ticker="NEW2", sector="Technology",
|
||
current_price=5.0, active_pool=active_pool,
|
||
risk_tier=risk_tier, portfolio_state=portfolio,
|
||
correlation_matrix={}, earnings_calendar={},
|
||
absolute_position_cap=500.0,
|
||
)
|
||
|
||
if not result_new.rejected and not result_existing.rejected:
|
||
# With >= 3 sectors, no bonus — allocations should be equal
|
||
assert result_new.dollar_amount == result_existing.dollar_amount
|
||
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Property 5: Active Pool computation invariant
|
||
# **Validates: Requirements 3.3**
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestProperty5ActivePoolComputation:
|
||
"""Property 5: Active Pool computation invariant.
|
||
|
||
**Validates: Requirements 3.3**
|
||
"""
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
total_portfolio_value=st.floats(min_value=100.0, max_value=50000.0, allow_nan=False, allow_infinity=False),
|
||
reserve_pool_pct=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
|
||
)
|
||
def test_active_pool_equals_total_minus_reserve(
|
||
self, total_portfolio_value: float, reserve_pool_pct: float,
|
||
) -> None:
|
||
"""Active Pool = total_portfolio_value - reserve_pool_balance."""
|
||
reserve_pool_balance = total_portfolio_value * reserve_pool_pct
|
||
active_pool = total_portfolio_value - reserve_pool_balance
|
||
|
||
assert active_pool >= -0.01, (
|
||
f"Active pool should be non-negative: {active_pool}"
|
||
)
|
||
assert abs(active_pool - (total_portfolio_value - reserve_pool_balance)) < 1e-9
|
||
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Property 19: Earnings proximity adjustments
|
||
# **Validates: Requirements 10.2, 10.3**
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestProperty19EarningsProximity:
|
||
"""Property 19: Earnings proximity adjustments.
|
||
|
||
**Validates: Requirements 10.2, 10.3**
|
||
"""
|
||
|
||
sizer = PositionSizer()
|
||
|
||
def _base_args(self, active_pool: float = 5000.0) -> dict:
|
||
"""Common arguments for position sizer calls."""
|
||
risk_tier = RiskTierConfig(
|
||
name="moderate", min_confidence=0.5, max_position_pct=0.10,
|
||
stop_loss_atr_multiplier=2.0, reward_risk_ratio=1.5,
|
||
max_sector_pct=0.50, max_portfolio_heat=0.50,
|
||
)
|
||
portfolio = PortfolioState(active_pool=active_pool)
|
||
return dict(
|
||
confidence=0.8,
|
||
ticker="EARN",
|
||
sector="Technology",
|
||
current_price=10.0,
|
||
active_pool=active_pool,
|
||
risk_tier=risk_tier,
|
||
portfolio_state=portfolio,
|
||
correlation_matrix={},
|
||
absolute_position_cap=500.0,
|
||
)
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
days_until=st.floats(min_value=1.01, max_value=3.0, allow_nan=False, allow_infinity=False),
|
||
)
|
||
def test_50_pct_reduction_within_3_trading_days(self, days_until: float) -> None:
|
||
"""50% reduction when earnings within 3 trading days (but > 1 day)."""
|
||
now = datetime.utcnow()
|
||
earnings_date = now + timedelta(days=days_until)
|
||
|
||
args = self._base_args()
|
||
result_with_earnings = self.sizer.compute(
|
||
**args, earnings_calendar={"EARN": earnings_date},
|
||
)
|
||
result_no_earnings = self.sizer.compute(
|
||
**args, earnings_calendar={},
|
||
)
|
||
|
||
if not result_with_earnings.rejected and not result_no_earnings.rejected:
|
||
# With earnings proximity, allocation should be ~50% of normal
|
||
ratio = result_with_earnings.dollar_amount / result_no_earnings.dollar_amount
|
||
# Allow some tolerance due to share rounding
|
||
assert ratio <= 0.6, (
|
||
f"Expected ~50% reduction, got ratio={ratio:.4f}"
|
||
)
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
days_until=st.floats(min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False),
|
||
)
|
||
def test_rejection_within_1_trading_day(self, days_until: float) -> None:
|
||
"""Trade rejected when earnings within 1 trading day."""
|
||
now = datetime.utcnow()
|
||
earnings_date = now + timedelta(days=days_until)
|
||
|
||
args = self._base_args()
|
||
result = self.sizer.compute(
|
||
**args, earnings_calendar={"EARN": earnings_date},
|
||
)
|
||
assert result.rejected is True
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
days_until=st.floats(min_value=4.0, max_value=30.0, allow_nan=False, allow_infinity=False),
|
||
)
|
||
def test_normal_sizing_outside_earnings_window(self, days_until: float) -> None:
|
||
"""Normal sizing when earnings are outside the 3-day window."""
|
||
now = datetime.utcnow()
|
||
earnings_date = now + timedelta(days=days_until)
|
||
|
||
args = self._base_args()
|
||
result_with_earnings = self.sizer.compute(
|
||
**args, earnings_calendar={"EARN": earnings_date},
|
||
)
|
||
result_no_earnings = self.sizer.compute(
|
||
**args, earnings_calendar={},
|
||
)
|
||
|
||
if not result_with_earnings.rejected and not result_no_earnings.rejected:
|
||
assert result_with_earnings.dollar_amount == result_no_earnings.dollar_amount
|
||
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Property 24: Portfolio heat computation and threshold enforcement
|
||
# **Validates: Requirements 13.1, 13.2**
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestProperty24PortfolioHeat:
|
||
"""Property 24: Portfolio heat computation and threshold enforcement.
|
||
|
||
**Validates: Requirements 13.1, 13.2**
|
||
"""
|
||
|
||
sizer = PositionSizer()
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
entry_prices=st.lists(
|
||
st.floats(min_value=10.0, max_value=200.0, allow_nan=False, allow_infinity=False),
|
||
min_size=1, max_size=5,
|
||
),
|
||
stop_loss_pcts=st.lists(
|
||
st.floats(min_value=0.02, max_value=0.20, allow_nan=False, allow_infinity=False),
|
||
min_size=1, max_size=5,
|
||
),
|
||
quantities=st.lists(
|
||
st.integers(min_value=1, max_value=50),
|
||
min_size=1, max_size=5,
|
||
),
|
||
)
|
||
def test_heat_computation_formula(
|
||
self, entry_prices: list[float], stop_loss_pcts: list[float],
|
||
quantities: list[int],
|
||
) -> None:
|
||
"""Heat = sum of position_value * (entry_price - stop_loss_price) / entry_price."""
|
||
n = min(len(entry_prices), len(stop_loss_pcts), len(quantities))
|
||
assume(n >= 1)
|
||
|
||
expected_heat = 0.0
|
||
for i in range(n):
|
||
entry = entry_prices[i]
|
||
stop_pct = stop_loss_pcts[i]
|
||
qty = quantities[i]
|
||
stop_loss = entry * (1.0 - stop_pct)
|
||
position_value = qty * entry
|
||
heat_contribution = position_value * (entry - stop_loss) / entry
|
||
expected_heat += heat_contribution
|
||
|
||
# Verify the formula: heat_contribution = position_value * stop_pct
|
||
recomputed = 0.0
|
||
for i in range(n):
|
||
entry = entry_prices[i]
|
||
stop_pct = stop_loss_pcts[i]
|
||
qty = quantities[i]
|
||
position_value = qty * entry
|
||
recomputed += position_value * stop_pct
|
||
|
||
assert abs(expected_heat - recomputed) < 1e-6
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
active_pool=st.floats(min_value=2000.0, max_value=10000.0, allow_nan=False, allow_infinity=False),
|
||
max_heat_pct=st.floats(min_value=0.05, max_value=0.30, allow_nan=False, allow_infinity=False),
|
||
)
|
||
def test_new_entries_rejected_when_heat_exceeds_max(
|
||
self, active_pool: float, max_heat_pct: float,
|
||
) -> None:
|
||
"""New entries rejected when portfolio heat exceeds max_portfolio_heat."""
|
||
# Set portfolio heat to exceed the max
|
||
max_heat_dollars = max_heat_pct * active_pool
|
||
current_heat = max_heat_dollars * 1.1 # 10% over the limit
|
||
|
||
risk_tier = RiskTierConfig(
|
||
name="moderate", min_confidence=0.5, max_position_pct=0.10,
|
||
stop_loss_atr_multiplier=2.0, reward_risk_ratio=1.5,
|
||
max_sector_pct=0.50, max_portfolio_heat=max_heat_pct,
|
||
)
|
||
|
||
portfolio = PortfolioState(
|
||
positions=[],
|
||
total_value=active_pool + 500.0,
|
||
cash=active_pool,
|
||
active_pool=active_pool,
|
||
reserve_pool=500.0,
|
||
sector_exposure={},
|
||
portfolio_heat=current_heat,
|
||
open_position_count=0,
|
||
)
|
||
|
||
result = self.sizer.compute(
|
||
confidence=0.8, ticker="NEW", sector="Technology",
|
||
current_price=10.0, active_pool=active_pool,
|
||
risk_tier=risk_tier, portfolio_state=portfolio,
|
||
correlation_matrix={}, earnings_calendar={},
|
||
absolute_position_cap=500.0,
|
||
)
|
||
assert result.rejected is True
|
||
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Property 7: Active Pool minimum halts new entries but allows exits
|
||
# **Validates: Requirements 3.5**
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestProperty7ActivePoolMinimum:
|
||
"""Property 7: Active Pool minimum halts new entries but allows exits.
|
||
|
||
**Validates: Requirements 3.5**
|
||
"""
|
||
|
||
sizer = PositionSizer()
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
active_pool=st.floats(min_value=1.0, max_value=99.99, allow_nan=False, allow_infinity=False),
|
||
)
|
||
def test_buy_orders_rejected_when_active_pool_below_minimum(
|
||
self, active_pool: float,
|
||
) -> None:
|
||
"""Buy orders rejected when Active Pool < minimum ($100 default)."""
|
||
risk_tier = RiskTierConfig(
|
||
name="moderate", min_confidence=0.5, max_position_pct=0.10,
|
||
stop_loss_atr_multiplier=2.0, reward_risk_ratio=1.5,
|
||
max_sector_pct=0.30, max_portfolio_heat=0.20,
|
||
)
|
||
|
||
portfolio = PortfolioState(
|
||
positions=[],
|
||
total_value=active_pool + 50.0,
|
||
cash=active_pool,
|
||
active_pool=active_pool,
|
||
reserve_pool=50.0,
|
||
sector_exposure={},
|
||
portfolio_heat=0.0,
|
||
open_position_count=0,
|
||
)
|
||
|
||
result = self.sizer.compute(
|
||
confidence=0.8, ticker="TEST", sector="Technology",
|
||
current_price=10.0, active_pool=active_pool,
|
||
risk_tier=risk_tier, portfolio_state=portfolio,
|
||
correlation_matrix={}, earnings_calendar={},
|
||
absolute_position_cap=50.0,
|
||
active_pool_minimum=100.0,
|
||
)
|
||
assert result.rejected is True
|
||
assert "below minimum" in result.rejection_reason.lower() or "active pool" in result.rejection_reason.lower()
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
active_pool=st.floats(min_value=100.0, max_value=10000.0, allow_nan=False, allow_infinity=False),
|
||
)
|
||
def test_buy_orders_allowed_when_active_pool_above_minimum(
|
||
self, active_pool: float,
|
||
) -> None:
|
||
"""Buy orders allowed when Active Pool >= minimum."""
|
||
risk_tier = RiskTierConfig(
|
||
name="moderate", min_confidence=0.5, max_position_pct=0.10,
|
||
stop_loss_atr_multiplier=2.0, reward_risk_ratio=1.5,
|
||
max_sector_pct=0.50, max_portfolio_heat=0.50,
|
||
)
|
||
|
||
portfolio = PortfolioState(
|
||
positions=[],
|
||
total_value=active_pool + 500.0,
|
||
cash=active_pool,
|
||
active_pool=active_pool,
|
||
reserve_pool=500.0,
|
||
sector_exposure={},
|
||
portfolio_heat=0.0,
|
||
open_position_count=0,
|
||
)
|
||
|
||
result = self.sizer.compute(
|
||
confidence=0.8, ticker="TEST", sector="Technology",
|
||
current_price=5.0, active_pool=active_pool,
|
||
risk_tier=risk_tier, portfolio_state=portfolio,
|
||
correlation_matrix={}, earnings_calendar={},
|
||
absolute_position_cap=500.0,
|
||
active_pool_minimum=100.0,
|
||
)
|
||
# Should not be rejected due to active pool minimum
|
||
# (may still be rejected for other reasons like heat, but not for active pool)
|
||
if result.rejected:
|
||
assert "active pool" not in result.rejection_reason.lower() or "below minimum" not in result.rejection_reason.lower()
|