"""Fill simulation for entries and exits.""" from __future__ import annotations import datetime as dt import math import uuid from typing import Any from libs.backtest.domain import ( ExecutionConfig, ExitReason, FilledTrade, OpenPosition, PlannedOrder, PositionStatus, ) from libs.common.logging import get_logger logger = get_logger(__name__) # --------------------------------------------------------------------------- # Slippage helpers # --------------------------------------------------------------------------- def _long_entry_fill(open_price: float, slippage_bps: float) -> float: """Buy at open + slippage (pays more).""" return open_price * (1.0 + slippage_bps / 10_000) def _long_exit_fill(price: float, slippage_bps: float) -> float: """Sell at price - slippage (receives less).""" return price * (1.0 - slippage_bps / 10_000) # --------------------------------------------------------------------------- # Entry simulation # --------------------------------------------------------------------------- def simulate_entry( plan: PlannedOrder, bar: dict[str, Any], config: ExecutionConfig, position_id: str | None = None, ) -> OpenPosition | None: """Simulate filling a planned entry at the bar's open. Returns None (position NOT opened) if bar is missing or open is invalid. No zero imputation — missing bar = no entry. """ if bar is None: logger.warning("entry_skip_missing_bar", event_id=plan.candidate.event_id) return None bar_open = bar.get("open") if bar_open is None or bar_open <= 0: logger.warning("entry_skip_invalid_open", event_id=plan.candidate.event_id, bar=bar) return None if plan.skip_reason is not None: logger.debug("entry_skip_gate_rejected", reason=plan.skip_reason) return None if plan.shares <= 0: logger.warning("entry_skip_zero_shares", event_id=plan.candidate.event_id) return None fill_price = _long_entry_fill(float(bar_open), config.slippage_bps_base) slippage_bps_actual = (fill_price / float(bar_open) - 1.0) * 10_000 pid = position_id or str(uuid.uuid4()) # entry_date here is the bar date (execution_date of the candidate) bar_date_raw = bar.get("date") if isinstance(bar_date_raw, str): entry_date = dt.date.fromisoformat(bar_date_raw) elif isinstance(bar_date_raw, dt.date): entry_date = bar_date_raw else: entry_date = plan.candidate.execution_date return OpenPosition( position_id=pid, plan=plan, entry_date=entry_date, entry_price=fill_price, entry_fill_slippage_bps=slippage_bps_actual, current_stop=plan.stop_price, target_price=plan.target_price, peak_price=fill_price, shares_open=plan.shares, shares_total=plan.shares, days_held=0, status=PositionStatus.ENTERED, ) # --------------------------------------------------------------------------- # Exit simulation # --------------------------------------------------------------------------- def simulate_exit( position: OpenPosition, bar: dict[str, Any], config: ExecutionConfig, current_date: dt.date, ) -> FilledTrade | None: """Check if position should exit on this bar. Returns FilledTrade or None. Handles: - Stop loss (low ≤ stop_price) - Target (high ≥ target_price) - Same-bar conflict (controlled by same_bar_priority) - Time exit (days_held >= max_holding_days) - Kill switch / missing bar handled upstream Slippage is applied in the unfavorable direction for long positions. """ if bar is None: return None bar_low = bar.get("low") bar_high = bar.get("high") bar_close = bar.get("close") slippage = config.slippage_bps_base stop_hit = bar_low is not None and float(bar_low) <= position.current_stop target_hit = bar_high is not None and float(bar_high) >= position.target_price exit_reason: ExitReason | None = None exit_fill_price: float | None = None if stop_hit and target_hit: # Same-bar conflict if config.same_bar_priority == "stop_first_conservative": exit_reason = ExitReason.STOP exit_fill_price = _long_exit_fill(position.current_stop, slippage) else: # target_first_aggressive exit_reason = ExitReason.TARGET exit_fill_price = _long_exit_fill(position.target_price, slippage) elif stop_hit: exit_reason = ExitReason.STOP exit_fill_price = _long_exit_fill(position.current_stop, slippage) elif target_hit: exit_reason = ExitReason.TARGET exit_fill_price = _long_exit_fill(position.target_price, slippage) elif position.days_held >= config.max_holding_days: exit_reason = ExitReason.TIME if bar_close is not None and float(bar_close) > 0: exit_fill_price = _long_exit_fill(float(bar_close), slippage) else: exit_fill_price = position.entry_price # fallback (shouldn't happen) if exit_reason is None or exit_fill_price is None: return None return _build_filled_trade(position, exit_fill_price, exit_reason, current_date, config) def simulate_kill_switch_exit( position: OpenPosition, bar: dict[str, Any] | None, current_date: dt.date, config: ExecutionConfig, ) -> FilledTrade: """Force-close a position due to kill switch (portfolio drawdown).""" slippage = config.slippage_bps_base if bar is not None and bar.get("close") is not None: exit_price = _long_exit_fill(float(bar["close"]), slippage) else: exit_price = position.entry_price # last known price fallback return _build_filled_trade( position, exit_price, ExitReason.KILL_SWITCH, current_date, config ) def simulate_missing_bar_exit( position: OpenPosition, current_date: dt.date, config: ExecutionConfig, ) -> FilledTrade: """Close a position when bar data is unavailable for too long.""" return _build_filled_trade( position, position.entry_price, ExitReason.MISSING_BAR, current_date, config ) # --------------------------------------------------------------------------- # Trailing stop update # --------------------------------------------------------------------------- def update_trailing_stop(position: OpenPosition, bar: dict[str, Any]) -> None: """Ratchet stop up to bar low (never down). Mutates position in place.""" bar_low = bar.get("low") if bar_low is not None: new_stop = max(position.current_stop, float(bar_low)) position.current_stop = new_stop # Track peak price bar_high = bar.get("high") if bar_high is not None: position.peak_price = max(position.peak_price, float(bar_high)) # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _build_filled_trade( position: OpenPosition, exit_price: float, exit_reason: ExitReason, exit_date: dt.date, config: ExecutionConfig, ) -> FilledTrade: shares = position.shares_open commission = shares * config.commission_per_share * 2 # entry + exit legs gross_pnl = (exit_price - position.entry_price) * shares net_pnl = gross_pnl - commission entry_price = position.entry_price pnl_pct = (exit_price - entry_price) / entry_price if entry_price != 0 else 0.0 # R-multiple uses actual fill price stop_distance = entry_price - position.plan.stop_price if stop_distance > 0: r_multiple = (exit_price - entry_price) / stop_distance else: r_multiple = 0.0 holding_days = (exit_date - position.entry_date).days trade_id = str(uuid.uuid4()) return FilledTrade( trade_id=trade_id, position_id=position.position_id, event_id=position.plan.candidate.event_id, symbol=position.plan.candidate.symbol, entry_date=position.entry_date, exit_date=exit_date, entry_price=position.entry_price, exit_price=exit_price, exit_reason=exit_reason, shares=shares, commission=commission, slippage_bps=config.slippage_bps_base, gross_pnl=gross_pnl, net_pnl=net_pnl, pnl_pct=pnl_pct, r_multiple=r_multiple, holding_days=holding_days, )