/** * NCAA Football CFP Simulator * * Monte Carlo simulation of the College Football Playoff (12-team format, 2024–present). * * Algorithm: * 1. Load all participants for the sports season from DB * 2. Load Elo/FPI ratings from participantExpectedValues.sourceElo * (entered via Admin → Elo Ratings page; use FPI, S&P+, or any Elo-scale rating) * 3. If sourceOdds (American format) are also stored, build a normalized selection * weight from implied championship probability (used for field selection in step 4) * and blend into per-game win probability (ELO_WEIGHT=0.6 / ODDS_WEIGHT=0.4). * 4. Per simulation, select 12 teams for the CFP field: * - If the pool has exactly 12 teams: use all of them (post-bracket mode). * - If the pool has >12 teams: weighted sample without replacement using each * team's selection weight — odds-derived if available, Elo-based otherwise. * Teams with stronger championship odds are sampled more often, naturally * encoding both selection probability and bracket strength into one signal. * 5. Seed the 12 selected teams by Elo (best Elo = seed 1). * 6. Simulate the CFP bracket: * First Round (not scoring): 5v12, 6v11, 7v10, 8v9 * Quarterfinals (scoring): 1 vs 8/9w, 4 vs 5/12w, 3 vs 6/11w, 2 vs 7/10w * Semifinals (scoring): QF1w vs QF2w, QF3w vs QF4w * National Championship: SF1w vs SF2w * 7. Track placement counts per scoring tier across all simulations. * 8. Convert counts to probability distributions. * * Pre-bracket vs post-bracket mode: * Pre-bracket (>12 participants): probabilities reflect both selection uncertainty * and bracket performance. A bubble team might appear in only 40% of simulated * fields, so its champion probability accounts for that. * Post-bracket (exactly 12 participants): deterministic field, bracket-only sim. * * Win probability (per game): eloWinProbability() from probability-engine (400-divisor). * Blended win probability: ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb (when odds present). * * Selection weight (pre-bracket mode): * With sourceOdds: normalized implied championship probability (vig removed, sums to 1). * Without sourceOdds: softmax on Elo with temperature SELECTION_TEMP (sharply favors * higher-rated teams — a 200-point Elo gap yields ~7× selection weight difference). * * Placement tiers → SimulationProbabilities mapping: * probFirst = National Champion (1 per sim) * probSecond = Championship game loser (1 per sim) * probThird / probFourth = Semifinal losers (2 per sim — split evenly) * probFifth–probEighth = Quarterfinal losers (4 per sim — split evenly) * First Round losers → all 0 (score 0 fantasy points) * Teams not selected → all 0 (not in field for that sim) * * Admin setup: * 1. Create a Sport with simulatorType = "ncaa_football_bracket" * 2. Create a Sports Season and add all contender participants (12 or more) * 3. Enter FPI ratings via Admin → Elo Ratings (stored as sourceElo) * 4. Optionally enter championship futures odds via Admin → Futures Odds (sourceOdds) * — strongly recommended for pre-bracket mode; drives both selection and bracket strength * 5. Run simulation via Admin → Simulate */ import { database } from "~/database/context"; import { eq } from "drizzle-orm"; import * as schema from "~/database/schema"; import { convertAmericanOddsToProbability, convertFuturesToElo, eloWinProbability, } from "~/services/probability-engine"; import type { Simulator, SimulationResult } from "./types"; // ─── Simulation parameters ──────────────────────────────────────────────────── const NUM_SIMULATIONS = 50_000; const BRACKET_SIZE = 12; /** * Blend weights for per-game win probability when sourceOdds are present. * Lower Elo weight than other sports (0.7) gives more influence to Vegas * championship odds, which are highly informative in college football. */ const ELO_WEIGHT = 0.6; const ODDS_WEIGHT = 1 - ELO_WEIGHT; /** * Softmax temperature for Elo-based selection weights (pre-bracket mode, no odds). * At T=100, a 200-point Elo gap produces ~7× weight difference — enough to strongly * favour the top teams while still giving bubble teams meaningful selection probability. */ const SELECTION_TEMP = 100; // ─── Types ──────────────────────────────────────────────────────────────────── interface Team { participantId: string; elo: number; /** Normalized futures win probability (0–1). Used for blending per-game win prob. */ oddsProb: number; /** * Weight used for probabilistic CFP field selection (pre-bracket mode only). * Derived from oddsProb when available; otherwise softmax on Elo. */ selectionWeight: number; } // ─── Helpers ───────────────────────────────────────────────────────────────── /** * Blended win probability for team1 vs team2. * Falls back to pure Elo when no futures data is present. */ function blendedWinProb(team1: Team, team2: Team): number { const eloProbValue = eloWinProbability(team1.elo, team2.elo); if (team1.oddsProb === 0 && team2.oddsProb === 0) { return eloProbValue; } const oddsSum = team1.oddsProb + team2.oddsProb; const oddsProbValue = oddsSum > 0 ? team1.oddsProb / oddsSum : 0.5; return ELO_WEIGHT * eloProbValue + ODDS_WEIGHT * oddsProbValue; } function simGame(team1: Team, team2: Team): { winner: Team; loser: Team } { const p1Wins = Math.random() < blendedWinProb(team1, team2); return p1Wins ? { winner: team1, loser: team2 } : { winner: team2, loser: team1 }; } /** * Weighted sample without replacement — selects `n` teams from `pool` where each * team's probability of being drawn is proportional to its selectionWeight. * Returns the selected teams sorted by Elo descending (seed 1 = best Elo). */ function sampleBracketField(pool: Team[], n: number): Team[] { const remaining = [...pool]; const selected: Team[] = []; for (let i = 0; i < n; i++) { const totalWeight = remaining.reduce((sum, t) => sum + t.selectionWeight, 0); let r = Math.random() * totalWeight; let j = 0; for (; j < remaining.length - 1; j++) { r -= remaining[j].selectionWeight; if (r <= 0) break; } selected.push(remaining[j]); remaining.splice(j, 1); } // Seed by Elo so that the best team in the sampled field is always seed 1. return selected.toSorted((a, b) => b.elo - a.elo); } // ─── Bracket simulation ─────────────────────────────────────────────────────── interface PlacementCounts { champion: number; finalist: number; sfLoser: number; qfLoser: number; } /** * Simulate one full 12-team CFP bracket. * * Seeding (teams sorted best→worst Elo, index 0 = seed 1): * First Round: [4]v[11], [5]v[10], [6]v[9], [7]v[8] * Quarterfinals: [0] vs fr4w, [3] vs fr1w, [2] vs fr2w, [1] vs fr3w * Semifinals: qf1w vs qf2w, qf3w vs qf4w * Championship: sf1w vs sf2w */ function simulateBracket(teams: Team[], counts: Map): void { // ── First Round (seeds 5–12) ─────────────────────────────────────────────── const fr1 = simGame(teams[4], teams[11]); // 5 vs 12 const fr2 = simGame(teams[5], teams[10]); // 6 vs 11 const fr3 = simGame(teams[6], teams[9]); // 7 vs 10 const fr4 = simGame(teams[7], teams[8]); // 8 vs 9 // ── Quarterfinals (seeds 1–4 get byes) ──────────────────────────────────── const qf1 = simGame(teams[0], fr4.winner); // 1 vs 8/9 winner const qf2 = simGame(teams[3], fr1.winner); // 4 vs 5/12 winner const qf3 = simGame(teams[2], fr2.winner); // 3 vs 6/11 winner const qf4 = simGame(teams[1], fr3.winner); // 2 vs 7/10 winner const bump = (id: string, key: keyof PlacementCounts) => { const entry = counts.get(id); if (entry) entry[key]++; }; bump(qf1.loser.participantId, "qfLoser"); bump(qf2.loser.participantId, "qfLoser"); bump(qf3.loser.participantId, "qfLoser"); bump(qf4.loser.participantId, "qfLoser"); // ── Semifinals ──────────────────────────────────────────────────────────── const sf1 = simGame(qf1.winner, qf2.winner); const sf2 = simGame(qf3.winner, qf4.winner); bump(sf1.loser.participantId, "sfLoser"); bump(sf2.loser.participantId, "sfLoser"); // ── National Championship ───────────────────────────────────────────────── const final = simGame(sf1.winner, sf2.winner); bump(final.winner.participantId, "champion"); bump(final.loser.participantId, "finalist"); } // ─── Simulator ──────────────────────────────────────────────────────────────── export class NCAAFootballSimulator implements Simulator { async simulate(sportsSeasonId: string): Promise { const db = database(); // 1. Load all participants for this sports season. const participants = await db .select({ id: schema.participants.id }) .from(schema.participants) .where(eq(schema.participants.sportsSeasonId, sportsSeasonId)); if (participants.length < BRACKET_SIZE) { throw new Error( `CFP simulator requires at least ${BRACKET_SIZE} participants, ` + `found ${participants.length}. Add all contender teams to the sports season.` ); } // 2. Load Elo/FPI ratings and optional futures odds in a single query. const evRows = await db .select({ participantId: schema.participantExpectedValues.participantId, sourceElo: schema.participantExpectedValues.sourceElo, sourceOdds: schema.participantExpectedValues.sourceOdds, }) .from(schema.participantExpectedValues) .where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)); // Build Elo and raw odds maps in a single pass. const eloFromDb = new Map(); const rawOddsProbs = new Map(); for (const row of evRows) { if (row.sourceElo !== null) { eloFromDb.set(row.participantId, row.sourceElo); } if (row.sourceOdds !== null) { rawOddsProbs.set(row.participantId, convertAmericanOddsToProbability(row.sourceOdds)); } } // 3. Build normalized odds probability map (vig removed). const normalizedOddsMap = new Map(); if (rawOddsProbs.size > 0) { const rawSum = [...rawOddsProbs.values()].reduce((a, b) => a + b, 0); for (const [id, prob] of rawOddsProbs) { normalizedOddsMap.set(id, rawSum > 0 ? prob / rawSum : 0); } // Backfill Elo from futures for any team missing sourceElo. if (eloFromDb.size < participants.length) { const oddsInput = evRows .filter((r) => r.sourceOdds !== null && !eloFromDb.has(r.participantId)) .map((r) => ({ participantId: r.participantId, odds: r.sourceOdds ?? 0 })); if (oddsInput.length > 0) { const oddsEloMap = convertFuturesToElo(oddsInput, "american"); for (const [id, elo] of oddsEloMap) { eloFromDb.set(id, elo); } } } } // 4. Build team list with Elo, oddsProb, and selectionWeight. const hasOdds = normalizedOddsMap.size > 0; const allTeams: Team[] = participants.map((p) => ({ participantId: p.id, elo: eloFromDb.get(p.id) ?? 1500, oddsProb: normalizedOddsMap.get(p.id) ?? 0, selectionWeight: 0, // computed below })); if (hasOdds) { // Selection weight = normalized championship implied probability. // This encodes both "probability of making the field" and "strength once there." for (const team of allTeams) { team.selectionWeight = normalizedOddsMap.get(team.participantId) ?? 0; } } else { // No odds: softmax on Elo so top-rated teams are strongly favoured. const eloValues = allTeams.map((t) => t.elo); const maxElo = Math.max(...eloValues); // Subtract max for numerical stability before exp(). const expWeights = allTeams.map((t) => Math.exp((t.elo - maxElo) / SELECTION_TEMP)); const expSum = expWeights.reduce((a, b) => a + b, 0); for (let i = 0; i < allTeams.length; i++) { allTeams[i].selectionWeight = expWeights[i] / expSum; } } const preBracketMode = participants.length > BRACKET_SIZE; // In post-bracket mode (exactly 12), sort once and reuse the same field every sim. const deterministicField = preBracketMode ? null : [...allTeams].toSorted((a, b) => b.elo - a.elo); // 5. Initialise placement count accumulators for all participants. const allParticipantIds = participants.map((p) => p.id); const counts = new Map( allParticipantIds.map((id) => [id, { champion: 0, finalist: 0, sfLoser: 0, qfLoser: 0 }]) ); // 6. Run Monte Carlo simulations. for (let s = 0; s < NUM_SIMULATIONS; s++) { const field = preBracketMode ? sampleBracketField(allTeams, BRACKET_SIZE) : (deterministicField ?? []); simulateBracket(field, counts); } // 7. Convert counts to probability distributions. // SF losers: 2 per sim → each team's share = sfLoser / (2 * N). // QF losers: 4 per sim → each team's share = qfLoser / (4 * N). const sfDivisor = 2 * NUM_SIMULATIONS; const qfDivisor = 4 * NUM_SIMULATIONS; return allParticipantIds.map((id) => { const c = counts.get(id) ?? { champion: 0, finalist: 0, sfLoser: 0, qfLoser: 0 }; const sfProb = c.sfLoser / sfDivisor; const qfProb = c.qfLoser / qfDivisor; return { participantId: id, probabilities: { probFirst: c.champion / NUM_SIMULATIONS, probSecond: c.finalist / NUM_SIMULATIONS, probThird: sfProb, probFourth: sfProb, probFifth: qfProb, probSixth: qfProb, probSeventh: qfProb, probEighth: qfProb, }, source: "cfp_monte_carlo", }; }); } }