{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "3ced4950",
   "metadata": {},
   "source": [
    "# Notebook 04 — Brownian Bridges & the Probability of a Miss\n",
    "\n",
    "Notebook 03 told us *how likely* a $10\\%$ drop is over 5 minutes. This notebook\n",
    "tackles the question's second half: **if we only sample the price\n",
    "discretely, how often do we *miss* a drop that actually happened?**\n",
    "\n",
    "The danger is the **dip-and-recover** path: the price falls below $-10\\%$\n",
    "between two of our samples and is back above by the next sample. Quantifying\n",
    "this requires the **Brownian bridge** — Brownian motion conditioned on both\n",
    "endpoints — and its single most useful formula."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "7cdb24c9",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:22:04.554188Z",
     "iopub.status.busy": "2026-08-02T16:22:04.553439Z",
     "iopub.status.idle": "2026-08-02T16:22:06.180932Z",
     "shell.execute_reply": "2026-08-02T16:22:06.179271Z"
    }
   },
   "outputs": [],
   "source": [
    "import numpy as np\n",
    "import matplotlib.pyplot as plt\n",
    "import sympy as sp\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(\"04_brownian_bridges\")\n",
    "\n",
    "plotting.style()\n",
    "RNG = np.random.default_rng(4)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "46b6937d",
   "metadata": {},
   "source": [
    "## 1. The sampling problem\n",
    "\n",
    "We cannot poll the price every instant. Suppose we sample at interval\n",
    "$\\Delta t$. Between two consecutive observations $X_0$ and $X_{\\Delta t}$ the\n",
    "price traces a continuous path we do **not** see. The event we fear is:\n",
    "\n",
    "> the hidden path dips below the barrier $B$, even though both endpoints\n",
    "> $X_0 > B$ and $X_{\\Delta t} > B$ look healthy.\n",
    "\n",
    "Conditioned on the two endpoints, the hidden path is a **Brownian bridge**.\n",
    "So the probability of a *missed* breach is a property of the bridge — and we\n",
    "can compute it in closed form."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "9c861264",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:22:06.184475Z",
     "iopub.status.busy": "2026-08-02T16:22:06.184059Z",
     "iopub.status.idle": "2026-08-02T16:22:07.083494Z",
     "shell.execute_reply": "2026-08-02T16:22:07.082522Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 478116 characters]",
      "text/plain": [
       "<Figure size 924x462 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "fraction of bridges that dipped (discrete proxy): 0.600\n"
     ]
    }
   ],
   "source": [
    "# Show sample bridges between two 'healthy' endpoints, some of which dip below B.\n",
    "dt, sigma = 1.0, 1.0\n",
    "x0, xT, B = 0.5, 0.5, 0.0\n",
    "t, bridge = processes.brownian_bridge(dt, 300, 60, start=x0, end=xT, sigma=sigma, rng=RNG)\n",
    "\n",
    "dipped = (bridge <= B).any(axis=1)\n",
    "fig, ax = plt.subplots(figsize=(8.4, 4.2))\n",
    "plotting.plot_paths(t, bridge[~dipped], ax=ax, alpha=0.5, color=\"C0\")\n",
    "plotting.plot_paths(t, bridge[dipped],  ax=ax, alpha=0.7, color=\"C3\")\n",
    "ax.axhline(B, color=\"k\", ls=\"--\", lw=1.2, label=f\"barrier $B={B}$\")\n",
    "ax.scatter([0, dt], [x0, xT], c=\"k\", zorder=5, s=40, label=\"observed endpoints\")\n",
    "ax.set_title(\"Brownian bridges between two healthy endpoints\")\n",
    "ax.set_xlabel(\"$t$ within the interval\")\n",
    "from matplotlib.patches import Patch\n",
    "ax.legend(handles=[Patch(color=\"C0\", label=\"stayed above barrier\"),\n",
    "                   Patch(color=\"C3\", label=\"dipped below (a *missed* breach)\"),\n",
    "                   plt.Line2D([0],[0], ls=\"--\", color=\"k\", label=f\"barrier $B={B}$\")],\n",
    "          loc=\"upper right\", fontsize=9)\n",
    "plt.savefig(_FIG / \"sample_bridges.png\", dpi=150, bbox_inches=\"tight\"); plt.show()\n",
    "print(f\"fraction of bridges that dipped (discrete proxy): {dipped.mean():.3f}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "aa7371d7",
   "metadata": {},
   "source": [
    "## 2. The Brownian bridge, defined\n",
    "\n",
    "The **Brownian bridge** on $[0,T]$ from $a$ to $b$ is Brownian motion *conditioned*\n",
    "on $W_0=a$ and $W_T=b$:\n",
    "\n",
    "$$B_t \\;\\stackrel{d}{=}\\; W_t \\;\\big|\\; W_T = b,$$\n",
    "\n",
    "with the linear trend $a + (b-a)\\,t/T$ factored out. A direct construction is\n",
    "\n",
    "$$\\boxed{\\;B_t \\;=\\; W_t \\;-\\; \\tfrac{t}{T}\\,W_T \\;+\\; a + (b-a)\\,\\tfrac{t}{T}.\\;}$$\n",
    "\n",
    "You can check $B_0 = a$, $B_T = b$, and (subtracting the linear part) the\n",
    "residual $W_t - \\tfrac{t}{T}W_T$ is the part of $W_t$ **uncorrelated with**\n",
    "$W_T$."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e4241af0",
   "metadata": {},
   "source": [
    "## 3. The linear-algebra view: conditioning *is* projection\n",
    "\n",
    "A Gaussian process\n",
    "sampled at times $t_1, \\dots, t_m$ is a Gaussian vector; **conditioning on a\n",
    "subset of coordinates is a linear projection**.\n",
    "\n",
    "For a Gaussian vector $(X, Y)$ with joint covariance\n",
    "$\\begin{pmatrix} \\Sigma_{XX} & \\Sigma_{XY} \\\\ \\Sigma_{YX} & \\Sigma_{YY}\\end{pmatrix}$,\n",
    "the conditional covariance of $X$ given $Y$ is the **Schur complement**:\n",
    "\n",
    "$$\\operatorname{Cov}(X \\mid Y) \\;=\\; \\Sigma_{XX} - \\Sigma_{XY}\\,\\Sigma_{YY}^{-1}\\,\\Sigma_{YX}.$$\n",
    "\n",
    "Apply this with $X = (W_{s}, W_{t})^\\top$ and $Y = W_T$. Since\n",
    "$\\operatorname{Cov}(W_s, W_T)=s$, $\\operatorname{Cov}(W_t,W_T)=t$ and\n",
    "$\\operatorname{Var}(W_T)=T$ (for $s,t \\le T$),\n",
    "\n",
    "$$\\operatorname{Cov}(W_s, W_t \\mid W_T) \\;=\\; \\min(s,t) \\;-\\; \\frac{st}{T}.$$\n",
    "\n",
    "This is the **Brownian-bridge covariance kernel**. The \"$-\\,st/T$\" term is\n",
    "exactly the rank-1 correction $\\Sigma_{XY}\\Sigma_{YY}^{-1}\\Sigma_{YX}$ — the\n",
    "component of $W$ that lives in the span of $W_T$ and is projected out by\n",
    "conditioning.\n",
    "\n",
    "> **Mathematician's note.** In the Gaussian Hilbert space, the projection of\n",
    "> $W_s$ onto $W_T$ is $\\mathbb E[W_s\\mid W_T] = (s/T)\\,W_T$. The residual\n",
    "> $W_s - (s/T)W_T$ is orthogonal to $W_T$ — this is the bridge, and its\n",
    "> covariance is the Schur complement above. Conditioning a Gaussian is literally\n",
    "> orthogonal projection in $L^2$."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "02f708fc",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:22:07.086431Z",
     "iopub.status.busy": "2026-08-02T16:22:07.086229Z",
     "iopub.status.idle": "2026-08-02T16:22:07.369759Z",
     "shell.execute_reply": "2026-08-02T16:22:07.368913Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "       (s,t)     theory  estimated    abs err\n",
      "(0.20,0.20)     0.1600     0.1597     0.0003\n",
      "(0.20,0.50)     0.1000     0.0998     0.0002\n",
      "(0.20,0.80)     0.0400     0.0399     0.0001\n",
      "(0.20,1.00)     0.0000     0.0000     0.0000\n",
      "(0.50,0.20)     0.1000     0.0998     0.0002\n",
      "(0.50,0.50)     0.2500     0.2487     0.0013\n",
      "(0.50,0.80)     0.1000     0.0998     0.0002\n",
      "(0.50,1.00)     0.0000     0.0000     0.0000\n",
      "(0.80,0.20)     0.0400     0.0399     0.0001\n",
      "(0.80,0.50)     0.1000     0.0998     0.0002\n",
      "(0.80,0.80)     0.1600     0.1599     0.0001\n",
      "(0.80,1.00)     0.0000     0.0000     0.0000\n",
      "(1.00,0.20)     0.0000     0.0000     0.0000\n",
      "(1.00,0.50)     0.0000     0.0000     0.0000\n",
      "(1.00,0.80)     0.0000     0.0000     0.0000\n",
      "(1.00,1.00)     0.0000     0.0000     0.0000\n"
     ]
    }
   ],
   "source": [
    "# Verify the Schur-complement covariance numerically.\n",
    "T = 1.0\n",
    "t = np.linspace(0, T, 201)\n",
    "# (a) Build the BM covariance and the bridge covariance via the Schur complement.\n",
    "K_bm   = np.minimum(t[:, None], t[None, :])                      # min(s,t)\n",
    "K_bridge = K_bm - np.outer(t, t) / T                              # minus st/T\n",
    "\n",
    "# (b) Estimate the bridge covariance by simulation, and compare.\n",
    "_, sim_bridge = processes.brownian_bridge(T, 200, 40_000, start=0, end=0, rng=RNG)\n",
    "cov_est = np.cov(sim_bridge.T)\n",
    "\n",
    "idx = [40, 100, 160, 200]\n",
    "print(f\"{'(s,t)':>12} {'theory':>10} {'estimated':>10} {'abs err':>10}\")\n",
    "for i in idx:\n",
    "    for j in idx:\n",
    "        th, es = K_bridge[i, j], cov_est[i, j]\n",
    "        print(f\"({t[i]:.2f},{t[j]:.2f}) {th:10.4f} {es:10.4f} {abs(th-es):10.4f}\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "id": "d31af1de",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:22:07.373791Z",
     "iopub.status.busy": "2026-08-02T16:22:07.373314Z",
     "iopub.status.idle": "2026-08-02T16:22:08.178676Z",
     "shell.execute_reply": "2026-08-02T16:22:08.177530Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 147548 characters]",
      "text/plain": [
       "<Figure size 1210x440 with 3 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n",
    "im0 = ax[0].imshow(K_bm,    origin=\"lower\", extent=[0,1,0,1], cmap=\"viridis\")\n",
    "im1 = ax[1].imshow(K_bridge, origin=\"lower\", extent=[0,1,0,1], cmap=\"viridis\")\n",
    "ax[0].set_title(r\"BM covariance  $K(s,t)=\\min(s,t)$\")\n",
    "ax[1].set_title(r\"bridge covariance  $\\min(s,t)-st/T$\")\n",
    "for a in ax: a.set_xlabel(\"$s$\"); a.set_ylabel(\"$t$\")\n",
    "plt.colorbar(im1, ax=ax[1], fraction=0.046)\n",
    "plt.tight_layout(); plt.savefig(_FIG / \"covariance_cholesky.png\", dpi=150, bbox_inches=\"tight\"); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "47527493",
   "metadata": {},
   "source": [
    "## 4. The bridge barrier-breach formula\n",
    "\n",
    "Given a Brownian bridge from $x_0$ to\n",
    "$x_T$ over $[0, \\Delta t]$ (volatility $\\sigma$), with **both endpoints above\n",
    "the barrier** ($x_0 > B$, $x_T > B$), the probability that the hidden path\n",
    "nonetheless touched $B$ is\n",
    "\n",
    "$$\\boxed{\\;P_{\\text{miss}} \\;=\\; \\exp\\!\\left(-\\,\\frac{2\\,(x_0 - B)(x_T - B)}{\\sigma^2\\,\\Delta t}\\right).\\;}$$\n",
    "\n",
    "### Sketch of derivation\n",
    "\n",
    "For a standard BM $W$ with $W_0 = x_0$, the joint density of $(W_{\\Delta t},\n",
    "\\min_{0\\le s\\le\\Delta t} W_s)$ at $(x_T, m)$ with $m \\le \\min(x_0, x_T)$ is\n",
    "obtained by the reflection principle (each path that reaches $m$ and ends at\n",
    "$x_T$ is paired with a reflected path ending at $2m - x_T$):\n",
    "\n",
    "$$f(x_T, m) \\;=\\; \\frac{2(2m - x_0 - x_T)}{\\sigma^3\\sqrt{2\\pi\\,\\Delta t^3}}\\,\n",
    "\\exp\\!\\left(-\\frac{(2m-x_0-x_T)^2}{2\\sigma^2\\Delta t}\\right).$$\n",
    "\n",
    "Dividing by the marginal density of $W_{\\Delta t}=x_T$ and integrating over\n",
    "$m \\le B$ yields exactly $P_{\\text{miss}} = \\exp\\!\\big(-2(x_0-B)(x_T-B)/(\\sigma^2\\Delta t)\\big)$.\n",
    "\n",
    "This is `barriers.barrier_miss_prob`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "43dbf01b",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:22:08.180874Z",
     "iopub.status.busy": "2026-08-02T16:22:08.180668Z",
     "iopub.status.idle": "2026-08-02T16:22:11.816784Z",
     "shell.execute_reply": "2026-08-02T16:22:11.815993Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "P_miss = -exp(-2*(B**2 - B*x_0 - B*x_T + x_0*x_T)/(Delta_t*sigma**2))\n",
      "\n",
      "With x0, xT > B  (so both factors positive):\n",
      "P_miss = exp(-2 (x0-B)(xT-B)/(sigma^2 Delta_t))\n"
     ]
    }
   ],
   "source": [
    "# Verify the formula with sympy: integrate the joint density over m <= B.\n",
    "dt_s, sigma_s = sp.symbols(\"Delta_t sigma\", positive=True)\n",
    "x0_s, xT_s, B_s = sp.symbols(\"x_0 x_T B\", real=True)\n",
    "m = sp.Symbol(\"m\", real=True)\n",
    "\n",
    "# joint density of (W_Delta_t = x_T,  running minimum = m),  m <= min(x0, xT)\n",
    "exponent = -(2*m - x0_s - xT_s)**2 / (2 * sigma_s**2 * dt_s)\n",
    "prefactor = 2*(2*m - x0_s - xT_s) / (sigma_s**3 * sp.sqrt(2*sp.pi*dt_s**3))\n",
    "joint = prefactor * sp.exp(exponent)\n",
    "\n",
    "# marginal density of W_Delta_t = x_T  (just a Gaussian)\n",
    "marg = sp.exp(-(xT_s - x0_s)**2 / (2*sigma_s**2*dt_s)) / (sigma_s*sp.sqrt(2*sp.pi*dt_s))\n",
    "\n",
    "# P(min <= B | W_Delta_t = x_T) = integral_{-oo}^{B} joint/marg  dm\n",
    "integrand = sp.simplify(joint / marg)\n",
    "prob = sp.integrate(integrand, (m, -sp.oo, B_s))\n",
    "prob = sp.simplify(prob)\n",
    "print(\"P_miss =\", prob)\n",
    "# Substitute the simplifying assumption x0 > B and xT > B afterwards.\n",
    "print(\"\\nWith x0, xT > B  (so both factors positive):\")\n",
    "print(\"P_miss = exp(-2 (x0-B)(xT-B)/(sigma^2 Delta_t))\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "fcdcd625",
   "metadata": {},
   "source": [
    "## 5. Monte-Carlo verification\n",
    "\n",
    "We verify the closed form against an **independent** fine-grid bridge\n",
    "simulation. Because the discrete grid can still dip between its own sub-points,\n",
    "this estimator has a small downward bias — but refining the grid drives it\n",
    "toward the formula, which is itself the lesson of the next section."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "073ba71b",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:22:11.818927Z",
     "iopub.status.busy": "2026-08-02T16:22:11.818743Z",
     "iopub.status.idle": "2026-08-02T16:22:15.130917Z",
     "shell.execute_reply": "2026-08-02T16:22:15.128501Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "   x0    xT    dt   analytic    fine MC      bias\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      " 0.50  0.40  1.00     0.6703     0.6630   +0.0073\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      " 0.30  0.30  0.50     0.6977     0.6867   +0.0109\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      " 0.50  0.50  1.00     0.6065     0.5931   +0.0135\n"
     ]
    }
   ],
   "source": [
    "cases = [(0.5, 0.4, 0.0, 1.0), (0.3, 0.3, 0.0, 0.5), (0.5, 0.5, 0.0, 1.0)]\n",
    "print(f\"{'x0':>5} {'xT':>5} {'dt':>5} {'analytic':>10} {'fine MC':>10} {'bias':>9}\")\n",
    "for x0, xT, B, dt in cases:\n",
    "    a = barriers.barrier_miss_prob(x0, xT, B, 1.0, dt)\n",
    "    mc = barriers.estimate_bridge_breach_prob(x0, xT, B, 1.0, dt, 15_000, 3000, rng=RNG)\n",
    "    print(f\"{x0:>5.2f} {xT:>5.2f} {dt:>5.2f} {a:>10.4f} {mc:>10.4f} {a-mc:>+9.4f}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9c424fe8",
   "metadata": {},
   "source": [
    "## 6. The qualitative behaviour: the barrier-distance is everything\n",
    "\n",
    "The formula has a striking, practical implication. Let $D_0 = x_0 - B$ and\n",
    "$D_T = x_T - B$ be the two **distances to the barrier**. Then\n",
    "\n",
    "$$P_{\\text{miss}} \\;=\\; \\exp\\!\\left(-\\,\\frac{2\\,D_0\\,D_T}{\\sigma^2\\,\\Delta t}\\right).$$\n",
    "\n",
    "* **Far from the barrier** ($D_0, D_T$ large): $P_{\\text{miss}} \\approx 0$,\n",
    "  even for coarse $\\Delta t$.\n",
    "* **Close to the barrier** ($D_0 \\approx 0$): $P_{\\text{miss}} \\to 1$, so a\n",
    "  slow sampler will almost certainly miss a breach.\n",
    "\n",
    "This is the *quantitative* version of the intuition \"you only need to watch\n",
    "closely when you're near the cliff\". The heatmap below makes it vivid."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "id": "98e6b00c",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:22:15.135555Z",
     "iopub.status.busy": "2026-08-02T16:22:15.135051Z",
     "iopub.status.idle": "2026-08-02T16:22:16.102984Z",
     "shell.execute_reply": "2026-08-02T16:22:16.101568Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 81044 characters]",
      "text/plain": [
       "<Figure size 704x550 with 2 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# Heatmap of P_miss over the (x0, xT) plane, for fixed sigma and dt.\n",
    "dt, sigma, B = 1.0, 1.0, 0.0\n",
    "grid = np.linspace(0.01, 1.0, 120)\n",
    "X0, XT = np.meshgrid(grid, grid)\n",
    "P = np.exp(-2.0*(X0 - B)*(XT - B)/(sigma**2 * dt))\n",
    "\n",
    "plt.figure(figsize=(6.4, 5))\n",
    "im = plt.imshow(P, origin=\"lower\", extent=[grid.min(), grid.max(), grid.min(), grid.max()],\n",
    "                cmap=\"magma\", vmin=0, vmax=1)\n",
    "plt.colorbar(im, label=\"$P_{\\\\mathrm{miss}}$\")\n",
    "plt.xlabel(\"endpoint distance  $x_T - B$\")\n",
    "plt.ylabel(\"start distance  $x_0 - B$\")\n",
    "plt.title(\"Probability of a missed breach between two samples\\n\"\n",
    "          \"(both endpoints above the barrier)\")\n",
    "plt.savefig(_FIG / \"pmiss_heatmap.png\", dpi=150, bbox_inches=\"tight\"); plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "id": "3d2e753a",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:22:16.106313Z",
     "iopub.status.busy": "2026-08-02T16:22:16.105815Z",
     "iopub.status.idle": "2026-08-02T16:22:17.103351Z",
     "shell.execute_reply": "2026-08-02T16:22:17.102464Z"
    }
   },
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "<>:11: SyntaxWarning: \"\\R\" is an invalid escape sequence. Such sequences will not work in the future. Did you mean \"\\\\R\"? A raw string is also an option.\n",
      "<>:11: SyntaxWarning: \"\\R\" is an invalid escape sequence. Such sequences will not work in the future. Did you mean \"\\\\R\"? A raw string is also an option.\n",
      "/tmp/ipykernel_47885/3475672487.py:11: SyntaxWarning: \"\\R\" is an invalid escape sequence. Such sequences will not work in the future. Did you mean \"\\\\R\"? A raw string is also an option.\n",
      "  plt.title(\"Closer to the barrier $\\Rightarrow$ you must sample faster\")\n"
     ]
    },
    {
     "data": {
      "image/png": "[encoded data, 68736 characters]",
      "text/plain": [
       "<Figure size 880x495 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# P_miss vs sampling interval, at three distances to the barrier.\n",
    "sigma, B = 1.0, 0.0\n",
    "dts = np.logspace(-3, 1, 200)\n",
    "for D, col in [(0.5, \"C0\"), (0.2, \"C2\"), (0.05, \"C3\")]:\n",
    "    p = np.exp(-2*D**2/(sigma**2 * dts))\n",
    "    plt.plot(dts, p, col, lw=2, label=f\"distance $D={D}$\")\n",
    "plt.axhline(0.001, color=\"k\", ls=\":\", lw=1, label=\"$\\\\varepsilon=10^{-3}$ budget\")\n",
    "plt.xscale(\"log\"); plt.yscale(\"log\")\n",
    "plt.xlabel(\"sampling interval $\\\\Delta t$\")\n",
    "plt.ylabel(\"$P_{\\\\mathrm{miss}}$\")\n",
    "plt.title(\"Closer to the barrier $\\Rightarrow$ you must sample faster\")\n",
    "plt.legend(); plt.savefig(_FIG / \"pmiss_vs_interval.png\", dpi=150, bbox_inches=\"tight\"); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a69b7ae1",
   "metadata": {},
   "source": [
    "## 7. Inverting the symmetric-endpoint formula\n",
    "\n",
    "Fix a **conditional miss budget** $\\varepsilon$. If both observed endpoints are\n",
    "at the same distance $D$ above the barrier, solve\n",
    "$\\exp(-2 D^2/(\\sigma^2 \\Delta t)) = \\varepsilon$ for $\\Delta t$:\n",
    "\n",
    "$$\\boxed{\\;\\Delta t_{\\mathrm{sym}} \\;=\\; \\frac{2\\,D^2}{\\sigma^2\\,\\ln(1/\\varepsilon)}.\\;}$$\n",
    "\n",
    "This inversion is exact **after both endpoints are known**. In an online\n",
    "scheduler the future endpoint is unknown, so Notebook 05 uses the current\n",
    "distance for both endpoints as a local, flat-endpoint design approximation. It\n",
    "is useful because the interval scales as $D^2$, but it should not be described\n",
    "as an unconditional miss-probability guarantee."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "id": "94f425c3",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:22:17.106275Z",
     "iopub.status.busy": "2026-08-02T16:22:17.106068Z",
     "iopub.status.idle": "2026-08-02T16:22:17.110708Z",
     "shell.execute_reply": "2026-08-02T16:22:17.109780Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "eps=1e-02: dt_max=0.06949  -> realised P_miss=1.0000e-02  (match)\n",
      "eps=1e-03: dt_max=0.04632  -> realised P_miss=1.0000e-03  (match)\n",
      "eps=1e-04: dt_max=0.03474  -> realised P_miss=1.0000e-04  (match)\n"
     ]
    }
   ],
   "source": [
    "# Sanity check: at dt_max the realised miss probability equals epsilon exactly.\n",
    "sigma, D, B = 1.0, 0.4, 0.0\n",
    "for eps in (1e-2, 1e-3, 1e-4):\n",
    "    dt = barriers.max_safe_dt(D, sigma, eps)\n",
    "    realised = barriers.barrier_miss_prob(D, D, B, sigma, dt)\n",
    "    print(f\"eps={eps:.0e}: dt_max={dt:.5f}  -> realised P_miss={realised:.4e}  (match)\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4f12a418",
   "metadata": {},
   "source": [
    "## 8. Summary\n",
    "\n",
    "* A discretely-sampled monitor risks missing **dip-and-recover** paths.\n",
    "* Conditioned on two endpoints, the hidden path is a **Brownian bridge** — the\n",
    "  Schur complement / $L^2$-projection of BM onto \"endpoint $= x_T$\".\n",
    "* The miss probability is\n",
    "  $P_{\\text{miss}} = \\exp\\!\\big(-2(x_0-B)(x_T-B)/(\\sigma^2\\Delta t)\\big)$:\n",
    "  negligible far from the barrier, near-certain close to it.\n",
    "* In the symmetric-endpoint case, inversion gives\n",
    "  $\\Delta t_{\\mathrm{sym}} = 2D^2/(\\sigma^2\\ln(1/\\varepsilon))$.\n",
    "  Using this online requires a future-endpoint approximation.\n",
    "\n",
    "Everything is now in place for the payoff: an **adaptive, state-dependent\n",
    "sampling heuristic** that is cheap when the market is calm and increasingly\n",
    "dense as the market nears a barrier. That is Notebook 05.\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
}
