feat: autonomous trading engine — full implementation
- 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
This commit is contained in:
@@ -0,0 +1,925 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user