You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

260 lines
8.3 KiB
Python

"""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,
)