16

Kelly Criterion: How Much to Bet

drag Y for bet fraction · click to redraw

You have an edge: a biased coin pays when you win and loses your stake when you lose, with win probability . How much of your bankroll should you bet on each flip? Bet too little and you leave money on the table. Bet too much and the *geometric* drag of losses ruins you, even though the *arithmetic* expectation is strongly positive. The Kelly criterion answers this by maximizing — the long-run growth rate of log-wealth — giving the optimal fraction , where is the win odds and . For this coin, . This sandbox simulates equity paths starting at 1 unit over bets, each betting the same fraction of current wealth every round. Faint cyan lines are the individual paths, the shaded band is the percentile fan, and the solid white line is the median path. **Drag your cursor up or down** to scrub : at all paths are flat; near the median climbs steeply on a log scale, with the fan wide but the floor mostly above ruin; at a single loss wipes you out, so most paths sink to zero while a lucky few moonshot; and for — the *over-betting* regime — the geometric growth rate flips negative and almost every path goes to ruin despite the favorable expectation. The live log-growth rate and the ruin probability (paths under 0.01) update as you drag. Click anywhere to draw a fresh set of coin flips and see how much of the spread is just luck.

idle
234 lines · vanilla
view source
// Kelly Criterion sandbox.
// Coin pays 2:1 on win, p = 0.55. Kelly f* = (b*p - q)/b = (2*0.55 - 0.45)/2 = 0.325.
// User scrubs mouseY to set bet fraction f in [0, 1]. Click to redraw outcomes.

const N_PATHS = 100;
const N_BETS = 500;
const P_WIN = 0.55;
const B_PAYOUT = 2.0;
const F_STAR = (B_PAYOUT * P_WIN - (1 - P_WIN)) / B_PAYOUT; // 0.325
const W0 = 1.0;
const RUIN_THRESH = 0.01;

// outcomes[path][bet] = +1 win, -1 loss. Drawn once; rescaled per frame by f.
let outcomes = null;
let seedTag = 0;

// per-frame buffers
let paths = null; // Float32Array [N_PATHS * (N_BETS+1)] of log-wealth
let medArr = null;
let p05Arr = null;
let p95Arr = null;
let lastF = -1;
let lastSeedTag = -1;
let liveStats = { logGrowth: 0, ruinPct: 0, finalMedian: 1 };

function rebuildOutcomes() {
  outcomes = [];
  for (let i = 0; i < N_PATHS; i++) {
    const row = new Int8Array(N_BETS);
    for (let j = 0; j < N_BETS; j++) {
      row[j] = Math.random() < P_WIN ? 1 : -1;
    }
    outcomes.push(row);
  }
  seedTag++;
}

function recomputePaths(f) {
  // log(W_t) = log(W_{t-1}) + log(1 + f*b)  on win
  //                          + log(1 - f)   on loss
  // When f=1 and loss: log(0) = -Infinity. Clamp to log(1e-300) sentinel for "ruined".
  const RUIN_LOG = Math.log(1e-300);
  const logWin  = Math.log(1 + f * B_PAYOUT);
  const logLoss = f >= 1 ? RUIN_LOG : Math.log(1 - f);

  if (!paths) paths = new Float32Array(N_PATHS * (N_BETS + 1));
  for (let i = 0; i < N_PATHS; i++) {
    const base = i * (N_BETS + 1);
    paths[base] = 0; // log(W0) = 0
    const row = outcomes[i];
    let lw = 0;
    let dead = false;
    for (let j = 0; j < N_BETS; j++) {
      if (!dead) {
        lw += row[j] === 1 ? logWin : logLoss;
        if (lw <= RUIN_LOG + 1) { dead = true; lw = RUIN_LOG; }
      }
      paths[base + j + 1] = lw;
    }
  }

  // For each bet step, sort to get percentiles. Sample-stride for perf.
  const STRIDE = 4;
  const nKey = Math.floor((N_BETS + 1) / STRIDE) + 1;
  if (!medArr || medArr.length !== nKey) {
    medArr = new Float32Array(nKey);
    p05Arr = new Float32Array(nKey);
    p95Arr = new Float32Array(nKey);
  }
  const col = new Float32Array(N_PATHS);
  let k = 0;
  for (let j = 0; j <= N_BETS; j += STRIDE) {
    for (let i = 0; i < N_PATHS; i++) col[i] = paths[i * (N_BETS + 1) + j];
    // Float32Array.sort is numeric ascending by default
    col.sort();
    p05Arr[k] = col[Math.floor(0.05 * (N_PATHS - 1))];
    medArr[k] = col[Math.floor(0.50 * (N_PATHS - 1))];
    p95Arr[k] = col[Math.floor(0.95 * (N_PATHS - 1))];
    k++;
  }
  // pad last cell if loop didn't land exactly on N_BETS
  while (k < nKey) { p05Arr[k] = p05Arr[k-1]; medArr[k] = medArr[k-1]; p95Arr[k] = p95Arr[k-1]; k++; }

  // Stats: log-growth rate = mean of final log wealth / N_BETS
  // Ruin = fraction of paths whose final wealth < RUIN_THRESH (i.e. log < log(RUIN_THRESH))
  let sumLog = 0;
  let ruin = 0;
  const ruinLogThresh = Math.log(RUIN_THRESH);
  for (let i = 0; i < N_PATHS; i++) {
    const lw = paths[i * (N_BETS + 1) + N_BETS];
    sumLog += lw;
    if (lw < ruinLogThresh) ruin++;
  }
  liveStats.logGrowth = sumLog / N_PATHS / N_BETS;
  liveStats.ruinPct = 100 * ruin / N_PATHS;
  liveStats.finalMedian = Math.exp(medArr[nKey - 1]);
}

