{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "03f5f117",
   "metadata": {},
   "source": [
    "# Notebook 05 — The Adaptive Barrier Monitor\n",
    "\n",
    "We now assemble the full answer. Notebooks 02–04 give an exact Brownian-bridge\n",
    "crossing probability **conditional on two observed endpoints**. If both\n",
    "endpoints are approximated by the current barrier distance $D$, its inversion is\n",
    "\n",
    "$$\\Delta t_{\\mathrm{proxy}} \\;=\\; \\frac{2\\,D^2}{\\sigma^2\\,\\ln(1/\\varepsilon)},\n",
    "\\qquad D = X_t - B.$$\n",
    "\n",
    "This produces a useful **state-dependent scheduling heuristic**: cheap when the\n",
    "market is calm and increasingly dense near a barrier. It is not an unconditional\n",
    "miss guarantee—the future endpoint is unknown—and it does not control jumps.\n",
    "We implement the heuristic with consistent time units, compare its sampling cost\n",
    "with a fixed-rate baseline, and then make the jump-risk limitation explicit."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "8ef360fd",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:22:18.975392Z",
     "iopub.status.busy": "2026-08-02T16:22:18.975150Z",
     "iopub.status.idle": "2026-08-02T16:22:20.372605Z",
     "shell.execute_reply": "2026-08-02T16:22:20.371673Z"
    }
   },
   "outputs": [],
   "source": [
    "import numpy as np\n",
    "import pandas as pd\n",
    "import matplotlib.pyplot as plt\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",
    "\n",
    "from pathlib import Path\n",
    "\n",
    "def _repo_root():\n",
    "    \"\"\"Find the repository root without requiring the cache to exist already.\"\"\"\n",
    "    p = Path.cwd()\n",
    "    for cand in [p, *p.parents]:\n",
    "        if (cand / \"pyproject.toml\").is_file() or (\n",
    "            (cand / \"notebooks\").is_dir() and (cand / \"src\").is_dir()\n",
    "        ):\n",
    "            return cand\n",
    "    return p\n",
    "\n",
    "_CACHE_DIR = _repo_root() / \"data\" / \"cache\"\n",
    "_CHART_URL = \"https://query1.finance.yahoo.com/v8/finance/chart/{ticker}\"\n",
    "\n",
    "def cache_path(ticker, interval, range_):\n",
    "    _CACHE_DIR.mkdir(parents=True, exist_ok=True)\n",
    "    return _CACHE_DIR / f\"{ticker}_{interval}_{range_}.csv\"\n",
    "\n",
    "def fetch_intraday(ticker=\"SPY\", interval=\"5m\", range_=\"5d\", use_cache=True):\n",
    "    \"\"\"Intraday OHLCV as a tidy DataFrame (UTC index); cached under data/cache/.\"\"\"\n",
    "    path = cache_path(ticker, interval, range_)\n",
    "    if use_cache and path.exists():\n",
    "        df = pd.read_csv(path, index_col=0, parse_dates=True)\n",
    "        if df.index.tz is None:\n",
    "            df.index = df.index.tz_localize(\"UTC\")\n",
    "        return df\n",
    "    import requests\n",
    "    resp = requests.get(\n",
    "        _CHART_URL.format(ticker=ticker),\n",
    "        params={\"interval\": interval, \"range\": range_, \"includePrePost\": \"false\"},\n",
    "        headers={\"User-Agent\": \"Mozilla/5.0\"}, timeout=30,\n",
    "    )\n",
    "    resp.raise_for_status()\n",
    "    result = resp.json()[\"chart\"][\"result\"][0]\n",
    "    timestamps = pd.to_datetime(result[\"timestamp\"], unit=\"s\", utc=True)\n",
    "    quote = result[\"indicators\"][\"quote\"][0]\n",
    "    df = pd.DataFrame(\n",
    "        {\"open\": quote[\"open\"], \"high\": quote[\"high\"], \"low\": quote[\"low\"],\n",
    "         \"close\": quote[\"close\"], \"volume\": quote[\"volume\"]},\n",
    "        index=timestamps,\n",
    "    )\n",
    "    df.index.name = \"timestamp\"\n",
    "    df = df.dropna(subset=[\"close\"])\n",
    "    if use_cache:\n",
    "        df.to_csv(path)\n",
    "    return df\n",
    "\n",
    "def log_returns(prices):\n",
    "    \"\"\"Log-returns ln(p_t / p_{t-1}).\"\"\"\n",
    "    return pd.Series(np.log(prices).diff().dropna(), name=\"log_return\")\n",
    "\n",
    "data = types.SimpleNamespace(fetch_intraday=fetch_intraday, log_returns=log_returns, cache_path=cache_path)\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(\"05_adaptive_detector\")\n",
    "\n",
    "plotting.style()\n",
    "RNG = np.random.default_rng(5)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "abb884d6",
   "metadata": {},
   "source": [
    "## 1. The algorithm\n",
    "\n",
    "Choose a diffusion-design parameter $\\varepsilon$. At each observation we know\n",
    "the current relative log-price $X_t=\\log(S_t/S_0)$ and its distance\n",
    "$D=X_t-B$ from the lower barrier. We schedule the next observation using\n",
    "\n",
    "$$t_{\\text{next}} = t + \\min\\!\\left(\n",
    "\\frac{2D^2}{\\sigma^2\\ln(1/\\varepsilon)},\\;\\Delta t_{\\text{cap}}\n",
    "\\right).$$\n",
    "\n",
    "Here the time grid and $\\sigma$ must use matching units. In the implementation\n",
    "below, time is measured in minutes and $\\sigma$ is the standard deviation per\n",
    "$\\sqrt{\\text{minute}}$.\n",
    "\n",
    "```\n",
    "observe X_t at time t\n",
    "if X_t <= B: ALERT and stop\n",
    "D <- X_t - B\n",
    "dt <- 2 D^2 / (sigma^2 * ln(1/eps))   # local flat-endpoint proxy\n",
    "dt <- min(dt, dt_cap)\n",
    "schedule next sample at t + dt\n",
    "```\n",
    "\n",
    "The rate grows like $1/D^2$ near the barrier. The formula is exact only for a\n",
    "Brownian bridge whose two endpoint distances both equal $D$; online it is a\n",
    "local approximation."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "380d9756",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:22:20.374526Z",
     "iopub.status.busy": "2026-08-02T16:22:20.374251Z",
     "iopub.status.idle": "2026-08-02T16:22:20.379690Z",
     "shell.execute_reply": "2026-08-02T16:22:20.378943Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "sigma (annual)       = 0.3\n",
      "sigma (per sqrt(min))= 0.000957\n",
      "design parameter eps = 0.001\n"
     ]
    }
   ],
   "source": [
    "# The adaptive scheduler. Given a fine ground-truth relative log-price path,\n",
    "# return the indices that an online monitor would sample.\n",
    "def adaptive_schedule(t, X, B, sigma, eps, dt_cap=None, stop_on_breach=False):\n",
    "    \"\"\"Local symmetric-endpoint bridge proxy; t and sigma must share units.\"\"\"\n",
    "    idx = [0]\n",
    "    n = len(t)\n",
    "    i = 0\n",
    "    while i < n - 1:\n",
    "        if stop_on_breach and X[i] <= B:\n",
    "            break\n",
    "        D = max(float(X[i] - B), 1e-12)\n",
    "        dt = barriers.max_safe_dt(D, sigma, eps)\n",
    "        if dt_cap is not None:\n",
    "            dt = min(dt, dt_cap)\n",
    "        target = t[i] + dt\n",
    "        j = int(np.searchsorted(t, target, side=\"left\"))\n",
    "        j = min(max(j, i + 1), n - 1)\n",
    "        idx.append(j)\n",
    "        i = j\n",
    "    return np.asarray(idx, dtype=int)\n",
    "\n",
    "# Work in minutes, so volatility is expressed per sqrt(minute).\n",
    "sigma_ann = config.TYPICAL_ANNUAL_VOL\n",
    "sigma_min = config.horizon_vol(sigma_ann, 1.0)\n",
    "eps = 1e-3\n",
    "print(f\"sigma (annual)       = {sigma_ann}\")\n",
    "print(f\"sigma (per sqrt(min))= {sigma_min:.6f}\")\n",
    "print(f\"design parameter eps = {eps}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "03e55b75",
   "metadata": {},
   "source": [
    "## 2. The allowed interval vs. proximity to the barrier\n",
    "\n",
    "The feedback law in action: $\\Delta t_{\\max}$ is *enormous* when the price is\n",
    "healthy and collapses to milliseconds only at the barrier. Work in **minutes**\n",
    "by using the per-minute volatility."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "c2a82e54",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:22:20.381705Z",
     "iopub.status.busy": "2026-08-02T16:22:20.381505Z",
     "iopub.status.idle": "2026-08-02T16:22:21.651355Z",
     "shell.execute_reply": "2026-08-02T16:22:21.649982Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      " drawdown   D (log)   dt_max (min)         dt_max\n",
      "    0.00%    0.1054        3509.71         58.5 h\n",
      "    1.00%    0.0953        2872.07         47.9 h\n",
      "    2.00%    0.0852        2292.79         38.2 h\n",
      "    3.00%    0.0749        1773.76         29.6 h\n",
      "    5.00%    0.0541         924.24     924.24 min\n",
      "    7.00%    0.0328         339.93     339.93 min\n",
      "    8.00%    0.0220         152.73     152.73 min\n",
      "    9.00%    0.0110          38.60      38.60 min\n",
      "    9.50%    0.0055           9.70       9.70 min\n",
      "    9.90%    0.0011           0.39         23.4 s\n",
      "    9.99%    0.0001           0.00          0.2 s\n"
     ]
    },
    {
     "data": {
      "image/png": "[encoded data, 64312 characters]",
      "text/plain": [
       "<Figure size 924x462 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# How the safe interval depends on how far below the start the price is.\n",
    "sigma_min = config.horizon_vol(sigma_ann, 1.0)\n",
    "drawdown_pct = np.array([0.0, 1, 2, 3, 5, 7, 8, 9, 9.5, 9.9, 9.99])  # % below start\n",
    "rows = []\n",
    "for dd in drawdown_pct:\n",
    "    X = np.log(1 - dd/100.0)                         # current log-price (start = 0)\n",
    "    D = X - config.LOG_BARRIER                       # distance to barrier\n",
    "    dt_min = barriers.max_safe_dt(D, sigma_min, eps) # in minutes\n",
    "    rows.append((dd, D, dt_min))\n",
    "\n",
    "print(f\"{'drawdown':>9} {'D (log)':>9} {'dt_max (min)':>14} {'dt_max':>14}\")\n",
    "for dd, D, dt in rows:\n",
    "    secs = dt*60\n",
    "    pretty = f\"{secs:.1f} s\" if secs < 120 else f\"{dt:.2f} min\" if dt<1440 else f\"{dt/60:.1f} h\" if dt<14400 else f\"{dt/1440:.1f} d\"\n",
    "    print(f\"{dd:>8.2f}% {D:>9.4f} {dt:>14.2f} {pretty:>14}\")\n",
    "\n",
    "ds = np.linspace(0.001, 9.999, 400)            # drawdown from start, in %\n",
    "D = np.log(1 - ds/100.0) - config.LOG_BARRIER  # distance to the -10% barrier\n",
    "plt.figure(figsize=(8.4, 4.2))\n",
    "plt.plot(ds, barriers.max_safe_dt(D, sigma_min, eps))\n",
    "plt.yscale(\"log\")\n",
    "plt.xlabel(\"current drawdown from start (%)\")\n",
    "plt.ylabel(r\"$\\Delta t_{\\max}$ (minutes, log scale)\")\n",
    "plt.title(f\"Adaptive interval vs drawdown  ($\\\\sigma$=30%/yr, $\\\\varepsilon$={eps})\")\n",
    "plt.axhline(1/60000, color=\"k\", ls=\":\", lw=1, label=\"1 millisecond\")\n",
    "plt.grid(alpha=0.3, which=\"both\"); plt.legend(); plt.savefig(_FIG / \"safe_interval_vs_distance.png\", dpi=150, bbox_inches=\"tight\"); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1b31b7ee",
   "metadata": {},
   "source": [
    "The reading is dramatic, but interpret it correctly. Under the local\n",
    "symmetric-endpoint diffusion proxy, a price far from the barrier permits a long\n",
    "interval, while the interval collapses quadratically near the trigger. This\n",
    "explains why blind millisecond polling is inefficient under a continuous\n",
    "low-volatility model. It does **not** imply that a jump cannot cross and recover\n",
    "between polls."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "0c8dadfb",
   "metadata": {},
   "source": [
    "## 3. Fixed-rate vs adaptive on a controlled jump stress path\n",
    "\n",
    "To make the mechanics visible, we construct a five-minute GBM path and impose a\n",
    "persistent $12\\%$ downward jump halfway through the window. This is a controlled\n",
    "stress scenario, not a calibrated estimate of jump frequency. The adaptive\n",
    "monitor uses a three-second sanity cap and stops once a sampled price confirms\n",
    "the breach; the fixed baseline checks every millisecond."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "id": "0e87492e",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:22:21.657405Z",
     "iopub.status.busy": "2026-08-02T16:22:21.656997Z",
     "iopub.status.idle": "2026-08-02T16:22:21.695746Z",
     "shell.execute_reply": "2026-08-02T16:22:21.694657Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "true breach occurred : True\n",
      "  minimum price      : 88.30  (-11.7%)\n",
      "adaptive samples used:      51   detected: True\n",
      "fixed 1ms samples    : 300,001   detected: True\n",
      "compression ratio    : 5,882x fewer samples\n"
     ]
    }
   ],
   "source": [
    "# Controlled five-minute stress path on a genuine 1 ms grid.\n",
    "S0 = 100.0\n",
    "T_min = 5.0\n",
    "n_fine = int(T_min * 60 * 1000)                 # 300,000 one-millisecond steps\n",
    "T_years = T_min / config.TRADING_MINUTES_PER_YEAR\n",
    "\n",
    "t_years, S = processes.geometric_brownian_motion(\n",
    "    S0, T_years, n_fine, n_paths=1, mu=0.0, sigma=sigma_ann,\n",
    "    rng=np.random.default_rng(51),\n",
    ")\n",
    "t_minutes = t_years * config.TRADING_MINUTES_PER_YEAR\n",
    "\n",
    "# Impose a persistent -12% log jump at the midpoint so a breach definitely occurs.\n",
    "shock_idx = n_fine // 2\n",
    "S[0, shock_idx:] *= np.exp(-0.12)\n",
    "X = np.log(S[0] / S0)                            # relative log-price\n",
    "B = config.LOG_BARRIER\n",
    "\n",
    "idx_adapt = adaptive_schedule(\n",
    "    t_minutes, X, B, sigma_min, eps,\n",
    "    dt_cap=0.05,                                  # at most 3 seconds\n",
    "    stop_on_breach=True,\n",
    ")\n",
    "idx_fixed = np.arange(n_fine + 1, dtype=int)      # every millisecond\n",
    "\n",
    "truth_breach = bool((X <= B).any())\n",
    "adapt_detect = bool((X[idx_adapt] <= B).any())\n",
    "fixed_detect = bool((X[idx_fixed] <= B).any())\n",
    "\n",
    "print(f\"true breach occurred : {truth_breach}\")\n",
    "print(f\"  minimum price      : {S[0].min():.2f}  ({(S[0].min()/S0-1)*100:.1f}%)\")\n",
    "print(f\"adaptive samples used: {len(idx_adapt):>7,}   detected: {adapt_detect}\")\n",
    "print(f\"fixed 1ms samples    : {len(idx_fixed):>7,}   detected: {fixed_detect}\")\n",
    "print(f\"compression ratio    : {len(idx_fixed)/len(idx_adapt):,.0f}x fewer samples\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "7020f046",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:22:21.698571Z",
     "iopub.status.busy": "2026-08-02T16:22:21.698325Z",
     "iopub.status.idle": "2026-08-02T16:22:22.311489Z",
     "shell.execute_reply": "2026-08-02T16:22:22.310482Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 47148 characters]",
      "text/plain": [
       "<Figure size 1045x506 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "fig, ax = plt.subplots(figsize=(9.5, 4.6))\n",
    "ax.plot(t_minutes, S[0], color=\"0.5\", lw=0.8, label=\"stress-path price\")\n",
    "ax.scatter(t_minutes[idx_adapt], S[0][idx_adapt],\n",
    "           c=\"C3\", s=18, zorder=5, label=f\"adaptive samples ({len(idx_adapt)})\")\n",
    "ax.axhline(0.9*S0, color=\"k\", ls=\"--\", lw=1.2, label=\"-10% barrier\")\n",
    "ax.set_xlabel(\"time (minutes)\"); ax.set_ylabel(\"price\")\n",
    "ax.set_title(\"Adaptive sampling with a three-second cap; stop after alert\")\n",
    "ax.legend(loc=\"upper right\")\n",
    "plt.savefig(_FIG / \"adaptive_vs_fixed_path.png\", dpi=150, bbox_inches=\"tight\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "aba0fe75",
   "metadata": {},
   "source": [
    "## 4. Cost analysis with a practical polling cap\n",
    "\n",
    "The bridge proxy alone can suggest intervals longer than a trading day when the\n",
    "price is far from a $-10\\%$ barrier. An online monitor cannot adapt to an\n",
    "unobserved approach, so a **hard periodic cap is essential**. Here we impose a\n",
    "one-minute cap and compare it with blind 1 ms polling over a 390-minute trading\n",
    "day."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "5b4cb7b7",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:22:22.314617Z",
     "iopub.status.busy": "2026-08-02T16:22:22.314362Z",
     "iopub.status.idle": "2026-08-02T16:22:23.392042Z",
     "shell.execute_reply": "2026-08-02T16:22:23.390794Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "fixed 1ms polling:     23,400,000 samples / trading day\n",
      "adaptive hard cap: 1 minute\n",
      "\n",
      "          scenario   adaptive samples/day   paths alerting      speedup\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "      daily vol 1%                    386            0/200      60,622x\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "      daily vol 3%                    386            0/200      60,622x\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "      daily vol 7%                    370           23/200      63,243x\n"
     ]
    }
   ],
   "source": [
    "fixed_per_day = 390 * 60 * 1000\n",
    "poll_cap_minutes = 1.0\n",
    "print(f\"fixed 1ms polling: {fixed_per_day:>14,} samples / trading day\")\n",
    "print(f\"adaptive hard cap: {poll_cap_minutes:.0f} minute\\n\")\n",
    "\n",
    "scenarios = [\n",
    "    (\"daily vol 1%\", 0.01),\n",
    "    (\"daily vol 3%\", 0.03),\n",
    "    (\"daily vol 7%\", 0.07),\n",
    "]\n",
    "print(f\"{'scenario':>18} {'adaptive samples/day':>22} {'paths alerting':>16} {'speedup':>12}\")\n",
    "for name, daily_vol in scenarios:\n",
    "    sigma_scen = daily_vol * np.sqrt(252)\n",
    "    T_day_years = 390 / config.TRADING_MINUTES_PER_YEAR\n",
    "    npaths, ngrid = 200, 5000\n",
    "    rng = np.random.default_rng(11)\n",
    "    tday_minutes = np.linspace(0.0, 390.0, ngrid + 1)\n",
    "    counts, alerts = [], 0\n",
    "    for _ in range(npaths):\n",
    "        _, Sd = processes.geometric_brownian_motion(\n",
    "            S0, T_day_years, ngrid, 1, 0.0, sigma_scen, rng=rng\n",
    "        )\n",
    "        Xd = np.log(Sd[0] / S0)\n",
    "        idx = adaptive_schedule(\n",
    "            tday_minutes, Xd, B,\n",
    "            config.horizon_vol(sigma_scen, 1.0), eps,\n",
    "            dt_cap=poll_cap_minutes,\n",
    "            stop_on_breach=True,\n",
    "        )\n",
    "        alerts += int((Xd[idx] <= B).any())\n",
    "        counts.append(len(idx))\n",
    "    mean_samples = int(np.mean(counts))\n",
    "    print(f\"{name:>18} {mean_samples:>22,} {alerts:>12}/{npaths:<3} {fixed_per_day/mean_samples:>11,.0f}x\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1ae2cf82",
   "metadata": {},
   "source": [
    "With the one-minute safety cap, the monitor still reduces the polling load by\n",
    "roughly five orders of magnitude relative to 1 ms polling in this stylised\n",
    "experiment. The cap—not the bridge formula—sets the calm-market latency ceiling;\n",
    "the state-dependent rule only becomes denser than the cap near the barrier.\n",
    "These numbers remain model- and parameter-dependent, and jumps are outside the\n",
    "Brownian miss calculation."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3ae48b0d",
   "metadata": {},
   "source": [
    "## 5. Beyond GBM: jumps are where extreme moves live\n",
    "\n",
    "Notebook 03 showed a $10\\%$-in-$5$-minutes drop is a $\\sim 49\\sigma$ event\n",
    "under pure GBM — effectively impossible. The **Merton jump-diffusion** superposes\n",
    "a compound Poisson process of discrete jumps on the diffusion:\n",
    "\n",
    "$$\\frac{dS_t}{S_t} \\;=\\; (\\mu - \\lambda \\kappa)\\,dt + \\sigma\\,dW_t + (J_t - 1)\\,dN_t,$$\n",
    "\n",
    "where $N_t$ is a Poisson process of rate $\\lambda$, the jump sizes satisfy\n",
    "$\\log J \\sim \\mathcal N(\\mu_J, \\sigma_J^2)$, and\n",
    "$\\kappa = \\mathbb E[J - 1]$. Downward jumps have $\\mu_J < 0$.\n",
    "\n",
    "Let's see how the *terminal* drop distribution changes when jumps are switched\n",
    "on — and confirm that $10\\%$ drops become genuinely attainable."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "id": "5af4075c",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:22:23.394936Z",
     "iopub.status.busy": "2026-08-02T16:22:23.394715Z",
     "iopub.status.idle": "2026-08-02T16:22:27.158074Z",
     "shell.execute_reply": "2026-08-02T16:22:27.157068Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 48560 characters]",
      "text/plain": [
       "<Figure size 990x484 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "    GBM (no jumps): P(5-min return <= -10%) = 0.000e+00\n",
      "      Merton jumps: P(5-min return <= -10%) = 2.500e-04\n"
     ]
    }
   ],
   "source": [
    "# Compare terminal 5-min returns: pure GBM vs jump-diffusion.\n",
    "T = 5 / config.TRADING_MINUTES_PER_YEAR\n",
    "n_paths, n_steps = 40_000, 300\n",
    "ret = {}\n",
    "ret[\"GBM (no jumps)\"] = np.log(\n",
    "    processes.geometric_brownian_motion(S0, T, n_steps, n_paths, 0.0, sigma_ann, rng=RNG)[1][:,-1] / S0)\n",
    "ret[\"Merton jumps\"]   = np.log(\n",
    "    processes.merton_jump_diffusion(S0, T, n_steps, n_paths, mu=0.0, sigma=sigma_ann,\n",
    "        jump_intensity=10.0, jump_mean=-0.20, jump_sigma=0.15, rng=RNG)[1][:,-1] / S0)\n",
    "\n",
    "plt.figure(figsize=(9, 4.4))\n",
    "for (name, r), col in zip(ret.items(), [\"C0\", \"C3\"]):\n",
    "    plt.hist(r, bins=400, density=True, alpha=0.5, color=col, label=name, range=(-0.15, 0.05))\n",
    "plt.axvline(config.LOG_BARRIER, color=\"k\", ls=\"--\", lw=1.4, label=\"10% drop barrier\")\n",
    "plt.xlabel(\"5-minute log-return\"); plt.ylabel(\"density\")\n",
    "plt.yscale(\"log\")\n",
    "plt.title(\"Jumps put real mass on the -10% tail that GBM leaves empty\")\n",
    "plt.legend(); plt.savefig(_FIG / \"gbm_vs_jump_returns.png\", dpi=150, bbox_inches=\"tight\"); plt.show()\n",
    "\n",
    "for name, r in ret.items():\n",
    "    print(f\"{name:>18}: P(5-min return <= -10%) = {(r <= config.LOG_BARRIER).mean():.3e}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "36280d7d",
   "metadata": {},
   "source": [
    "## 6. The event-driven upgrade\n",
    "\n",
    "The adaptive sampler is *time-based*: it polls faster as the price nears the\n",
    "barrier. But a jump can blow through $-10\\%$ between *any* two samples, however\n",
    "close. The fully honest answer is therefore to combine the two ideas:\n",
    "\n",
    "> **Hybrid sampler.** Run the adaptive time-based poll for the diffusion. In\n",
    "> parallel, subscribe to a **microstructure signal** — order-book imbalance,\n",
    "> trade-flow acceleration, realised-volatility spike — and the moment that\n",
    "> signal fires, **switch to event-driven / interrupt-driven** sampling,\n",
    "> effectively moving to continuous monitoring until calm returns.\n",
    "\n",
    "This mirrors how real trading systems are built: a slow poll handles the boring\n",
    "$99.9\\%$ of the time, and a fast event loop handles the rare, violent moments.\n",
    "\n",
    "> **Quant's note.** Under a jump-diffusion the probability of a $-10\\%$ move\n",
    "> over a horizon $T$ is, to first order in $\\lambda T$,\n",
    "> $P \\approx \\lambda T \\cdot P(\\log J \\le B)$. The sampling problem stops being\n",
    "> \"did diffusion wander to the barrier\" and becomes \"did a jump arrive and was\n",
    "> it large enough\" — a Poisson detection problem, best solved event-driven."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f6a7b0bc",
   "metadata": {},
   "source": [
    "## 7. Reality check on live data\n",
    "\n",
    "Scan one month of **SPY** 5-minute bars. The largest 5-minute move, and the\n",
    "closest the adaptive barrier ever came to being triggered, make the point that\n",
    "$-10\\%$-in-$5$-minutes simply does not occur in ordinary history."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "id": "fe7e9476",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:22:27.160095Z",
     "iopub.status.busy": "2026-08-02T16:22:27.159895Z",
     "iopub.status.idle": "2026-08-02T16:22:27.182835Z",
     "shell.execute_reply": "2026-08-02T16:22:27.181918Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "SPY, 1 month of 5-min bars (1639 bars)\n",
      "  worst 5-min log-return : -0.0130  (-1.30%)\n",
      "  implied annual vol     : 0.132\n",
      "  10% barrier in log-ret : -0.1054\n",
      "  how many 5-min bars <= -10%: 0\n",
      "  -> exactly as GBM predicts: zero such events in normal history.\n",
      "\n",
      "  closest approach to barrier: D = 0.0923\n",
      "  => proxy interval there    : 13851.3 minutes (vs 5-minute bars!)\n"
     ]
    }
   ],
   "source": [
    "try:\n",
    "    spy = data.fetch_intraday(\"SPY\", interval=\"5m\", range_=\"1mo\")\n",
    "    have = True\n",
    "except Exception as exc:\n",
    "    print(\"Optional market-data scan unavailable; continuing without it.\")\n",
    "    have = False\n",
    "\n",
    "if have:\n",
    "    r5 = data.log_returns(spy[\"close\"])\n",
    "    worst = float(r5.min())\n",
    "    ann_vol = float(r5.std() * np.sqrt(252 * 78))\n",
    "    print(f\"SPY, 1 month of 5-min bars ({len(spy)} bars)\")\n",
    "    print(f\"  worst 5-min log-return : {worst:+.4f}  ({(np.exp(worst)-1)*100:+.2f}%)\")\n",
    "    print(f\"  implied annual vol     : {ann_vol:.3f}\")\n",
    "    print(f\"  10% barrier in log-ret : {config.LOG_BARRIER:.4f}\")\n",
    "    print(f\"  how many 5-min bars <= -10%: {(r5 <= config.LOG_BARRIER).sum()}\")\n",
    "    print(\"  -> exactly as GBM predicts: zero such events in normal history.\\n\")\n",
    "\n",
    "    # How many adaptive samples would we have taken across the whole month,\n",
    "    # had we been monitoring each bar's price vs a -10% barrier from session open?\n",
    "    # (Per-bar distance D ~ |log_barrier| - |5min move|, which is huge -> very few samples.)\n",
    "    sigma_min = config.horizon_vol(ann_vol, 1.0)\n",
    "    typical_D = worst - config.LOG_BARRIER        # closest the price came (log units)\n",
    "    dt_typical = barriers.max_safe_dt(typical_D, sigma_min, eps)\n",
    "    print(f\"  closest approach to barrier: D = {typical_D:.4f}\")\n",
    "    print(f\"  => proxy interval there    : {dt_typical:.1f} minutes \"\n",
    "          f\"(vs 5-minute bars!)\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9805bbd4",
   "metadata": {},
   "source": [
    "## 8. The complete answer\n",
    "\n",
    "Putting all five notebooks together:\n",
    "\n",
    "**1. The model.** Under GBM, $dS=\\mu S\\,dt+\\sigma S\\,dW$, the relative log-price\n",
    "$X_t=\\log(S_t/S_0)$ is arithmetic Brownian motion with drift. A $10\\%$ drop is\n",
    "hitting $B=\\log 0.9$.\n",
    "\n",
    "**2. The probability.** The event is a first-passage event. With zero drift,\n",
    "$P(\\tau_B\\le T)=2\\Phi(B/(\\sigma\\sqrt T))$. At $30\\%$ annual volatility, a\n",
    "$10\\%$ five-minute drop is roughly a $49\\sigma$ diffusion event, so GBM assigns\n",
    "it probability below ordinary floating-point resolution. Real extreme moves\n",
    "require richer models, including jumps and market microstructure.\n",
    "\n",
    "**3. The sampling rate.** Conditional on two observed endpoints, the Brownian\n",
    "bridge gives the exact hidden-crossing probability\n",
    "$\\exp(-2(x_0-B)(x_T-B)/(\\sigma^2\\Delta t))$. Replacing the unknown future\n",
    "endpoint distance by the current distance yields the local heuristic\n",
    "$\\Delta t\\propto D^2$: poll sparsely far from the barrier and densely nearby.\n",
    "This can reduce compute dramatically in diffusion simulations, but it is not an\n",
    "unconditional miss guarantee and cannot anticipate jumps. A production design\n",
    "would combine state-dependent polling with event-driven market-data triggers."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "539a3076",
   "metadata": {},
   "source": [
    "## 9. Summary of the series\n",
    "\n",
    "| Notebook | What you gained |\n",
    "|----------|-----------------|\n",
    "| 01 | Log-returns, Brownian motion, the $\\min(s,t)$ kernel, the $\\sqrt T$ law |\n",
    "| 02 | GBM, Itô's lemma, the $-\\tfrac12\\sigma^2$ correction, exact solution |\n",
    "| 03 | First-passage times, reflection principle, the \"$49\\sigma$\" reality check |\n",
    "| 04 | Brownian bridges, conditioning-as-projection, the miss formula |\n",
    "| 05 | The adaptive detector, jumps, the event-driven upgrade, the full answer |\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
}
