library(plotly)
library(dplyr)
library(tidyr)
library(ggplot2)
library(knitr)
library(kableExtra)
library(scales)
library(htmltools)
library(sessioninfo)
# ── sandstone brand palette ─────────────────────────────────────────────────────────
plot_background <- "#FEFEFA"
plot_blacktext <- "#2C2C2C"
plot_greytext <- "#707073"
plot_bluetext <- "#000066"
plot_redtext <- "#800000"
plot_fill_beige <- "#FFE2C3"
plot_fill_blue <- "#447099"
plot_fill_brightblue <- "#0A0AFF"
plot_fill_crimson <- "#B94A48"
plot_fill_lightgrey <- "#E8E8E8"
plot_fill_lightblue <- "#BDD7E7"
plot_fill_red <- "#DC143C"
blue_shade_1 <- "#EFF3FF"; blue_shade_2 <- "#BDD7E7"
blue_shade_3 <- "#6BAED6"; blue_shade_4 <- "#2171B5"
green_shade_1 <- "#EDF8E9"; green_shade_2 <- "#BAE4B3"
green_shade_3 <- "#74C476"; green_shade_4 <- "#238B45"
grey_shade_1 <- "#F7F7F7"; grey_shade_2 <- "#CCCCCC"
grey_shade_3 <- "#969696"; grey_shade_4 <- "#525252"
red_shade_1 <- "#FEE5D9"; red_shade_2 <- "#FCAE91"
red_shade_3 <- "#FB6A4A"; red_shade_4 <- "#CB181D"
yellow_blue_shade_1 <- "#FFFFCC"; yellow_blue_shade_2 <- "#C7E9B4"
yellow_blue_shade_3 <- "#7FCDBB"; yellow_blue_shade_4 <- "#41B6C4"
yellow_blue_shade_5 <- "#1D91C0"; yellow_blue_shade_6 <- "#225EA8"
yellow_blue_shade_7 <- "#0C2C84"
# ── Shared ggplot theme ────────────────────────────────────────────────────────
theme_kelly <- theme_minimal(base_family = "Roboto") +
theme(
plot.background = element_rect(fill = plot_background, color = NA),
panel.background = element_rect(fill = plot_background, color = NA),
panel.grid.major = element_line(color = plot_fill_lightgrey, linewidth = 0.4),
panel.grid.minor = element_blank(),
text = element_text(color = plot_blacktext),
plot.title = element_text(size = 13, face = "bold"),
plot.subtitle = element_text(size = 11, color = plot_greytext),
axis.text = element_text(size = 10, color = plot_greytext),
axis.title = element_text(size = 11, color = plot_blacktext),
legend.position = "bottom",
legend.text = element_text(size = 10),
plot.caption = element_text(size = 9, color = plot_greytext, hjust = 0)
)
# ── Scenario definitions ───────────────────────────────────────────────────────
scenarios <- list(
s1 = list(
label = "Scenario 1",
title = "The Danger Zone",
subtitle = "Sparse data, wide posterior — the point estimate misleads",
alpha_prior = 2, beta_prior = 2,
wins = 7, n_trials = 12,
b_odds = 2.0,
true_p = NULL
),
s2 = list(
label = "Scenario 2",
title = "The Transition",
subtitle = "Moderate data — p25 turns meaningfully positive",
alpha_prior = 3, beta_prior = 2,
wins = 15, n_trials = 25,
b_odds = 2.0,
true_p = NULL
),
s3 = list(
label = "Scenario 3",
title = "The Experienced Operator",
subtitle = "Rich data — p25 converges toward full Kelly",
alpha_prior = 4, beta_prior = 2,
wins = 32, n_trials = 50,
b_odds = 2.0,
true_p = NULL
),
s4 = list(
label = "Scenario 4",
title = "Optimism Meets Reality",
subtitle = "Identical inputs to Scenario 1 — but the true edge is lower than estimated",
alpha_prior = 2, beta_prior = 2,
wins = 7, n_trials = 12,
b_odds = 2.0,
true_p = 0.52
)
)
# ── Core helper functions ──────────────────────────────────────────────────────
posterior_params <- function(sc) {
list(
alpha_post = sc$alpha_prior + sc$wins,
beta_post = sc$beta_prior + (sc$n_trials - sc$wins)
)
}
kelly_fraction <- function(p, b_decimal) {
b_net <- b_decimal - 1
pmax((b_net * p - (1 - p)) / b_net, 0)
}
simulate_path <- function(p_draw, b_decimal, n, f_pct) {
b_net <- b_decimal - 1
bk <- numeric(n + 1)
bk[1] <- 100000
for (i in seq_len(n)) {
win <- rbinom(1, 1, p_draw)
bk[i+1] <- bk[i] * (1 + f_pct * ifelse(win == 1, b_net, -1))
if (bk[i+1] <= 0) { bk[(i+1):(n+1)] <- 0; break }
}
bk
}
# ── Plot 1: Prior / Likelihood / Posterior density overlay ─────────────────────
make_density_plot <- function(sc) {
pp <- posterior_params(sc)
p_seq <- seq(0.001, 0.999, length.out = 500)
prior_y <- dbeta(p_seq, sc$alpha_prior, sc$beta_prior)
posterior_y <- dbeta(p_seq, pp$alpha_post, pp$beta_post)
lik_raw <- dbinom(sc$wins, sc$n_trials, p_seq)
lik_y <- lik_raw / max(lik_raw) * max(posterior_y)
post_mean <- pp$alpha_post / (pp$alpha_post + pp$beta_post)
df <- bind_rows(
data.frame(p = p_seq, density = prior_y, curve = "Prior"),
data.frame(p = p_seq, density = lik_y, curve = "Likelihood (scaled)"),
data.frame(p = p_seq, density = posterior_y, curve = "Posterior")
) |>
mutate(curve = factor(curve,
levels = c("Prior", "Likelihood (scaled)", "Posterior")))
g <- ggplot(df, aes(x = p, y = density, color = curve)) +
geom_line(aes(linetype = curve), linewidth = 0.9) +
geom_area(
data = filter(df, curve == "Posterior"),
aes(fill = curve), alpha = 0.15, show.legend = FALSE
) +
scale_color_manual(values = c(
"Prior" = grey_shade_4,
"Likelihood (scaled)" = grey_shade_3,
"Posterior" = blue_shade_4
)) +
scale_fill_manual(values = c("Posterior" = blue_shade_2)) +
scale_linetype_manual(values = c(
"Prior" = "dotted",
"Likelihood (scaled)" = "dashed",
"Posterior" = "solid"
)) +
scale_x_continuous(labels = percent_format(accuracy = 1), limits = c(0, 1)) +
labs(x = "Win probability p", y = "Density", color = NULL, linetype = NULL) +
theme_kelly
ggplotly(g) |>
layout(
paper_bgcolor = plot_background,
plot_bgcolor = plot_background,
shapes = list(list(
type = "line", x0 = post_mean, x1 = post_mean, y0 = 0, y1 = 1,
yref = "paper",
line = list(color = blue_shade_4, width = 1.5, dash = "dot")
)),
annotations = list(list(
x = post_mean + 0.01, y = 0.95, yref = "paper", xanchor = "left",
text = sprintf("posterior mean<br>= %.3f", post_mean),
showarrow = FALSE,
font = list(size = 11, color = blue_shade_4, family = "Roboto")
)),
legend = list(orientation = "h", x = 0.5, xanchor = "center",
y = -0.2, font = list(size = 11))
)
}
# ── Plot 2: Kelly fraction distribution ────────────────────────────────────────
make_kelly_plot <- function(sc, N = 10000, seed = 42) {
set.seed(seed)
pp <- posterior_params(sc)
b_net <- sc$b_odds - 1
post_p <- rbeta(N, pp$alpha_post, pp$beta_post)
f_stars <- pmax((b_net * post_p - (1 - post_p)) / b_net, 0)
sorted <- sort(f_stars)
p10 <- sorted[floor(0.10 * N)]
p25 <- sorted[floor(0.25 * N)]
p50 <- sorted[floor(0.50 * N)]
mean_f <- mean(f_stars)
# Histogram bins
hi <- max(max(f_stars) * 1.05, 0.01)
n_bins <- 55
bw <- hi / n_bins
counts <- as.integer(tabulate(
pmin(floor(f_stars / bw) + 1, n_bins), nbins = n_bins
))
bin_x <- (seq_len(n_bins) - 0.5) * bw
ymax <- max(counts) * 1.22
pct_zero <- mean(f_stars == 0)
vlines <- list(
list(val = p10, col = red_shade_4, lbl = sprintf("p10 = %.1f%%", p10*100)),
list(val = p25, col = red_shade_3, lbl = sprintf("p25 = %.1f%%", p25*100)),
list(val = p50, col = green_shade_4, lbl = sprintf("p50 = %.1f%%", p50*100)),
list(val = mean_f, col = plot_blacktext, lbl = sprintf("mean = %.1f%%", mean_f*100))
)
shapes <- lapply(vlines, function(v)
list(type = "line", x0 = v$val, x1 = v$val, y0 = 0, y1 = ymax,
line = list(color = v$col, width = 1.8,
dash = ifelse(v$val == mean_f, "dot", "solid")))
)
anns <- c(
lapply(seq_along(vlines), function(i) {
v <- vlines[[i]]
list(x = v$val, y = ymax * (0.98 - (i-1)*0.10),
xanchor = "left", text = paste0(" ", v$lbl),
showarrow = FALSE,
font = list(size = 11, color = v$col, family = "Roboto"))
}),
if (pct_zero > 0.05) list(list(
x = 0.5, y = 1.02, xref = "paper", yref = "paper",
xanchor = "center",
text = sprintf("%.0f%% of draws recommend zero stake (edge below break-even)",
pct_zero * 100),
showarrow = FALSE,
font = list(size = 11, color = plot_greytext, family = "Roboto")
))
)
plot_ly() |>
add_bars(
x = bin_x, y = counts, width = bw * 0.88,
marker = list(color = paste0(blue_shade_3, "bb"),
line = list(color = blue_shade_4, width = 0.5)),
hovertemplate = "f* = %{x:.3f}<br>count = %{y}<extra></extra>",
showlegend = FALSE
) |>
layout(
paper_bgcolor = plot_background, plot_bgcolor = plot_background,
font = list(family = "Roboto", color = plot_blacktext),
xaxis = list(title = "Recommended Kelly fraction f*", tickformat = ".0%",
gridcolor = plot_fill_lightgrey),
yaxis = list(title = "Count (of 10,000 draws)",
gridcolor = plot_fill_lightgrey),
shapes = shapes, annotations = anns,
margin = list(t = 40, r = 20, b = 50, l = 70),
bargap = 0.05
)
}
# ── Sensitivity table data ─────────────────────────────────────────────────────
make_sensitivity_data <- function(sc, N_sim = 2000, n_bets = 200,
bankroll = 100000, seed = 42) {
set.seed(seed)
pp <- posterior_params(sc)
post_p <- rbeta(N_sim, pp$alpha_post, pp$beta_post)
f_draws <- kelly_fraction(post_p, sc$b_odds)
f_sorted <- sort(f_draws)
pcts <- c(p10 = 0.10, p25 = 0.25, p50 = 0.50, p75 = 0.75)
f_values <- sapply(pcts, function(q) f_sorted[floor(q * N_sim)])
f_values <- c(f_values, `Fixed 2%` = 0.02)
results <- lapply(names(f_values), function(nm) {
f <- f_values[nm]
paths <- sapply(seq_len(N_sim), function(i) {
p_draw <- if (!is.null(sc$true_p)) sc$true_p else post_p[i]
simulate_path(p_draw, sc$b_odds, n_bets, f)
})
terminal <- paths[n_bets + 1, ]
drawdowns <- apply(paths, 2, function(p) {
peak <- cummax(p)
max((peak - p) / peak, na.rm = TRUE)
})
data.frame(
Strategy = nm,
Stake = percent(f, accuracy = 0.1),
`Bet ($100k)` = dollar(bankroll * f, accuracy = 1),
`Median final bankroll` = dollar(median(terminal), accuracy = 1),
`Median drawdown` = percent(median(drawdowns), accuracy = 0.1),
`P(drawdown > 20%)` = percent(mean(drawdowns > 0.20), accuracy = 0.1),
check.names = FALSE
)
})
list(table = do.call(rbind, results), f_values = f_values)
}
# ── Trajectory + terminal histogram ───────────────────────────────────────────
make_trajectory_plots <- function(sc, f_values, N_paths = 200,
n_bets = 500, bankroll = 100000, seed = 99) {
set.seed(seed)
pp <- posterior_params(sc)
strat_names <- c("Full Kelly (p50)", "Half Kelly",
"p25 (recommended)", "Fixed 2%")
strat_colors <- c(red_shade_4, yellow_blue_shade_5,
blue_shade_4, grey_shade_4)
strat_fracs <- c(f_values["p50"], f_values["p50"] * 0.5,
f_values["p25"], 0.02)
med_paths <- vector("list", 4)
q25_paths <- vector("list", 4)
q75_paths <- vector("list", 4)
terminals <- vector("list", 4)
for (i in seq_len(4)) {
f <- strat_fracs[i]
mat <- sapply(seq_len(N_paths), function(j) {
p_draw <- if (!is.null(sc$true_p)) {
sc$true_p
} else {
rbeta(1, pp$alpha_post, pp$beta_post)
}
simulate_path(p_draw, sc$b_odds, n_bets, f)
})
med_paths[[i]] <- apply(mat, 1, median)
q25_paths[[i]] <- apply(mat, 1, quantile, 0.25)
q75_paths[[i]] <- apply(mat, 1, quantile, 0.75)
terminals[[i]] <- pmax(mat[n_bets + 1, ], 1)
}
bet_seq <- 0:n_bets
# ── Trajectory panel ────────────────────────────────────────────────────────
p1 <- plot_ly()
for (i in seq_len(4)) {
col <- strat_colors[i]
med <- pmax(med_paths[[i]], 1)
q25 <- pmax(q25_paths[[i]], 1)
q75 <- pmax(q75_paths[[i]], 1)
p1 <- p1 |> add_trace(
x = c(bet_seq, rev(bet_seq)), y = c(q75, rev(q25)),
type = "scatter", mode = "none", fill = "toself",
fillcolor = paste0(col, "22"), showlegend = FALSE, hoverinfo = "skip"
)
p1 <- p1 |> add_trace(
x = bet_seq, y = med, type = "scatter", mode = "lines",
line = list(color = col, width = 2), name = strat_names[i],
hovertemplate = paste0("<b>", strat_names[i], "</b><br>",
"Bet %{x}<br>$%{y:,.0f}<extra></extra>")
)
}
p1 <- p1 |> layout(
paper_bgcolor = plot_background, plot_bgcolor = plot_background,
font = list(family = "Roboto", color = plot_blacktext),
xaxis = list(title = paste0("Bet number (n = ", n_bets, ")"),
gridcolor = plot_fill_lightgrey, range = c(0, n_bets)),
yaxis = list(
title = "Bankroll ($)",
type = "log",
tickvals = c(1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9, 1e10, 1e11, 1e12),
ticktext = c("$1K", "$10K", "$100K", "$1M", "$10M",
"$100M", "$1B", "$10B", "$100B", "$1T"),
gridcolor = plot_fill_lightgrey
),
legend = list(orientation = "h", x = 0.5, xanchor = "center",
y = 1.12, yanchor = "top", font = list(size = 11)),
margin = list(t = 55, r = 20, b = 50, l = 90),
hovermode = "x unified", height = 370
)
# ── Terminal histogram panel ─────────────────────────────────────────────────
all_term <- unlist(terminals)
log_lo <- floor(min(log10(all_term)))
log_hi <- ceiling(max(log10(all_term)))
log_breaks <- seq(log_lo, log_hi, length.out = 46)
break_vals <- 10^log_breaks
p2 <- plot_ly()
for (i in seq_len(4)) {
col <- strat_colors[i]
log_vals <- log10(terminals[[i]])
h <- hist(log_vals, breaks = log_breaks, plot = FALSE)
mids <- as.numeric(10^h$mids)
counts <- as.integer(h$counts)
widths <- as.numeric(diff(break_vals) * 0.85)
p2 <- p2 |> add_bars(
x = mids, y = counts, width = widths,
name = strat_names[i], showlegend = TRUE,
marker = list(color = paste0(col, "99"),
line = list(color = col, width = 0.5)),
opacity = 0.72,
hovertemplate = paste0("<b>", strat_names[i], "</b><br>",
"Terminal: ~$%{x:,.0f}<br>",
"Count: %{y}<extra></extra>")
)
}
p2 <- p2 |> layout(
paper_bgcolor = plot_background, plot_bgcolor = plot_background,
font = list(family = "Roboto", color = plot_blacktext),
barmode = "overlay",
xaxis = list(
title = "Terminal bankroll after 500 bets ($)",
type = "log",
tickvals = c(1e3, 1e4, 1e5, 1e6, 1e7),
ticktext = c("$1k", "$10k", "$100k", "$1M", "$10M"),
gridcolor = plot_fill_lightgrey
),
yaxis = list(title = paste0("Count (of ", N_paths, " runs)"),
gridcolor = plot_fill_lightgrey),
shapes = list(list(
type = "line", x0 = bankroll, x1 = bankroll, y0 = 0, y1 = 1,
yref = "paper",
line = list(color = plot_blacktext, width = 1.5, dash = "dot")
)),
annotations = list(list(
x = bankroll, y = 0.96, yref = "paper", xanchor = "left",
text = " starting<br> bankroll", showarrow = FALSE,
font = list(size = 11, color = plot_blacktext, family = "Roboto")
)),
legend = list(orientation = "h", x = 0.5, xanchor = "center",
y = 1.12, yanchor = "top", font = list(size = 11)),
margin = list(t = 55, r = 20, b = 60, l = 90), height = 300
)
browsable(tagList(
as_widget(p1),
tags$div(style = "height: 28px;"),
as_widget(p2)
))
}