"""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, 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 * 1.5 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, ) -> float: """Compute target price at target_r multiples of risk.""" 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, ) -> str | None: """Run 7-step entry gate. Returns skip_reason string or None (pass). Gates (in order): 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 """ # 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" return None # all gates passed def build_planned_order( candidate: Candidate, portfolio_state: DailyPortfolioState, open_positions: list[OpenPosition], config: BacktestConfig, cooldown_remaining: int = 0, ) -> 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 ) stop_price = compute_stop_price(candidate, config.risk) target_r = config.execution.target_1_r or 2.0 target_price = compute_target_price(candidate.entry_price_est, stop_price, target_r) 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: 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, )