{
  "cells": [
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "# 📘 Fincept Notebook — Monte Carlo Price Simulation\n",
        "\n",
        "**Quant · Intermediate · ~22 min · pandas + numpy**\n",
        "\n",
        "Geometric Brownian Motion (GBM) is the workhorse model for simulating asset prices — it's the same diffusion that sits underneath the Black-Scholes formula. A price evolves with a deterministic **drift** (the trend) and a random **diffusion** (the noise), and the magic of Monte Carlo is that by simulating thousands of independent paths we can read off the *entire distribution* of where the price might land — not just a point forecast. From that distribution we extract percentile cones, Value at Risk, and shortfall probabilities.\n",
        "\n",
        "**What you'll learn**\n",
        "- The GBM equation and how drift vs diffusion shape a price path\n",
        "- How to simulate thousands of daily-stepped paths with numpy and cumulate log returns\n",
        "- How to read percentile cones, 95% Value at Risk, and the probability of ending below the start\n",
        "\n",
        "> **Requires** `pandas` and `numpy` (bundled with Fincept Terminal's Python environment).\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## 1. The model and parameters\n",
        "\n",
        "Under GBM the log return over a small time step `dt` is normally distributed:\n",
        "\n",
        "$$\\Delta \\ln S \\sim \\mathcal{N}\\big((\\mu - \\tfrac{1}{2}\\sigma^2)\\,dt,\\;\\; \\sigma^2\\,dt\\big)$$\n",
        "\n",
        "- **μ** — annual drift (expected continuously-compounded return).\n",
        "- **σ** — annual volatility (diffusion).\n",
        "- The `−½σ²` term is the **Itô correction**: because of Jensen's inequality, the *expected log price* drifts slower than μ even though the *expected price* grows at μ.\n",
        "- We step daily with `dt = 1/252` (252 trading days/year) over a 1-year horizon.\n",
        "\n",
        "A single path is built by cumulating these log-return increments and exponentiating.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "fincept_title": "Parameters & One Path"
      },
      "outputs": [],
      "source": [
        "try:\n",
        "    import numpy as np\n",
        "    import pandas as pd\n",
        "except ImportError:\n",
        "    raise SystemExit(\"Fincept Notebook needs pandas + numpy — install with: pip install pandas numpy\")\n",
        "\n",
        "np.random.seed(7)\n",
        "\n",
        "S0      = 100.0    # starting price\n",
        "MU      = 0.08     # annual drift\n",
        "SIGMA   = 0.20     # annual volatility\n",
        "DAYS    = 252      # 1-year horizon in trading days\n",
        "dt      = 1.0 / 252\n",
        "\n",
        "# Per-step log-return distribution\n",
        "mean_step = (MU - 0.5 * SIGMA**2) * dt\n",
        "std_step  = SIGMA * np.sqrt(dt)\n",
        "\n",
        "# Build ONE example path\n",
        "z = np.random.normal(size=DAYS)\n",
        "log_rets = mean_step + std_step * z\n",
        "path = S0 * np.exp(np.cumsum(log_rets))\n",
        "path = np.insert(path, 0, S0)   # prepend t=0\n",
        "\n",
        "print(\"GBM parameters\")\n",
        "print(f\"  S0={S0}  mu={MU}  sigma={SIGMA}  horizon={DAYS} days  dt={dt:.6f}\")\n",
        "print(f\"  per-step log-return mean = {mean_step:.6f}, std = {std_step:.6f}\")\n",
        "print()\n",
        "snap = pd.DataFrame({\"day\": [0, 21, 63, 126, 189, 252],\n",
        "                     \"price\": [path[i] for i in [0, 21, 63, 126, 189, 252]]})\n",
        "print(\"One simulated path (snapshots)\")\n",
        "print(snap.round(4).to_string(index=False))\n",
        "print()\n",
        "print(f\"  Terminal price of this single path: {path[-1]:.4f}\")"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## 2. Simulate 10,000 paths\n",
        "\n",
        "One path tells us almost nothing — it's a single draw from an infinite set of futures. We now simulate **10,000 independent paths** at once. The trick is to generate a `(n_paths × DAYS)` matrix of standard normals, scale into log returns, cumulate *along the time axis*, and exponentiate. Vectorizing with numpy keeps this well under a second. Each row is one possible future; each column is a point in time.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "fincept_title": "Simulate Paths"
      },
      "outputs": [],
      "source": [
        "try:\n",
        "    import numpy as np\n",
        "    import pandas as pd\n",
        "except ImportError:\n",
        "    raise SystemExit(\"Fincept Notebook needs pandas + numpy — install with: pip install pandas numpy\")\n",
        "\n",
        "np.random.seed(7)\n",
        "\n",
        "S0, MU, SIGMA, DAYS = 100.0, 0.08, 0.20, 252\n",
        "dt = 1.0 / 252\n",
        "N_PATHS = 10000\n",
        "\n",
        "mean_step = (MU - 0.5 * SIGMA**2) * dt\n",
        "std_step  = SIGMA * np.sqrt(dt)\n",
        "\n",
        "# (N_PATHS x DAYS) matrix of log-return increments\n",
        "Z = np.random.normal(size=(N_PATHS, DAYS))\n",
        "log_rets = mean_step + std_step * Z\n",
        "log_price = np.cumsum(log_rets, axis=1)          # cumulate over time\n",
        "paths = S0 * np.exp(log_price)                    # (N_PATHS x DAYS)\n",
        "paths = np.hstack([np.full((N_PATHS, 1), S0), paths])  # prepend t=0 column\n",
        "\n",
        "terminal = paths[:, -1]\n",
        "\n",
        "print(f\"Simulated {N_PATHS:,} GBM paths over {DAYS} days (seed=7)\")\n",
        "print(f\"  paths matrix shape: {paths.shape}\")\n",
        "print()\n",
        "# Theory check: E[S_T] = S0 * exp(mu*T) under GBM\n",
        "expected_ST = S0 * np.exp(MU * 1.0)\n",
        "print(f\"  Theoretical E[S_T] = S0*exp(mu*T) = {expected_ST:.4f}\")\n",
        "print(f\"  Simulated  mean S_T               = {terminal.mean():.4f}\")\n",
        "print(f\"  Simulated  median S_T             = {np.median(terminal):.4f}\")\n",
        "print()\n",
        "print(\"  (mean > median because the lognormal terminal distribution is right-skewed)\")"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## 3. The terminal price distribution\n",
        "\n",
        "Because log returns are normal, the terminal price `S_T` is **lognormal** — bounded below by zero, with a long right tail. We summarize it with percentiles rather than just mean ± std, since the distribution is asymmetric. The 5th and 95th percentiles bracket a 90% confidence range for where the price lands after one year.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "fincept_title": "Terminal Distribution"
      },
      "outputs": [],
      "source": [
        "try:\n",
        "    import numpy as np\n",
        "    import pandas as pd\n",
        "except ImportError:\n",
        "    raise SystemExit(\"Fincept Notebook needs pandas + numpy — install with: pip install pandas numpy\")\n",
        "\n",
        "np.random.seed(7)\n",
        "\n",
        "S0, MU, SIGMA, DAYS = 100.0, 0.08, 0.20, 252\n",
        "dt = 1.0 / 252\n",
        "N_PATHS = 10000\n",
        "mean_step = (MU - 0.5 * SIGMA**2) * dt\n",
        "std_step  = SIGMA * np.sqrt(dt)\n",
        "\n",
        "Z = np.random.normal(size=(N_PATHS, DAYS))\n",
        "log_price = np.cumsum(mean_step + std_step * Z, axis=1)\n",
        "terminal = S0 * np.exp(log_price[:, -1])\n",
        "\n",
        "pcts = [5, 25, 50, 75, 95]\n",
        "vals = np.percentile(terminal, pcts)\n",
        "\n",
        "dist = pd.DataFrame({\n",
        "    \"statistic\": [\"mean\", \"std\"] + [f\"p{p}\" for p in pcts],\n",
        "    \"price\":     [terminal.mean(), terminal.std()] + list(vals),\n",
        "})\n",
        "dist[\"return_%\"] = (dist[\"price\"] / S0 - 1.0) * 100\n",
        "\n",
        "print(\"Terminal price (S_T) distribution after 1 year\")\n",
        "print(dist.round(4).to_string(index=False))\n",
        "print()\n",
        "print(f\"  90% of outcomes fall between {vals[0]:.2f} (p5) and {vals[-1]:.2f} (p95)\")\n",
        "print(f\"  That is a return range of {(vals[0]/S0-1)*100:.1f}% to {(vals[-1]/S0-1)*100:.1f}%\")"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## 4. The percentile cone over time\n",
        "\n",
        "Uncertainty doesn't arrive all at once — it **widens with the square root of time** (`σ√t`). If we slice the path matrix at several horizons and take percentiles at each, we get a *cone* that fans out from S0. This is the picture you see in fan charts: the median tracks the drift, while the p5/p95 envelope grows steadily wider the further out we look.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "fincept_title": "Percentile Cone"
      },
      "outputs": [],
      "source": [
        "try:\n",
        "    import numpy as np\n",
        "    import pandas as pd\n",
        "except ImportError:\n",
        "    raise SystemExit(\"Fincept Notebook needs pandas + numpy — install with: pip install pandas numpy\")\n",
        "\n",
        "np.random.seed(7)\n",
        "\n",
        "S0, MU, SIGMA, DAYS = 100.0, 0.08, 0.20, 252\n",
        "dt = 1.0 / 252\n",
        "N_PATHS = 10000\n",
        "mean_step = (MU - 0.5 * SIGMA**2) * dt\n",
        "std_step  = SIGMA * np.sqrt(dt)\n",
        "\n",
        "Z = np.random.normal(size=(N_PATHS, DAYS))\n",
        "log_price = np.cumsum(mean_step + std_step * Z, axis=1)\n",
        "paths = S0 * np.exp(log_price)\n",
        "paths = np.hstack([np.full((N_PATHS, 1), S0), paths])   # include t=0\n",
        "\n",
        "horizons = [0, 21, 63, 126, 189, 252]   # ~0, 1m, 3m, 6m, 9m, 12m\n",
        "rows = []\n",
        "for h in horizons:\n",
        "    col = paths[:, h]\n",
        "    rows.append({\n",
        "        \"day\": h,\n",
        "        \"p5\":  np.percentile(col, 5),\n",
        "        \"p25\": np.percentile(col, 25),\n",
        "        \"p50\": np.percentile(col, 50),\n",
        "        \"p75\": np.percentile(col, 75),\n",
        "        \"p95\": np.percentile(col, 95),\n",
        "    })\n",
        "cone = pd.DataFrame(rows)\n",
        "cone[\"p95_minus_p5\"] = cone[\"p95\"] - cone[\"p5\"]   # width of the cone\n",
        "\n",
        "print(\"Percentile cone of price over time\")\n",
        "print(cone.round(3).to_string(index=False))\n",
        "print()\n",
        "print(\"The (p95 - p5) width grows with horizon ~ sqrt(time): uncertainty compounds.\")"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## 5. Value at Risk and downside probability\n",
        "\n",
        "Two risk numbers fall straight out of the terminal distribution:\n",
        "\n",
        "- **1-year 95% Value at Risk (VaR):** the loss not exceeded with 95% confidence. We take the 5th percentile of the *return* distribution; VaR is the magnitude of that loss. \"There is a 5% chance of losing at least X.\"\n",
        "- **Probability of ending below S0:** simply the fraction of paths whose terminal price is under the starting price — the chance the year is a net loss.\n",
        "\n",
        "These are *historical-free* — they come entirely from the model and its parameters.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "fincept_title": "VaR & Shortfall"
      },
      "outputs": [],
      "source": [
        "try:\n",
        "    import numpy as np\n",
        "    import pandas as pd\n",
        "except ImportError:\n",
        "    raise SystemExit(\"Fincept Notebook needs pandas + numpy — install with: pip install pandas numpy\")\n",
        "\n",
        "np.random.seed(7)\n",
        "\n",
        "S0, MU, SIGMA, DAYS = 100.0, 0.08, 0.20, 252\n",
        "dt = 1.0 / 252\n",
        "N_PATHS = 10000\n",
        "mean_step = (MU - 0.5 * SIGMA**2) * dt\n",
        "std_step  = SIGMA * np.sqrt(dt)\n",
        "\n",
        "Z = np.random.normal(size=(N_PATHS, DAYS))\n",
        "log_price = np.cumsum(mean_step + std_step * Z, axis=1)\n",
        "terminal = S0 * np.exp(log_price[:, -1])\n",
        "\n",
        "ret = terminal / S0 - 1.0                 # 1-year simple return per path\n",
        "\n",
        "var95_ret = np.percentile(ret, 5)         # 5th percentile of returns\n",
        "var95_loss = -var95_ret                    # VaR as a positive loss number\n",
        "var95_dollars = S0 * var95_loss            # on a $S0 position\n",
        "\n",
        "# Conditional VaR (expected shortfall): average loss in the worst 5%\n",
        "cvar95 = -ret[ret <= var95_ret].mean()\n",
        "\n",
        "prob_below_S0 = float(np.mean(terminal < S0))\n",
        "\n",
        "risk = pd.DataFrame({\n",
        "    \"metric\": [\"95% VaR (return)\", \"95% VaR ($ on 100)\", \"95% CVaR / ES (return)\",\n",
        "               \"P(end < S0)\", \"mean 1y return\"],\n",
        "    \"value\":  [f\"{var95_loss*100:.2f}%\", f\"${var95_dollars:.2f}\",\n",
        "               f\"{cvar95*100:.2f}%\", f\"{prob_below_S0*100:.2f}%\",\n",
        "               f\"{ret.mean()*100:.2f}%\"],\n",
        "})\n",
        "print(\"1-year risk metrics from the Monte Carlo distribution\")\n",
        "print(risk.to_string(index=False))\n",
        "print()\n",
        "print(f\"Interpretation: with 95% confidence the 1y loss won't exceed {var95_loss*100:.1f}%;\")\n",
        "print(f\"in the worst 5% of years the average loss is {cvar95*100:.1f}%.\")"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## 6. Drift vs diffusion — and the limits of GBM\n",
        "\n",
        "The two forces in GBM pull in different ways. **Drift** (`μ`) is the deterministic engine — over long horizons it dominates and pushes the median upward. **Diffusion** (`σ`) is the random shock — it dominates short-horizon outcomes and is what creates the cone's width. Crucially, because shocks compound multiplicatively and volatility imposes the `−½σ²` drag, a high-vol asset can have a *median* return well below its drift even when its *mean* matches it.\n",
        "\n",
        "**Where GBM breaks down:** it assumes constant volatility, normally distributed returns, and no jumps. Real markets show fat tails, volatility clustering, and sudden gaps — so true VaR is usually worse than GBM suggests. Still, GBM is the right first model: it is analytically tractable, captures drift + diffusion cleanly, and is the baseline every more-realistic model (stochastic vol, jump-diffusion) is measured against.\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "---\n",
        "*— Fincept Notebook · part of Fincept Terminal. Edit any cell and press Ctrl+Enter to run.*\n"
      ]
    }
  ],
  "metadata": {
    "kernelspec": {
      "display_name": "Python 3",
      "language": "python",
      "name": "python3"
    },
    "language_info": {
      "name": "python"
    }
  },
  "nbformat": 4,
  "nbformat_minor": 5
}
