{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "7c25b74c",
   "metadata": {},
   "source": [
    "# Notebook 03 — First-Passage Times & the Reflection Principle\n",
    "\n",
    "We now answer the first half of the question: **what is the\n",
    "probability that the stock drops 10% *at any moment* during a 5-minute window?**\n",
    "\n",
    "The crucial word is *at any moment*. The price could breach the $-10\\%$ line\n",
    "and recover before the 5 minutes are up. So this is *not* the probability that\n",
    "the endpoint is below $-10\\%$ — it is the probability that the process ever\n",
    "**touches** a barrier. That is a **first-passage time** problem."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "a378556a",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:21:55.281775Z",
     "iopub.status.busy": "2026-08-02T16:21:55.281332Z",
     "iopub.status.idle": "2026-08-02T16:21:56.855845Z",
     "shell.execute_reply": "2026-08-02T16:21:56.854872Z"
    }
   },
   "outputs": [],
   "source": [
    "import numpy as np\n",
    "import matplotlib.pyplot as plt\n",
    "import sympy as sp\n",
    "from scipy.stats import norm\n",
    "\n",
    "# =====================================================================\n",
    "# Helper functions -- self-contained small library,\n",
    "# included verbatim at the top of every notebook so each one runs standalone.\n",
    "# (This cell is identical across the series.)\n",
    "# =====================================================================\n",
    "import types\n",
    "from scipy.stats import norm\n",
    "import matplotlib as mpl\n",
    "\n",
    "# --- config: trading-time & volatility conventions (annualised sigma) ---\n",
    "TRADING_DAYS_PER_YEAR = 252\n",
    "TRADING_HOURS_PER_DAY = 6.5\n",
    "TRADING_MINUTES_PER_HOUR = 60\n",
    "TRADING_MINUTES_PER_DAY = TRADING_HOURS_PER_DAY * TRADING_MINUTES_PER_HOUR     # 390\n",
    "TRADING_MINUTES_PER_YEAR = TRADING_DAYS_PER_YEAR * TRADING_MINUTES_PER_DAY      # 98280\n",
    "DROP_FRACTION = 0.10                       # a 10% drop\n",
    "WINDOW_MINUTES = 5                         # over a 5-minute window\n",
    "WINDOW_YEARS = WINDOW_MINUTES / TRADING_MINUTES_PER_YEAR    # ~5.09e-5\n",
    "LOG_BARRIER = float(np.log(1.0 - DROP_FRACTION))            # ln(0.9) ~ -0.10536\n",
    "TYPICAL_ANNUAL_VOL = 0.30                  # representative liquid large-cap volatility\n",
    "\n",
    "def minutes_to_years(minutes):\n",
    "    return minutes / TRADING_MINUTES_PER_YEAR\n",
    "\n",
    "def horizon_vol(sigma_annual, minutes):\n",
    "    \"\"\"sigma over `minutes` of trading time via Brownian scaling sigma*sqrt(T).\"\"\"\n",
    "    return sigma_annual * np.sqrt(minutes_to_years(minutes))\n",
    "\n",
    "def trading_minutes_to_seconds(minutes):\n",
    "    return minutes * 60.0\n",
    "\n",
    "# --- processes: samplers; each returns (t, X) with X of shape (n_paths, n_steps+1) ---\n",
    "def _as_rng(rng):\n",
    "    return np.random.default_rng() if rng is None else rng\n",
    "\n",
    "def time_grid(T, n_steps):\n",
    "    return np.linspace(0.0, T, n_steps + 1)\n",
    "\n",
    "def brownian_motion(T, n_steps, n_paths=1, drift=0.0, sigma=1.0, rng=None):\n",
    "    \"\"\"Arithmetic BM, dX = drift*dt + sigma*dW, via independent Gaussian increments.\"\"\"\n",
    "    rng = _as_rng(rng)\n",
    "    t = time_grid(T, n_steps)\n",
    "    dt = T / n_steps\n",
    "    dW = rng.normal(loc=drift * dt, scale=sigma * np.sqrt(dt), size=(n_paths, n_steps))\n",
    "    X = np.zeros((n_paths, n_steps + 1))\n",
    "    X[:, 1:] = np.cumsum(dW, axis=1)\n",
    "    return t, X\n",
    "\n",
    "def brownian_motion_cholesky(T, n_steps, n_paths=1, rng=None):\n",
    "    \"\"\"Standard BM via the min(s,t) covariance (Gram) matrix and its Cholesky factor L (x = L z).\"\"\"\n",
    "    rng = _as_rng(rng)\n",
    "    t = time_grid(T, n_steps)\n",
    "    t_inner = t[1:]\n",
    "    K = np.minimum(t_inner[:, None], t_inner[None, :])     # min(s,t) Gram matrix\n",
    "    L = np.linalg.cholesky(K)\n",
    "    z = rng.standard_normal(size=(n_paths, n_steps))\n",
    "    X = np.zeros((n_paths, n_steps + 1))\n",
    "    X[:, 1:] = z @ L.T\n",
    "    return t, X\n",
    "\n",
    "def geometric_brownian_motion(S0, T, n_steps, n_paths=1, mu=0.0, sigma=1.0, rng=None):\n",
    "    \"\"\"GBM, dS = mu*S*dt + sigma*S*dW, via the exact log-space solution (no Euler error).\"\"\"\n",
    "    rng = _as_rng(rng)\n",
    "    t, X = brownian_motion(T, n_steps, n_paths, drift=mu - 0.5 * sigma**2, sigma=sigma, rng=rng)\n",
    "    return t, S0 * np.exp(X)\n",
    "\n",
    "def brownian_bridge(T, n_steps, n_paths=1, start=0.0, end=0.0, sigma=1.0, rng=None):\n",
    "    \"\"\"BM conditioned on B(0)=start, B(T)=end.\"\"\"\n",
    "    rng = _as_rng(rng)\n",
    "    t, W = brownian_motion(T, n_steps, n_paths, drift=0.0, sigma=sigma, rng=rng)\n",
    "    linear = start + (end - start) * (t / T)\n",
    "    bridge = W - np.outer(W[:, -1], t / T) + linear[None, :]\n",
    "    return t, bridge\n",
    "\n",
    "def merton_jump_diffusion(S0, T, n_steps, n_paths=1, mu=0.0, sigma=0.2,\n",
    "                          jump_intensity=0.0, jump_mean=-0.10, jump_sigma=0.15, rng=None):\n",
    "    \"\"\"Merton jump-diffusion; jump_intensity=0 reduces exactly to GBM.\"\"\"\n",
    "    rng = _as_rng(rng)\n",
    "    dt = T / n_steps\n",
    "    k = np.exp(jump_mean + 0.5 * jump_sigma**2) - 1.0          # E[J-1]\n",
    "    nu = mu - 0.5 * sigma**2 - jump_intensity * k               # compensated drift\n",
    "    t, X = brownian_motion(T, n_steps, n_paths, drift=nu, sigma=sigma, rng=rng)\n",
    "    counts = rng.poisson(jump_intensity * dt, size=(n_paths, n_steps))\n",
    "    jump_log = np.zeros((n_paths, n_steps))\n",
    "    K = int(counts.max()) if counts.size else 0\n",
    "    if K > 0:\n",
    "        logJ = rng.normal(jump_mean, jump_sigma, size=(n_paths, n_steps, K))\n",
    "        mask = np.arange(K) < counts[:, :, None]\n",
    "        jump_log = (logJ * mask).sum(axis=2)\n",
    "    jump_path = np.zeros((n_paths, n_steps + 1))\n",
    "    jump_path[:, 1:] = np.cumsum(jump_log, axis=1)\n",
    "    return t, S0 * np.exp(X + jump_path)\n",
    "\n",
    "# --- barriers: first-passage CDFs & the Brownian-bridge miss formula (log-price BM, B<0) ---\n",
    "def first_passage_cdf_zero_drift(B, T, sigma):\n",
    "    \"\"\"P(tau_B<=T) for zero-drift BM, via the reflection principle: 2*Phi(B/(sigma*sqrt(T))).\"\"\"\n",
    "    return 2.0 * norm.cdf(B / (sigma * np.sqrt(T)))\n",
    "\n",
    "def first_passage_cdf(B, T, nu, sigma):\n",
    "    \"\"\"P(tau_B<=T) for BM with drift nu (Bachelier-Levy).\"\"\"\n",
    "    sT = sigma * np.sqrt(T)\n",
    "    return norm.cdf((B - nu * T) / sT) + np.exp(2.0 * nu * B / sigma**2) * norm.cdf((B + nu * T) / sT)\n",
    "\n",
    "def barrier_miss_prob(x0, xT, B, sigma, dt):\n",
    "    \"\"\"P(BM crossed B inside (0,dt) | both endpoints above B): exp(-2(x0-B)(xT-B)/(sigma^2 dt)).\"\"\"\n",
    "    if x0 <= B or xT <= B:\n",
    "        return 1.0\n",
    "    return float(np.exp(-2.0 * (x0 - B) * (xT - B) / (sigma**2 * dt)))\n",
    "\n",
    "def max_safe_dt(D, sigma, epsilon):\n",
    "    \"\"\"Largest sampling interval keeping P(miss)<=epsilon: dt = 2*D^2/(sigma^2 * ln(1/epsilon)).\"\"\"\n",
    "    if not (0.0 < epsilon < 1.0):\n",
    "        raise ValueError(\"epsilon must lie in (0, 1)\")\n",
    "    return 2.0 * D**2 / (sigma**2 * np.log(1.0 / epsilon))\n",
    "\n",
    "def estimate_first_passage_prob(B, T, nu, sigma, n_paths, n_steps, rng=None):\n",
    "    \"\"\"Naive MC of P(tau_B<=T) via discrete grid checks (O(sqrt(dt)) downward bias).\"\"\"\n",
    "    rng = _as_rng(rng)\n",
    "    _, X = brownian_motion(T, n_steps, n_paths, drift=nu, sigma=sigma, rng=rng)\n",
    "    return float(np.mean(np.any(X <= B, axis=1)))\n",
    "\n",
    "def estimate_first_passage_prob_bb(B, T, nu, sigma, n_paths, n_steps, rng=None):\n",
    "    \"\"\"Bias-free MC of P(tau_B<=T) using the Brownian-bridge crossing probability per step.\"\"\"\n",
    "    rng = _as_rng(rng)\n",
    "    _, X = brownian_motion(T, n_steps, n_paths, drift=nu, sigma=sigma, rng=rng)\n",
    "    dt = T / n_steps\n",
    "    x0, x1 = X[:, :-1], X[:, 1:]\n",
    "    both_above = (x0 > B) & (x1 > B)\n",
    "    p_cross = np.where(both_above, np.exp(-2.0 * (x0 - B) * (x1 - B) / (sigma**2 * dt)), 1.0)\n",
    "    u = rng.uniform(size=p_cross.shape)\n",
    "    return float(np.mean(np.any(u < p_cross, axis=1)))\n",
    "\n",
    "def estimate_bridge_breach_prob(x0, xT, B, sigma, dt, n_paths, n_inner, rng=None):\n",
    "    \"\"\"MC check of barrier_miss_prob by simulating bridges pinned at x0, xT.\"\"\"\n",
    "    rng = _as_rng(rng)\n",
    "    _, bridge = brownian_bridge(dt, n_inner, n_paths, start=x0, end=xT, sigma=sigma, rng=rng)\n",
    "    return float(np.mean(np.any(bridge <= B, axis=1)))\n",
    "\n",
    "# --- plotting helpers ---\n",
    "def style():\n",
    "    \"\"\"Apply the project's matplotlib style (serif maths, clean spines).\"\"\"\n",
    "    mpl.rcParams.update({\n",
    "        \"figure.figsize\": (8.0, 4.5), \"figure.dpi\": 110, \"savefig.dpi\": 140,\n",
    "        \"axes.grid\": True, \"grid.alpha\": 0.3,\n",
    "        \"axes.spines.top\": False, \"axes.spines.right\": False,\n",
    "        \"font.size\": 11, \"mathtext.fontset\": \"cm\",\n",
    "        \"axes.titlesize\": 12, \"axes.labelsize\": 11, \"legend.frameon\": False,\n",
    "    })\n",
    "\n",
    "def plot_paths(t, X, ax=None, alpha=0.5, lw=0.8, color=\"C0\", label=None, **kwargs):\n",
    "    \"\"\"Plot each row of X (shape (n_paths, len(t))) against t.\"\"\"\n",
    "    if ax is None:\n",
    "        _, ax = plt.subplots()\n",
    "    X = np.atleast_2d(X)\n",
    "    for i, row in enumerate(X):\n",
    "        ax.plot(t, row, color=color, alpha=alpha, lw=lw, label=label if i == 0 else None, **kwargs)\n",
    "    ax.set_xlabel(\"time\")\n",
    "    return ax\n",
    "\n",
    "def hline(ax, y, color=\"crimson\", label=None, ls=\"--\", lw=1.4):\n",
    "    \"\"\"Draw a horizontal barrier line.\"\"\"\n",
    "    ax.axhline(y, color=color, ls=ls, lw=lw, label=label)\n",
    "    return ax\n",
    "\n",
    "# --- module namespaces (so existing call sites like processes.X / config.Y keep working) ---\n",
    "config = types.SimpleNamespace(\n",
    "    TRADING_DAYS_PER_YEAR=TRADING_DAYS_PER_YEAR, TRADING_HOURS_PER_DAY=TRADING_HOURS_PER_DAY,\n",
    "    TRADING_MINUTES_PER_HOUR=TRADING_MINUTES_PER_HOUR, TRADING_MINUTES_PER_DAY=TRADING_MINUTES_PER_DAY,\n",
    "    TRADING_MINUTES_PER_YEAR=TRADING_MINUTES_PER_YEAR, DROP_FRACTION=DROP_FRACTION,\n",
    "    WINDOW_MINUTES=WINDOW_MINUTES, WINDOW_YEARS=WINDOW_YEARS, LOG_BARRIER=LOG_BARRIER,\n",
    "    TYPICAL_ANNUAL_VOL=TYPICAL_ANNUAL_VOL, minutes_to_years=minutes_to_years,\n",
    "    horizon_vol=horizon_vol, trading_minutes_to_seconds=trading_minutes_to_seconds,\n",
    ")\n",
    "processes = types.SimpleNamespace(\n",
    "    brownian_motion=brownian_motion, brownian_motion_cholesky=brownian_motion_cholesky,\n",
    "    geometric_brownian_motion=geometric_brownian_motion, brownian_bridge=brownian_bridge,\n",
    "    merton_jump_diffusion=merton_jump_diffusion,\n",
    ")\n",
    "barriers = types.SimpleNamespace(\n",
    "    first_passage_cdf_zero_drift=first_passage_cdf_zero_drift, first_passage_cdf=first_passage_cdf,\n",
    "    barrier_miss_prob=barrier_miss_prob, max_safe_dt=max_safe_dt,\n",
    "    estimate_first_passage_prob=estimate_first_passage_prob,\n",
    "    estimate_first_passage_prob_bb=estimate_first_passage_prob_bb,\n",
    "    estimate_bridge_breach_prob=estimate_bridge_breach_prob,\n",
    ")\n",
    "plotting = types.SimpleNamespace(style=style, plot_paths=plot_paths, hline=hline)\n",
    "\n",
    "from pathlib import Path\n",
    "\n",
    "def _img_subdir(sub):\n",
    "    \"\"\"Resolve <repo>/images/<sub>, creating it; cwd-independent.\"\"\"\n",
    "    root = Path.cwd()\n",
    "    for cand in (root, *root.parents):\n",
    "        if (cand / \"notebooks\").is_dir():\n",
    "            root = cand\n",
    "            break\n",
    "    d = root / \"images\" / sub\n",
    "    d.mkdir(parents=True, exist_ok=True)\n",
    "    return d\n",
    "\n",
    "_FIG = _img_subdir(\"03_first_passage\")\n",
    "\n",
    "plotting.style()\n",
    "RNG = np.random.default_rng(3)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a94bff07",
   "metadata": {},
   "source": [
    "## 1. The event, stated precisely\n",
    "\n",
    "From Notebook 02, the log-price $X_t = \\log S_t$ is an arithmetic Brownian\n",
    "motion\n",
    "\n",
    "$$dX_t = \\nu\\,dt + \\sigma\\,dW_t, \\qquad X_0 = \\log S_0.$$\n",
    "\n",
    "A $10\\%$ drop from $S_0$ means $S_t \\le 0.9\\,S_0$, i.e.\n",
    "\n",
    "$$X_t - X_0 \\;\\le\\; \\log 0.9 \\;=:\\; B \\;\\approx\\; -0.10536.$$\n",
    "\n",
    "We measure time *relative to now*, so set $X_0 = 0$ without loss of generality.\n",
    "The **first-passage time** of the barrier $B < 0$ is the random variable\n",
    "\n",
    "$$\\boxed{\\;\\tau_B \\;:=\\; \\inf\\{\\, t \\ge 0 \\,:\\, X_t \\le B \\,\\}.\\;}$$\n",
    "\n",
    "The quantity we want is its CDF at the window length $T = 5\\text{ min}$:\n",
    "\n",
    "$$P(\\tau_B \\le T) \\;=\\; P\\!\\big(\\text{price has touched }-10\\%\\text{ by time }T\\big).$$"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "200faf74",
   "metadata": {},
   "source": [
    "## 2. Why \"endpoint below\" is the wrong question\n",
    "\n",
    "The natural but *incorrect* shortcut is to compute\n",
    "$P(X_T \\le B) = \\Phi\\!\\big(B/(\\sigma\\sqrt T)\\big)$. This misses every path\n",
    "that dipped below $B$ and climbed back above $B$ by time $T$.\n",
    "\n",
    "Let's visualise this directly: simulate many paths, and colour those that\n",
    "touched the barrier but finished above it — the \"dip-and-recover\" paths."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "eada7561",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:21:56.858583Z",
     "iopub.status.busy": "2026-08-02T16:21:56.858239Z",
     "iopub.status.idle": "2026-08-02T16:21:58.477488Z",
     "shell.execute_reply": "2026-08-02T16:21:58.476276Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 449256 characters]",
      "text/plain": [
       "<Figure size 990x484 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "P(X_T <= B)           = 0.378\n",
      "P(tau_B <= T) touched = 0.743   <-- strictly larger\n"
     ]
    }
   ],
   "source": [
    "# Visualise the dip-and-recover paths that P(X_T <= B) misses.\n",
    "T, sigma, nu, B = 1.0, 1.0, 0.0, -0.3\n",
    "n_steps, n_paths = 600, 400\n",
    "t, X = processes.brownian_motion(T, n_steps, n_paths, drift=nu, sigma=sigma, rng=RNG)\n",
    "\n",
    "touched = (X <= B).any(axis=1)          # tau_B <= T   (discrete proxy)\n",
    "end_below = X[:, -1] <= B               # X_T <= B\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(9, 4.4))\n",
    "# untouched paths in pale grey\n",
    "plotting.plot_paths(t, X[~touched], ax=ax, alpha=0.25, color=\"0.7\")\n",
    "# dip-and-recover: touched but ended above B -- the ones P(X_T<=B) misses!\n",
    "plotting.plot_paths(t, X[touched & ~end_below], ax=ax, alpha=0.8, color=\"C1\")\n",
    "# touched and ended below\n",
    "plotting.plot_paths(t, X[touched & end_below], ax=ax, alpha=0.7, color=\"C3\")\n",
    "ax.axhline(B, color=\"k\", ls=\"--\", lw=1.2, label=f\"barrier $B={B}$\")\n",
    "ax.set_title(f\"'endpoint below' misses the dip-and-recover paths\")\n",
    "ax.set_xlabel(\"time\"); ax.legend(loc=\"upper right\")\n",
    "\n",
    "from matplotlib.patches import Patch\n",
    "ax.legend(handles=[\n",
    "    Patch(color=\"0.7\", label=\"never touched\"),\n",
    "    Patch(color=\"C1\",   label=\"touched but recovered  (missed by $P(X_T\\\\leq B)$)\"),\n",
    "    Patch(color=\"C3\",   label=\"touched and ended below\"),\n",
    "], loc=\"upper right\", fontsize=9)\n",
    "plt.savefig(_FIG / \"dip_and_recover.png\", dpi=150, bbox_inches=\"tight\"); plt.show()\n",
    "\n",
    "print(f\"P(X_T <= B)           = {end_below.mean():.3f}\")\n",
    "print(f\"P(tau_B <= T) touched = {touched.mean():.3f}   <-- strictly larger\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7d62283c",
   "metadata": {},
   "source": [
    "The two quantities differ by a factor approaching **2** in the zero-drift\n",
    "limit, as the reflection principle now shows."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d1f8a58d",
   "metadata": {},
   "source": [
    "## 3. The reflection principle (zero drift)\n",
    "\n",
    "**Theorem (reflection principle).** *Let $W$ be a standard Brownian motion and\n",
    "$b < 0$. For any $T \\ge 0$,*\n",
    "\n",
    "$$P\\!\\big(\\min_{0\\le s\\le T} W_s \\le b\\big) \\;=\\; 2\\,P(W_T \\le b)\n",
    "\\;=\\; 2\\,\\Phi\\!\\left(\\frac{b}{\\sqrt T}\\right).$$\n",
    "\n",
    "**Intuition.** Reflect the path *after* its first hit of $b$: the map\n",
    "$W_s \\mapsto W_s$ for $s\\le\\tau_b$ and $W_s \\mapsto 2b - W_s$ for $s>\\tau_b$\n",
    "is a measure-preserving involution on the path space. It pairs each path that\n",
    "ends below $b$ with a reflected path that ends above $b$ but also touched $b$.\n",
    "So the \"touched\" mass is exactly twice the \"ended below\" mass.\n",
    "\n",
    "For our drifted-and-scaled log-price with $\\nu=0$,\n",
    "$X_t = \\sigma W_t$, and $P(\\tau_B\\le T) = 2\\,\\Phi\\!\\big(B/(\\sigma\\sqrt T)\\big)$.\n",
    "\n",
    "> **Note.** The reflection principle is the strong Markov\n",
    "> property in disguise: at the stopping time $\\tau_b$, the process\n",
    "> $(W_{\\tau_b+s}-b)$ is a fresh Brownian motion independent of the past, and by\n",
    "> symmetry it is equally likely to be above or below $b$ at time $T$."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "7f58b116",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:21:58.481406Z",
     "iopub.status.busy": "2026-08-02T16:21:58.481086Z",
     "iopub.status.idle": "2026-08-02T16:21:58.488304Z",
     "shell.execute_reply": "2026-08-02T16:21:58.487167Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "P(tau_B <= T) = 2*Phi(B/(sqrt(T)*sigma))\n",
      "\n",
      "Under the hood, Phi is the standard normal CDF.\n"
     ]
    }
   ],
   "source": [
    "# Symbolic statement of the reflection-principle formula.\n",
    "B_s, T_s, sigma_s = sp.symbols(\"B T sigma\", positive=False)\n",
    "prob = 2 * sp.Function(\"Phi\")(B_s / (sigma_s * sp.sqrt(T_s)))\n",
    "print(\"P(tau_B <= T) =\", prob)\n",
    "print(\"\\nUnder the hood, Phi is the standard normal CDF.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1d7aaea6",
   "metadata": {},
   "source": [
    "## 4. Monte-Carlo verification\n",
    "\n",
    "We check the closed form against two independent Monte-Carlo estimators:\n",
    "\n",
    "* **naive** — simulate on a fine grid and check whether any grid point is\n",
    "  $\\le B$. Independent of all formulae, but has an $O(\\sqrt{\\Delta t})$\n",
    "  *downward* bias (it misses dips between grid points);\n",
    "* **bridge-corrected** — uses the Brownian-bridge crossing probability of\n",
    "  Notebook 04 within each step. Bias-free."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "id": "03b830c8",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:21:58.490712Z",
     "iopub.status.busy": "2026-08-02T16:21:58.490433Z",
     "iopub.status.idle": "2026-08-02T16:22:01.967448Z",
     "shell.execute_reply": "2026-08-02T16:22:01.966586Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "     B   analytic   naive MC  bridge MC\n",
      " -0.10     0.9203     0.8985     0.9213\n",
      " -0.20     0.8415     0.8185     0.8387\n",
      " -0.30     0.7642     0.7417     0.7601\n",
      " -0.40     0.6892     0.6652     0.6828\n",
      " -0.50     0.6171     0.5984     0.6195\n",
      " -0.70     0.4839     0.4695     0.4843\n"
     ]
    },
    {
     "data": {
      "image/png": "[encoded data, 75372 characters]",
      "text/plain": [
       "<Figure size 825x440 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# Compare analytic, naive MC, and bridge-corrected MC over a sweep of barriers.\n",
    "T, sigma, nu = 1.0, 1.0, 0.0\n",
    "Bs = np.array([-0.1, -0.2, -0.3, -0.4, -0.5, -0.7])\n",
    "analytic = [barriers.first_passage_cdf(B, T, nu, sigma) for B in Bs]\n",
    "naive    = [barriers.estimate_first_passage_prob(B, T, nu, sigma, 20_000, 500, rng=RNG) for B in Bs]\n",
    "bridge   = [barriers.estimate_first_passage_prob_bb(B, T, nu, sigma, 30_000, 200, rng=RNG) for B in Bs]\n",
    "\n",
    "print(f\"{'B':>6} {'analytic':>10} {'naive MC':>10} {'bridge MC':>10}\")\n",
    "for B, a, n, b in zip(Bs, analytic, naive, bridge):\n",
    "    print(f\"{B:>6.2f} {a:>10.4f} {n:>10.4f} {b:>10.4f}\")\n",
    "\n",
    "plt.figure(figsize=(7.5, 4))\n",
    "plt.plot(Bs, analytic, \"k-o\", lw=2, label=\"analytic (reflection principle)\")\n",
    "plt.plot(Bs, naive,  \"C1^--\", label=\"naive MC (slight downward bias)\")\n",
    "plt.plot(Bs, bridge, \"C2s:\",  label=\"bridge-corrected MC (bias-free)\")\n",
    "plt.xlabel(\"barrier $B$\"); plt.ylabel(\"$P(\\\\tau_B \\\\leq T)$\")\n",
    "plt.title(\"First-passage CDF: closed form vs Monte-Carlo\")\n",
    "plt.legend(); plt.savefig(_FIG / \"mc_vs_closed_form.png\", dpi=150, bbox_inches=\"tight\"); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "5acda073",
   "metadata": {},
   "source": [
    "## 5. Adding drift: the Bachelier–Lévy formula\n",
    "\n",
    "Over 5 minutes the drift $\\nu$ is negligible, but for completeness the\n",
    "first-passage CDF of $X_t = \\nu t + \\sigma W_t$ to a lower barrier $B < 0$ is\n",
    "the **Bachelier–Lévy** formula:\n",
    "\n",
    "$$P(\\tau_B \\le T) \\;=\\; \\Phi\\!\\left(\\frac{B - \\nu T}{\\sigma\\sqrt T}\\right)\n",
    "\\;+\\; e^{\\,2\\nu B/\\sigma^2}\\,\\Phi\\!\\left(\\frac{B + \\nu T}{\\sigma\\sqrt T}\\right).$$\n",
    "\n",
    "Setting $\\nu = 0$ recovers the reflection principle (both $\\Phi$-terms\n",
    "coincide, giving $2\\Phi(B/(\\sigma\\sqrt T))$). This is `barriers.first_passage_cdf`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "94df62a3",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:22:01.969567Z",
     "iopub.status.busy": "2026-08-02T16:22:01.969381Z",
     "iopub.status.idle": "2026-08-02T16:22:01.974550Z",
     "shell.execute_reply": "2026-08-02T16:22:01.973573Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "5-min, 10% drop, sigma=30%:\n",
      "  P(tau<=T) with nu=0    : 0.000e+00\n",
      "  P(tau<=T) with nu=5%/yr: 0.000e+00\n",
      "  -> drift is utterly negligible at this horizon. We take nu = 0.\n"
     ]
    }
   ],
   "source": [
    "# Check drift's effect is tiny over realistic 5-minute horizons.\n",
    "B = config.LOG_BARRIER\n",
    "T = config.WINDOW_YEARS\n",
    "sigma = config.TYPICAL_ANNUAL_VOL\n",
    "nu_zero  = 0.0\n",
    "nu_small = 0.05      # 5% annual drift -- already large for a stock\n",
    "p0 = barriers.first_passage_cdf(B, T, nu_zero,  sigma)\n",
    "p1 = barriers.first_passage_cdf(B, T, nu_small, sigma)\n",
    "print(f\"5-min, 10% drop, sigma=30%:\")\n",
    "print(f\"  P(tau<=T) with nu=0    : {p0:.3e}\")\n",
    "print(f\"  P(tau<=T) with nu=5%/yr: {p1:.3e}\")\n",
    "print(\"  -> drift is utterly negligible at this horizon. We take nu = 0.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e857fc09",
   "metadata": {},
   "source": [
    "## 6. The reality check: a \"$\\ge 40\\sigma$\" event\n",
    "\n",
    "Now plug in the numbers. With a $30\\%$ annualised volatility and the trading-time\n",
    "convention ($252$ days $\\times$ $6.5$h $\\times$ $60$min $= 98{,}280$ trading\n",
    "minutes per year),\n",
    "\n",
    "$$\\sigma_{5\\text{min}} \\;=\\; 0.30\\,\\sqrt{\\frac{5}{98{,}280}}\n",
    "\\;\\approx\\; 2.14\\times 10^{-3}.$$\n",
    "\n",
    "The barrier is $|B| = |\\ln 0.9| \\approx 0.1054$. The drop is therefore\n",
    "\n",
    "$$\\frac{|B|}{\\sigma_{5\\text{min}}} \\;\\approx\\; 49 \\text{ standard deviations}.$$"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "283ed565",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:22:01.977251Z",
     "iopub.status.busy": "2026-08-02T16:22:01.976645Z",
     "iopub.status.idle": "2026-08-02T16:22:01.982861Z",
     "shell.execute_reply": "2026-08-02T16:22:01.981898Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "trading time (98,280 min/yr)      : sigma_5min=0.00214  ->  49.2 sigma,  P(tau<=5min)=0.00e+00\n",
      "calendar time (525,600 min/yr)    : sigma_5min=0.00092  -> 113.9 sigma,  P(tau<=5min)=0.00e+00\n",
      "\n",
      "For scale: a 7-sigma event occurs about once per trillion years of data.\n",
      "A 49-sigma event, under GBM, simply does not happen.\n"
     ]
    }
   ],
   "source": [
    "# The headline numbers, computed three ways.\n",
    "sigma_ann = config.TYPICAL_ANNUAL_VOL\n",
    "for label, mins_per_year in [\n",
    "    (\"trading time (98,280 min/yr)\", config.TRADING_MINUTES_PER_YEAR),\n",
    "    (\"calendar time (525,600 min/yr)\", 365.25*24*60),\n",
    "]:\n",
    "    T5 = 5 / mins_per_year\n",
    "    sig5 = sigma_ann * np.sqrt(T5)\n",
    "    n_sig = abs(config.LOG_BARRIER) / sig5\n",
    "    prob = barriers.first_passage_cdf_zero_drift(config.LOG_BARRIER, T5, sigma_ann)\n",
    "    print(f\"{label:34s}: sigma_5min={sig5:.5f}  -> {n_sig:5.1f} sigma,  \"\n",
    "          f\"P(tau<=5min)={prob:.2e}\")\n",
    "\n",
    "print(\"\\nFor scale: a 7-sigma event occurs about once per trillion years of data.\")\n",
    "print(\"A 49-sigma event, under GBM, simply does not happen.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d6badfd6",
   "metadata": {},
   "source": [
    "### So where do real extreme moves come from?\n",
    "\n",
    "If GBM says \"$10\\%$ in 5 minutes never happens\", and yet real markets *have*\n",
    "produced such moves (6 May 2010, 24 August 2015, the COVID crash of March\n",
    "2020), then **GBM is the wrong model for tails**. The fix is **jump-diffusion**:\n",
    "superpose rare, discrete jumps (a compound Poisson process) on top of the\n",
    "continuous Brownian motion. We build and simulate that in Notebook 05."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6319b7f7",
   "metadata": {},
   "source": [
    "## 7. A subtlety of wording: \"in a 5-minute window\"\n",
    "\n",
    "The phrase \"drops by 10% in a 5-minute window\" has two readings:\n",
    "\n",
    "1. **Fixed origin** (what we computed): starting now, does the price touch\n",
    "   $-10\\%$ within the next 5 minutes? This is $P(\\tau_B \\le T)$.\n",
    "2. **Rolling window / max drawdown**: at *any* point during the day, did the\n",
    "   price fall $10\\%$ from its value 5 minutes earlier? This is the distribution\n",
    "   of the **maximum drawdown** of Brownian motion over an interval — a much\n",
    "   harder problem.\n",
    "   \n",
    "We adopt reading (1), matching the spirit of the answer. "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "id": "e483d702",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:22:01.985572Z",
     "iopub.status.busy": "2026-08-02T16:22:01.985258Z",
     "iopub.status.idle": "2026-08-02T16:22:02.744042Z",
     "shell.execute_reply": "2026-08-02T16:22:02.742610Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 73656 characters]",
      "text/plain": [
       "<Figure size 880x495 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# A single picture summarising the fixed-origin first-passage probability.\n",
    "Bs = np.linspace(-0.6, 0.0, 200)\n",
    "for sigma_ann, col in [(0.10, \"C0\"), (0.30, \"C2\"), (0.60, \"C3\")]:\n",
    "    T = 5 / config.TRADING_MINUTES_PER_YEAR\n",
    "    probs = [barriers.first_passage_cdf(B, T, 0.0, sigma_ann) for B in Bs]\n",
    "    plt.plot(Bs, probs, col, lw=2, label=f\"$\\\\sigma$={sigma_ann:.0%}/yr\")\n",
    "plt.axvline(config.LOG_BARRIER, color=\"k\", ls=\"--\", lw=1,\n",
    "            label=f\"10% drop ($B$=ln0.9={config.LOG_BARRIER:.3f})\")\n",
    "plt.xlabel(\"log-barrier $B$ (relative to start)\")\n",
    "plt.ylabel(\"$P(\\\\tau_B \\\\leq 5\\\\,\\\\mathrm{min})$\")\n",
    "plt.title(\"Probability of touching a barrier within 5 minutes (zero drift)\")\n",
    "plt.yscale(\"log\"); plt.legend(); plt.savefig(_FIG / \"first_passage_summary.png\", dpi=150, bbox_inches=\"tight\"); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "5c5a58b1",
   "metadata": {},
   "source": [
    "## 8. Summary\n",
    "\n",
    "* \"$10\\%$ within 5 minutes\" is a **first-passage** event\n",
    "  $\\{\\tau_B \\le T\\}$, *not* an endpoint event.\n",
    "* The **reflection principle** gives $P(\\tau_B\\le T)=2\\,\\Phi(B/(\\sigma\\sqrt T))$\n",
    "  for zero drift; the **Bachelier–Lévy** formula adds drift.\n",
    "* Over 5 minutes the drift is irrelevant; under GBM a 10% drop is a\n",
    "  $\\ge\\!40\\sigma$ event — essentially impossible.\n",
    "* Real extreme moves therefore require **jumps**, which we model in Notebook 05.\n",
    "\n",
    "We have answered *how probable* the event is. The question's second\n",
    "half — *how does this affect the sampling rate?* — is about **missing** a\n",
    "breach between two discrete checks. That is the **Brownian bridge** problem of\n",
    "Notebook 04.\n",
    "\n",
    "---\n"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": ".venv",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.14.6"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