function init() {
  rebuildOutcomes();
}

// Log-wealth display range (in nats). Capped for stable axes.
// log(1) = 0, log(1e6) ≈ 13.8, log(1e-6) ≈ -13.8
const LOG_MIN = -13.8;
const LOG_MAX = 13.8;

function logToY(lw, py, ph) {
  const t = (lw - LOG_MIN) / (LOG_MAX - LOG_MIN);
  return py + ph * (1 - Math.max(0, Math.min(1, t)));
}

function yToLog(y, py, ph) {
  const t = 1 - (y - py) / ph;
  return LOG_MIN + (LOG_MAX - LOG_MIN) * t;
}

function fmtMoney(w) {
  if (!isFinite(w)) return '∞';
  if (w >= 1e9) return '$' + (w/1e9).toFixed(1) + 'B';
  if (w >= 1e6) return '$' + (w/1e6).toFixed(1) + 'M';
  if (w >= 1e3) return '$' + (w/1e3).toFixed(1) + 'k';
  if (w >= 1)   return '$' + w.toFixed(2);
  if (w >= 1e-3) return '$' + w.toFixed(3);
  if (w >= 1e-6) return '$' + w.toExponential(1);
  return '~$0';
}

function tick({ ctx, width, height, input }) {
  // Redraw outcomes on click.
  const clicks = input.consumeClicks();
  if (clicks.length) rebuildOutcomes();

  // mouseY scrubs f.
  const my = (typeof input.mouseY === 'number' && input.mouseY >= 0)
    ? input.mouseY : height * (1 - F_STAR);
  const fRaw = 1 - my / height;
  const f = Math.max(0, Math.min(1, fRaw));

  if (f !== lastF || seedTag !== lastSeedTag) {
    recomputePaths(f);
    lastF = f;
    lastSeedTag = seedTag;
  }

  // Background.
  ctx.fillStyle = '#05070b';
  ctx.fillRect(0, 0, width, height);

  // Plot area.
  const padL = 56, padR = 14, padT = 44, padB = 28;
  const px = padL, py = padT, pw = width - padL - padR, ph = height - padT - padB;

  // Plot bg.
  ctx.fillStyle = '#08101a';
  ctx.fillRect(px, py, pw, ph);
  ctx.strokeStyle = '#152033';
  ctx.lineWidth = 1;
  ctx.strokeRect(px + 0.5, py + 0.5, pw - 1, ph - 1);

  // Horizontal gridlines at decades.
  ctx.font = '10px monospace';
  ctx.textAlign = 'right';
  ctx.fillStyle = '#3a4458';
  for (let dec = -6; dec <= 6; dec++) {
    const lw = dec * Math.LN10;
    if (lw < LOG_MIN || lw > LOG_MAX) continue;
    const yy = logToY(lw, py, ph);
    ctx.strokeStyle = dec === 0 ? '#26405a' : '#142030';
    ctx.beginPath();
    ctx.moveTo(px, yy);
    ctx.lineTo(px + pw, yy);
    ctx.stroke();
    const label = dec === 0 ? '$1' :
      dec > 0 ? '$1e' + dec : '$1e' + dec;
    ctx.fillStyle = dec === 0 ? '#8fa3c0' : '#3a4458';
    ctx.fillText(label, px - 6, yy + 3);
  }

  // 5/95 fan as polygon.
  const STRIDE = 4;
  const nKey = p05Arr.length;
  ctx.fillStyle = 'rgba(95, 211, 255, 0.13)';
  ctx.beginPath();
  for (let k = 0; k < nKey; k++) {
    const j = Math.min(N_BETS, k * STRIDE);
    const cx = px + pw * j / N_BETS;
    const cy = logToY(p95Arr[k], py, ph);
    if (k === 0) ctx.moveTo(cx, cy); else ctx.lineTo(cx, cy);
  }
  for (let k = nKey - 1; k >= 0; k--) {
    const j = Math.min(N_BETS, k * STRIDE);
    const cx = px + pw * j / N_BETS;
    const cy = logToY(p05Arr[k], py, ph);
    ctx.lineTo(cx, cy);
  }
  ctx.closePath();
  ctx.fill();

  // Faint individual paths.
  ctx.strokeStyle = 'rgba(95, 211, 255, 0.22)';
  ctx.lineWidth = 1;
  for (let i = 0; i < N_PATHS; i++) {
    ctx.beginPath();
    for (let j = 0; j <= N_BETS; j += 2) {
      const lw = paths[i * (N_BETS + 1) + j];
      const cx = px + pw * j / N_BETS;
      const cy = logToY(lw, py, ph);
      if (j === 0) ctx.moveTo(cx, cy); else ctx.lineTo(cx, cy);
    }
    ctx.stroke();
  }

  // Median in solid white.
  ctx.strokeStyle = '#ffffff';
  ctx.lineWidth = 2;
  ctx.beginPath();
  for (let k = 0; k < nKey; k++) {
    const j = Math.min(N_BETS, k * STRIDE);
    const cx = px + pw * j / N_BETS;
    const cy = logToY(medArr[k], py, ph);
    if (k === 0) ctx.moveTo(cx, cy); else ctx.lineTo(cx, cy);
  }
  ctx.stroke();

  // 5/95 lines on top.
  ctx.strokeStyle = 'rgba(95, 211, 255, 0.75)';
  ctx.lineWidth = 1.2;
  for (const arr of [p05Arr, p95Arr]) {
    ctx.beginPath();
    for (let k = 0; k < nKey; k++) {
      const j = Math.min(N_BETS, k * STRIDE);
      const cx = px + pw * j / N_BETS;
      const cy = logToY(arr[k], py, ph);
      if (k === 0) ctx.moveTo(cx, cy); else ctx.lineTo(cx, cy);
    }
    ctx.stroke();
  }

  // X-axis labels.
  ctx.fillStyle = '#5a6478';
  ctx.font = '10px monospace';
  ctx.textAlign = 'center';
  for (let q = 0; q <= 4; q++) {
    const j = Math.round(N_BETS * q / 4);
    const cx = px + pw * q / 4;
    ctx.fillText(String(j), cx, py + ph + 14);
  }
  ctx.fillText('bets', px + pw / 2, py + ph + 24);

  // Right edge: vertical "f slider" indicator.
  const sliderX = width - 6;
  ctx.strokeStyle = '#1a2535';
  ctx.beginPath(); ctx.moveTo(sliderX, padT); ctx.lineTo(sliderX, height - padB); ctx.stroke();
  // f* marker
  const yStar = padT + (height - padT - padB) * (1 - F_STAR);
  ctx.strokeStyle = '#7cf08a';
  ctx.beginPath(); ctx.moveTo(sliderX - 4, yStar); ctx.lineTo(sliderX + 2, yStar); ctx.stroke();
  // current f marker
  const yF = padT + (height - padT - padB) * (1 - f);
  ctx.fillStyle = '#ffcf66';
  ctx.beginPath(); ctx.arc(sliderX - 1, yF, 3, 0, Math.PI * 2); ctx.fill();

  // Header text.
  ctx.textAlign = 'left';
  ctx.fillStyle = '#e8ecf4';
  ctx.font = 'bold 13px monospace';
  ctx.fillText('Kelly Criterion', 10, 18);
  ctx.fillStyle = '#8fa3c0';
  ctx.font = '11px monospace';
  ctx.fillText('coin pays 2:1, p=0.55  ·  f* = 0.325', 10, 34);

  // Right-aligned live stats.
  ctx.textAlign = 'right';
  const fColor = Math.abs(f - F_STAR) < 0.02 ? '#7cf08a'
              : f > 2 * F_STAR ? '#ff7a6a'
              : '#ffcf66';
  ctx.fillStyle = fColor;
  ctx.font = 'bold 13px monospace';
  ctx.fillText('f = ' + f.toFixed(3), width - 14, 18);
  ctx.font = '11px monospace';
  ctx.fillStyle = '#8fa3c0';
  const growthPct = (Math.exp(liveStats.logGrowth) - 1) * 100;
  ctx.fillText(
    'log-growth/bet: ' + liveStats.logGrowth.toFixed(4)
    + ' (' + (growthPct >= 0 ? '+' : '') + growthPct.toFixed(2) + '%)',
    width - 14, 34
  );
  const ruinColor = liveStats.ruinPct > 30 ? '#ff7a6a'
                  : liveStats.ruinPct > 5 ? '#ffcf66' : '#8fa3c0';
  ctx.fillStyle = ruinColor;
  ctx.fillText('ruin: ' + liveStats.ruinPct.toFixed(0) + '%  ·  median end: ' + fmtMoney(liveStats.finalMedian), width - 14, 48);
}

Comments (0)

Log in to comment.