brackt/app/services/simulations/ncaa-football-simulator.ts
Chris Parsons 4d5fea05ab
Add NCAA Football CFP simulator (12-team bracket) (#278)
Fixes #124

* Add NCAA Football CFP simulator (12-team bracket)

Implements a Monte Carlo simulator for the College Football Playoff using
the 2024-present 12-team format. Elo/FPI ratings are entered manually via
the existing admin Elo Ratings page; championship futures odds can
optionally be blended in (60% Elo / 40% odds).

- Add CFP_12 bracket template (First Round not scoring, QFs onward score)
- Add generateCFP12Bracket() with correct seeding: 5v12, 6v11, 7v10, 8v9
  in First Round; seeds 1–4 receive QF byes
- Add NCAAFootballSimulator: 50k Monte Carlo sims, seeds teams by blended
  Elo+odds strength, tracks champion/finalist/SF/QF placement tiers
- Register ncaa_football_bracket simulator type in registry and schema enum
- Add migration 0071: ALTER TYPE simulator_type ADD VALUE 'ncaa_football_bracket'
- Add tests: 30 tests covering bracket template structure and simulator
  probability distributions, seeding, edge cases, futures blending

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix lint errors in NCAA Football CFP simulator

Replace non-null assertions with optional chaining, change let to const,
use toSorted() instead of sort(), and add a bump() helper to avoid
repeated map lookups with non-null assertions in simulateBracket.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix TS2345 in NCAA football simulator test

Add ?? 0 fallback so optional-chained probFirst is number, not number | undefined.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 09:48:32 -04:00

350 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* NCAA Football CFP Simulator
*
* Monte Carlo simulation of the College Football Playoff (12-team format, 2024present).
*
* 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)
* probFifthprobEighth = 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 (01). 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<string, PlacementCounts>): void {
// ── First Round (seeds 512) ───────────────────────────────────────────────
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 14 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<SimulationResult[]> {
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<string, number>();
const rawOddsProbs = new Map<string, number>();
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<string, number>();
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<string, PlacementCounts>(
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",
};
});
}
}