From d83615a7b1b3014dd1935ffc34a68e5534ee47ac Mon Sep 17 00:00:00 2001 From: Ian Alloway Date: Mon, 22 Jun 2026 11:35:22 -0400 Subject: [PATCH] feat: Add collapsible KellySizing simulator and OddsDrift sparklines to DailyPicks page --- package-lock.json | 12 - src/components/KellySimulator.tsx | 355 ++++++++++++++++++++++++++++++ src/pages/DailyPicks.tsx | 173 ++++++++++----- 3 files changed, 476 insertions(+), 64 deletions(-) create mode 100644 src/components/KellySimulator.tsx diff --git a/package-lock.json b/package-lock.json index 1986e90..7b5ddce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3050,9 +3050,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3070,9 +3067,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3090,9 +3084,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3110,9 +3101,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/src/components/KellySimulator.tsx b/src/components/KellySimulator.tsx new file mode 100644 index 0000000..d7be78e --- /dev/null +++ b/src/components/KellySimulator.tsx @@ -0,0 +1,355 @@ +import { useState, useEffect, useMemo, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Slider } from "@/components/ui/slider"; +import { Badge } from "@/components/ui/badge"; +import { Play, RotateCcw, HelpCircle, TrendingUp, ChevronDown, ChevronUp } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +interface SimulationPoint { + betNum: number; + bankroll: number; +} + +export default function KellySimulator() { + const [isOpen, setIsOpen] = useState(true); + + // Inputs + const [bankroll, setBankroll] = useState(1000); + const [winProb, setWinProb] = useState(55); + const [americanOdds, setAmericanOdds] = useState(-110); + const [kellyFraction, setKellyFraction] = useState(0.5); // Half Kelly default + + // Simulation state + const [simulationData, setSimulationData] = useState([]); + const [simulationResult, setSimulationResult] = useState<{ + finalBankroll: number; + maxBankroll: number; + minBankroll: number; + winCount: number; + lossCount: number; + } | null>(null); + + // Convert American Odds to net decimal odds (b) + const netDecimalOdds = useMemo(() => { + if (americanOdds === 0) return 0; + if (americanOdds > 0) { + return americanOdds / 100; + } else { + return 100 / Math.abs(americanOdds); + } + }, [americanOdds]); + + // Kelly % calculation: f* = (p * (b + 1) - 1) / b + const calculatedKellyPct = useMemo(() => { + const p = winProb / 100; + const b = netDecimalOdds; + if (b <= 0) return 0; + const fStar = (p * (b + 1) - 1) / b; + return Math.max(0, fStar); + }, [winProb, netDecimalOdds]); + + // Safe sizing percentage + const appliedKellyPct = useMemo(() => { + return calculatedKellyPct * kellyFraction; + }, [calculatedKellyPct, kellyFraction]); + + // Run the 100-bet simulation + const runSimulation = useCallback(() => { + let currentBankroll = bankroll; + const data: SimulationPoint[] = [{ betNum: 0, bankroll: currentBankroll }]; + const b = netDecimalOdds; + const p = winProb / 100; + + let max = currentBankroll; + let min = currentBankroll; + let wins = 0; + let losses = 0; + + for (let i = 1; i <= 100; i++) { + if (currentBankroll <= 1) { + currentBankroll = 0; + data.push({ betNum: i, bankroll: 0 }); + continue; + } + + // Calculate bet size + const betSize = currentBankroll * appliedKellyPct; + const isWin = Math.random() <= p; + + if (isWin) { + currentBankroll += betSize * b; + wins++; + } else { + currentBankroll -= betSize; + losses++; + } + + // Keep it formatted + currentBankroll = Math.round(currentBankroll * 100) / 100; + + if (currentBankroll > max) max = currentBankroll; + if (currentBankroll < min) min = currentBankroll; + + data.push({ betNum: i, bankroll: currentBankroll }); + } + + setSimulationData(data); + setSimulationResult({ + finalBankroll: currentBankroll, + maxBankroll: Math.round(max), + minBankroll: Math.round(min), + winCount: wins, + lossCount: losses, + }); + }, [bankroll, winProb, netDecimalOdds, appliedKellyPct]); + + // Run simulation on load + useEffect(() => { + runSimulation(); + }, [runSimulation]); + + // Chart Dimensions & calculations + const chartHeight = 160; + const chartWidth = 400; + const points = useMemo(() => { + if (simulationData.length === 0) return ""; + const maxVal = Math.max(...simulationData.map((d) => d.bankroll), bankroll * 1.5); + const minVal = Math.min(...simulationData.map((d) => d.bankroll), 0); + const range = maxVal - minVal || 1; + + return simulationData + .map((d, index) => { + const x = (index / (simulationData.length - 1)) * chartWidth; + const y = chartHeight - ((d.bankroll - minVal) / range) * chartHeight; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(" "); + }, [simulationData, bankroll]); + + return ( +
+
setIsOpen(!isOpen)} + > +
+
+ +
+
+

