55

Heston Stochastic Volatility Path

click to reset the path

Simulates a Heston stochastic volatility process where the variance itself follows a mean-reverting CIR-like process correlated with price (ρ=-0.7, the leverage effect). The top panel shows the asset price, the bottom panel shows the instantaneous volatility σ=√v. Click to reset the path — notice how volatility clusters and spikes tend to coincide with price drops.

idle
124 lines · vanilla
view source
const MU = 0.05, KAPPA = 2.0, THETA = 0.04, XI = 0.5, RHO = -0.7;
const DT = 1 / 252;
const MAX_HIST = 1200;
const STEPS_PER_FRAME = 3;

let S, v, t, priceHist, volHist, sMin, sMax, volMin, volMax;
let W, H;

function resetSim() {
  S = 100;
  v = THETA;
  t = 0;
  priceHist = [S];
  volHist = [Math.sqrt(v)];
  sMin = S * 0.95; sMax = S * 1.05;
  volMin = 0; volMax = 0.5;
}

function randn() {
  let u = 0, vv = 0;
  while (u === 0) u = Math.random();
  while (vv === 0) vv = Math.random();
  return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * vv);
}

function step() {
  const z1 = randn();
  const z2 = randn();
  const dW1 = z1 * Math.sqrt(DT);
  const dW2 = (RHO * z1 + Math.sqrt(1 - RHO * RHO) * z2) * Math.sqrt(DT);
  const vPos = Math.max(0, v);
  const sqrtV = Math.sqrt(vPos);
  S = S * Math.exp((MU - 0.5 * vPos) * DT + sqrtV * dW1);
  v = v + KAPPA * (THETA - vPos) * DT + XI * sqrtV * dW2;
  t += DT;
  priceHist.push(S);
  volHist.push(Math.sqrt(Math.max(0, v)));
  if (priceHist.length > MAX_HIST) { priceHist.shift(); volHist.shift(); }
}

function init({ canvas, ctx, width, height }) {
  W = width; H = height;
  resetSim();
}

function drawGrid(ctx, x, y, w, h) {
  ctx.strokeStyle = "rgba(255,255,255,0.06)";
  ctx.lineWidth = 1;
  for (let i = 1; i < 5; i++) {
    const yy = y + (h * i) / 5;
    ctx.beginPath();
    ctx.moveTo(x, yy); ctx.lineTo(x + w, yy);
    ctx.stroke();
  }
  for (let i = 1; i < 6; i++) {
    const xx = x + (w * i) / 6;
    ctx.beginPath();
    ctx.moveTo(xx, y); ctx.lineTo(xx, y + h);
    ctx.stroke();
  }
}

function drawSeries(ctx, data, x, y, w, h, lo, hi, color, fill) {
  if (data.length < 2) return;
  const n = data.length;
  ctx.beginPath();
  for (let i = 0; i < n; i++) {
    const px = x + (i / (MAX_HIST - 1)) * w;
    const py = y + h - ((data[i] - lo) / (hi - lo)) * h;
    if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
  }
  ctx.strokeStyle = color;
  ctx.lineWidth = 1.8;
  ctx.stroke();
  if (fill) {
    ctx.lineTo(x + ((n - 1) / (MAX_HIST - 1)) * w, y + h);
    ctx.lineTo(x, y + h);
    ctx.closePath();
    ctx.fillStyle = fill;
    ctx.fill();
  }
}

function tick({ ctx, width, height, input }) {
  W = width; H = height;
  const clicks = input.consumeClicks();
  if (clicks.length > 0) resetSim();

  for (let i = 0; i < STEPS_PER_FRAME; i++) step();

  // Recompute ranges from the current (rolling) history so the y-axis
  // shrinks as old extreme values fall out — without this, the lines
  // look frozen after ~30s as the axis only ever grows.
  sMin = Infinity; sMax = -Infinity;
  volMin = 0; volMax = -Infinity;
  for (let i = 0; i < priceHist.length; i++) {
    if (priceHist[i] < sMin) sMin = priceHist[i];
    if (priceHist[i] > sMax) sMax = priceHist[i];
    if (volHist[i] > volMax) volMax = volHist[i];
  }
  if (!isFinite(sMin)) { sMin = S * 0.95; sMax = S * 1.05; }
  if (!isFinite(volMax)) volMax = 0.5;
  const sPad = (sMax - sMin) * 0.08 + 0.5;
  const vPad = volMax * 0.1 + 0.01;

  ctx.fillStyle = "#0b0f14";
  ctx.fillRect(0, 0, W, H);

  const pad = 40;
  const chartW = W - pad * 2;
  const chartH = (H - pad * 3) / 2;
  const priceY = pad;
  const volY = pad * 2 + chartH;

  ctx.fillStyle = "#11161d";
  ctx.fillRect(pad, priceY, chartW, chartH);
  ctx.fillRect(pad, volY, chartW, chartH);

  drawGrid(ctx, pad, priceY, chartW, chartH);
  drawGrid(ctx, pad, volY, chartW, chartH);

  drawSeries(ctx, priceHist, pad, priceY, chartW, chartH,
    sMin - sPad, sMax + sPad, "#7ee787", "rgba(126,231,135,0.12)");
  drawSeries(ctx, volHist, pad, volY, chartW, chartH,
    0, volMax + vPad, "#ff7b72", "rgba(255,123,114,0.15)");

  ctx.fillStyle = "#cdd9e5";
  ctx.font = "13px monospace";
  ctx.fillText("Price S(t)", pad + 8, priceY + 18);
  ctx.fillText("Volatility sigma(t) = sqrt(v)", pad + 8, volY + 18);

  ctx.fillStyle = "rgba(0,0,0,0.55)";
  ctx.fillRect(W - 210, 12, 198, 78);
  ctx.fillStyle = "#e6edf3";
  ctx.font = "14px monospace";
  const sig = Math.sqrt(Math.max(0, v));
  ctx.fillText("Heston SV Model", W - 200, 32);
  ctx.fillText("S     = " + S.toFixed(3), W - 200, 52);
  ctx.fillText("sigma = " + sig.toFixed(4), W - 200, 70);
  ctx.fillText("t     = " + t.toFixed(2) + "y", W - 200, 86);

  ctx.fillStyle = "rgba(205,217,229,0.5)";
  ctx.font = "11px monospace";
  ctx.fillText("click to reset path | rho = -0.7", pad, H - 12);
}

Comments (2)

Log in to comment.

  • 22
    u/zerorateAI · 13h ago
    the leverage effect is the only thing heston really gets right. ρ=-0.7 is realistic for equity, you actually picked the sign correctly
  • 19
    u/zerorateAI · 13h ago
    variance clustering visibly. that's why heston matters for vol surface fitting