"""Position sizing, entry gates, and order planning for the backtester.""" from __future__ import annotations import math from typing import Any from libs.backtest.domain import ( BacktestConfig, Candidate, DailyPortfolioState, EventTypeProfile, OpenPosition, PlannedOrder, RiskConfig, ) from libs.common.logging import get_logger logger = get_logger(__name__) # Default drawdown kill-switch threshold (not in JSON schema) _KILL_SWITCH_DRAWDOWN_PCT = 25.0 def compute_stop_price(candidate: Candidate, config: RiskConfig) -> float: """Compute stop price based on ATR-14 or a percentage fallback. Uses entry_price_est (reaction close) as the price basis. Actual fill uses the real open + slippage; R-multiple uses actual fill price. """ price = candidate.entry_price_est if candidate.atr_14 and candidate.atr_14 > 0: stop_distance = candidate.atr_14 * config.stop_atr_multiplier else: # Fallback: 2% of price stop_distance = price * 0.02 return max(0.01, price - stop_distance) def compute_target_price( entry_price_est: float, stop_price: float, target_r: float = 2.0, *, target_model: str = "fixed_r", target_atr_multiplier: float = 1.5, atr_14: float | None = None, ) -> float: """Compute target price using fixed R-multiple or ATR-based model. Models: - "fixed_r": target = entry + risk * target_r (original) - "atr_multiple": target = entry + atr_14 * target_atr_multiplier """ if target_model == "atr_multiple" and atr_14 and atr_14 > 0: return entry_price_est + atr_14 * target_atr_multiplier # Default: fixed R-multiple risk = entry_price_est - stop_price if risk <= 0: return entry_price_est * 1.10 # 10% default target return entry_price_est + risk * target_r def compute_shares( equity: float, entry_price: float, stop_price: float, config: RiskConfig, ) -> int: """Compute integer share count. Always math.floor() — never round up.""" stop_distance = entry_price - stop_price if stop_distance <= 0: return 0 risk_dollars = equity * config.per_trade_risk_pct raw_shares = risk_dollars / stop_distance return max(0, math.floor(raw_shares)) def _count_sector_positions(open_positions: list[OpenPosition], sector: str) -> int: return sum(1 for p in open_positions if p.plan.candidate.sector == sector) def _open_symbols(open_positions: list[OpenPosition]) -> set[str]: return {p.plan.candidate.symbol for p in open_positions} def run_entry_gates( candidate: Candidate, portfolio_state: DailyPortfolioState, open_positions: list[OpenPosition], config: BacktestConfig, cooldown_remaining: int = 0, macro_data: dict[str, Any] | None = None, ) -> str | None: """Run entry gates. Returns skip_reason string or None (pass). Gates (in order): 0. Macro regime (SPY below SMA — hard block only if size_scaler >= 1.0) 1. Kill switch (drawdown >= threshold) 2. Max total positions 3. Duplicate symbol already open 4. Sector concentration 5. Daily new risk budget 6. Cash available (estimated position cost) 7. Loss-streak cooldown 8. (removed — SUE gate) 9. Event-type direction filter (bullish_only) 10. High one-off risk (veto: oneoff_penalty >= threshold) 11. Low parse confidence (veto: parse_confidence < threshold) 12. Unknown direction (veto: event_direction == "unknown") 13. Bearish direction (veto: event_direction == "bearish") """ # Gate 0: Macro regime filter (hard block only when size_scaler >= 1.0) if config.risk.macro_regime_enabled and macro_data: spy_close = macro_data.get("spy_close") spy_sma = macro_data.get("spy_sma_20") if spy_close is not None and spy_sma is not None and spy_close < spy_sma: if config.risk.macro_regime_size_scaler >= 1.0: return "macro_regime_unfavorable" # else: size scaler applied in build_planned_order # Gate 1: Kill switch if portfolio_state.current_drawdown_pct >= _KILL_SWITCH_DRAWDOWN_PCT: return "kill_switch_drawdown" # Gate 2: Max positions if len(open_positions) >= config.risk.max_positions: return "max_positions_reached" # Gate 3: Duplicate symbol if candidate.symbol in _open_symbols(open_positions): return "duplicate_symbol" # Gate 4: Sector concentration sector_count = _count_sector_positions(open_positions, candidate.sector) if sector_count >= config.risk.max_positions_per_sector: return "sector_limit" # Gate 5: Daily new risk budget trade_risk = portfolio_state.equity * config.risk.per_trade_risk_pct daily_budget = portfolio_state.equity * config.risk.max_daily_new_risk_pct if portfolio_state.daily_new_risk_used + trade_risk > daily_budget: return "daily_risk_budget" # Gate 6: Cash available (estimate position cost) stop_price = compute_stop_price(candidate, config.risk) est_shares = compute_shares( portfolio_state.equity, candidate.entry_price_est, stop_price, config.risk, ) est_cost = est_shares * candidate.entry_price_est if est_cost > portfolio_state.cash_available: return "insufficient_cash" # Gate 7: Cooldown if cooldown_remaining > 0: return "cooldown" # Gate 9: Event-type direction filter profile = config.get_event_profile(candidate.event_type) if profile and profile.direction_filter == "bullish_only": reaction = candidate.features.get("reaction_day_return") if reaction is not None and float(reaction) < 0: return "direction_filter_bearish" # --- Veto gates: document quality hard filters --- # Gate 10: High one-off risk oneoff = candidate.features.get("oneoff_penalty") if oneoff is not None and float(oneoff) >= config.risk.veto_oneoff_penalty: return "high_oneoff_risk" # Gate 11: Low parse confidence parse_conf = candidate.features.get("parse_confidence_overall") if parse_conf is not None and float(parse_conf) < config.risk.veto_parse_confidence_min: return "low_parse_confidence" # Gate 12: Unknown direction event_dir = candidate.features.get("event_direction") if config.risk.veto_unknown_direction and event_dir is not None and str(event_dir).lower() == "unknown": return "unknown_direction" # Gate 13: Bearish direction (all event types, document-based) if config.risk.veto_bearish_direction and event_dir is not None and str(event_dir).lower() == "bearish": return "bearish_direction" return None # all gates passed def build_planned_order( candidate: Candidate, portfolio_state: DailyPortfolioState, open_positions: list[OpenPosition], config: BacktestConfig, cooldown_remaining: int = 0, macro_data: dict[str, Any] | None = None, ) -> PlannedOrder: """Build a PlannedOrder. skip_reason is non-None if any gate rejected it.""" skip_reason = run_entry_gates( candidate, portfolio_state, open_positions, config, cooldown_remaining, macro_data=macro_data, ) # Apply event-type-specific overrides for stop/target ATR multipliers profile = config.get_event_profile(candidate.event_type) stop_atr_mult = ( profile.stop_atr_multiplier_override if profile and profile.stop_atr_multiplier_override is not None else config.risk.stop_atr_multiplier ) target_atr_mult = ( profile.target_atr_multiplier_override if profile and profile.target_atr_multiplier_override is not None else config.execution.target_atr_multiplier ) stop_price = compute_stop_price( candidate, RiskConfig(**{**config.risk.model_dump(), "stop_atr_multiplier": stop_atr_mult}) ) target_r = config.execution.target_1_r or 2.0 target_price = compute_target_price( candidate.entry_price_est, stop_price, target_r, target_model=config.execution.target_model, target_atr_multiplier=target_atr_mult, atr_14=candidate.atr_14, ) shares = 0 risk_dollars = 0.0 if skip_reason is None: shares = compute_shares( portfolio_state.equity, candidate.entry_price_est, stop_price, config.risk, ) if shares == 0: skip_reason = "zero_shares" else: # Apply macro size scaler when SPY < SMA and scaler < 1.0 if ( config.risk.macro_regime_enabled and config.risk.macro_regime_size_scaler < 1.0 and macro_data ): spy_close = macro_data.get("spy_close") spy_sma = macro_data.get("spy_sma_20") if ( spy_close is not None and spy_sma is not None and spy_close < spy_sma ): shares = max(1, math.floor(shares * config.risk.macro_regime_size_scaler)) risk_dollars = (candidate.entry_price_est - stop_price) * shares return PlannedOrder( candidate=candidate, shares=shares, entry_price_limit=candidate.entry_price_est, stop_price=stop_price, target_price=target_price, risk_dollars=risk_dollars, skip_reason=skip_reason, )