A daily loss limit is the cheapest, highest-EV responsible-gambling feature a bet tracker can ship. It exists in some sportsbooks but rarely in the pickem and prediction-market apps where bettors take their largest stakes per hour. Building one on the client in your tracker takes about 80 lines of TypeScript, runs without a server, and catches the only moment that matters: the moment after a bad loss when the brain is screaming to chase. This guide is the engineering walkthrough. The actual implementation ships on the bettor desk; everything below is reproducible in any web app you maintain.
What the tool does
- Stores a user-configured daily loss limit (e.g., $250).
- Tracks realized losses since the local-time day start (4 AM rollover).
- Blocks the "submit bet" action when adding a new bet would push the day's net loss past the limit.
- Renders a banner across the tracker while in the locked state, with the rollover countdown.
- Persists state in localStorage so a refresh does not reset progress.
- Provides a deliberate 24-hour cool-off if the user tries to raise the limit while locked (you cannot increase the limit on a tilt day).
Day boundary math (the bit everyone gets wrong)
The most common bug is defining "day" as UTC midnight. That hands a bettor in the US a day-boundary at 7 or 8 PM — right in the middle of NFL Sunday — which is exactly when a limit is most valuable. Use local time, and roll over at 4 AM, not midnight. The reason is behavioral: a tilt session at 1 AM after Monday Night Football is the most dangerous betting hour of the week, and a midnight rollover unlocks fresh exposure right in the middle of it.
function dayStartMs(now = new Date()): number {
const d = new Date(now);
// If it is currently before 4 AM local, the "day" started 4 AM yesterday.
if (d.getHours() < 4) d.setDate(d.getDate() - 1);
d.setHours(4, 0, 0, 0);
return d.getTime();
}
DST is handled implicitly because setHours(4) operates on local-time hours; the wall-clock 4 AM is the boundary on every calendar day, including the spring-forward and fall-back transition days.
The state shape
interface LossLimitState {
limitUsd: number; // user-set limit, default 0 = disabled
dayStartMs: number; // ms timestamp of current 4 AM local
realizedLossUsd: number; // sum of losing-bet net loss since dayStartMs
pendingRaiseUsd: number | null; // queued limit raise, applies at next rollover
pendingRaiseAt: number | null; // ms timestamp when raise applies
lockedSince: number | null; // ms timestamp when limit was hit
}
Persist this in localStorage under a stable key (shark.lossLimit.v1). Bump the version suffix if you change the shape — there is no migration path for old keys, you just default and let the user reconfigure.
The kill-switch function
function canPlaceBet(state: LossLimitState, proposedStake: number, now = Date.now()): {
ok: boolean;
reason?: string;
rolloverAt?: number;
} {
if (state.limitUsd === 0) return { ok: true };
rolloverIfNeeded(state, now);
// Worst-case scenario: the proposed bet loses in full.
const worstCaseLoss = state.realizedLossUsd + proposedStake;
if (worstCaseLoss > state.limitUsd) {
return {
ok: false,
reason: `Daily loss limit would be exceeded. Limit: $${state.limitUsd}. ` +
`Realized loss today: $${state.realizedLossUsd}. ` +
`This bet's worst case: -$${proposedStake}.`,
rolloverAt: nextDayStartMs(now)
};
}
return { ok: true };
}
Two design choices worth flagging:
- Worst-case stake comparison, not net-of-payout. A bet at +200 wins more than it loses, but if you measure exposure on net outcome you will let a $500 stake through on a $250 daily limit because "the win pays $1000" — and then it loses and you are -$500 on the day. Always size against the worst case.
- Block at submit, not at settle. The bet that pushes you past the limit is the bet you must not place. Once it is placed and settles, the loss is already realized.
The rollover logic
function rolloverIfNeeded(state: LossLimitState, now: number): void {
const currentDayStart = dayStartMs(new Date(now));
if (state.dayStartMs >= currentDayStart) return;
// It is a new day. Apply any queued limit raise.
if (state.pendingRaiseUsd !== null && state.pendingRaiseAt !== null && now >= state.pendingRaiseAt) {
state.limitUsd = state.pendingRaiseUsd;
state.pendingRaiseUsd = null;
state.pendingRaiseAt = null;
}
state.dayStartMs = currentDayStart;
state.realizedLossUsd = 0;
state.lockedSince = null;
persist(state);
}
The "pending raise" mechanic is the cool-off enforcement. When the user is locked and tries to bump the limit, the new limit does not apply immediately — it queues for the next rollover (at minimum 24 hours away if locked). This is the single most important design choice in the tool, because the lock instinct is to immediately raise the limit and keep going. The queue is what makes the limit actually mean something.
UI integration
Three surfaces the tool touches:
- Bet entry form. Before the user clicks "save bet," call
canPlaceBet(state, stake). On false, render a modal with the reason and a countdown to the rollover. - Persistent banner. When locked, render a sticky banner at the top of the desk with "Daily loss limit reached. Resumes at 4:00 AM (in 6h 23m)."
- Settings panel. A simple input to set the limit when unlocked. When locked, the input is disabled with a tooltip explaining the queued-raise rule.
The test cases that catch the edge bugs
Five unit tests every implementation needs. Skip any and you will ship the bug.
- DST spring-forward. Day starts at 4 AM March 9, 2026 local. The next day should start at 4 AM March 10. The naive
+ 24*60*60*1000implementation produces 3 AM March 10. Always recompute from a fresh Date. - Realized loss tracking only counts settled losses. Pending bets do not reduce the available budget; the kill-switch already handles pending exposure via
worstCaseLoss = realized + proposed. - Win followed by loss. If a $100 stake at -110 wins ($91 profit) then a $100 stake loses (-$100), realizedLoss = $100, not $9. The "net" interpretation undercounts.
- Multi-tab race. Two browser tabs both reading and writing localStorage will clobber each other. Use a single source of truth — either the storage event listener pattern (re-read on focus) or a BroadcastChannel.
- Limit raise during lock. User raises from $250 to $500 while locked. Queue should set pendingRaiseUsd=500 and pendingRaiseAt=nextDayStartMs. Confirm the limit is still 250 until rollover.
Why net-of-vig is the wrong frame here
It is tempting to track "expected loss" by subtracting expected value from realized stake — but expected value is the model talking, not reality. A bettor with a 5% edge can lose 10 bets in a row, and that is the day the limit needs to bite. The limit is on realized losses against the bankroll, full stop. The bankroll management basics piece covers why the unit-of-bankroll frame is the only one that prevents ruin, and the Kelly criterion explainer shows where stake sizes come from in the first place; the loss-limit is the safety net beneath both.
Integration with the bet log
If you already log bets — see the tracking guide — you have the input data. The loss-limit tool needs only one signal from each settled bet: the net profit/loss in dollars. On settle:
function recordBetSettlement(state: LossLimitState, profitLossUsd: number, now = Date.now()): void {
rolloverIfNeeded(state, now);
if (profitLossUsd < 0) {
state.realizedLossUsd += Math.abs(profitLossUsd);
if (state.realizedLossUsd >= state.limitUsd && state.lockedSince === null) {
state.lockedSince = now;
}
}
persist(state);
}
A winning bet does not subtract from realized loss. The mental model is "today's losses" — they only go up. A $200 winner on a -$250 loss day still leaves realizedLoss = $250 (locked), even though net P/L for the day is now -$50. That is intentional: the tilt risk does not go away because of one mid-tilt win.
Server-backed escalation (optional)
Pure client-side is enough for the friction tool, but some bettors want a stronger enforcement. The pattern is: sync the LossLimitState to a server endpoint (debounced 500ms), and on app load fetch the server state if it exists and is newer than the local copy. The server copy is the source of truth on conflict, which means clearStorage on the device does not bypass the limit — the user has to call support to unlock. Implementation is roughly 40 lines of additional code; we run it on the desk behind an opt-in flag.
What this tool does not do
- Block visits to sportsbook websites. It is a tracker-level guard, not a system-wide block. Browser-extension or OS-level tools (Gamban, BetBlocker) do the latter.
- Detect bets placed on other apps. You have to log every bet for the limit to know about it. The tracker is the source of truth.
- Replace operator-level deposit limits. Use both. The loss limit catches in-session tilt; the deposit limit catches week-over-week creep.
Ship it
The pattern above is roughly 80 lines of TypeScript, runs entirely client-side, and adds about 0.5 KB to the bundle. Drop it into any bet tracker; the only adapter you need is "settle event → profit/loss number." For bettors using the bettor desk, the tool is already wired in — open the desk, set a limit in settings, and the kill-switch is live. For developers building their own trackers, the test cases above are the contract you must pass. The next piece in the cluster, Kelly with a loss floor, builds the staking math that pairs with this guardrail.
Named example board
Keep the page grounded with actual decisions. Josh Allen rushing props, Bijan Robinson usage, Puka Nacua target volume, Amon-Ra St. Brown reception stability, and Travis Kelce touchdown equity are all different cases even when they sit on the same fantasy or betting screen. The point is to map the name to the input that matters most.
- Role example: routes, carries, targets, and red-zone work before highlights.
- Market example: spread, total, team total, or prop price before prediction.
- Fantasy example: ADP, roster build, and scoring format before ranking.
- Review example: compare the final result to the original input, not only the box score.
Price examples and pass rules
Use names as evidence, not decoration. The useful SEO win is that Josh Allen, Ja'Marr Chase, Bijan Robinson and Puka Nacua and Chiefs, Bills, Eagles and Lions appear inside decisions, thresholds, and internal links instead of being dumped into a keyword list.
- Spread example: if Chiefs-Broncos opens Chiefs -3.5 and your fair number is -2.8, +3.5 is the bet, +3 is a pass, and the moneyline needs roughly +155 or better before it replaces the spread.
- Total example: if a Bills outdoor total opens 46.5 and wind moves from 8 mph to 21 mph, an under projection at 42.8 still needs a playable number; under 45 or better is different from chasing 43.5.
- Futures example: Bengals AFC North +280 is 26.3% before hold. If your fair number is 30%, stake modestly, track portfolio correlation, and avoid stacking every Burrow, Chase, and Higgins bet into the same thesis.
- CLV rule: a good write-up is not enough. Track whether the spread, total, prop, or futures price closed better than your entry before grading the process.
Use closing-line value guide, vig and hold guide, bet tracking workflow to keep the examples attached to measurable prices.
Research note board
Use this table to turn the guide into a decision note. The point is to know when the idea is actionable and when it is only context.
| Angle | Input to verify | Example application | Pass when |
|---|---|---|---|
| Market price | Spread, total, moneyline, prop price, or futures hold | Chiefs and Bills compared through vig | The price has moved past the number that created the edge |
| Football or sport context | Role, pace, weather, injury status, opponent style | Josh Allen role news mapped to the relevant market | The original input changes or remains unconfirmed |
| Review loop | Entry, close, result, and reason code | closing line value logged with a clear thesis | You cannot explain whether the process beat the market |
Bet responsibly — set limits, never chase losses.
Expected bankroll growth at 55% edge
Expected geometric growth of a $100 bankroll under different Kelly multipliers across 1000 bets at p=0.55, decimal=2. Full Kelly maximises long-run growth but produces the deepest drawdowns; fractional Kelly trades growth for variance.
Drawdown by Kelly fraction
Median and 95th-percentile max drawdown by Kelly fraction over a 1000-bet horizon. Halving Kelly almost halves drawdown; quartering it cuts drawdown by ~70%. Figures are illustrative ballparks from the Kelly literature.



