{"nbformat":4,"nbformat_minor":5,"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.8.0"},"colab":{"provenance":[]}},"cells":[{"cell_type":"markdown","metadata":{"id":"nCJO4y9jWvkt"},"source":["# Event-Driven Backtest\n","---\n","\n","## Overview\n","\n","This notebook implements an **event-driven backtesting engine** (Notebook 67) — the natural evolution of the vectorized backtest (Notebook 66).\n","\n","Instead of applying logic across arrays in bulk, the engine processes a **prioritized event queue** tick-by-tick on 1-minute candles. Each event is handled in strict chronological order, enabling:\n","\n","| Feature | Description |\n","|---------|-------------|\n","| **News Events** | Simulated macro announcements that spike volatility and widen spreads |\n","| **ATR-Based Exits** | Dynamic stop-loss and take-profit levels computed from Average True Range |\n","| **Volatility Regime Filter** | Blocks new entries when ATR exceeds a configurable multiplier |\n","| **Trailing Stops** | Stop-loss that ratchets up (long) or down (short) as price moves favorably |\n","| **Time-Based Exits** | Automatically close positions that have been open beyond a max bar limit |\n","| **Direction Reversal** | Close and flip on signal change — identical to vectorized version |\n","| **Max Drawdown Guard** | Hard stop at 50% equity loss |\n","\n","---\n","\n","## Event Types (Priority Queue)\n","\n","Events are processed in this priority order per timestamp:\n","\n","| Priority | Event Type | Description |\n","|----------|-----------|-------------|\n","| 1 | `NEWS` | Macro shock — widens spread, may force close |\n","| 2 | `ATR_EXIT` | Volatility-driven stop triggered |\n","| 3 | `TRAILING_STOP` | Trailing stop updated or triggered |\n","| 4 | `TP_HIT` | Take-profit level breached |\n","| 5 | `SL_HIT` | Stop-loss level breached |\n","| 6 | `TIME_EXIT` | Maximum hold duration exceeded |\n","| 7 | `SIGNAL` | New directional prediction from model |\n","\n","---\n","\n","> Tick-level precision on 1-minute OHLCV data. For bulk vectorized approximation, refer to Notebook 66.\n"],"id":"nCJO4y9jWvkt"},{"cell_type":"markdown","metadata":{"id":"-HSBt6Q4Wvk0"},"source":["## Step 1 — Install Dependencies\n"],"id":"-HSBt6Q4Wvk0"},{"cell_type":"code","execution_count":1,"metadata":{"id":"3ZLBj4zlWvk1","executionInfo":{"status":"ok","timestamp":1780640725177,"user_tz":-300,"elapsed":8379,"user":{"displayName":"Arsalan Bakhtiar","userId":"11466190921627274456"}}},"outputs":[],"source":["# Install required packages\n","!pip install pandas numpy --quiet\n"],"id":"3ZLBj4zlWvk1"},{"cell_type":"markdown","metadata":{"id":"WZ_itu0eWvk4"},"source":["## Step 2 — Import Libraries\n"],"id":"WZ_itu0eWvk4"},{"cell_type":"code","execution_count":2,"metadata":{"colab":{"base_uri":"https://localhost:8080/"},"id":"au3kN4t5Wvk4","executionInfo":{"status":"ok","timestamp":1780640725534,"user_tz":-300,"elapsed":363,"user":{"displayName":"Arsalan Bakhtiar","userId":"11466190921627274456"}},"outputId":"e98a3477-caf1-4e39-cdd0-3ca32784f8a9"},"outputs":[{"output_type":"stream","name":"stdout","text":["pandas version : 2.2.2\n","numpy version  : 2.0.2\n","Environment ready.\n"]}],"source":["import pandas as pd\n","import numpy as np\n","import heapq          # Min-heap for the priority event queue\n","import warnings\n","from dataclasses import dataclass, field\n","from typing import Optional\n","from enum import IntEnum\n","\n","warnings.filterwarnings('ignore')\n","pd.set_option('display.float_format', '{:.6f}'.format)\n","pd.set_option('display.max_columns', 20)\n","pd.set_option('display.width', 140)\n","\n","print(f\"pandas version : {pd.__version__}\")\n","print(f\"numpy version  : {np.__version__}\")\n","print(\"Environment ready.\")\n"],"id":"au3kN4t5Wvk4"},{"cell_type":"markdown","metadata":{"id":"mxP3__JeWvk5"},"source":["## Step 3 — Event System\n","\n","### Event Priority Enum\n","\n","Each event type carries an integer priority. Lower number = processed first within the same timestamp.\n","The priority ordering ensures news shocks are handled before TP/SL checks, and TP/SL before new signal entries.\n"],"id":"mxP3__JeWvk5"},{"cell_type":"code","execution_count":3,"metadata":{"colab":{"base_uri":"https://localhost:8080/"},"id":"4ygqUmwqWvk6","executionInfo":{"status":"ok","timestamp":1780640725588,"user_tz":-300,"elapsed":21,"user":{"displayName":"Arsalan Bakhtiar","userId":"11466190921627274456"}},"outputId":"36a94c5a-1900-4474-944f-0a8ae8eb4d1a"},"outputs":[{"output_type":"stream","name":"stdout","text":["Event system defined.\n","  Event priorities: {'NEWS': 1, 'ATR_EXIT': 2, 'TRAILING_STOP': 3, 'TP_HIT': 4, 'SL_HIT': 5, 'TIME_EXIT': 6, 'SIGNAL': 7}\n"]}],"source":["class EventPriority(IntEnum):\n","    \"\"\"\n","    Processing priority for events sharing the same timestamp.\n","    Lower value = processed first.\n","    \"\"\"\n","    NEWS          = 1   # Macro shock — highest priority\n","    ATR_EXIT      = 2   # Volatility-driven exit\n","    TRAILING_STOP = 3   # Trailing stop update/trigger\n","    TP_HIT        = 4   # Take-profit trigger\n","    SL_HIT        = 5   # Stop-loss trigger\n","    TIME_EXIT     = 6   # Max hold duration exceeded\n","    SIGNAL        = 7   # Directional model prediction — lowest priority\n","\n","\n","@dataclass(order=True)\n","class Event:\n","    \"\"\"\n","    A single event in the simulation queue.\n","\n","    Sorting key: (timestamp, priority) — heapq uses __lt__ via dataclass ordering.\n","\n","    Attributes\n","    ----------\n","    timestamp : np.datetime64\n","        When the event fires.\n","    priority : int\n","        Processing order within the same timestamp (EventPriority values).\n","    event_type : str\n","        Human-readable event label (e.g., 'SIGNAL', 'NEWS', 'TP_HIT').\n","    payload : dict\n","        Arbitrary event data (direction, price levels, news magnitude, etc.).\n","    \"\"\"\n","    timestamp  : object          # np.datetime64 — sortable\n","    priority   : int             # EventPriority value\n","    event_type : str  = field(compare=False)\n","    payload    : dict = field(default_factory=dict, compare=False)\n","\n","\n","print(\"Event system defined.\")\n","print(f\"  Event priorities: { {e.name: int(e) for e in EventPriority} }\")\n"],"id":"4ygqUmwqWvk6"},{"cell_type":"markdown","metadata":{"id":"PscifxuFWvk8"},"source":["## Step 4 — ATR Calculator\n","\n","The **Average True Range (ATR)** measures market volatility by averaging the true range over a lookback window.\n","\n","```\n","True Range = max(High − Low, |High − Prev_Close|, |Low − Prev_Close|)\n","ATR(n)     = EMA(True Range, n)\n","```\n","\n","ATR serves two roles in this engine:\n","1. **Dynamic TP/SL sizing** — TP = entry ± ATR × `atr_tp_mult`, SL = entry ∓ ATR × `atr_sl_mult`\n","2. **Volatility regime filter** — If ATR > threshold, new entries are blocked\n"],"id":"PscifxuFWvk8"},{"cell_type":"code","execution_count":4,"metadata":{"colab":{"base_uri":"https://localhost:8080/"},"id":"9W7TPOvnWvk8","executionInfo":{"status":"ok","timestamp":1780640725595,"user_tz":-300,"elapsed":5,"user":{"displayName":"Arsalan Bakhtiar","userId":"11466190921627274456"}},"outputId":"1a43724b-4b2d-4ba4-a4c0-8042fee82f5a"},"outputs":[{"output_type":"stream","name":"stdout","text":["ATR calculator defined.\n"]}],"source":["def compute_atr(np_1m: np.ndarray,\n","               idx_high: int,\n","               idx_low: int,\n","               idx_close: int,\n","               period: int = 14) -> np.ndarray:\n","    \"\"\"\n","    Compute ATR for each row of a 1-minute OHLCV array.\n","\n","    Parameters\n","    ----------\n","    np_1m    : np.ndarray — full 1-minute candle array\n","    idx_high : int        — column index for High\n","    idx_low  : int        — column index for Low\n","    idx_close: int        — column index for Close\n","    period   : int        — ATR lookback (default 14)\n","\n","    Returns\n","    -------\n","    np.ndarray of shape (n,) — ATR value at each row (NaN for first `period` rows)\n","    \"\"\"\n","    highs  = np_1m[:, idx_high].astype(float)\n","    lows   = np_1m[:, idx_low].astype(float)\n","    closes = np_1m[:, idx_close].astype(float)\n","\n","    n  = len(highs)\n","    tr = np.empty(n)\n","    tr[0] = highs[0] - lows[0]\n","\n","    for i in range(1, n):\n","        hl   = highs[i]  - lows[i]\n","        hpc  = abs(highs[i]  - closes[i - 1])\n","        lpc  = abs(lows[i]   - closes[i - 1])\n","        tr[i] = max(hl, hpc, lpc)\n","\n","    # Wilder smoothing (equivalent to EMA with alpha = 1/period)\n","    atr = np.full(n, np.nan)\n","    atr[period - 1] = np.mean(tr[:period])\n","    alpha = 1.0 / period\n","    for i in range(period, n):\n","        atr[i] = alpha * tr[i] + (1 - alpha) * atr[i - 1]\n","\n","    return atr\n","\n","\n","print(\"ATR calculator defined.\")"],"id":"9W7TPOvnWvk8"},{"cell_type":"markdown","metadata":{"id":"bAKV4CkSWvk9"},"source":["## Step 5 — Mock Data Generation\n","\n","Generates synthetic 1-minute OHLCV data and model predictions.\n","\n","**News events** are randomly injected at ~2% of all timestamps to simulate macro announcements (e.g., Fed rate decisions, CPI prints). Each news event has a `magnitude` (0–1) representing shock severity.\n"],"id":"bAKV4CkSWvk9"},{"cell_type":"code","execution_count":5,"metadata":{"colab":{"base_uri":"https://localhost:8080/"},"id":"Dq9R-UwPWvk-","executionInfo":{"status":"ok","timestamp":1780640725637,"user_tz":-300,"elapsed":40,"user":{"displayName":"Arsalan Bakhtiar","userId":"11466190921627274456"}},"outputId":"04c67d63-2dd6-4fa9-9b68-1f1f223f5949"},"outputs":[{"output_type":"stream","name":"stdout","text":["Mock data generators defined.\n"]}],"source":["def generate_mock_1m_data(n_days: int = 15,\n","                         base_price: float = 30000.0,\n","                         seed: int = 42) -> tuple:\n","    \"\"\"\n","    Generate synthetic 1-minute OHLCV candle data.\n","\n","    Returns\n","    -------\n","    tuple: (np_1m, idx_datetime, idx_open, idx_high, idx_low, idx_close)\n","    \"\"\"\n","    n_minutes = n_days * 24 * 60\n","    datetimes = pd.date_range(start='2024-01-01', periods=n_minutes, freq='1min').values\n","\n","    np.random.seed(seed)\n","    returns      = np.random.normal(0, 0.001, n_minutes)\n","    close_prices = base_price * np.cumprod(1 + returns)\n","    open_prices  = np.roll(close_prices, 1)\n","    open_prices[0] = base_price\n","\n","    high_prices = np.maximum(open_prices, close_prices) * (\n","        1 + np.abs(np.random.normal(0, 0.0005, n_minutes)))\n","    low_prices  = np.minimum(open_prices, close_prices) * (\n","        1 - np.abs(np.random.normal(0, 0.0005, n_minutes)))\n","    volumes     = np.random.randint(1, 100, n_minutes).astype(float)\n","\n","    np_1m = np.column_stack([\n","        datetimes.astype(object),\n","        open_prices, high_prices, low_prices, close_prices, volumes\n","    ])\n","\n","    return np_1m, 0, 1, 2, 3, 4  # array, idx_dt, idx_open, idx_high, idx_low, idx_close\n","\n","\n","def generate_mock_predictions(n_periods: int = 80,\n","                               freq: str = '4h',\n","                               seed: int = 99) -> pd.DataFrame:\n","    \"\"\"\n","    Generate synthetic directional prediction signals (+1, -1, 0).\n","    \"\"\"\n","    datetimes = pd.date_range(start='2024-01-01', periods=n_periods, freq=freq)\n","    np.random.seed(seed)\n","    directions = np.random.choice([1, -1, 0], size=n_periods, p=[0.45, 0.45, 0.10])\n","    return pd.DataFrame({'datetime': datetimes, 'predicted_direction': directions})\n","\n","\n","def generate_news_events(np_1m: np.ndarray,\n","                         idx_datetime: int,\n","                         news_rate: float = 0.02,\n","                         seed: int = 7) -> list:\n","    \"\"\"\n","    Randomly inject news events at ~`news_rate` fraction of 1-minute timestamps.\n","\n","    Returns\n","    -------\n","    list of dicts: [{timestamp, magnitude, label}, ...]\n","    \"\"\"\n","    np.random.seed(seed)\n","    n = len(np_1m)\n","    mask = np.random.random(n) < news_rate\n","    indices = np.where(mask)[0]\n","\n","    news_labels = [\n","        'Fed Rate Decision', 'CPI Print', 'NFP Report',\n","        'GDP Release', 'FOMC Minutes', 'Earnings Surprise',\n","        'Geopolitical Shock', 'Liquidity Crunch',\n","    ]\n","\n","    events = []\n","    for idx in indices:\n","        events.append({\n","            'timestamp' : np_1m[idx, idx_datetime],\n","            'magnitude' : round(np.random.uniform(0.1, 1.0), 2),\n","            'label'     : np.random.choice(news_labels),\n","        })\n","    return events\n","\n","\n","print(\"Mock data generators defined.\")"],"id":"Dq9R-UwPWvk-"},{"cell_type":"markdown","metadata":{"id":"Os_yOAw9Wvk-"},"source":["## Step 6 — Event-Driven Engine\n","\n","### Architecture\n","\n","The `EventDrivenBacktest` class manages:\n","\n","1. **Event Queue** — A min-heap sorted by `(timestamp, priority)`\n","2. **Position State** — `in_position`, `buy_price`, `trailing_stop`, `bars_held`\n","3. **ATR Array** — Pre-computed for every 1-minute candle\n","4. **Dispatch Table** — Maps event types to handler methods\n","\n","### Key Handler Methods\n","\n","| Method | Trigger | Action |\n","|--------|---------|--------|\n","| `_handle_signal()` | New model prediction | Open/close/flip position |\n","| `_handle_news()` | News event fires | Widen spread; force-close if magnitude > threshold |\n","| `_handle_atr_exit()` | ATR spike detected | Close position if volatility too high |\n","| `_handle_trailing_stop()` | Price moves favorably | Ratchet stop; trigger if breached |\n","| `_handle_tp_sl()` | Price crosses TP/SL | Close with P&L |\n","| `_handle_time_exit()` | Bars held > max | Close regardless of P&L |\n"],"id":"Os_yOAw9Wvk-"},{"cell_type":"code","execution_count":6,"metadata":{"colab":{"base_uri":"https://localhost:8080/"},"id":"_KPgouslWvk_","executionInfo":{"status":"ok","timestamp":1780640725726,"user_tz":-300,"elapsed":87,"user":{"displayName":"Arsalan Bakhtiar","userId":"11466190921627274456"}},"outputId":"9ba579db-6ecb-4bad-a882-8341d1ee109d"},"outputs":[{"output_type":"stream","name":"stdout","text":["EventDrivenBacktest class defined.\n"]}],"source":["class EventDrivenBacktest:\n","    \"\"\"\n","    Event-driven backtesting engine with news, ATR, trailing stop,\n","    time-exit, and volatility regime filtering.\n","\n","    Parameters\n","    ----------\n","    np_1m               : np.ndarray  — 1-minute OHLCV array\n","    idx_datetime        : int         — column index: datetime\n","    idx_open            : int         — column index: open\n","    idx_high            : int         — column index: high\n","    idx_low             : int         — column index: low\n","    idx_close           : int         — column index: close\n","    starting_balance    : float       — initial capital (USD)\n","    atr_period          : int         — ATR lookback window (minutes)\n","    atr_sl_mult         : float       — ATR × mult = stop-loss distance\n","    atr_tp_mult         : float       — ATR × mult = take-profit distance\n","    atr_vol_threshold   : float       — ATR × mult above which entries are blocked\n","    trailing_stop_mult  : float       — ATR × mult = trailing stop distance\n","    max_bars_in_trade   : int         — max 1-minute bars before time exit\n","    news_force_close_mag: float       — news magnitude above which position is force-closed\n","    transaction_fee     : float       — per-leg fee (%)\n","    slippage            : float       — per-trade slippage (%)\n","    leverage            : float       — leverage multiplier\n","    buy_after_minutes   : int         — candle offset before entry\n","    \"\"\"\n","\n","    def __init__(\n","        self,\n","        np_1m,\n","        idx_datetime, idx_open, idx_high, idx_low, idx_close,\n","        starting_balance    = 1000.0,\n","        atr_period          = 14,\n","        atr_sl_mult         = 1.5,\n","        atr_tp_mult         = 2.5,\n","        atr_vol_threshold   = 3.0,\n","        trailing_stop_mult  = 1.0,\n","        max_bars_in_trade   = 240,\n","        news_force_close_mag= 0.6,\n","        transaction_fee     = 0.05,\n","        slippage            = 0.0,\n","        leverage            = 1.0,\n","        buy_after_minutes   = 0,\n","    ):\n","        # ── Data ──────────────────────────────────────────────────────────\n","        self.np_1m          = np_1m\n","        self.idx_dt         = idx_datetime\n","        self.idx_open       = idx_open\n","        self.idx_high       = idx_high\n","        self.idx_low        = idx_low\n","        self.idx_close      = idx_close\n","\n","        # ── Parameters ────────────────────────────────────────────────────\n","        self.starting_balance     = starting_balance\n","        self.atr_period           = atr_period\n","        self.atr_sl_mult          = atr_sl_mult\n","        self.atr_tp_mult          = atr_tp_mult\n","        self.atr_vol_threshold    = atr_vol_threshold\n","        self.trailing_stop_mult   = trailing_stop_mult\n","        self.max_bars_in_trade    = max_bars_in_trade\n","        self.news_force_close_mag = news_force_close_mag\n","        self.fee_pct              = transaction_fee * leverage\n","        self.slippage             = slippage\n","        self.leverage             = leverage\n","        self.buy_after_minutes    = buy_after_minutes\n","\n","        # ── State ─────────────────────────────────────────────────────────\n","        self.balance          = starting_balance\n","        self.in_position      = False\n","        self.direction        = 0       # +1 long / -1 short\n","        self.buy_price        = 0.0\n","        self.tp_price         = 0.0\n","        self.sl_price         = 0.0\n","        self.trailing_stop_px = 0.0\n","        self.bars_held        = 0\n","        self.entry_bar_idx    = 0\n","        self.ledger           = []      # list of trade records\n","\n","        # ── Pre-compute ATR ───────────────────────────────────────────────\n","        self.atr_array = compute_atr(\n","            np_1m, idx_high, idx_low, idx_close, period=atr_period\n","        )\n","\n","        # ── Build datetime lookup ─────────────────────────────────────────\n","        self.dt_series = pd.to_datetime(np_1m[:, idx_datetime])\n","        self.dt_to_idx = {dt: i for i, dt in enumerate(self.dt_series)}\n","\n","    # ── Ledger ────────────────────────────────────────────────────────────\n","    def _record(self, timestamp, event_type: str, action: str,\n","                buy_price: float, sell_price: float, pnl: float, note: str = ''):\n","        self.ledger.append({\n","            'datetime'  : timestamp,\n","            'event_type': event_type,\n","            'direction' : 'long' if self.direction > 0 else ('short' if self.direction < 0 else 'none'),\n","            'action'    : action,\n","            'buy_price' : round(buy_price, 4),\n","            'sell_price': round(sell_price, 4),\n","            'balance'   : round(self.balance, 4),\n","            'pnl'       : round(pnl, 4),\n","            'note'      : note,\n","        })\n","\n","    # ── PnL Calculator ────────────────────────────────────────────────────\n","    def _calc_pnl(self, sell_price: float) -> float:\n","        if self.direction > 0:\n","            raw = ((sell_price - self.buy_price) / self.buy_price) * 100\n","        else:\n","            raw = ((self.buy_price - sell_price) / self.buy_price) * 100\n","        return raw * self.leverage - self.fee_pct - self.slippage\n","\n","    # ── Open Position ─────────────────────────────────────────────────────\n","    def _open_position(self, timestamp, price: float, direction: int,\n","                       bar_idx: int, atr: float, event_type: str):\n","        self.direction    = direction\n","        self.buy_price    = price\n","        self.in_position  = True\n","        self.bars_held    = 0\n","        self.entry_bar_idx = bar_idx\n","\n","        # ATR-based TP/SL\n","        if direction > 0:\n","            self.tp_price         = price + atr * self.atr_tp_mult\n","            self.sl_price         = price - atr * self.atr_sl_mult\n","            self.trailing_stop_px = price - atr * self.trailing_stop_mult\n","        else:\n","            self.tp_price         = price - atr * self.atr_tp_mult\n","            self.sl_price         = price + atr * self.atr_sl_mult\n","            self.trailing_stop_px = price + atr * self.trailing_stop_mult\n","\n","        # Entry fee\n","        entry_pnl      = -self.fee_pct - self.slippage\n","        self.balance  += self.balance * (entry_pnl / 100)\n","\n","        self._record(timestamp, event_type, 'BUY', price, 0.0, entry_pnl,\n","                     f'TP={self.tp_price:.2f} SL={self.sl_price:.2f} ATR={atr:.2f}')\n","\n","    # ── Close Position ────────────────────────────────────────────────────\n","    def _close_position(self, timestamp, price: float,\n","                        event_type: str, note: str = ''):\n","        pnl           = self._calc_pnl(price)\n","        self.balance += self.balance * (pnl / 100)\n","        direction_save = self.direction\n","        self.in_position  = False\n","        self.direction     = 0\n","        self.bars_held     = 0\n","\n","        label = 'SELL-WIN' if pnl > 0 else 'SELL-LOSS'\n","        self._record(timestamp, event_type, label,\n","                     self.buy_price, price, pnl, note)\n","\n","    # ── Event Handlers ────────────────────────────────────────────────────\n","    def _handle_signal(self, ts, payload: dict, bar_idx: int):\n","        new_dir = payload.get('direction', 0)\n","\n","        if new_dir == 0:\n","            return  # Neutral — hold or do nothing\n","\n","        atr = self.atr_array[bar_idx]\n","        if np.isnan(atr):\n","            return  # Not enough history for ATR\n","\n","        # Volatility regime filter: block entry if ATR too high\n","        price = float(self.np_1m[bar_idx, self.idx_open])\n","        vol_threshold_px = price * (self.atr_vol_threshold / 100)\n","        if atr > vol_threshold_px:\n","            self._record(ts, 'SIGNAL', 'BLOCKED', price, 0.0, 0.0,\n","                         f'ATR {atr:.2f} > vol threshold {vol_threshold_px:.2f}')\n","            return\n","\n","        if self.in_position:\n","            if new_dir == self.direction:\n","                return  # Same direction — hold\n","            # Direction flip — close then re-enter\n","            self._close_position(ts, price, 'SIGNAL', note='direction_flip')\n","\n","        self._open_position(ts, price, new_dir, bar_idx, atr, 'SIGNAL')\n","\n","    def _handle_news(self, ts, payload: dict, bar_idx: int):\n","        magnitude = payload.get('magnitude', 0.5)\n","        label     = payload.get('label', 'News')\n","        price     = float(self.np_1m[bar_idx, self.idx_close])\n","\n","        note = f'{label} mag={magnitude:.2f}'\n","\n","        if self.in_position and magnitude >= self.news_force_close_mag:\n","            # High-magnitude news → force close with widened spread (extra slippage)\n","            spread_penalty = magnitude * 0.5  # additional % cost\n","            pnl = self._calc_pnl(price) - spread_penalty\n","            self.balance  += self.balance * (pnl / 100)\n","            self.in_position = False\n","            self.direction   = 0\n","            self.bars_held   = 0\n","            self._record(ts, 'NEWS', 'FORCE_CLOSE',\n","                         self.buy_price, price, pnl, note)\n","        else:\n","            # Low-magnitude news — log but do not close\n","            self._record(ts, 'NEWS', 'NOTED', 0.0, 0.0, 0.0, note)\n","\n","    def _handle_atr_exit(self, ts, payload: dict, bar_idx: int):\n","        \"\"\"Close position if current ATR has spiked beyond threshold.\"\"\"\n","        if not self.in_position:\n","            return\n","        atr   = self.atr_array[bar_idx]\n","        price = float(self.np_1m[bar_idx, self.idx_close])\n","        if np.isnan(atr):\n","            return\n","\n","        vol_threshold_px = price * (self.atr_vol_threshold / 100)\n","        if atr > vol_threshold_px:\n","            self._close_position(ts, price, 'ATR_EXIT',\n","                                 note=f'ATR={atr:.2f} > threshold={vol_threshold_px:.2f}')\n","\n","    def _handle_trailing_stop(self, ts, payload: dict, bar_idx: int):\n","        \"\"\"Update trailing stop and trigger if price breaches it.\"\"\"\n","        if not self.in_position:\n","            return\n","\n","        high  = float(self.np_1m[bar_idx, self.idx_high])\n","        low   = float(self.np_1m[bar_idx, self.idx_low])\n","        atr   = self.atr_array[bar_idx]\n","        if np.isnan(atr):\n","            return\n","\n","        trail_dist = atr * self.trailing_stop_mult\n","\n","        if self.direction > 0:   # LONG — trail stop moves up with high\n","            new_trail = high - trail_dist\n","            if new_trail > self.trailing_stop_px:\n","                self.trailing_stop_px = new_trail\n","            if low <= self.trailing_stop_px:\n","                self._close_position(ts, self.trailing_stop_px,\n","                                     'TRAILING_STOP',\n","                                     note=f'trail_px={self.trailing_stop_px:.2f}')\n","        else:                     # SHORT — trail stop moves down with low\n","            new_trail = low + trail_dist\n","            if new_trail < self.trailing_stop_px:\n","                self.trailing_stop_px = new_trail\n","            if high >= self.trailing_stop_px:\n","                self._close_position(ts, self.trailing_stop_px,\n","                                     'TRAILING_STOP',\n","                                     note=f'trail_px={self.trailing_stop_px:.2f}')\n","\n","    def _handle_tp_sl(self, ts, payload: dict, bar_idx: int):\n","        \"\"\"Check static ATR-based TP and SL levels.\"\"\"\n","        if not self.in_position:\n","            return\n","\n","        high = float(self.np_1m[bar_idx, self.idx_high])\n","        low  = float(self.np_1m[bar_idx, self.idx_low])\n","\n","        if self.direction > 0:   # LONG\n","            if high >= self.tp_price:\n","                self._close_position(ts, self.tp_price, 'TP_HIT',\n","                                     note=f'TP={self.tp_price:.2f}')\n","                return\n","            if low <= self.sl_price:\n","                self._close_position(ts, self.sl_price, 'SL_HIT',\n","                                     note=f'SL={self.sl_price:.2f}')\n","        else:                    # SHORT\n","            if low <= self.tp_price:\n","                self._close_position(ts, self.tp_price, 'TP_HIT',\n","                                     note=f'TP={self.tp_price:.2f}')\n","                return\n","            if high >= self.sl_price:\n","                self._close_position(ts, self.sl_price, 'SL_HIT',\n","                                     note=f'SL={self.sl_price:.2f}')\n","\n","    def _handle_time_exit(self, ts, payload: dict, bar_idx: int):\n","        \"\"\"Force-close a position that has exceeded max_bars_in_trade.\"\"\"\n","        if not self.in_position:\n","            return\n","        price = float(self.np_1m[bar_idx, self.idx_close])\n","        self._close_position(ts, price, 'TIME_EXIT',\n","                             note=f'bars_held={self.bars_held}')\n","\n","    # ── Dispatch Table ────────────────────────────────────────────────────\n","    _HANDLERS = {\n","        'NEWS'         : _handle_news,\n","        'ATR_EXIT'     : _handle_atr_exit,\n","        'TRAILING_STOP': _handle_trailing_stop,\n","        'TP_HIT'       : _handle_tp_sl,\n","        'SL_HIT'       : _handle_tp_sl,\n","        'TIME_EXIT'    : _handle_time_exit,\n","        'SIGNAL'       : _handle_signal,\n","    }\n","\n","    # ── Build Event Queue ─────────────────────────────────────────────────\n","    def _build_queue(self,\n","                     df_predictions: pd.DataFrame,\n","                     news_events   : list) -> list:\n","        \"\"\"\n","        Populate the min-heap with SIGNAL and NEWS events.\n","        Per-bar events (ATR_EXIT, TRAILING_STOP, TP_HIT, TIME_EXIT) are\n","        injected on-the-fly during the main loop.\n","        \"\"\"\n","        heap = []\n","\n","        # ── SIGNAL events from model predictions ─────────────────────────\n","        for _, row in df_predictions.iterrows():\n","            ts  = row['datetime']\n","            evt = Event(\n","                timestamp  = ts,\n","                priority   = int(EventPriority.SIGNAL),\n","                event_type = 'SIGNAL',\n","                payload    = {'direction': int(row['predicted_direction'])},\n","            )\n","            heapq.heappush(heap, evt)\n","\n","        # ── NEWS events ───────────────────────────────────────────────────\n","        for n in news_events:\n","            ts  = pd.Timestamp(n['timestamp'])\n","            evt = Event(\n","                timestamp  = ts,\n","                priority   = int(EventPriority.NEWS),\n","                event_type = 'NEWS',\n","                payload    = {'magnitude': n['magnitude'], 'label': n['label']},\n","            )\n","            heapq.heappush(heap, evt)\n","\n","        return heap\n","\n","    # ── Main Run Loop ─────────────────────────────────────────────────────\n","    def run(self,\n","            df_predictions: pd.DataFrame,\n","            news_events   : list = None) -> pd.DataFrame:\n","        \"\"\"\n","        Execute the event-driven simulation.\n","\n","        Parameters\n","        ----------\n","        df_predictions : pd.DataFrame\n","            Columns: ['datetime', 'predicted_direction']\n","        news_events : list of dicts\n","            Each dict: {timestamp, magnitude, label}\n","\n","        Returns\n","        -------\n","        pd.DataFrame — trade ledger\n","        \"\"\"\n","        if news_events is None:\n","            news_events = []\n","\n","        df_predictions = df_predictions.copy()\n","        df_predictions['datetime'] = pd.to_datetime(df_predictions['datetime'])\n","\n","        heap = self._build_queue(df_predictions, news_events)\n","\n","        breaking_balance = self.starting_balance * 0.5\n","        dt_series        = pd.to_datetime(self.np_1m[:, self.idx_dt])\n","\n","        print(f\"Queue loaded: {len(heap)} initial events (signals + news)\")\n","        print(\"Processing event queue...\")\n","\n","        processed = 0\n","\n","        while heap:\n","            evt = heapq.heappop(heap)\n","            ts  = pd.Timestamp(evt.timestamp)\n","\n","            # Find nearest 1-minute bar index\n","            idx_candidates = np.searchsorted(dt_series.values, ts.to_datetime64())\n","            bar_idx = int(min(idx_candidates, len(self.np_1m) - 1))\n","\n","            # Track bars held and inject per-bar events\n","            if self.in_position:\n","                self.bars_held = bar_idx - self.entry_bar_idx\n","\n","                # Inject ATR_EXIT check\n","                heapq.heappush(heap, Event(ts, int(EventPriority.ATR_EXIT),\n","                                           'ATR_EXIT', {}))\n","                # Inject TRAILING_STOP update\n","                heapq.heappush(heap, Event(ts, int(EventPriority.TRAILING_STOP),\n","                                           'TRAILING_STOP', {}))\n","                # Inject TP/SL check\n","                heapq.heappush(heap, Event(ts, int(EventPriority.TP_HIT),\n","                                           'TP_HIT', {}))\n","                # Inject TIME_EXIT if over max bars\n","                if self.bars_held >= self.max_bars_in_trade:\n","                    heapq.heappush(heap, Event(ts, int(EventPriority.TIME_EXIT),\n","                                               'TIME_EXIT', {}))\n","\n","            # Dispatch event to handler\n","            handler = self._HANDLERS.get(evt.event_type)\n","            if handler:\n","                handler(self, ts, evt.payload, bar_idx)\n","\n","            processed += 1\n","\n","            # Max drawdown guard\n","            if self.balance < breaking_balance:\n","                print(f\"  !! Max drawdown triggered at {ts} — balance ${self.balance:.2f}\")\n","                if self.in_position:\n","                    price = float(self.np_1m[bar_idx, self.idx_close])\n","                    self._close_position(ts, price, 'MAX_DRAWDOWN', note='50% equity loss')\n","                break\n","\n","        print(f\"Events processed: {processed}\")\n","\n","        df_ledger = pd.DataFrame(self.ledger)\n","        if not df_ledger.empty:\n","            df_ledger['pnl_cumsum'] = df_ledger['pnl'].cumsum().round(4)\n","\n","        return df_ledger\n","\n","\n","print(\"EventDrivenBacktest class defined.\")\n"],"id":"_KPgouslWvk_"},{"cell_type":"markdown","metadata":{"id":"11d4d957"},"source":["### Functional Refactoring: Helper Functions\n","\n","Instead of a class, the backtesting engine's state (`balance`, `in_position`, `buy_price`, `ledger`, etc.) will be managed by a dictionary, `backtest_state`, passed between functions.\n","\n","These helper functions modify or calculate values based on the `backtest_state`."],"id":"11d4d957"},{"cell_type":"code","metadata":{"id":"abace810","executionInfo":{"status":"ok","timestamp":1780640725734,"user_tz":-300,"elapsed":4,"user":{"displayName":"Arsalan Bakhtiar","userId":"11466190921627274456"}}},"source":["def record_trade(backtest_state: dict, timestamp, event_type: str, action: str,\n","                 buy_price: float, sell_price: float, pnl: float, note: str = '') -> None:\n","    \"\"\"\n","    Records a trade or significant event in the ledger.\n","    Modifies `backtest_state['ledger']` in place.\n","    \"\"\"\n","    direction_label = 'long' if backtest_state['current_state']['direction'] > 0 else \\\n","                      ('short' if backtest_state['current_state']['direction'] < 0 else 'none')\n","    backtest_state['ledger'].append({\n","        'datetime'  : timestamp,\n","        'event_type': event_type,\n","        'direction' : direction_label,\n","        'action'    : action,\n","        'buy_price' : round(buy_price, 4),\n","        'sell_price': round(sell_price, 4),\n","        'balance'   : round(backtest_state['current_state']['balance'], 4),\n","        'pnl'       : round(pnl, 4),\n","        'note'      : note,\n","    })"],"id":"abace810","execution_count":7,"outputs":[]},{"cell_type":"code","metadata":{"id":"775be6a2","executionInfo":{"status":"ok","timestamp":1780640725753,"user_tz":-300,"elapsed":7,"user":{"displayName":"Arsalan Bakhtiar","userId":"11466190921627274456"}}},"source":["def calculate_pnl(backtest_state: dict, sell_price: float) -> float:\n","    \"\"\"\n","    Calculates the PnL for a closed position.\n","    This is a pure function that only reads from `backtest_state`.\n","    \"\"\"\n","    direction   = backtest_state['current_state']['direction']\n","    buy_price   = backtest_state['current_state']['buy_price']\n","    leverage    = backtest_state['params']['leverage']\n","    fee_pct     = backtest_state['params']['fee_pct']\n","    slippage_pct = backtest_state['params']['slippage']\n","\n","    if direction > 0:\n","        raw = ((sell_price - buy_price) / buy_price) * 100\n","    else:\n","        raw = ((buy_price - sell_price) / buy_price) * 100\n","    return raw * leverage - fee_pct - slippage_pct"],"id":"775be6a2","execution_count":8,"outputs":[]},{"cell_type":"code","metadata":{"id":"a16fbe30","executionInfo":{"status":"ok","timestamp":1780640725764,"user_tz":-300,"elapsed":15,"user":{"displayName":"Arsalan Bakhtiar","userId":"11466190921627274456"}}},"source":["def open_position(backtest_state: dict, timestamp, price: float, direction: int,\n","                  bar_idx: int, atr: float, event_type: str) -> dict:\n","    \"\"\"\n","    Opens a new position and updates the `backtest_state`.\n","    \"\"\"\n","    state_copy = backtest_state.copy()\n","    current_state = state_copy['current_state']\n","    params = state_copy['params']\n","\n","    current_state['direction']    = direction\n","    current_state['buy_price']    = price\n","    current_state['in_position']  = True\n","    current_state['bars_held']    = 0\n","    current_state['entry_bar_idx'] = bar_idx\n","\n","    # ATR-based TP/SL\n","    if direction > 0:\n","        current_state['tp_price']         = price + atr * params['atr_tp_mult']\n","        current_state['sl_price']         = price - atr * params['atr_sl_mult']\n","        current_state['trailing_stop_px'] = price - atr * params['trailing_stop_mult']\n","    else:\n","        current_state['tp_price']         = price - atr * params['atr_tp_mult']\n","        current_state['sl_price']         = price + atr * params['atr_sl_mult']\n","        current_state['trailing_stop_px'] = price + atr * params['trailing_stop_mult']\n","\n","    # Entry fee\n","    entry_pnl = -params['fee_pct'] - params['slippage']\n","    current_state['balance'] += current_state['balance'] * (entry_pnl / 100)\n","\n","    record_trade(state_copy, timestamp, event_type, 'BUY', price, 0.0, entry_pnl,\n","                 f\"TP={current_state['tp_price']:.2f} SL={current_state['sl_price']:.2f} ATR={atr:.2f}\")\n","\n","    return state_copy"],"id":"a16fbe30","execution_count":9,"outputs":[]},{"cell_type":"code","metadata":{"id":"876cdf3d","executionInfo":{"status":"ok","timestamp":1780640725770,"user_tz":-300,"elapsed":3,"user":{"displayName":"Arsalan Bakhtiar","userId":"11466190921627274456"}}},"source":["def close_position(backtest_state: dict, timestamp, price: float,\n","                   event_type: str, note: str = '') -> dict:\n","    \"\"\"\n","    Closes an open position and updates the `backtest_state`.\n","    \"\"\"\n","    state_copy = backtest_state.copy()\n","    current_state = state_copy['current_state']\n","\n","    pnl = calculate_pnl(state_copy, price)\n","    current_state['balance'] += current_state['balance'] * (pnl / 100)\n","\n","    label = 'SELL-WIN' if pnl > 0 else 'SELL-LOSS'\n","\n","    record_trade(state_copy, timestamp, event_type, label,\n","                 current_state['buy_price'], price, pnl, note)\n","\n","    # Reset position state\n","    current_state['in_position']  = False\n","    current_state['direction']     = 0\n","    current_state['bars_held']     = 0\n","    current_state['buy_price']     = 0.0\n","    current_state['tp_price']      = 0.0\n","    current_state['sl_price']      = 0.0\n","    current_state['trailing_stop_px'] = 0.0\n","    current_state['entry_bar_idx'] = 0\n","\n","    return state_copy"],"id":"876cdf3d","execution_count":10,"outputs":[]},{"cell_type":"markdown","metadata":{"id":"aa8f9d13"},"source":["### Functional Refactoring: Event Handlers\n","\n","Each event handler is now a standalone function that takes the current `backtest_state` and relevant data, returning an updated `backtest_state`."],"id":"aa8f9d13"},{"cell_type":"code","metadata":{"id":"ba822502","executionInfo":{"status":"ok","timestamp":1780640725789,"user_tz":-300,"elapsed":17,"user":{"displayName":"Arsalan Bakhtiar","userId":"11466190921627274456"}}},"source":["def handle_signal(backtest_state: dict, ts, payload: dict, bar_idx: int) -> dict:\n","    \"\"\"\n","    Handles a SIGNAL event: opens, closes, or flips a position.\n","    \"\"\"\n","    state_copy = backtest_state.copy()\n","    current_state = state_copy['current_state']\n","    params = state_copy['params']\n","    runtime_data = state_copy['runtime_data']\n","    data_indices = state_copy['data_indices']\n","\n","    new_dir = payload.get('direction', 0)\n","\n","    if new_dir == 0:\n","        return state_copy  # Neutral — hold or do nothing\n","\n","    atr = runtime_data['atr_array'][bar_idx]\n","    if np.isnan(atr):\n","        return state_copy  # Not enough history for ATR\n","\n","    price = float(runtime_data['np_1m'][bar_idx, data_indices['idx_open']])\n","    vol_threshold_px = price * (params['atr_vol_threshold'] / 100)\n","\n","    # Volatility regime filter: block entry if ATR too high\n","    if atr > vol_threshold_px:\n","        record_trade(state_copy, ts, 'SIGNAL', 'BLOCKED', price, 0.0, 0.0,\n","                     f'ATR {atr:.2f} > vol threshold {vol_threshold_px:.2f}')\n","        return state_copy\n","\n","    if current_state['in_position']:\n","        if new_dir == current_state['direction']:\n","            return state_copy  # Same direction — hold\n","        # Direction flip — close then re-enter\n","        state_copy = close_position(state_copy, ts, price, 'SIGNAL', note='direction_flip')\n","\n","    state_copy = open_position(state_copy, ts, price, new_dir, bar_idx, atr, 'SIGNAL')\n","    return state_copy"],"id":"ba822502","execution_count":11,"outputs":[]},{"cell_type":"code","metadata":{"id":"35c4bfee","executionInfo":{"status":"ok","timestamp":1780640725810,"user_tz":-300,"elapsed":23,"user":{"displayName":"Arsalan Bakhtiar","userId":"11466190921627274456"}}},"source":["def handle_news(backtest_state: dict, ts, payload: dict, bar_idx: int) -> dict:\n","    \"\"\"\n","    Handles a NEWS event: force-closes a position if magnitude is high.\n","    \"\"\"\n","    state_copy = backtest_state.copy()\n","    current_state = state_copy['current_state']\n","    params = state_copy['params']\n","    runtime_data = state_copy['runtime_data']\n","    data_indices = state_copy['data_indices']\n","\n","    magnitude = payload.get('magnitude', 0.5)\n","    label     = payload.get('label', 'News')\n","    price     = float(runtime_data['np_1m'][bar_idx, data_indices['idx_close']])\n","\n","    note = f'{label} mag={magnitude:.2f}'\n","\n","    if current_state['in_position'] and magnitude >= params['news_force_close_mag']:\n","        # High-magnitude news → force close with widened spread (extra slippage)\n","        spread_penalty = magnitude * 0.5  # additional % cost\n","        pnl = calculate_pnl(state_copy, price) - spread_penalty\n","        current_state['balance'] += current_state['balance'] * (pnl / 100)\n","\n","        record_trade(state_copy, ts, 'NEWS', 'FORCE_CLOSE',\n","                     current_state['buy_price'], price, pnl, note)\n","\n","        # Reset position state\n","        current_state['in_position'] = False\n","        current_state['direction']   = 0\n","        current_state['bars_held']   = 0\n","\n","    else:\n","        # Low-magnitude news — log but do not close\n","        record_trade(state_copy, ts, 'NEWS', 'NOTED', 0.0, 0.0, 0.0, note)\n","\n","    return state_copy"],"id":"35c4bfee","execution_count":12,"outputs":[]},{"cell_type":"code","metadata":{"id":"49283145","executionInfo":{"status":"ok","timestamp":1780640725812,"user_tz":-300,"elapsed":19,"user":{"displayName":"Arsalan Bakhtiar","userId":"11466190921627274456"}}},"source":["def handle_atr_exit(backtest_state: dict, ts, payload: dict, bar_idx: int) -> dict:\n","    \"\"\"\n","    Closes position if current ATR has spiked beyond threshold.\n","    \"\"\"\n","    state_copy = backtest_state.copy()\n","    current_state = state_copy['current_state']\n","    params = state_copy['params']\n","    runtime_data = state_copy['runtime_data']\n","    data_indices = state_copy['data_indices']\n","\n","    if not current_state['in_position']:\n","        return state_copy\n","\n","    atr = runtime_data['atr_array'][bar_idx]\n","    if np.isnan(atr):\n","        return state_copy\n","\n","    price = float(runtime_data['np_1m'][bar_idx, data_indices['idx_close']])\n","    vol_threshold_px = price * (params['atr_vol_threshold'] / 100)\n","\n","    if atr > vol_threshold_px:\n","        state_copy = close_position(state_copy, ts, price, 'ATR_EXIT',\n","                                     note=f'ATR={atr:.2f} > threshold={vol_threshold_px:.2f}')\n","\n","    return state_copy"],"id":"49283145","execution_count":13,"outputs":[]},{"cell_type":"code","metadata":{"id":"936f1088","executionInfo":{"status":"ok","timestamp":1780640725814,"user_tz":-300,"elapsed":9,"user":{"displayName":"Arsalan Bakhtiar","userId":"11466190921627274456"}}},"source":["def handle_trailing_stop(backtest_state: dict, ts, payload: dict, bar_idx: int) -> dict:\n","    \"\"\"\n","    Updates trailing stop and triggers if price breaches it.\n","    \"\"\"\n","    state_copy = backtest_state.copy()\n","    current_state = state_copy['current_state']\n","    params = state_copy['params']\n","    runtime_data = state_copy['runtime_data']\n","    data_indices = state_copy['data_indices']\n","\n","    if not current_state['in_position']:\n","        return state_copy\n","\n","    high  = float(runtime_data['np_1m'][bar_idx, data_indices['idx_high']])\n","    low   = float(runtime_data['np_1m'][bar_idx, data_indices['idx_low']])\n","    atr   = runtime_data['atr_array'][bar_idx]\n","\n","    if np.isnan(atr):\n","        return state_copy\n","\n","    trail_dist = atr * params['trailing_stop_mult']\n","\n","    if current_state['direction'] > 0:   # LONG — trail stop moves up with high\n","        new_trail = high - trail_dist\n","        if new_trail > current_state['trailing_stop_px']:\n","            current_state['trailing_stop_px'] = new_trail\n","        if low <= current_state['trailing_stop_px']:\n","            state_copy = close_position(state_copy, ts, current_state['trailing_stop_px'],\n","                                         'TRAILING_STOP',\n","                                         note=f'trail_px={current_state['trailing_stop_px']:.2f}')\n","    else:                     # SHORT — trail stop moves down with low\n","        new_trail = low + trail_dist\n","        if new_trail < current_state['trailing_stop_px']:\n","            current_state['trailing_stop_px'] = new_trail\n","        if high >= current_state['trailing_stop_px']:\n","            state_copy = close_position(state_copy, ts, current_state['trailing_stop_px'],\n","                                         'TRAILING_STOP',\n","                                         note=f'trail_px={current_state['trailing_stop_px']:.2f}')\n","\n","    return state_copy"],"id":"936f1088","execution_count":14,"outputs":[]},{"cell_type":"code","metadata":{"id":"b04a366c","executionInfo":{"status":"ok","timestamp":1780640725829,"user_tz":-300,"elapsed":20,"user":{"displayName":"Arsalan Bakhtiar","userId":"11466190921627274456"}}},"source":["def handle_tp_sl(backtest_state: dict, ts, payload: dict, bar_idx: int) -> dict:\n","    \"\"\"\n","    Checks static ATR-based Take Profit (TP) and Stop Loss (SL) levels.\n","    \"\"\"\n","    state_copy = backtest_state.copy()\n","    current_state = state_copy['current_state']\n","    runtime_data = state_copy['runtime_data']\n","    data_indices = state_copy['data_indices']\n","\n","    if not current_state['in_position']:\n","        return state_copy\n","\n","    high = float(runtime_data['np_1m'][bar_idx, data_indices['idx_high']])\n","    low  = float(runtime_data['np_1m'][bar_idx, data_indices['idx_low']])\n","\n","    if current_state['direction'] > 0:   # LONG\n","        if high >= current_state['tp_price']:\n","            state_copy = close_position(state_copy, ts, current_state['tp_price'], 'TP_HIT',\n","                                         note=f'TP={current_state['tp_price']:.2f}')\n","            return state_copy\n","        if low <= current_state['sl_price']:\n","            state_copy = close_position(state_copy, ts, current_state['sl_price'], 'SL_HIT',\n","                                         note=f'SL={current_state['sl_price']:.2f}')\n","            return state_copy\n","    else:                    # SHORT\n","        if low <= current_state['tp_price']:\n","            state_copy = close_position(state_copy, ts, current_state['tp_price'], 'TP_HIT',\n","                                         note=f'TP={current_state['tp_price']:.2f}')\n","            return state_copy\n","        if high >= current_state['sl_price']:\n","            state_copy = close_position(state_copy, ts, current_state['sl_price'], 'SL_HIT',\n","                                         note=f'SL={current_state['sl_price']:.2f}')\n","            return state_copy\n","\n","    return state_copy"],"id":"b04a366c","execution_count":15,"outputs":[]},{"cell_type":"code","metadata":{"id":"abe30817","executionInfo":{"status":"ok","timestamp":1780640725833,"user_tz":-300,"elapsed":16,"user":{"displayName":"Arsalan Bakhtiar","userId":"11466190921627274456"}}},"source":["def handle_time_exit(backtest_state: dict, ts, payload: dict, bar_idx: int) -> dict:\n","    \"\"\"\n","    Force-closes a position that has exceeded `max_bars_in_trade`.\n","    \"\"\"\n","    state_copy = backtest_state.copy()\n","    current_state = state_copy['current_state']\n","    runtime_data = state_copy['runtime_data']\n","    data_indices = state_copy['data_indices']\n","\n","    if not current_state['in_position']:\n","        return state_copy\n","\n","    price = float(runtime_data['np_1m'][bar_idx, data_indices['idx_close']])\n","    state_copy = close_position(state_copy, ts, price, 'TIME_EXIT',\n","                                 note=f'bars_held={current_state['bars_held']}')\n","    return state_copy"],"id":"abe30817","execution_count":16,"outputs":[]},{"cell_type":"markdown","metadata":{"id":"9811ab15"},"source":["### Functional Refactoring: Dispatch Table and Queue Builder\n","\n","The `EVENT_HANDLERS` dictionary maps event types to their corresponding functional handlers. The `build_event_queue` function remains largely the same, populating the min-heap with initial events."],"id":"9811ab15"},{"cell_type":"code","metadata":{"colab":{"base_uri":"https://localhost:8080/"},"id":"4e361d06","executionInfo":{"status":"ok","timestamp":1780640725863,"user_tz":-300,"elapsed":31,"user":{"displayName":"Arsalan Bakhtiar","userId":"11466190921627274456"}},"outputId":"918785aa-8440-444e-d8de-deab75652981"},"source":["# Dispatch table mapping event types to handler functions\n","EVENT_HANDLERS = {\n","    'NEWS'         : handle_news,\n","    'ATR_EXIT'     : handle_atr_exit,\n","    'TRAILING_STOP': handle_trailing_stop,\n","    'TP_HIT'       : handle_tp_sl,\n","    'SL_HIT'       : handle_tp_sl,\n","    'TIME_EXIT'    : handle_time_exit,\n","    'SIGNAL'       : handle_signal,\n","}\n","\n","print(\"Event handler dispatch table defined.\")"],"id":"4e361d06","execution_count":17,"outputs":[{"output_type":"stream","name":"stdout","text":["Event handler dispatch table defined.\n"]}]},{"cell_type":"code","metadata":{"colab":{"base_uri":"https://localhost:8080/"},"id":"558d8677","executionInfo":{"status":"ok","timestamp":1780640725864,"user_tz":-300,"elapsed":21,"user":{"displayName":"Arsalan Bakhtiar","userId":"11466190921627274456"}},"outputId":"47545bb9-2ebe-4a3c-cb3f-67235fb07fc9"},"source":["import pandas as pd\n","import heapq\n","from dataclasses import dataclass, field\n","from typing import Optional\n","from enum import IntEnum\n","\n","# EventPriority and Event are assumed to be defined in the global scope (e.g., cell 4ygqUmwqWvk6)\n","\n","def build_event_queue(df_predictions: pd.DataFrame, news_events: list) -> list:\n","    \"\"\"\n","    Populates the min-heap with SIGNAL and NEWS events.\n","    Per-bar events are injected on-the-fly during the main loop.\n","    \"\"\"\n","    heap = []\n","\n","    # SIGNAL events from model predictions\n","    for _, row in df_predictions.iterrows():\n","        ts  = row['datetime']\n","        evt = Event(\n","            timestamp  = ts,\n","            priority   = int(EventPriority.SIGNAL),\n","            event_type = 'SIGNAL',\n","            payload    = {'direction': int(row['predicted_direction'])},\n","        )\n","        heapq.heappush(heap, evt)\n","\n","    # NEWS events\n","    for n in news_events:\n","        ts  = pd.Timestamp(n['timestamp'])\n","        evt = Event(\n","            timestamp  = ts,\n","            priority   = int(EventPriority.NEWS),\n","            event_type = 'NEWS',\n","            payload    = {'magnitude': n['magnitude'], 'label': n['label']},\n","        )\n","        heapq.heappush(heap, evt)\n","\n","    return heap\n","\n","print(\"Event queue builder defined.\")"],"id":"558d8677","execution_count":18,"outputs":[{"output_type":"stream","name":"stdout","text":["Event queue builder defined.\n"]}]},{"cell_type":"markdown","metadata":{"id":"60012b0c"},"source":["### Functional Refactoring: Main Execution Loop\n","\n","The `execute_backtest_run` function now encapsulates the core logic of the backtesting engine, managing the event queue and dispatching events to the appropriate handler functions. It takes an `initial_state` dictionary and returns the final `backtest_state` and the `df_ledger`."],"id":"60012b0c"},{"cell_type":"code","metadata":{"colab":{"base_uri":"https://localhost:8080/"},"id":"d5b05b61","executionInfo":{"status":"ok","timestamp":1780640725866,"user_tz":-300,"elapsed":17,"user":{"displayName":"Arsalan Bakhtiar","userId":"11466190921627274456"}},"outputId":"9cdf89a3-ad7b-40ee-f89d-f8b469f4e198"},"source":["import pandas as pd\n","import numpy as np\n","import heapq\n","from dataclasses import dataclass, field\n","from typing import Optional\n","from enum import IntEnum\n","\n","# EventPriority and Event are assumed to be defined in the global scope (e.g., cell 4ygqUmwqWvk6)\n","\n","def execute_backtest_run(\n","    initial_state: dict,\n","    df_predictions: pd.DataFrame,\n","    news_events: list = None\n",") -> tuple[dict, pd.DataFrame]:\n","    \"\"\"\n","    Executes the event-driven simulation with a functional approach.\n","\n","    Parameters\n","    ----------\n","    initial_state : dict\n","        The initial state dictionary for the backtest.\n","    df_predictions : pd.DataFrame\n","        Columns: ['datetime', 'predicted_direction']\n","    news_events : list of dicts, optional\n","        Each dict: {timestamp, magnitude, label}\n","\n","    Returns\n","    -------\n","    tuple[dict, pd.DataFrame]\n","        The final backtest state and the trade ledger DataFrame.\n","    \"\"\"\n","    if news_events is None:\n","        news_events = []\n","\n","    # Create a mutable copy of the initial state\n","    backtest_state = initial_state.copy()\n","    backtest_state['ledger'] = initial_state['ledger'].copy() # ensure ledger is a mutable list\n","\n","    df_predictions = df_predictions.copy()\n","    df_predictions['datetime'] = pd.to_datetime(df_predictions['datetime'])\n","\n","    # build_event_queue relies on Event and EventPriority\n","    heap = build_event_queue(df_predictions, news_events)\n","\n","    breaking_balance = backtest_state['params']['starting_balance'] * 0.5\n","    dt_series        = backtest_state['runtime_data']['dt_series']\n","    np_1m            = backtest_state['runtime_data']['np_1m']\n","    idx_dt           = backtest_state['data_indices']['idx_dt']\n","\n","    print(f\"Queue loaded: {len(heap)} initial events (signals + news)\")\n","    print(\"Processing event queue...\")\n","\n","    processed = 0\n","\n","    while heap:\n","        evt = heapq.heappop(heap)\n","        ts  = pd.Timestamp(evt.timestamp)\n","\n","        # Find nearest 1-minute bar index\n","        # Corrected typo from to_datetime600() to to_datetime64()\n","        idx_candidates = np.searchsorted(dt_series.values, ts.to_datetime64())\n","        bar_idx = int(min(idx_candidates, len(np_1m) - 1))\n","\n","        # Track bars held and inject per-bar events\n","        if backtest_state['current_state']['in_position']:\n","            backtest_state['current_state']['bars_held'] = bar_idx - backtest_state['current_state']['entry_bar_idx']\n","\n","            # Inject ATR_EXIT check\n","            heapq.heappush(heap, Event(ts, int(EventPriority.ATR_EXIT),\n","                                       'ATR_EXIT', {}))\n","            # Inject TRAILING_STOP update\n","            heapq.heappush(heap, Event(ts, int(EventPriority.TRAILING_STOP),\n","                                       'TRAILING_STOP', {}))\n","            # Inject TP/SL check\n","            heapq.heappush(heap, Event(ts, int(EventPriority.TP_HIT),\n","                                       'TP_HIT', {}))\n","            # Inject TIME_EXIT if over max bars\n","            if backtest_state['current_state']['bars_held'] >= backtest_state['params']['max_bars_in_trade']:\n","                heapq.heappush(heap, Event(ts, int(EventPriority.TIME_EXIT),\n","                                           'TIME_EXIT', {}))\n","\n","        # Dispatch event to handler\n","        handler = EVENT_HANDLERS.get(evt.event_type)\n","        if handler:\n","            # Pass the entire state and update it with the handler's return value\n","            backtest_state = handler(backtest_state, ts, evt.payload, bar_idx)\n","\n","        processed += 1\n","\n","        # Max drawdown guard\n","        if backtest_state['current_state']['balance'] < breaking_balance:\n","            print(f\"  !! Max drawdown triggered at {ts} — balance ${backtest_state['current_state']['balance']:.2f}\")\n","            if backtest_state['current_state']['in_position']:\n","                price = float(np_1m[bar_idx, backtest_state['data_indices']['idx_close']])\n","                backtest_state = close_position(backtest_state, ts, price, 'MAX_DRAWDOWN', note='50% equity loss')\n","            break\n","\n","    print(f\"Events processed: {processed}\")\n","\n","    df_ledger = pd.DataFrame(backtest_state['ledger'])\n","    if not df_ledger.empty:\n","        df_ledger['pnl_cumsum'] = df_ledger['pnl'].cumsum().round(4)\n","\n","    return backtest_state, df_ledger\n","\n","print(\"Functional backtest execution loop defined.\")"],"id":"d5b05b61","execution_count":19,"outputs":[{"output_type":"stream","name":"stdout","text":["Functional backtest execution loop defined.\n"]}]},{"cell_type":"markdown","metadata":{"id":"TQVho8XkWvlA"},"source":["## Step 7 — Execute Event-Driven Backtest\n","\n","Configure all parameters in the cell below, then run. The engine will:\n","1. Generate 1-minute mock OHLCV data\n","2. Generate model prediction signals\n","3. Inject random news events\n","4. Build and drain the event queue\n","5. Return a full trade ledger\n"],"id":"TQVho8XkWvlA"},{"cell_type":"code","execution_count":null,"metadata":{"colab":{"base_uri":"https://localhost:8080/"},"id":"u314wRGGWvlA","outputId":"ac72c782-b294-4d6d-c827-bc5756da3996"},"outputs":[{"output_type":"stream","name":"stdout","text":["=======================================================\n","  EVENT-DRIVEN BACKTEST — NOTEBOOK 67\n","=======================================================\n","\n","[1/4] Generating 1-minute OHLCV data...\n","      Shape : (21600, 6)\n","      Range : 1704067200000000000  →  1705363140000000000\n","\n","[2/4] Generating model predictions...\n","      Periods : 80\n","      Signals : {-1: 35, 1: 35, 0: 10}\n","\n","[3/4] Generating news events...\n","      News events injected : 415\n","      Magnitude range     : 0.10 – 0.99\n","      High-impact (>=0.6) : 185\n","\n","[4/4] Running event-driven engine (functional)...\n","Queue loaded: 495 initial events (signals + news)\n","Processing event queue...\n"]}],"source":["import pandas as pd\n","import numpy as np\n","\n","def run_event_driven_simulation(\n","    # Data\n","    n_days          = 15,\n","    base_price      = 30000.0,\n","    n_periods       = 80,\n","    freq            = '4h',\n","    news_rate       = 0.02,\n","\n","    # Capital\n","    starting_balance = 1000.0,\n","    leverage         = 1.0,\n","    transaction_fee  = 0.05,\n","    slippage         = 0.0,\n","\n","    # ATR\n","    atr_period         = 14,\n","    atr_sl_mult        = 1.5,\n","    atr_tp_mult        = 2.5,\n","    atr_vol_threshold  = 3.0,   # % of price; blocks entry if ATR > this\n","    trailing_stop_mult = 1.0,\n","\n","    # Trade management\n","    max_bars_in_trade    = 240,   # 4 hours at 1-minute resolution\n","    news_force_close_mag = 0.6,   # magnitude >= this → force close\n","    buy_after_minutes    = 0,\n","):\n","    print(\"=\" * 55)\n","    print(\"  EVENT-DRIVEN BACKTEST — NOTEBOOK 67\")\n","    print(\"=\" * 55)\n","\n","    # ── Generate data ──────────────────────────────────────────────\n","    print(\"\\n[1/4] Generating 1-minute OHLCV data...\")\n","    np_1m, idx_dt, idx_open, idx_high, idx_low, idx_close = generate_mock_1m_data(\n","        n_days=n_days, base_price=base_price\n","    )\n","    print(f\"      Shape : {np_1m.shape}\")\n","    print(f\"      Range : {np_1m[0, idx_dt]}  →  {np_1m[-1, idx_dt]}\")\n","\n","    # ── Generate predictions ───────────────────────────────────────\n","    print(\"\\n[2/4] Generating model predictions...\")\n","    df_pred = generate_mock_predictions(n_periods=n_periods, freq=freq)\n","    print(f\"      Periods : {len(df_pred)}\")\n","    print(f\"      Signals : {df_pred['predicted_direction'].value_counts().to_dict()}\")\n","\n","    # ── Generate news events ───────────────────────────────────────\n","    print(\"\\n[3/4] Generating news events...\")\n","    news = generate_news_events(np_1m, idx_dt, news_rate=news_rate)\n","    print(f\"      News events injected : {len(news)}\")\n","    if news:\n","        mags = [n['magnitude'] for n in news]\n","        print(f\"      Magnitude range     : {min(mags):.2f} – {max(mags):.2f}\")\n","        high_impact = [n for n in news if n['magnitude'] >= news_force_close_mag]\n","        print(f\"      High-impact (>={news_force_close_mag:.1f}) : {len(high_impact)}\")\n","\n","    # ── Initialize functional backtest state ───────────────────────\n","    print(\"\\n[4/4] Running event-driven engine (functional)...\")\n","    initial_state = {\n","        'params': {\n","            'starting_balance': starting_balance,\n","            'atr_period': atr_period,\n","            'atr_sl_mult': atr_sl_mult,\n","            'atr_tp_mult': atr_tp_mult,\n","            'atr_vol_threshold': atr_vol_threshold,\n","            'trailing_stop_mult': trailing_stop_mult,\n","            'max_bars_in_trade': max_bars_in_trade,\n","            'news_force_close_mag': news_force_close_mag,\n","            'fee_pct': transaction_fee * leverage,\n","            'slippage': slippage,\n","            'leverage': leverage,\n","            'buy_after_minutes': buy_after_minutes,\n","        },\n","        'current_state': {\n","            'balance': starting_balance,\n","            'in_position': False,\n","            'direction': 0,\n","            'buy_price': 0.0,\n","            'tp_price': 0.0,\n","            'sl_price': 0.0,\n","            'trailing_stop_px': 0.0,\n","            'bars_held': 0,\n","            'entry_bar_idx': 0,\n","        },\n","        'runtime_data': {\n","            'np_1m': np_1m,\n","            'atr_array': compute_atr(np_1m, idx_high, idx_low, idx_close, period=atr_period),\n","            'dt_series': pd.to_datetime(np_1m[:, idx_dt]),\n","        },\n","        'data_indices': {\n","            'idx_dt': idx_dt,\n","            'idx_open': idx_open,\n","            'idx_high': idx_high,\n","            'idx_low': idx_low,\n","            'idx_close': idx_close,\n","        },\n","        'ledger': [],\n","    }\n","\n","    final_state, df_ledger = execute_backtest_run(initial_state, df_pred, news)\n","\n","    final_balance = round(final_state['current_state']['balance'], 2)\n","    pnl_pct       = round(df_ledger['pnl'].sum(), 4) if not df_ledger.empty else 0\n","\n","    print(\"\\n\" + \"=\" * 55)\n","    print(f\"  Starting Balance : ${starting_balance:,.2f}\")\n","    print(f\"  Final Balance    : ${final_balance:,.2f}\")\n","    print(f\"  Net PnL (%)     : {pnl_pct:.4f}%\")\n","    print(f\"  Total Events     : {len(df_ledger)}\")\n","    print(\"=\" * 55)\n","\n","    return df_ledger, final_state\n","\n","\n","# ── Run with default parameters ────────────────────────────────────────────\n","df_ledger, engine = run_event_driven_simulation(\n","    starting_balance    = 1000.0,\n","    leverage            = 1.0,\n","    transaction_fee     = 0.05,\n","    slippage            = 0.0,\n","    atr_period          = 14,\n","    atr_sl_mult         = 1.5,\n","    atr_tp_mult         = 2.5,\n","    atr_vol_threshold   = 3.0,\n","    trailing_stop_mult  = 1.0,\n","    max_bars_in_trade   = 240,\n","    news_force_close_mag= 0.6,\n","    news_rate           = 0.02,\n",")"],"id":"u314wRGGWvlA"},{"cell_type":"markdown","metadata":{"id":"P9wgBdfoWvlB"},"source":["## Step 8 — Trade Ledger Inspection\n","\n","The ledger records every event that produced an action.\n","`event_type` tells you what triggered each row.\n"],"id":"P9wgBdfoWvlB"},{"cell_type":"code","execution_count":null,"metadata":{"id":"Ji4Ln9afWvlB"},"outputs":[],"source":["if not df_ledger.empty:\n","    pd.set_option('display.max_rows', 120)\n","    pd.set_option('display.float_format', '{:.4f}'.format)\n","\n","    print(f\"Total ledger records : {len(df_ledger)}\")\n","    print(f\"Columns              : {df_ledger.columns.tolist()}\")\n","    print()\n","    display(df_ledger)\n","else:\n","    print(\"No ledger records generated.\")"],"id":"Ji4Ln9afWvlB"},{"cell_type":"markdown","metadata":{"id":"TBezI9fhWvlB"},"source":["## Step 9 — Performance Analysis\n","\n","Breaks down results by exit type so you can see how much P&L came from:\n","- ATR-based TP hits vs SL hits\n","- Trailing stop exits\n","- News force-closes\n","- Time exits (held too long)\n","- Direction flips\n"],"id":"TBezI9fhWvlB"},{"cell_type":"code","execution_count":null,"metadata":{"id":"JfqmlpUiWvlB"},"outputs":[],"source":["if not df_ledger.empty:\n","\n","    # ── Event type distribution ────────────────────────────────────────\n","    print(\"Event Type Distribution:\")\n","    print(\"-\" * 40)\n","    print(df_ledger['event_type'].value_counts().to_string())\n","    print()\n","\n","    # ── Action distribution ────────────────────────────────────────────\n","    print(\"Action Distribution:\")\n","    print(\"-\" * 40)\n","    print(df_ledger['action'].value_counts().to_string())\n","    print()\n","\n","    # ── Exit-only P&L breakdown ────────────────────────────────────────\n","    exit_actions = ['SELL-WIN', 'SELL-LOSS', 'FORCE_CLOSE']\n","    df_exits = df_ledger[df_ledger['action'].isin(exit_actions)].copy()\n","\n","    if not df_exits.empty:\n","        wins   = df_exits[df_exits['pnl'] > 0]\n","        losses = df_exits[df_exits['pnl'] <= 0]\n","\n","        win_rate     = len(wins) / len(df_exits) * 100\n","        avg_win      = wins['pnl'].mean()   if len(wins)   > 0 else 0\n","        avg_loss     = losses['pnl'].mean() if len(losses) > 0 else 0\n","        total_win    = wins['pnl'].sum()\n","        total_loss   = losses['pnl'].sum()\n","        profit_factor = (\n","            total_win / abs(total_loss)\n","            if total_loss != 0 else float('inf')\n","        )\n","\n","        print(\"=\" * 55)\n","        print(\"  PERFORMANCE METRICS\")\n","        print(\"=\" * 55)\n","        print(f\"  Exit Events      : {len(df_exits)}\")\n","        print(f\"  Winning Trades   : {len(wins)}\")\n","        print(f\"  Losing Trades    : {len(losses)}\")\n","        print(f\"  Win Rate         : {win_rate:.1f}%\")\n","        print(f\"  Avg Win PnL      : {avg_win:.4f}%\")\n","        print(f\"  Avg Loss PnL     : {avg_loss:.4f}%\")\n","        print(f\"  Profit Factor    : {profit_factor:.2f}\")\n","        print(f\"  Total PnL        : {df_exits['pnl'].sum():.4f}%\")\n","        print(f\"  Final Balance    : ${final_state['current_state']['balance']:,.2f}\")\n","        print(\"=\" * 55)\n","\n","        # ── P&L by exit type ───────────────────────────────────────────\n","        print(\"\\nP&L by Exit Event Type:\")\n","        print(\"-\" * 55)\n","        pnl_by_type = (\n","            df_exits.groupby('event_type')['pnl']\n","            .agg(count='count', total_pnl='sum', avg_pnl='mean')\n","            .round(4)\n","        )\n","        print(pnl_by_type.to_string())\n","\n","        # ── News event summary ─────────────────────────────────────────\n","        df_news = df_ledger[df_ledger['event_type'] == 'NEWS']\n","        if not df_news.empty:\n","            force_closed = df_news[df_news['action'] == 'FORCE_CLOSE']\n","            noted        = df_news[df_news['action'] == 'NOTED']\n","            print(f\"\\nNews Events Summary:\")\n","            print(\"-\" * 40)\n","            print(f\"  Total news events   : {len(df_news)}\")\n","            print(f\"  Force closes        : {len(force_closed)}\")\n","            print(f\"  Low impact (noted)  : {len(noted)}\")\n","            if len(force_closed) > 0:\n","                print(f\"  PnL from force-close: {force_closed['pnl'].sum():.4f}%\")\n","    else:\n","        print(\"No exit events recorded.\")\n","else:\n","    print(\"Ledger is empty.\")"],"id":"JfqmlpUiWvlB"},{"cell_type":"markdown","metadata":{"id":"Im6spxU6WvlC"},"source":["## Operational Notes & Next Steps\n","\n","### Connecting Real Data\n","Replace `generate_mock_1m_data()` with your own OHLCV loader:\n","```python\n","np_1m = your_dataframe[['datetime','open','high','low','close','volume']].to_numpy()\n","```\n","Column indices must match (0=datetime, 1=open, 2=high, 3=low, 4=close).\n","\n","### Connecting Real Predictions\n","Replace `generate_mock_predictions()` with your model output DataFrame — same two-column format: `datetime`, `predicted_direction`.\n","\n","### Connecting Real News\n","Replace `generate_news_events()` with a real news feed (e.g., Benzinga, NewsAPI). Map each article to a `magnitude` score using a sentiment model or keyword classifier.\n","\n","### ATR Parameter Tuning\n","| Parameter | Conservative | Aggressive |\n","|-----------|-------------|------------|\n","| `atr_sl_mult` | 2.0 | 1.0 |\n","| `atr_tp_mult` | 3.0 | 1.5 |\n","| `atr_vol_threshold` | 5.0 | 2.0 |\n","| `trailing_stop_mult` | 1.5 | 0.5 |\n","| `max_bars_in_trade` | 60 | 480 |\n","\n","### Production Upgrades\n","- Replace REST-based 1-minute data with **WebSocket streaming** for live deployment\n","- Add **position sizing** (Kelly criterion or fixed fractional) based on ATR signal confidence\n","- Log events to a database for post-trade analysis\n","- Add **multi-symbol** support by running one engine instance per instrument in parallel\n"],"id":"Im6spxU6WvlC"}]}