"""Deterministic base-frame bar replay with the spec's scaled-exit management. Fill & exit rules (spec §Entry, §Stop, §Targets & Position Management): * A resting limit at the M5 OB fills no earlier than the bar in which its plan is known (``created_time``); cancelled on expiry, session end, or if the OB distal edge is crossed before filling (``invalidate_before_fill``). * On fill the position is split: **50% books at the fixed 5R first target**; the remaining **runner** targets the structural secondary level. * **Break-even** for the runner follows ``breakeven_mode``: ``never`` keeps the original stop; ``after_first_target`` moves to BE once the first tranche books; ``after_further_bos`` (the faithful rule) moves to BE only once a *further* BOS in the trade direction prints — "no reason for price to return". * Same-bar stop/target collisions resolve stop-first (``same_bar_policy``); MAE/MFE track the whole trade. Consumed OB zones are not re-used (``first_touch_only``). One event-log row per plan (filled / unfilled / invalidated / rejected) — nothing is silently dropped. Option execution is mapped afterward on a size-weighted exit spot. """ from __future__ import annotations from math import floor from typing import Dict, List, Optional import numpy as np import pandas as pd from .config import Config from .confirm import Plan class _Trade: __slots__ = ("plan", "start", "expiry_pos", "entry_idx", "entry_price", "units_first", "units_runner", "first_done", "runner_stop", "be_moved", "mae", "mfe", "realized_pnl", "realized_units", "exit_spot_num", "first_exit", "runner_exit", "reasons") def __init__(self, plan: Plan, start: int, expiry_pos: int): self.plan = plan self.start = start self.expiry_pos = expiry_pos self.entry_idx: Optional[int] = None self.entry_price = float("nan") self.units_first = 0 self.units_runner = 0 self.first_done = False self.runner_stop = plan.stop_price self.be_moved = False self.mae = 0.0 self.mfe = 0.0 self.realized_pnl = 0.0 self.realized_units = 0 self.exit_spot_num = 0.0 # sum(units*exit_price) for weighted exit self.first_exit: Optional[tuple] = None # (idx, price, reason) self.runner_exit: Optional[tuple] = None self.reasons: List[str] = [] def _pos(index: pd.DatetimeIndex, ts) -> int: return int(index.searchsorted(ts, side="left")) def run(plans: List[Plan], base_df: pd.DataFrame, cfg: Config, option_map=None) -> List[dict]: index = base_df.index high = base_df["high"].to_numpy(dtype=float) low = base_df["low"].to_numpy(dtype=float) close = base_df["close"].to_numpy(dtype=float) times = index.time n = len(base_df) planned = [p for p in plans if p.planned] rejected = [p for p in plans if not p.planned] working: List[_Trade] = [] by_start: Dict[int, List[_Trade]] = {} for p in sorted(planned, key=lambda x: (x.created_time, x.entry_price)): start = _pos(index, p.created_time) if start >= n: rejected.append(_as_unfilled(p, "no_base_bar")) continue expiry_pos = max(min(_pos(index, p.expiry_time), n - 1), start) by_start.setdefault(start, []).append(_Trade(p, start, expiry_pos)) consumed: set = set() open_trades: List[_Trade] = [] rows: List[dict] = [] for t in range(n): # ---- 1) manage open trades ---- still: List[_Trade] = [] for tr in open_trades: _excursion(tr, high[t], low[t]) _maybe_breakeven(tr, index[t], cfg) done = _manage_exit(tr, t, high[t], low[t], close[t], times[t], cfg) if done: rows.append(_row(tr, base_df, cfg, option_map)) else: still.append(tr) open_trades = still # ---- 2) activate newly-tradeable orders ---- for tr in by_start.get(t, []): working.append(tr) # ---- 3) work resting limits ---- nxt: List[_Trade] = [] for tr in working: p = tr.plan if t > tr.expiry_pos: rows.append(_unfilled(tr, t, "unfilled_expired", base_df)); continue if cfg.first_touch_only and p.zone_key in consumed: rows.append(_unfilled(tr, t, "zone_consumed", base_df)); continue if cfg.intraday_only and times[t] >= cfg.intraday_exit_time: rows.append(_unfilled(tr, t, "session_end", base_df)); continue # first target reached before entry -> stale, cancel if _tgt_pre_entry(p, high[t], low[t]): rows.append(_unfilled(tr, t, "target_pre_entry", base_df)); continue # fill? if low[t] <= p.entry_price <= high[t]: if len(open_trades) >= cfg.max_concurrent: rows.append(_unfilled(tr, t, "skipped_concurrency", base_df)); continue _fill(tr, t, cfg) consumed.add(p.zone_key) _excursion(tr, high[t], low[t]) if _manage_exit(tr, t, high[t], low[t], close[t], times[t], cfg): rows.append(_row(tr, base_df, cfg, option_map)) else: open_trades.append(tr) continue if cfg.invalidate_before_fill and _invalidated(p, high[t], low[t]): rows.append(_unfilled(tr, t, "invalidated", base_df)); continue nxt.append(tr) working = nxt # flush leftovers at series end for tr in open_trades: _force_close(tr, n - 1, close[-1], "eod_series") rows.append(_row(tr, base_df, cfg, option_map)) for tr in working: rows.append(_unfilled(tr, min(tr.expiry_pos, n - 1), "unfilled_expired", base_df)) for p in rejected: rows.append(_reject_row(p)) return rows # ---- position lifecycle ---------------------------------------------------- def _fill(tr: _Trade, t: int, cfg: Config): p = tr.plan tr.entry_idx = t tr.entry_price = p.entry_price total = _size(p, cfg) tr.units_first = int(round(total * p.first_scale_fraction)) tr.units_runner = total - tr.units_first tr.runner_stop = p.stop_price def _size(p: Plan, cfg: Config) -> int: if p.risk <= 0: return 0 units = floor(cfg.risk_pct * cfg.start_equity / p.risk) if cfg.position_cap_pct is not None and p.entry_price > 0: cap = floor(cfg.position_cap_pct * cfg.start_equity / p.entry_price) units = min(units, cap) return max(units, 2) def _sign(p: Plan) -> float: return 1.0 if p.direction == "long" else -1.0 def _excursion(tr: _Trade, hi, lo): if tr.entry_idx is None: return if tr.plan.direction == "long": tr.mfe = max(tr.mfe, hi - tr.entry_price) tr.mae = min(tr.mae, lo - tr.entry_price) else: tr.mfe = max(tr.mfe, tr.entry_price - lo) tr.mae = min(tr.mae, tr.entry_price - hi) def _maybe_breakeven(tr: _Trade, ts, cfg: Config): if tr.be_moved or cfg.breakeven_mode == "never": return p = tr.plan move = False if cfg.breakeven_mode == "after_first_target": move = tr.first_done elif cfg.breakeven_mode == "after_further_bos": move = any(ts >= bt for bt in p.further_bos_times) if move: sign = _sign(p) tr.runner_stop = tr.entry_price + sign * p.breakeven_buffer tr.be_moved = True def _book(tr: _Trade, units: int, price: float, idx: int, reason: str): p = tr.plan sign = _sign(p) tr.realized_pnl += sign * (price - tr.entry_price) * units tr.realized_units += units tr.exit_spot_num += units * price tr.reasons.append(reason) def _manage_exit(tr: _Trade, t, hi, lo, cl, tm, cfg: Config) -> bool: """Advance exits on bar ``t``; return True when the whole position is closed.""" p = tr.plan long = p.direction == "long" # ---- phase A: first tranche + runner share the original stop ---- if not tr.first_done and tr.units_first > 0: hit_stop = (lo <= p.stop_price) if long else (hi >= p.stop_price) hit_first = (hi >= p.first_target_price) if long else (lo <= p.first_target_price) if hit_stop and hit_first: if cfg.same_bar_policy == "stop_first": _book(tr, tr.units_first + tr.units_runner, p.stop_price, t, "stop") tr.first_exit = (t, p.stop_price, "stop") tr.runner_exit = (t, p.stop_price, "stop") _force_close(tr, t, p.stop_price, "stop", already=True) return True # target-first _book(tr, tr.units_first, p.first_target_price, t, "first_target_5R") tr.first_exit = (t, p.first_target_price, "first_target_5R") tr.first_done = True _apply_be_on_first(tr, cfg) elif hit_stop: _book(tr, tr.units_first + tr.units_runner, p.stop_price, t, "stop") tr.first_exit = (t, p.stop_price, "stop") tr.runner_exit = (t, p.stop_price, "stop") return True elif hit_first: _book(tr, tr.units_first, p.first_target_price, t, "first_target_5R") tr.first_exit = (t, p.first_target_price, "first_target_5R") tr.first_done = True _apply_be_on_first(tr, cfg) # ---- phase B: runner only ---- if tr.first_done and tr.units_runner > 0 and tr.runner_exit is None: rstop = tr.runner_stop hit_stop = (lo <= rstop) if long else (hi >= rstop) hit_sec = (hi >= p.secondary_target_price) if long else (lo <= p.secondary_target_price) if hit_stop and hit_sec: px = rstop if cfg.same_bar_policy == "stop_first" else p.secondary_target_price reason = "runner_stop" if cfg.same_bar_policy == "stop_first" else "secondary_target" _book(tr, tr.units_runner, px, t, reason) tr.runner_exit = (t, px, reason) return True if hit_stop: _book(tr, tr.units_runner, rstop, t, "runner_stop") tr.runner_exit = (t, rstop, "runner_stop") return True if hit_sec: _book(tr, tr.units_runner, p.secondary_target_price, t, "secondary_target") tr.runner_exit = (t, p.secondary_target_price, "secondary_target") return True # ---- intraday / session force-close of anything still open ---- if cfg.intraday_only and tm >= cfg.intraday_exit_time: _force_close(tr, t, cl, "intraday_exit") return True # fully closed? if tr.realized_units >= tr.units_first + tr.units_runner and tr.units_first + tr.units_runner > 0: return True return False def _apply_be_on_first(tr: _Trade, cfg: Config): if cfg.breakeven_mode == "after_first_target" and not tr.be_moved: sign = _sign(tr.plan) tr.runner_stop = tr.entry_price + sign * tr.plan.breakeven_buffer tr.be_moved = True def _force_close(tr: _Trade, t, price, reason, already: bool = False): if already: return if not tr.first_done and tr.units_first > 0 and tr.first_exit is None: _book(tr, tr.units_first, price, t, reason) tr.first_exit = (t, price, reason) tr.first_done = True if tr.units_runner > 0 and tr.runner_exit is None: _book(tr, tr.units_runner, price, t, reason) tr.runner_exit = (t, price, reason) def _tgt_pre_entry(p: Plan, hi, lo) -> bool: return (hi >= p.first_target_price) if p.direction == "long" else (lo <= p.first_target_price) def _invalidated(p: Plan, hi, lo) -> bool: return (lo < p.invalidation_price) if p.direction == "long" else (hi > p.invalidation_price) # ---- event-log rows -------------------------------------------------------- def _base_row(p: Plan) -> dict: return { "symbol": p.symbol, "direction": p.direction, "trend": p.trend, "htf_break_kind": p.htf_break_kind, "qualified_by": p.qualified_by, "swept": p.swept, "sweep_level_type": p.sweep_level_type, "sweep_level_price": p.sweep_level_price, "sweep_time": p.sweep_time, "htf_known_time": p.htf_known_time, "htf_ob_low": p.htf_ob_low, "htf_ob_high": p.htf_ob_high, "htf_ob_time": p.htf_ob_time, "htf_touch_time": p.htf_touch_time, "m1_shift_time": p.m1_shift_time, "m1_shift_kind": p.m1_shift_kind, "m5_ob_low": p.m5_ob_low, "m5_ob_high": p.m5_ob_high, "m5_ob_time": p.m5_ob_time, "entry_price": p.entry_price, "stop_price": p.stop_price, "first_target_price": p.first_target_price, "secondary_target_price": p.secondary_target_price, "secondary_target_mode": p.secondary_target_mode, "planned_first_R": p.planned_first_R, "planned_secondary_R": p.planned_secondary_R, "created_time": p.created_time, "entry_timestamp": None, "first_exit_time": None, "first_exit_price": np.nan, "runner_exit_time": None, "runner_exit_price": np.nan, "exit_reason": None, "units_total": np.nan, "gross_pnl": np.nan, "costs": np.nan, "net_pnl": np.nan, "realized_R": np.nan, "mae": np.nan, "mfe": np.nan, "be_moved": False, "rejection_reason": p.rejection_reason, "option_symbol": None, "option_entry_price": np.nan, "option_exit_price": np.nan, "option_gross_pnl": np.nan, "option_costs": np.nan, "option_net_pnl": np.nan, } def _row(tr: _Trade, base_df: pd.DataFrame, cfg: Config, option_map) -> dict: p = tr.plan r = _base_row(p) idx = base_df.index units = tr.units_first + tr.units_runner gross = tr.realized_pnl wexit = tr.exit_spot_num / tr.realized_units if tr.realized_units else np.nan notional = (tr.entry_price + wexit) * units costs = notional * (cfg.commission_pct + cfg.slippage_pct) risk = p.risk exit_time = None if tr.runner_exit is not None: exit_time = idx[tr.runner_exit[0]] elif tr.first_exit is not None: exit_time = idx[tr.first_exit[0]] r.update( entry_timestamp=idx[tr.entry_idx], units_total=units, first_exit_time=(idx[tr.first_exit[0]] if tr.first_exit else None), first_exit_price=(tr.first_exit[1] if tr.first_exit else np.nan), runner_exit_time=(idx[tr.runner_exit[0]] if tr.runner_exit else None), runner_exit_price=(tr.runner_exit[1] if tr.runner_exit else np.nan), exit_reason="+".join(dict.fromkeys(tr.reasons)) or "open", gross_pnl=gross, costs=costs, net_pnl=gross - costs, realized_R=(gross / (risk * units)) if (risk > 0 and units) else np.nan, mae=tr.mae, mfe=tr.mfe, be_moved=tr.be_moved, ) if option_map is not None and cfg.options_mode and not np.isnan(wexit): r.update(option_map(p, idx[tr.entry_idx], exit_time, tr.entry_price, wexit)) return r def _unfilled(tr: _Trade, at_idx: int, reason: str, base_df: pd.DataFrame) -> dict: r = _base_row(tr.plan) r.update(exit_reason=reason, first_exit_time=base_df.index[at_idx]) return r def _as_unfilled(p: Plan, reason: str) -> Plan: p.meta["_unfilled_reason"] = reason return p def _reject_row(p: Plan) -> dict: r = _base_row(p) r.update(exit_reason=(p.rejection_reason or p.meta.get("_unfilled_reason", "unfilled"))) return r