Interactive Sizing Simulator

+

Model bankroll outcomes over 100 bets

+
+
+
+ {isOpen ? : } +
+
+ + {isOpen && ( + <> +
+ {/* Left Hand: Controls */} +
+
+ +
+ $ + setBankroll(Number(e.target.value))} + className="pl-7 bg-black/20 border-white/10 text-white rounded-xl focus:border-brand-500/50" + /> +
+
+ +
+
+ + setWinProb(Math.min(99, Math.max(1, Number(e.target.value))))} + className="mt-1.5 bg-black/20 border-white/10 text-white rounded-xl" + /> +
+
+ + setAmericanOdds(Number(e.target.value))} + className="mt-1.5 bg-black/20 border-white/10 text-white rounded-xl" + /> +
+
+ +
+
+ Kelly Fraction + + {(kellyFraction * 100).toFixed(0)}% ({kellyFraction === 1 ? "Full" : kellyFraction === 0.5 ? "Half" : kellyFraction === 0.25 ? "Quarter" : "Custom"}) + +
+
+ setKellyFraction(val[0])} + className="flex-1" + /> +
+
+ {[0.25, 0.5, 1.0].map((frac) => ( + + ))} +
+
+
+ + {/* Right Hand: Output & Simulation Chart */} +
+
+
+
+

Calculated Bet

