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.

458 lines
16 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)
def _short_entry_fill(open_price: float, slippage_bps: float) -> float:
"""Sell short at open - slippage (receives less)."""
return open_price * (1.0 - slippage_bps / 10_000)
def _short_exit_fill(price: float, slippage_bps: float) -> float:
"""Buy to cover at price + slippage (pays more)."""
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 or reaction close.
Returns None (position NOT opened) if bar is missing or the required price 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
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
entry_policy = plan.entry_timing_policy or "next_open"
if entry_policy == "reaction_close":
reference_price = bar.get("close")
if reference_price is None or reference_price <= 0:
logger.warning("entry_skip_invalid_close", event_id=plan.candidate.event_id, bar=bar)
return None
else:
reference_price = bar.get("open")
if reference_price is None or reference_price <= 0:
logger.warning("entry_skip_invalid_open", event_id=plan.candidate.event_id, bar=bar)
return None
is_short = plan.candidate.trade_direction == "short"
if is_short:
fill_price = _short_entry_fill(float(reference_price), config.slippage_bps_base)
else:
fill_price = _long_entry_fill(float(reference_price), config.slippage_bps_base)
slippage_bps_actual = abs(fill_price / float(reference_price) - 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
is_short = position.plan.candidate.trade_direction == "short"
# Direction-aware stop/target detection
if is_short:
# Short: stop is above entry (hit when price goes up), target below entry (hit when price drops)
stop_hit = bar_high is not None and float(bar_high) >= position.current_stop
target_hit = bar_low is not None and float(bar_low) <= position.target_price
exit_fill_fn = _short_exit_fill
else:
# Long: stop below entry (hit when price drops), target above entry (hit when price rises)
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_fill_fn = _long_exit_fill
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 = exit_fill_fn(position.current_stop, slippage)
else: # target_first_aggressive
exit_reason = ExitReason.TARGET
exit_fill_price = exit_fill_fn(position.target_price, slippage)
elif stop_hit:
exit_reason = ExitReason.STOP
exit_fill_price = exit_fill_fn(position.current_stop, slippage)
elif target_hit:
exit_reason = ExitReason.TARGET
exit_fill_price = exit_fill_fn(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 = exit_fill_fn(float(bar_close), slippage)
else:
exit_fill_price = position.entry_price # fallback (shouldn't happen)
# No-follow-through early exit
# Long: D+1 close < entry. Short: D+1 close > entry.
if (
exit_reason is None
and config.no_follow_through_exit
and position.days_held == 1
and bar_close is not None
):
close_val = float(bar_close)
nft_triggered = (close_val > position.entry_price) if is_short else (close_val < position.entry_price)
if nft_triggered:
exit_reason = ExitReason.NO_FOLLOW_THROUGH
exit_fill_price = exit_fill_fn(close_val, slippage)
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
is_short = position.plan.candidate.trade_direction == "short"
exit_fill_fn = _short_exit_fill if is_short else _long_exit_fill
if bar is not None and bar.get("close") is not None:
exit_price = exit_fill_fn(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 towards price for trailing model. Mutates position in place.
For long: ratchet stop UP (never down). Tracks peak_price as highest high.
For short: ratchet stop DOWN (never up). Tracks peak_price as lowest low.
Models:
- "bar_low"/"bar_high": trail to bar extreme (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.
"""
is_short = position.plan.candidate.trade_direction == "short"
# Track best price (peak for long = highest high, for short = lowest low)
if is_short:
bar_low = bar.get("low")
if bar_low is not None:
position.peak_price = min(position.peak_price, float(bar_low))
else:
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 is_short:
# Short: trail stop DOWN towards price (tighter = lower stop)
if trailing_model == "bar_low" or trailing_model == "bar_high":
bar_high = bar.get("high")
if bar_high is not None:
position.current_stop = min(position.current_stop, float(bar_high))
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 = min(position.current_stop, trail_stop)
else:
# Long: trail stop UP (original behavior)
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)."""
is_short = position.plan.candidate.trade_direction == "short"
commission = shares * config.commission_per_share # only exit leg for partial
if is_short:
gross_pnl = (position.entry_price - exit_price) * shares
else:
gross_pnl = (exit_price - position.entry_price) * shares
net_pnl = gross_pnl - commission
entry_price = position.entry_price
if is_short:
pnl_pct = (entry_price - exit_price) / entry_price if entry_price != 0 else 0.0
else:
pnl_pct = (exit_price - entry_price) / entry_price if entry_price != 0 else 0.0
stop_distance = abs(entry_price - position.plan.stop_price)
if stop_distance > 0:
r_multiple = gross_pnl / shares / 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,
event_date=position.plan.event_date or position.plan.candidate.event_date,
timing_class=position.plan.timing_class,
engine_id=position.plan.engine_id,
entry_timing_policy=position.plan.entry_timing_policy,
shadow_only=position.plan.shadow_only,
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:
is_short = position.plan.candidate.trade_direction == "short"
shares = position.shares_open
commission = shares * config.commission_per_share * 2 # entry + exit legs
if is_short:
gross_pnl = (position.entry_price - exit_price) * shares
else:
gross_pnl = (exit_price - position.entry_price) * shares
net_pnl = gross_pnl - commission
entry_price = position.entry_price
if is_short:
pnl_pct = (entry_price - exit_price) / entry_price if entry_price != 0 else 0.0
else:
pnl_pct = (exit_price - entry_price) / entry_price if entry_price != 0 else 0.0
# R-multiple uses actual fill price
stop_distance = abs(entry_price - position.plan.stop_price)
if stop_distance > 0:
r_multiple = gross_pnl / shares / 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,
event_date=position.plan.event_date or position.plan.candidate.event_date,
timing_class=position.plan.timing_class,
engine_id=position.plan.engine_id,
entry_timing_policy=position.plan.entry_timing_policy,
shadow_only=position.plan.shadow_only,
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,
)