A from-scratch Monte Carlo engine that plays out millions of hands of blackjack against a depleting shoe under Hi-Lo card counting, measures how player edge depends on the true count, and turns that into a Kelly-optimal bet ramp with a quantified risk of ruin.
The interesting part isn't blackjack, it's the pipeline: simulate → estimate edge and variance → size bets via Kelly → stress-test the sizing with a block bootstrap. Measuring a conditional edge, sizing positions against it and quantifying the probability of ruin is the same shape of problem as sizing any risky, repeated bet.
All numbers below are from a single run: 5,000,000 hands, 6-deck S17 DAS shoe at 75% penetration, seed 42. Reproduce with python scripts/run_simulation.py --n-hands 5000000 --bankroll 1000 --n-trajectories 10000 --horizon-hands 60000.
Edge vs. true count. Bucketed empirical edge (flat 1-unit bets) with an OLS fit overlaid — the fit is what actually drives the bet ramp, for reasons explained below.
edge(TC) ≈ -0.603% + 0.495%/count (R² fit on 5M hands)
overall house edge (flat bet, all counts pooled): -0.622%
Kelly-derived bet ramp. Half-Kelly, bankroll = 1,000 units, table spread 1–12 units.
Bankroll trajectories. 10,000 block-bootstrapped paths, 60,000 hands (~750 hours) each. The fan shows why this is hard: the median climbs steadily, but variance is wide enough that a meaningful share of paths are still underwater at the end of the horizon.
Risk of ruin vs. bankroll size, holding the bet ramp fixed — the standard bankroll-sizing chart from the advantage-play literature, reproduced here from the simulation rather than a textbook table.
| Bankroll (units) | Risk of ruin |
|---|---|
| 250 | 76.6% |
| 500 | 34.1% |
| 1,000 | 7.9% |
| 2,000 | 1.2% |
| 4,000 | 0.1% |
Confidence of a positive outcome over time, at the 1,000-unit bankroll:
| Confidence | Hours of play |
|---|---|
| 50% | 0.9 |
| 55% | 388 |
| 60%+ | not reached in 750 simulated hours |
The 50%→55% jump alone takes ~387 hours. This is the honest headline finding: a real, measurable edge exists and compounds correctly, but the variance-to-edge ratio in blackjack is high enough that being confident you're ahead — not just expected to be — takes an impractical amount of solo play. It's the same reason professional counting teams historically pooled bankrolls and hours across players rather than grinding solo.
Engine. src/bjsim/ plays real hands against a real, depleting shoe (shoe.py) — not a closed-form approximation. Basic strategy (strategy.py) is the standard published 4–8 deck, S17, DAS chart, hand-encoded and unit-tested against known cells (always split A,A/8,8; never split 10,10; double 11 vs. everything; etc.). The hand engine (engine.py) handles hits, stands, doubles, and splits (up to 4 hands, no resplit aces, double after split) recursively, then resolves against a dealer that plays out a fixed S17/H17 rule.
Why not fully vectorize hand play? Each draw depends on the shoe's current state and the outcome of the prior decision — it's an inherently sequential Markov chain, not an embarrassingly parallel operation. NumPy vectorization is used where it actually pays off: shoe shuffling, Hi-Lo count bookkeeping, and — the computationally dominant part — the bankroll/risk-of-ruin simulation, which is a genuine (trajectories × hands) matrix operation.
Edge measurement. The simulation bets a flat 1 unit on every hand, specifically to decouple "how good is this count" from "how much would I bet at this count." That produces one row per hand: the true count at the moment the bet was placed, and the resulting P&L. Grouping by true-count bucket gives an empirical edge and variance per count (analysis.py).
Smoothing. Raw per-bucket edges get noisy at extreme counts (few thousand hands land at TC +9 or +10 out of 5M total), which produces a bet ramp that swings around non-monotonically — worse, right where it's most tempting to bet big. Standard practice in the advantage-play literature (e.g., Schlesinger's Blackjack Attack) is to regress P&L on the true count directly rather than trust individual bucket means; analysis.fit_edge_line does that OLS fit, and the Kelly ramp is derived from the fitted line, not the raw buckets.
Kelly sizing. For a bet with small per-hand edge e and outcome variance v (both per unit wagered), the Kelly-optimal bankroll fraction is f* = e / v — the standard approximation used for blackjack, since outcomes aren't a simple binary win/lose (kelly.py). Measured variance came out to ≈1.3 units², matching the commonly-cited value in the literature (e.g. Griffin, The Theory of Blackjack) — a nice independent check that the simulation is behaving realistically. The ramp uses half-Kelly by default (kelly_multiplier=0.5): full Kelly is theoretically growth-optimal but assumes the edge estimate is exact, and in practice it's a noisy estimate from a finite sample re-applied to a non-stationary game (rules, penetration, and heat all vary table to table) — fractional Kelly is the standard hedge against that.
Risk of ruin via block bootstrap. Building bankroll paths by resimulating the shoe millions of times is wasteful — the same (true count, P&L) pairs from the original simulation can be reused, scaled by the derived bet ramp, since bet size scales blackjack P&L linearly without changing which cards are dealt or what basic strategy calls for. The subtlety is that hands within a shoe are correlated (the count drifts smoothly, not independently, hand to hand), so an i.i.d. bootstrap over individual hands would understate real variance. Instead, kelly.simulate_bankroll_paths resamples whole shoes with replacement and concatenates them — a block bootstrap, the standard fix for autocorrelated time series (the same idea used for backtesting with serially correlated financial returns). Once the (trajectories × hands) P&L matrix is built, ruin detection and confidence-curve calculation are fully vectorized NumPy.
Rather than trust a single absolute house-edge number against a remembered benchmark, the engine was checked against well-known relative rule effects, which are much better documented and harder to get right by accident:
| Rule change | Expected effect (literature) | Measured (1.5M hands) |
|---|---|---|
| S17 → H17 | dealer stands soft 17 is worth ≈ 0.2% to the player | 0.23% |
| DAS → no DAS | double-after-split is worth ≈ 0.14–0.2% | 0.19% |
| 6 decks → 1 deck | single deck is worth ≈ 0.4–0.5% | 0.53% |
All three land in the expected range, which is much stronger evidence of correctness than matching one absolute number would be — an engine with a compensating pair of bugs could still land near a target house edge by accident, but is very unlikely to reproduce three independent, well-documented relative effects at once.
- Dealer peeks for blackjack (standard US hole-card game); the no-hole-card (ENHC) variant, where doubled/split bets can still lose to a dealer blackjack, isn't modeled.
- No surrender. Basic strategy never surrenders here, which is standard for a "no surrender" table but does cost a small amount of edge relative to a surrender-enabled game.
- No resplitting aces, one card per split ace — both standard casino defaults, both configurable in
Rules. - Bootstrapped bankroll paths don't stop at ruin — a trajectory that dips to zero is flagged as ruined but its simulated path continues past that point (as if the player kept betting from a negative balance), since only the "did it ever cross zero" event is used. This slightly widens the visible spread in the fan chart below zero but doesn't affect the risk-of-ruin statistic itself.
- 80 hands/hour is an assumed table speed (heads-up or a quiet table plays faster; a full table slower) — pass
--hands-per-hourto change it. - Bet ramp is capped at a 1–12 unit spread, a realistic ceiling for avoiding obvious heat at a real table; an unconstrained Kelly ramp would size some high-count bets much larger.
src/bjsim/
rules.py table rule configuration
shoe.py shoe representation + Hi-Lo running/true count
hand.py hand-value arithmetic
strategy.py basic strategy tables (hard/soft/pairs)
engine.py hand-by-hand play (hit/stand/double/split) + flat-bet Monte Carlo driver
analysis.py edge/variance by true count, OLS smoothing
kelly.py bet ramp derivation + block-bootstrap risk of ruin
plotting.py report plots
scripts/
run_simulation.py end-to-end CLI: simulate -> analyze -> size -> risk -> plots
tests/ unit tests: strategy table spot-checks, shoe/count correctness, Kelly/ROR sanity
results/ generated CSVs, plots, and summary.json from the run above
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
pytest tests/ -v
python scripts/run_simulation.py \
--n-hands 5000000 \
--bankroll 1000 \
--n-trajectories 10000 \
--horizon-hands 60000Key flags: --n-hands (edge-measurement sample size), --bankroll/--kelly-multiplier/--min-bet/--max-bet (bet sizing), --n-trajectories/--horizon-hands (bankroll simulation), --hands-per-hour. Full list: python scripts/run_simulation.py --help.
- Strategy deviations (the "Illustrious 18") on top of basic strategy
- Compare Hi-Lo against other counting systems (KO, Omega II) for the same edge-per-effort
- Model ENHC and a surrender-enabled table as additional rule presets
- Replace the fixed 1–12 spread with a heat/detection-risk model that caps bet variance directly



