"""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) — with partial exit support - Same-bar conflict (controlled by same_bar_priority) - Time exit (days_held >= max_holding_days) - Kill switch / missing bar handled upstream Partial exits: when target_1_fraction < 1.0 and target is hit, exits only that fraction, moves stop to breakeven for remaining shares, and returns the partial FilledTrade. Remaining shares continue with trailing stop. 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 # --- Partial exit logic --- fraction = config.target_1_fraction if ( exit_reason == ExitReason.TARGET and fraction is not None and 0.0 < fraction < 1.0 and position.status != PositionStatus.PARTIALLY_EXITED ): partial_shares = max(1, math.floor(position.shares_open * fraction)) remaining_shares = position.shares_open - partial_shares if remaining_shares > 0: # Build partial fill trade partial_trade = _build_filled_trade_partial( position, exit_fill_price, exit_reason, current_date, config, shares=partial_shares, ) # Mutate position: reduce shares, move stop to breakeven, mark partial position.shares_open = remaining_shares position.current_stop = position.entry_price # breakeven stop position.status = PositionStatus.PARTIALLY_EXITED position.partial_fills.append(partial_trade) return partial_trade 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], trailing_model: str = "bar_low", warmup_days: int = 0, ) -> None: """Ratchet stop up based on trailing model (never down). Mutates position in place. Models: - "bar_low": trail to each day's low (tightest, aggressive) - "pct_3": trail at peak_price * (1 - 3%) — moderate - "pct_5": trail at peak_price * (1 - 5%) — wider Args: warmup_days: Skip trailing until position has been held this many days. """ # Always track peak price (even during warmup) bar_high = bar.get("high") if bar_high is not None: position.peak_price = max(position.peak_price, float(bar_high)) # Don't tighten stop during warmup period if position.days_held < warmup_days: return if trailing_model == "bar_low": 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 elif trailing_model.startswith("pct_"): try: trail_pct = float(trailing_model.split("_")[1]) / 100.0 except (IndexError, ValueError): trail_pct = 0.03 trail_stop = position.peak_price * (1.0 - trail_pct) position.current_stop = max(position.current_stop, trail_stop) # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _build_filled_trade_partial( position: OpenPosition, exit_price: float, exit_reason: ExitReason, exit_date: dt.date, config: ExecutionConfig, shares: int, ) -> FilledTrade: """Build a FilledTrade for a partial exit (specific share count).""" commission = shares * config.commission_per_share # only exit leg for partial 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 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, ) 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, )