Files
stonks-oracle/services/trading/position_sizer.py
T

459 lines
16 KiB
Python
Raw 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.
"""Position sizing engine for the autonomous trading system.
Computes dollar allocation and share quantity for a trade by applying
a sequential adjustment pipeline: confidence gate, correlation reduction,
sector exposure, diversification bonus, earnings proximity, portfolio
heat check, active-pool minimum, absolute cap, and share rounding.
Also provides v3 fractional Kelly position sizing (Requirements 14.114.7).
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from datetime import datetime, timezone
from services.trading.models import (
OpenPosition,
PortfolioState,
PositionSizeResult,
RiskTierConfig,
)
class PositionSizer:
"""Compute position size through a multi-step adjustment pipeline."""
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def compute(
self,
confidence: float,
ticker: str,
sector: str,
current_price: float,
active_pool: float,
risk_tier: RiskTierConfig,
portfolio_state: PortfolioState,
correlation_matrix: dict[tuple[str, str], float],
earnings_calendar: dict[str, datetime],
absolute_position_cap: float = 50.0,
active_pool_minimum: float = 100.0,
) -> PositionSizeResult:
"""Run the full adjustment pipeline and return a sizing result."""
adjustments: list[str] = []
# ---- 1. Active pool minimum check (early reject) -------------
if active_pool < active_pool_minimum:
return self._rejected(
f"Active pool ${active_pool:.2f} below minimum ${active_pool_minimum:.2f}",
adjustments,
)
# ---- 2. Confidence gate --------------------------------------
if confidence < risk_tier.min_confidence:
return self._rejected(
f"Confidence {confidence:.4f} below tier minimum {risk_tier.min_confidence}",
adjustments,
)
# ---- 3. Base sizing formula ----------------------------------
base_allocation_pct = risk_tier.max_position_pct * 0.5
multiplier = 1.0 # default multiplier
raw_pct = (
base_allocation_pct
* (confidence / risk_tier.min_confidence)
* multiplier
)
clamped_pct = min(raw_pct, risk_tier.max_position_pct)
dollar_amount = active_pool * clamped_pct
dollar_amount = min(dollar_amount, absolute_position_cap)
adjustments.append(
f"Base sizing: raw_pct={raw_pct:.6f}, clamped_pct={clamped_pct:.6f}, "
f"dollar=${dollar_amount:.2f}"
)
# ---- 4. Correlation reduction --------------------------------
dollar_amount, clamped_pct = self._apply_correlation_reduction(
ticker,
dollar_amount,
clamped_pct,
portfolio_state,
correlation_matrix,
adjustments,
)
if dollar_amount == 0.0:
return self._rejected(adjustments[-1], adjustments)
# ---- 5. Sector exposure reduction ----------------------------
dollar_amount, clamped_pct = self._apply_sector_exposure_reduction(
sector,
dollar_amount,
clamped_pct,
active_pool,
risk_tier,
portfolio_state,
adjustments,
)
# ---- 6. Diversification bonus --------------------------------
dollar_amount, clamped_pct = self._apply_diversification_bonus(
sector,
dollar_amount,
clamped_pct,
risk_tier,
portfolio_state,
adjustments,
)
# ---- 7. Earnings proximity -----------------------------------
result = self._apply_earnings_proximity(
ticker,
dollar_amount,
clamped_pct,
earnings_calendar,
adjustments,
)
if isinstance(result, PositionSizeResult):
return result
dollar_amount, clamped_pct = result
# ---- 8. Absolute cap enforcement (re-apply after adjustments) -
if dollar_amount > absolute_position_cap:
dollar_amount = absolute_position_cap
clamped_pct = dollar_amount / active_pool if active_pool > 0 else 0.0
adjustments.append(
f"Absolute cap enforced: capped to ${absolute_position_cap:.2f}"
)
# ---- 9. Portfolio heat check ---------------------------------
stop_loss_distance_pct = risk_tier.stop_loss_atr_multiplier * 0.02
new_position_heat = dollar_amount * stop_loss_distance_pct
max_heat_dollars = risk_tier.max_portfolio_heat * active_pool
current_heat = portfolio_state.portfolio_heat
if current_heat + new_position_heat > max_heat_dollars:
return self._rejected(
f"Portfolio heat would exceed limit: current={current_heat:.2f} + "
f"new={new_position_heat:.2f} > max={max_heat_dollars:.2f}",
adjustments,
)
# ---- 10. Share rounding --------------------------------------
if current_price <= 0:
return self._rejected("Invalid current price", adjustments)
share_quantity = math.floor(dollar_amount / current_price)
if share_quantity == 0:
return self._rejected(
f"Zero shares after rounding: ${dollar_amount:.2f} / ${current_price:.2f}",
adjustments,
)
# Final dollar amount based on whole shares
final_dollar = share_quantity * current_price
final_pct = final_dollar / active_pool if active_pool > 0 else 0.0
adjustments.append(
f"Final: {share_quantity} shares @ ${current_price:.2f} = ${final_dollar:.2f} "
f"({final_pct:.4%} of active pool)"
)
return PositionSizeResult(
dollar_amount=final_dollar,
share_quantity=share_quantity,
allocation_pct=final_pct,
adjustments=adjustments,
rejected=False,
rejection_reason="",
)
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
@staticmethod
def _rejected(reason: str, adjustments: list[str]) -> PositionSizeResult:
return PositionSizeResult(
dollar_amount=0.0,
share_quantity=0,
allocation_pct=0.0,
adjustments=adjustments,
rejected=True,
rejection_reason=reason,
)
@staticmethod
def _apply_correlation_reduction(
ticker: str,
dollar_amount: float,
allocation_pct: float,
portfolio_state: PortfolioState,
correlation_matrix: dict[tuple[str, str], float],
adjustments: list[str],
) -> tuple[float, float]:
"""Reduce or reject based on weighted average correlation."""
positions: list[OpenPosition] = portfolio_state.positions
if not positions:
return dollar_amount, allocation_pct
total_weight = 0.0
weighted_corr = 0.0
for pos in positions:
corr = correlation_matrix.get(
(ticker, pos.ticker),
correlation_matrix.get((pos.ticker, ticker), 0.0),
)
weight = pos.market_value
weighted_corr += corr * weight
total_weight += weight
if total_weight == 0.0:
return dollar_amount, allocation_pct
avg_corr = weighted_corr / total_weight
if avg_corr > 0.8:
adjustments.append(
f"Correlation rejection: avg={avg_corr:.4f} > 0.8"
)
return 0.0, 0.0
if avg_corr > 0.5:
# Reduce proportionally: scale factor goes from 1.0 at 0.5 to 0.0 at 0.8
reduction = (avg_corr - 0.5) / (0.8 - 0.5)
factor = 1.0 - reduction
new_dollar = dollar_amount * factor
new_pct = allocation_pct * factor
adjustments.append(
f"Correlation reduction: avg={avg_corr:.4f}, factor={factor:.4f}, "
f"${dollar_amount:.2f} -> ${new_dollar:.2f}"
)
return new_dollar, new_pct
return dollar_amount, allocation_pct
@staticmethod
def _apply_sector_exposure_reduction(
sector: str,
dollar_amount: float,
allocation_pct: float,
active_pool: float,
risk_tier: RiskTierConfig,
portfolio_state: PortfolioState,
adjustments: list[str],
) -> tuple[float, float]:
"""Reduce allocation if sector would exceed max_sector_pct."""
max_sector_dollars = risk_tier.max_sector_pct * active_pool
current_sector_exposure = portfolio_state.sector_exposure.get(sector, 0.0)
if current_sector_exposure + dollar_amount > max_sector_dollars:
available = max(max_sector_dollars - current_sector_exposure, 0.0)
if available <= 0:
adjustments.append(
f"Sector exposure at limit: {sector} "
f"${current_sector_exposure:.2f} >= max ${max_sector_dollars:.2f}"
)
return 0.0, 0.0
new_pct = available / active_pool if active_pool > 0 else 0.0
adjustments.append(
f"Sector exposure reduction: {sector} "
f"${current_sector_exposure:.2f} + ${dollar_amount:.2f} > "
f"max ${max_sector_dollars:.2f}, reduced to ${available:.2f}"
)
return available, new_pct
return dollar_amount, allocation_pct
@staticmethod
def _apply_diversification_bonus(
sector: str,
dollar_amount: float,
allocation_pct: float,
risk_tier: RiskTierConfig,
portfolio_state: PortfolioState,
adjustments: list[str],
) -> tuple[float, float]:
"""Apply 1.2x bonus for under-represented sectors when < 3 sectors held."""
existing_sectors = set(portfolio_state.sector_exposure.keys())
if len(existing_sectors) < 3 and sector not in existing_sectors:
bonus = 1.2
new_dollar = dollar_amount * bonus
new_pct = allocation_pct * bonus
# Re-clamp to max_position_pct after bonus
max_dollar = risk_tier.max_position_pct * (
portfolio_state.active_pool
if portfolio_state.active_pool > 0
else 1.0
)
if new_dollar > max_dollar:
new_dollar = max_dollar
new_pct = risk_tier.max_position_pct
adjustments.append(
f"Diversification bonus: 1.2x applied for new sector '{sector}' "
f"(portfolio has {len(existing_sectors)} sectors), "
f"${dollar_amount:.2f} -> ${new_dollar:.2f}"
)
return new_dollar, new_pct
return dollar_amount, allocation_pct
@staticmethod
def _apply_earnings_proximity(
ticker: str,
dollar_amount: float,
allocation_pct: float,
earnings_calendar: dict[str, datetime],
adjustments: list[str],
) -> tuple[float, float] | PositionSizeResult:
"""Reduce by 50% within 3 trading days; reject within 1 trading day."""
if ticker not in earnings_calendar:
return dollar_amount, allocation_pct
earnings_dt = earnings_calendar[ticker]
now = datetime.now(tz=timezone.utc)
delta = earnings_dt - now
# Use total_seconds for precise fractional-day comparison
trading_days_until = delta.total_seconds() / 86400.0
if trading_days_until < 0:
# Earnings already passed
return dollar_amount, allocation_pct
if trading_days_until <= 1:
adjustments.append(
f"Earnings rejection: {ticker} earnings in {trading_days_until:.1f} day(s)"
)
return PositionSizeResult(
dollar_amount=0.0,
share_quantity=0,
allocation_pct=0.0,
adjustments=adjustments,
rejected=True,
rejection_reason=f"Earnings within 1 trading day for {ticker}",
)
if trading_days_until <= 3:
new_dollar = dollar_amount * 0.5
new_pct = allocation_pct * 0.5
adjustments.append(
f"Earnings proximity: {ticker} earnings in {trading_days_until:.1f} days, "
f"50% reduction: ${dollar_amount:.2f} -> ${new_dollar:.2f}"
)
return new_dollar, new_pct
return dollar_amount, allocation_pct
# ===========================================================================
# v3 Fractional Kelly Position Sizing (Requirements 14.114.7)
# ===========================================================================
@dataclass(frozen=True)
class KellySizingResult:
"""Result of v3 fractional Kelly position sizing computation."""
portfolio_pct: float
f_kelly: float
reward_ratio: float
downgrade: bool
downgrade_reason: str
def _clamp(value: float, lo: float, hi: float) -> float:
"""Clamp value to [lo, hi]."""
return max(lo, min(hi, value))
def compute_reward_ratio(
confidence: float, strength: float, contradiction: float
) -> float:
"""Compute reward ratio b = clamp(1.2 + 2.0*confidence + 1.0*strength - contradiction, 1.2, 3.0).
Requirements: 14.2
"""
raw = 1.2 + 2.0 * confidence + 1.0 * strength - contradiction
return _clamp(raw, 1.2, 3.0)
def compute_kelly_sizing(
p_win: float,
b: float,
confidence: float,
data_quality: float,
contradiction: float,
max_position_pct: float,
available_caps: dict[str, float],
) -> KellySizingResult:
"""Compute fractional Kelly position sizing.
f_kelly = (p_win * b - (1 - p_win)) / b
portfolio_pct = clamp(max(0, f_kelly) * 0.25 * confidence * data_quality * (1 - contradiction), 0, max_position_pct)
Apply min of all capacity constraints from available_caps:
- sector_capacity
- correlation_capacity (0 if avg corr > 0.80)
- heat_capacity
Downgrade rules:
- If f_kelly <= 0 → portfolio_pct = 0, downgrade with reason "negative_edge"
- If portfolio_pct < 0.005 → downgrade with reason "position_below_minimum"
Requirements: 14.1, 14.2, 14.3, 14.4, 14.5, 14.6, 14.7
"""
# Compute Kelly fraction (Req 14.3)
f_kelly = (p_win * b - (1.0 - p_win)) / b
# Negative edge → immediate downgrade (Req 14.7)
if f_kelly <= 0.0:
return KellySizingResult(
portfolio_pct=0.0,
f_kelly=f_kelly,
reward_ratio=b,
downgrade=True,
downgrade_reason="negative_edge",
)
# Apply fractional Kelly with dampening factors (Req 14.4)
raw_pct = f_kelly * 0.25 * confidence * data_quality * (1.0 - contradiction)
portfolio_pct = _clamp(max(0.0, raw_pct), 0.0, max_position_pct)
# Apply capacity constraints (Req 14.5)
sector_capacity = available_caps.get("sector_capacity", max_position_pct)
correlation_capacity = available_caps.get("correlation_capacity", max_position_pct)
heat_capacity = available_caps.get("heat_capacity", max_position_pct)
# Correlation capacity of 0 means avg corr > 0.80 → force zero
portfolio_pct = min(
portfolio_pct,
max_position_pct,
sector_capacity,
correlation_capacity,
heat_capacity,
)
# Position below minimum threshold → downgrade (Req 14.6)
if portfolio_pct < 0.005:
return KellySizingResult(
portfolio_pct=0.0,
f_kelly=f_kelly,
reward_ratio=b,
downgrade=True,
downgrade_reason="position_below_minimum",
)
return KellySizingResult(
portfolio_pct=portfolio_pct,
f_kelly=f_kelly,
reward_ratio=b,
downgrade=False,
downgrade_reason="",
)