{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "2db18f8c",
   "metadata": {},
   "source": [
    "# Notebook 01 — From Random Walks to Brownian Motion\n",
    "\n",
    "### The question\n",
    "\n",
    "> *A stock price follows a random walk. You want an algorithm that alerts you\n",
    "> if the stock drops by 10% in a 5-minute window. Checking every millisecond is\n",
    "> too expensive. How do you **mathematically model** the probability of such a\n",
    "> drop, and how does that model change your **sampling rate**?*\n",
    "\n",
    "This first notebook builds the foundations: what we *mean* by \"a stock price\n",
    "follows a random walk\", and how that discrete object converges to **Brownian\n",
    "motion** — the continuous process all of the later probability statements are\n",
    "built on.\n",
    "\n",
    "### Roadmap of the series\n",
    "\n",
    "| # | Topic | Key result |\n",
    "|---|-------|-----------|\n",
    "| **01** | Random walks → Brownian motion | $W_t$, covariance kernel, the $\\sqrt{T}$ law |\n",
    "| 02 | Geometric Brownian motion & Itô's lemma | the log-price is arithmetic BM |\n",
    "| 03 | First-passage times & reflection principle | $P(\\tau \\le T)$ of touching a $-10\\%$ barrier |\n",
    "| 04 | Brownian bridges & the miss probability | $P(\\text{dip hidden between samples})$ |\n",
    "| 05 | the adaptive barrier monitor | state-dependent sampling rate |\n",
    "\n",
    "Two conventions: callouts marked **Mathematician's note** go deeper on rigour;\n",
    "those marked **Quant's note** explain market practice."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "542b3a76",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:21:40.880214Z",
     "iopub.status.busy": "2026-08-02T16:21:40.879371Z",
     "iopub.status.idle": "2026-08-02T16:21:42.672446Z",
     "shell.execute_reply": "2026-08-02T16:21:42.671506Z"
    }
   },
   "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(\"01_random_walks\")\n",
    "\n",
    "plotting.style()\n",
    "RNG = np.random.default_rng(1)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "401bbbdd",
   "metadata": {},
   "source": [
    "## 1. Returns, and why we work in log-space\n",
    "\n",
    "A stock price $S_t > 0$ is observed at times $0, \\Delta t, 2\\Delta t, \\dots$.\n",
    "The natural object is not the price itself but its **return**. There are two\n",
    "flavours:\n",
    "\n",
    "* **arithmetic (simple) return:** $\\quad R_n = \\dfrac{S_{n}}{S_{n-1}} - 1$\n",
    "* **log-return:** $\\quad X_n = \\log S_{n} - \\log S_{n-1} = \\log\\!\\left(1 + R_n\\right)$\n",
    "\n",
    "For small moves $X_n \\approx R_n$, but log-returns **add** over consecutive\n",
    "periods while simple returns compound:\n",
    "\n",
    "$$X_{0\\to N} = \\sum_{n=1}^{N} X_n = \\log S_N - \\log S_0.$$\n",
    "\n",
    "Additivity is exactly what makes log-returns a sum of independent increments —\n",
    "i.e. a **random walk** — which is the object the central limit theorem acts on.\n",
    "This is why quants model the **log-price** $X_t = \\log S_t$, not the price."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f11c019c",
   "metadata": {},
   "source": [
    "## 2. A discrete random walk for the log-price\n",
    "\n",
    "Assume the one-step log-returns $X_n$ are i.i.d. with mean $\\nu\\,\\Delta t$ and\n",
    "variance $\\sigma^2 \\Delta t$. Then the log-price after $N$ steps is\n",
    "\n",
    "$$X_{N} \\;=\\; X_0 + \\sum_{n=1}^{N} X_n.$$\n",
    "\n",
    "That sum *is* a random walk. To build intuition, let each $X_n$ be the simplest\n",
    "non-trivial step: $\\pm\\sqrt{\\Delta t}$ with equal probability (a scaled\n",
    "symmetric Bernoulli). This is the prototype that converges to Brownian motion."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "92ffc7c0",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:21:42.676452Z",
     "iopub.status.busy": "2026-08-02T16:21:42.675789Z",
     "iopub.status.idle": "2026-08-02T16:21:43.650285Z",
     "shell.execute_reply": "2026-08-02T16:21:43.649156Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 265172 characters]",
      "text/plain": [
       "<Figure size 1210x440 with 2 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# A scaled symmetric random walk for the log-price.\n",
    "delta_t = 1.0 / 252 / 78        # one 5-minute bar, in years (78 bars/day)\n",
    "N = 78 * 21                     # ~ one trading month of 5-minute bars\n",
    "steps = RNG.choice([-1.0, 1.0], size=(8, N)) * np.sqrt(delta_t)\n",
    "t = np.linspace(0, N * delta_t, N + 1)\n",
    "X = np.concatenate([np.zeros((8, 1)), np.cumsum(steps, axis=1)], axis=1)\n",
    "\n",
    "fig, (axL, axR) = plt.subplots(1, 2, figsize=(11, 4))\n",
    "plotting.plot_paths(t, X, ax=axL, alpha=0.7)\n",
    "axL.set_title(\"log-price random walk $X_t$\")\n",
    "axL.set_xlabel(\"time (years)\")\n",
    "S = 100 * np.exp(X)\n",
    "plotting.plot_paths(t, S, ax=axR, alpha=0.7)\n",
    "axR.set_title(r\"price $S_t = 100\\,e^{X_t}$ (always positive)\")\n",
    "axR.set_xlabel(\"time (years)\")\n",
    "plt.tight_layout(); plt.savefig(_FIG / \"scaled_random_walk.png\", dpi=150, bbox_inches=\"tight\"); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "72677e76",
   "metadata": {},
   "source": [
    "## 3. The scaling limit: Brownian motion\n",
    "\n",
    "Rescale the walk by looking at $W^{(n)}_t = \\frac{1}{\\sqrt{n}}\\sum_{k=1}^{\\lfloor nt\\rfloor}\\xi_k$\n",
    "with i.i.d. centred $\\xi_k$ of unit variance. **Donsker's theorem** (a\n",
    "functional CLT) says $W^{(n)} \\Rightarrow W$ as $n\\to\\infty$, where $W$ is a\n",
    "**standard Brownian motion** (Wiener process), characterised by:\n",
    "\n",
    "1. $W_0 = 0$ almost surely;\n",
    "2. **independent increments:** $W_t - W_s \\sim \\mathcal N(0,\\,t-s)$ for $0\\le s<t$;\n",
    "3. almost-sure continuity of the sample paths $t\\mapsto W_t$.\n",
    "\n",
    "From (1)–(2), $W_t \\sim \\mathcal N(0, t)$, so the standard deviation scales as\n",
    "$\\sqrt{t}$. This **$\\sqrt{T}$ law** is the single most important scaling in\n",
    "finance and we will measure it on real data at the end of this notebook.\n",
    "\n",
    "> **Mathematician's note.** Brownian motion is the *canonical* continuous\n",
    "> Gaussian process. Almost every path is continuous but **nowhere\n",
    "> differentiable**, and has quadratic variation $[W]_t = t$ — a fact we will\n",
    "> need in Notebook 02 to derive Itô's lemma."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "c2972a39",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:21:43.652820Z",
     "iopub.status.busy": "2026-08-02T16:21:43.652620Z",
     "iopub.status.idle": "2026-08-02T16:21:44.337515Z",
     "shell.execute_reply": "2026-08-02T16:21:44.336584Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 52688 characters]",
      "text/plain": [
       "<Figure size 770x440 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# Visualise the CLT: terminal distribution of a fine scaled random walk\n",
    "# approaches N(0, T). Here T = 1 (year), n steps per unit time.\n",
    "T, n = 1.0, 1000\n",
    "n_paths = 20_000\n",
    "steps = RNG.choice([-1.0, 1.0], size=(n_paths, n)) / np.sqrt(n)\n",
    "W_T = steps.sum(axis=1)\n",
    "\n",
    "from scipy.stats import norm\n",
    "grid = np.linspace(-4, 4, 400)\n",
    "plt.figure(figsize=(7, 4))\n",
    "plt.hist(W_T, bins=80, density=True, alpha=0.5, label=\"scaled random walk, $W_T^{(n)}$\")\n",
    "plt.plot(grid, norm.pdf(grid), \"k-\", lw=2, label=r\"$N(0,T)$ density\")\n",
    "plt.title(\"Donsker/CLT: the walk's endpoint converges to $W_T \\\\sim N(0,T)$\")\n",
    "plt.xlabel(\"$W_T$\"); plt.legend(); plt.savefig(_FIG / \"clt_terminal_dist.png\", dpi=150, bbox_inches=\"tight\"); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "81a8120a",
   "metadata": {},
   "source": [
    "## 4. Brownian motion as a Gaussian process: the linear-algebra view\n",
    "\n",
    "Here is the viewpoint that should feel like home. A Brownian motion is a\n",
    "**Gaussian process** — every finite vector of samples\n",
    "$X = (W_{t_1}, \\dots, W_{t_m})^\\top$ is jointly Gaussian — with **mean zero**\n",
    "and **covariance kernel**\n",
    "\n",
    "$$K(s, t) \\;=\\; \\operatorname{Cov}(W_s, W_t) \\;=\\; \\min(s, t).$$\n",
    "\n",
    "Discretising times $t_1 < \\dots < t_m$ turns the kernel into a symmetric\n",
    "**positive-definite Gram matrix** $K_{ij} = \\min(t_i, t_j)$.\n",
    "\n",
    "### Sampling a Gaussian vector with a prescribed covariance\n",
    "\n",
    "If $K = L L^\\top$ is the **Cholesky factorisation** and\n",
    "$z \\sim \\mathcal N(0, I_m)$, then\n",
    "\n",
    "$$x = L\\,z \\;\\;\\sim\\;\\; \\mathcal N(0,\\,K),$$\n",
    "\n",
    "because $\\operatorname{Cov}(Lz) = L\\,\\operatorname{Cov}(z)\\,L^\\top = L L^\\top = K$.\n",
    "So sampling a Brownian path is *literally* the Cholesky recipe."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "id": "28be7444",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:21:44.339586Z",
     "iopub.status.busy": "2026-08-02T16:21:44.339389Z",
     "iopub.status.idle": "2026-08-02T16:21:44.345546Z",
     "shell.execute_reply": "2026-08-02T16:21:44.344642Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "times t  = [0.    0.167 0.333 0.5   0.667 0.833 1.   ]\n",
      "\n",
      "Gram matrix K = min(s,t):\n",
      " [[0.    0.    0.    0.    0.    0.    0.   ]\n",
      " [0.    0.167 0.167 0.167 0.167 0.167 0.167]\n",
      " [0.    0.167 0.333 0.333 0.333 0.333 0.333]\n",
      " [0.    0.167 0.333 0.5   0.5   0.5   0.5  ]\n",
      " [0.    0.167 0.333 0.5   0.667 0.667 0.667]\n",
      " [0.    0.167 0.333 0.5   0.667 0.833 0.833]\n",
      " [0.    0.167 0.333 0.5   0.667 0.833 1.   ]]\n",
      "\n",
      "smallest eigenvalue of K: 0.0  -> singular, not positive-definite.\n",
      "Why? The t=0 row/column is all zeros because W_0 = 0 is deterministic.\n",
      "rank(K) = 6 of 7\n"
     ]
    }
   ],
   "source": [
    "# Build the covariance Gram matrix K_{ij} = min(t_i, t_j).\n",
    "T, m = 1.0, 6\n",
    "t = np.linspace(0, T, m + 1)\n",
    "K = np.minimum(t[:, None], t[None, :])\n",
    "print(\"times t  =\", np.round(t, 3))\n",
    "print(\"\\nGram matrix K = min(s,t):\\n\", np.round(K, 3))\n",
    "\n",
    "# A subtlety every mathematician notices: this K is only positive *semi*definite.\n",
    "print(\"\\nsmallest eigenvalue of K:\", round(float(np.linalg.eigvalsh(K).min()), 6),\n",
    "      \" -> singular, not positive-definite.\")\n",
    "print(\"Why? The t=0 row/column is all zeros because W_0 = 0 is deterministic.\")\n",
    "print(\"rank(K) =\", int(np.linalg.matrix_rank(K)), \"of\", K.shape[0])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4c9415cc",
   "metadata": {},
   "source": [
    "### Removing the degeneracy\n",
    "\n",
    "Because $W_0 = 0$ is deterministic, the full Gram matrix $K$ has a zero row and\n",
    "column and is only **positive semi-definite** — Cholesky refuses it. The fix is\n",
    "exactly what linear algebra suggests: work in the non-degenerate subspace.\n",
    "Drop the $t_0 = 0$ point, Cholesky-factor the interior block\n",
    "$K_{ij} = \\min(t_i, t_j)$ for $i, j \\ge 1$, sample the interior, and prepend the\n",
    "zero. This is what `processes.brownian_motion_cholesky` does."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "6c0f4a80",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:21:44.347764Z",
     "iopub.status.busy": "2026-08-02T16:21:44.347570Z",
     "iopub.status.idle": "2026-08-02T16:21:44.352864Z",
     "shell.execute_reply": "2026-08-02T16:21:44.351714Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "one sample path W: [ 0.      0.2301  0.173  -0.2512 -0.2921 -0.8803 -0.8443]\n",
      "\n",
      "check ||L L^T - K_inner|| = 1.1102230246251565e-16\n"
     ]
    }
   ],
   "source": [
    "# Factor the interior block and reconstruct a sample, by hand.\n",
    "K_inner = K[1:, 1:]\n",
    "L_inner = np.linalg.cholesky(K_inner)\n",
    "z = RNG.standard_normal(size=m)\n",
    "W_inner = L_inner @ z\n",
    "W = np.concatenate([[0.0], W_inner])           # prepend W_0 = 0\n",
    "\n",
    "print(\"one sample path W:\", np.round(W, 4))\n",
    "print(\"\\ncheck ||L L^T - K_inner|| =\", float(np.max(np.abs(L_inner @ L_inner.T - K_inner))))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "e6159be1",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:21:44.355150Z",
     "iopub.status.busy": "2026-08-02T16:21:44.354835Z",
     "iopub.status.idle": "2026-08-02T16:21:44.598087Z",
     "shell.execute_reply": "2026-08-02T16:21:44.597140Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "max |Cov_chol - K|  = 0.0091\n",
      "max |Cov_inc  - K|  = 0.008\n",
      "Var(W_T) [Cholesky] = 0.9909  (truth T = 1.0 )\n",
      "Var(W_T) [increments]= 0.9919\n"
     ]
    }
   ],
   "source": [
    "# Sample Brownian paths BOTH ways and confirm they share the same joint law.\n",
    "T, n_steps, n_paths = 1.0, 200, 30_000\n",
    "t, X_chol = processes.brownian_motion_cholesky(T, n_steps, n_paths, rng=RNG)\n",
    "t, X_inc  = processes.brownian_motion(T, n_steps, n_paths, rng=RNG)\n",
    "\n",
    "# Estimated covariance vs the kernel min(s,t), at a few time points.\n",
    "idx = [50, 100, 200]\n",
    "truth = np.minimum(t[idx, None], t[None, idx])\n",
    "cov_chol = np.cov(X_chol[:, idx].T)\n",
    "cov_inc  = np.cov(X_inc[:, idx].T)\n",
    "\n",
    "print(\"max |Cov_chol - K|  =\", round(float(np.max(np.abs(cov_chol - truth))), 4))\n",
    "print(\"max |Cov_inc  - K|  =\", round(float(np.max(np.abs(cov_inc  - truth))), 4))\n",
    "print(\"Var(W_T) [Cholesky] =\", round(float(X_chol[:, -1].var()), 4), \" (truth T =\", T, \")\")\n",
    "print(\"Var(W_T) [increments]=\", round(float(X_inc[:, -1].var()), 4))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "id": "31ced021",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:21:44.601000Z",
     "iopub.status.busy": "2026-08-02T16:21:44.600778Z",
     "iopub.status.idle": "2026-08-02T16:21:45.036377Z",
     "shell.execute_reply": "2026-08-02T16:21:45.035266Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 365352 characters]",
      "text/plain": [
       "<Figure size 880x462 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# Plot a few paths from each sampler -- visually indistinguishable.\n",
    "fig, ax = plt.subplots(figsize=(8, 4.2))\n",
    "_, P1 = processes.brownian_motion_cholesky(T, n_steps, 20, rng=RNG)\n",
    "_, P2 = processes.brownian_motion(T, n_steps, 20, rng=RNG)\n",
    "plotting.plot_paths(t, P1, ax=ax, alpha=0.6, color=\"C0\", label=\"Cholesky  $x = Lz$\")\n",
    "plotting.plot_paths(t, P2, ax=ax, alpha=0.6, color=\"C3\", label=\"increments $\\\\sum \\\\!\\\\sqrt{\\\\Delta t}\\\\,\\\\xi$\")\n",
    "ax.set_title(\"Two samplers, one process: standard Brownian motion\")\n",
    "ax.set_xlabel(\"time $t$\"); ax.legend(loc=\"upper left\"); plt.savefig(_FIG / \"samplers_comparison.png\", dpi=150, bbox_inches=\"tight\"); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7dd55ff0",
   "metadata": {},
   "source": [
    "### The $O(n)$ shortcut\n",
    "\n",
    "Cholesky is $O(n^3)$, which is painful for long paths. Brownian motion has a\n",
    "remarkable property that makes sampling $O(n)$: its **increments**\n",
    "$W_{t_{i+1}} - W_{t_i}$ over disjoint intervals are **independent** Gaussians\n",
    "of variance $\\Delta t$. So we may simply cumsum i.i.d.\n",
    "$\\mathcal N(0, \\Delta t)$ increments:\n",
    "\n",
    "$$W_{t_k} \\;=\\; \\sum_{i=0}^{k-1} \\sqrt{\\Delta t}\\, z_i, \\qquad z_i \\stackrel{iid}{\\sim} \\mathcal N(0,1).$$\n",
    "\n",
    "This is what `processes.brownian_motion` does, and we just verified it has\n",
    "the *same* joint law as the Cholesky sampler. Use Cholesky to **understand**,\n",
    "use increments to **compute**."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "id": "58b515a5",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:21:45.038868Z",
     "iopub.status.busy": "2026-08-02T16:21:45.038631Z",
     "iopub.status.idle": "2026-08-02T16:21:45.781150Z",
     "shell.execute_reply": "2026-08-02T16:21:45.779925Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Sampling 200 paths of length 4000:\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  Cholesky  O(n^3):  0.715 s\n",
      "  Increments O(n):  0.022 s   (speedup x32)\n"
     ]
    }
   ],
   "source": [
    "import time\n",
    "n_big = 4_000\n",
    "print(f\"Sampling 200 paths of length {n_big}:\")\n",
    "t0 = time.time(); processes.brownian_motion_cholesky(T, n_big, 200, rng=RNG); t_chol = time.time()-t0\n",
    "t0 = time.time(); processes.brownian_motion(T, n_big, 200, rng=RNG);        t_inc  = time.time()-t0\n",
    "print(f\"  Cholesky  O(n^3): {t_chol:6.3f} s\")\n",
    "print(f\"  Increments O(n): {t_inc:6.3f} s   (speedup x{t_chol/t_inc:,.0f})\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1937ac02",
   "metadata": {},
   "source": [
    "## 5. The $\\sqrt{T}$ scaling law and finance conventions\n",
    "\n",
    "Volatility $\\sigma$ is quoted **annualised**: it is the standard deviation of\n",
    "the log-return over one year. Brownian scaling then fixes every other horizon:\n",
    "\n",
    "$$\\operatorname{Std}\\!\\big[X_T\\big] \\;=\\; \\sigma\\sqrt{T}, \\qquad T \\text{ in years.}$$\n",
    "\n",
    "**What is \"a year\"?** Markets close overnight and at weekends, so quants use\n",
    "**trading time**: a US trading year has $252$ days, each $6.5$ hours ($390$\n",
    "minutes), giving $98{,}280$ trading minutes. A 5-minute bar is therefore\n",
    "\n",
    "$$T_{5\\text{min}} \\;=\\; \\frac{5}{98{,}280} \\;\\approx\\; 5.1\\times10^{-5} \\text{ years.}$$\n",
    "\n",
    "This is encoded in `config` so every notebook agrees on the convention."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "id": "24fdbb28",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:21:45.783344Z",
     "iopub.status.busy": "2026-08-02T16:21:45.783134Z",
     "iopub.status.idle": "2026-08-02T16:21:45.787418Z",
     "shell.execute_reply": "2026-08-02T16:21:45.786750Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "trading minutes per day  : 390.0\n",
      "trading minutes per year  : 98280.0\n",
      "5-minute window (years)   : 5.088e-05\n",
      "log-barrier ln(0.9)       : -0.10536\n",
      "\n",
      "annual vol 10%: 5-min std = 0.00071  (0.071% of price)\n",
      "annual vol 20%: 5-min std = 0.00143  (0.143% of price)\n",
      "annual vol 30%: 5-min std = 0.00214  (0.214% of price)\n"
     ]
    }
   ],
   "source": [
    "print(\"trading minutes per day  :\", config.TRADING_MINUTES_PER_DAY)\n",
    "print(\"trading minutes per year  :\", config.TRADING_MINUTES_PER_YEAR)\n",
    "print(\"5-minute window (years)   :\", f\"{config.WINDOW_YEARS:.3e}\")\n",
    "print(\"log-barrier ln(0.9)       :\", round(config.LOG_BARRIER, 5))\n",
    "print()\n",
    "for ann_vol in (0.10, 0.20, 0.30):\n",
    "    s = config.horizon_vol(ann_vol, config.WINDOW_MINUTES)\n",
    "    print(f\"annual vol {ann_vol:.0%}: 5-min std = {s:.5f}  \"\n",
    "          f\"({s*100:.3f}% of price)\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "795f4e9c",
   "metadata": {},
   "source": [
    "### Reality check on live data\n",
    "\n",
    "Pull one week of **SPY** (S&P 500 ETF) at both 1-minute and 5-minute bars.\n",
    "Estimate the standard deviation of the log-returns at each scale, and check\n",
    "the ratio is close to $\\sqrt{5} \\approx 2.236$. This is the $\\sqrt{T}$ law\n",
    "showing up in real market data."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 10,
   "id": "862db4a7",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-02T16:21:45.790126Z",
     "iopub.status.busy": "2026-08-02T16:21:45.789914Z",
     "iopub.status.idle": "2026-08-02T16:21:46.351592Z",
     "shell.execute_reply": "2026-08-02T16:21:46.350384Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "SPY 1-min log-returns: std = 0.000395  -> annualised = 0.124\n",
      "SPY 5-min log-returns: std = 0.000799  -> annualised = 0.112\n",
      "\n",
      "ratio std(5m)/std(1m) = 2.022   (theory sqrt(5) = 2.236)\n"
     ]
    },
    {
     "data": {
      "image/png": "[encoded data, 47004 characters]",
      "text/plain": [
       "<Figure size 880x418 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "try:\n",
    "    spy_1m = data.fetch_intraday(\"SPY\", interval=\"1m\", range_=\"5d\")\n",
    "    spy_5m = data.fetch_intraday(\"SPY\", interval=\"5m\", range_=\"5d\")\n",
    "    have_data = True\n",
    "except Exception as exc:\n",
    "    print(\"Live fetch failed (%s); falling back to synthetic GBM.\" % type(exc).__name__)\n",
    "    have_data = False\n",
    "\n",
    "if have_data:\n",
    "    r1 = data.log_returns(spy_1m[\"close\"])\n",
    "    r5 = data.log_returns(spy_5m[\"close\"])\n",
    "    s1, s5 = float(r1.std()), float(r5.std())\n",
    "    bars_per_year = config.TRADING_MINUTES_PER_YEAR // 1\n",
    "    print(f\"SPY 1-min log-returns: std = {s1:.6f}  -> annualised = {s1*np.sqrt(252*390):.3f}\")\n",
    "    print(f\"SPY 5-min log-returns: std = {s5:.6f}  -> annualised = {s5*np.sqrt(252* 78):.3f}\")\n",
    "    print(f\"\\nratio std(5m)/std(1m) = {s5/s1:.3f}   (theory sqrt(5) = {np.sqrt(5):.3f})\")\n",
    "\n",
    "    fig, ax = plt.subplots(figsize=(8, 3.8))\n",
    "    r5.hist(bins=60, density=True, alpha=0.6, ax=ax, label=\"5-min log-returns\")\n",
    "    grid = np.linspace(r5.min(), r5.max(), 200)\n",
    "    from scipy.stats import norm\n",
    "    ax.plot(grid, norm.pdf(grid, r5.mean(), r5.std()), \"k-\", lw=2, label=\"fitted Gaussian\")\n",
    "    ax.set_title(\"SPY 5-minute log-returns vs a Gaussian\")\n",
    "    ax.set_xlabel(\"log-return\"); ax.legend(); plt.savefig(_FIG / \"spy_1m_paths.png\", dpi=150, bbox_inches=\"tight\"); plt.show()\n",
    "else:\n",
    "    print(\"Skipping the empirical plot (no live data).\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cef9e3dd",
   "metadata": {},
   "source": [
    "> **Note.** The empirical ratio came out a touch *below* $\\sqrt{5}$.\n",
    "> That is the signature of **microstructure noise**: bid-ask bounce and\n",
    "> discrete tick sizes inject extra variance at the 1-minute scale, so\n",
    "> $\\operatorname{Var}(\\text{1-min return}) > \\sigma^2\\,\\Delta t_{1\\text{min}}$.\n",
    "> The $\\sqrt{T}$ law is cleanest at 5-minutes and above. Keep this in mind when\n",
    "> you read the \"10% in 5 minutes is a $30\\sigma$ event\" calculation in\n",
    "> Notebook 03: the *true* diffusive vol is even smaller than the 1-minute\n",
    "> sample suggests, which makes the 10% drop even more implausible under pure\n",
    "> Brownian motion."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d3824394",
   "metadata": {},
   "source": [
    "## 6. Summary\n",
    "\n",
    "* A stock's **log-price** is a sum of i.i.d. returns — a random walk.\n",
    "* By Donsker's theorem the scaling limit is **Brownian motion** $W_t$, the\n",
    "  Gaussian process with kernel $K(s,t) = \\min(s,t)$.\n",
    "* We can sample it via **Cholesky** $x = Lz$ (insight) or via independent\n",
    "  **increments** (speed) — same joint law.\n",
    "* Volatility scales as $\\sigma\\sqrt{T}$; a 5-minute window is a tiny fraction\n",
    "  of a trading year.\n",
    "\n",
    "We still need two things before we can answer the question:\n",
    "**(a)** a model for the *price* $S_t$ (not just the log), and the calculus for\n",
    "manipulating it — **Itô's lemma** (Notebook 02); and **(b)** the probability\n",
    "that the process *touches* a barrier within a window — the **first-passage\n",
    "time** (Notebook 03).\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
}
