16
Kelly Criterion: How Much to Bet
drag Y for bet fraction · click to redraw
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.