+
+ + {appliedKellyPct > 0 ? `${(appliedKellyPct * 100).toFixed(1)}%` : "0.0%"} + + {appliedKellyPct > 0 && ( + + (${(bankroll * appliedKellyPct).toFixed(0)}) + + )} +
+
+ 0 ? "text-emerald-400 border-emerald-400/20 bg-emerald-400/5" : "text-zinc-500" + }`} + > + {appliedKellyPct > 0 ? "Positive Edge" : "No Advantage"} + +
+ + {appliedKellyPct > 0 ? ( +

+ Suggested stake size is **${(bankroll * appliedKellyPct).toFixed(0)}** based on decimal odds of **{(netDecimalOdds + 1).toFixed(2)}**. +

+ ) : ( +

+ No positive edge found. Kelly sizing suggests placing **no bet ($0)**. +

+ )} +
+ + {/* SVG Sparkline */} +
+
+ +
+ +
+ Bet 0 + 100 Bets +
+ + {simulationData.length > 0 && ( + + {/* Horizontal baseline */} + + + {/* Sparkline Path */} + + + {/* SVG Gradients */} + + + + + + + + )} + + {simulationResult && ( +
+ Final: = bankroll ? "text-emerald-400" : "text-red-400"}>${simulationResult.finalBankroll.toFixed(0)} + Max: ${simulationResult.maxBankroll} + W/L: {simulationResult.winCount}/{simulationResult.lossCount} +
+ )} +
+
+
+ +
+ +
+ + )} +
+ ); +} diff --git a/src/pages/DailyPicks.tsx b/src/pages/DailyPicks.tsx index eb2ca7f..1efa54b 100644 --- a/src/pages/DailyPicks.tsx +++ b/src/pages/DailyPicks.tsx @@ -21,6 +21,7 @@ import CryptoPaymentModal, { type UnlockType } from "@/components/CryptoPaymentM import AccessSessionDialog from "@/components/AccessSessionDialog"; import PaymentOptionDialog from "@/components/PaymentOptionDialog"; import { useToast } from "@/components/ui/use-toast"; +import KellySimulator from "@/components/KellySimulator"; import { getAuthChangeEventName, getCurrentSiteUser, @@ -61,6 +62,50 @@ function getMarketAuditLabel(game: LiveMarketGame) { return "No verified line"; } +const getOddsDrift = (current: number | undefined, open: number | undefined) => { + if (current === undefined || open === undefined) return null; + const toDecimal = (american: number) => { + return american > 0 ? 1 + american / 100 : 1 + 100 / Math.abs(american); + }; + const decCurrent = toDecimal(current); + const decOpen = toDecimal(open); + const diff = decCurrent - decOpen; + const pct = (diff / (decOpen - 1)) * 100; + return { + diff, + pct, + favorable: diff > 0, + }; +}; + +function OddsSparkline({ open, current, favorable }: { open: number; current: number; favorable: boolean }) { + const color = favorable ? "#34d399" : "#f87171"; + const mid = (open + current) / 2 + (Math.random() - 0.5) * (current - open) * 0.2; + const toHeight = (val: number, min: number, max: number) => { + const range = max - min || 1; + return 10 - ((val - min) / range) * 8; + }; + const min = Math.min(open, current, mid); + const max = Math.max(open, current, mid); + + const y1 = toHeight(open, min, max); + const y2 = toHeight(mid, min, max); + const y3 = toHeight(current, min, max); + + return ( + + + + + ); +} + function PickCard({ entry, locked, @@ -123,6 +168,7 @@ function PickCard({ logo: game.awayLogo, score: game.awayScore, odds: game.odds?.awayMoneyline, + openOdds: game.odds?.awayMoneylineOpen, }, { side: "home", @@ -131,26 +177,44 @@ function PickCard({ logo: game.homeLogo, score: game.homeScore, odds: game.odds?.homeMoneyline, + openOdds: game.odds?.homeMoneylineOpen, }, - ].map((team) => ( -
-
-
- {team.logo ? ( - {team.name} - ) : null} -
-
{team.side}
-
{team.name}
+ ].map((team) => { + const drift = getOddsDrift(team.odds, team.openOdds); + return ( +
+
+
+ {team.logo ? ( + {team.name} + ) : null} +
+
{team.side}
+
{team.name}
+
+
+
+
{team.score ?? "-"}
+
+
{team.odds !== undefined ? formatOdds(team.odds) : "No line"}
+ {team.odds !== undefined && team.openOdds !== undefined && team.odds !== team.openOdds && drift && ( + + {drift.favorable ? "↑" : "↓"} + {Math.abs(drift.pct).toFixed(0)}% + + + )} +
-
-
-
{team.score ?? "-"}
-
{team.odds !== undefined ? formatOdds(team.odds) : "No line"}
-
- ))} + ); + })} {isThreeWay ? (
@@ -539,48 +603,53 @@ export default function DailyPicks() { There are no current games with posted lines right now. The site is intentionally showing an empty board instead of inventing one.
) : ( -
-
-
- -

Open board

- - {freePicks.length} visible - -
-
- {freePicks.map((entry) => ( - setShowCryptoModal(true)} /> - ))} -
-
- - {premiumPicks.length > 0 ? ( +
+
-
-
- -

Premium board

- - {hasPremiumBoard ? `${premiumPicks.length} unlocked` : `${premiumPicks.length} locked`} - -
- {!hasPremiumBoard ? ( -
- Execution sizing, stronger spots, and the rest of the slate - -
- ) : null} +
+ +

Open board

+ + {freePicks.length} visible +
- {premiumPicks.map((entry) => ( - setShowPaymentOptionModal(true)} /> + {freePicks.map((entry) => ( + setShowCryptoModal(true)} /> ))}
- ) : null} + + {premiumPicks.length > 0 ? ( +
+
+
+ +

Premium board

+ + {hasPremiumBoard ? `${premiumPicks.length} unlocked` : `${premiumPicks.length} locked`} + +
+ {!hasPremiumBoard ? ( +
+ Execution sizing, stronger spots, and the rest of the slate + +
+ ) : null} +
+
+ {premiumPicks.map((entry) => ( + setShowPaymentOptionModal(true)} /> + ))} +
+
+ ) : null} +
+
+ +
)}