brackt/app/services/simulations/ncaa-football-simulator.ts
Chris Parsons 8cfbd109da 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>
2026-04-08 12:55:19 +00:00

280 lines
12 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, blend the Elo-based and
* odds-based per-game win probabilities: P = ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb
* 4. Sort teams by blended strength (descending) to assign seeds 112
* 5. Simulate 50,000 CFP brackets per the official seeding structure:
* 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
* 6. Track placement counts per scoring tier
* 7. Convert counts to probability distributions
*
* Win probability: eloWinProbability() from probability-engine (standard 400-divisor Elo formula).
*
* Futures blending (when sourceOdds are present):
* P(game) = ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb
* ELO_WEIGHT = 0.6, ODDS_WEIGHT = 0.4
* A slightly lower Elo weight than other sports (0.7) gives more influence to
* Vegas championship odds, which are highly informative in college football.
* Falls back to Elo-only when no sourceOdds are stored.
*
* 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)
*
* Admin setup:
* 1. Create a Sport with simulatorType = "ncaa_football_bracket"
* 2. Create a Sports Season and add 12 team participants
* 3. Enter FPI ratings via Admin → Elo Ratings (stored as sourceElo)
* 4. Optionally enter championship futures odds via Admin → Futures Odds (stored as sourceOdds)
* 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;
/**
* Weight for the Elo-based probability component.
* Remaining (ODDS_WEIGHT) goes to the Vegas futures-derived component.
*/
const ELO_WEIGHT = 0.6;
const ODDS_WEIGHT = 1 - ELO_WEIGHT;
// ─── Types ────────────────────────────────────────────────────────────────────
interface Team {
participantId: string;
elo: number;
/** Normalized futures win probability (01). Used for blending when odds available. */
oddsProb: number;
}
// ─── Win probability helpers ──────────────────────────────────────────────────
/**
* Blended win probability for team1 vs team2.
* When oddsProbs are both 0 (no futures data), falls back to pure Elo.
*/
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 };
}
// ─── Bracket simulation ───────────────────────────────────────────────────────
interface PlacementCounts {
champion: number;
finalist: number;
sfLoser: number;
qfLoser: number;
}
/**
* Simulate one full 12-team CFP bracket.
*
* Seeding (teams sorted best→worst, 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
counts.get(qf1.loser.participantId)!.qfLoser++;
counts.get(qf2.loser.participantId)!.qfLoser++;
counts.get(qf3.loser.participantId)!.qfLoser++;
counts.get(qf4.loser.participantId)!.qfLoser++;
// ── Semifinals ────────────────────────────────────────────────────────────
const sf1 = simGame(qf1.winner, qf2.winner);
const sf2 = simGame(qf3.winner, qf4.winner);
counts.get(sf1.loser.participantId)!.sfLoser++;
counts.get(sf2.loser.participantId)!.sfLoser++;
// ── National Championship ─────────────────────────────────────────────────
const final = simGame(sf1.winner, sf2.winner);
counts.get(final.winner.participantId)!.champion++;
counts.get(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 === 0) {
throw new Error(`No participants found for sports season ${sportsSeasonId}.`);
}
// 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 odds maps in a single pass over evRows.
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).
// If no sourceOdds, all teams get oddsProb = 0 → falls back to pure Elo.
let 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 odds for any team that has sourceOdds but no sourceElo.
if (eloFromDb.size < participants.length) {
const oddsInput = [...rawOddsProbs.keys()]
.filter((id) => !eloFromDb.has(id))
.map((id) => ({ participantId: id, odds: evRows.find((r) => r.participantId === id)!.sourceOdds! }));
if (oddsInput.length > 0) {
const oddsEloMap = convertFuturesToElo(oddsInput, "american");
for (const [id, elo] of oddsEloMap) {
eloFromDb.set(id, elo);
}
}
}
}
// 4. Build and seed team list (top 12 by blended strength, best→worst).
const allTeams: Team[] = participants.map((p) => ({
participantId: p.id,
elo: eloFromDb.get(p.id) ?? 1500,
oddsProb: normalizedOddsMap.get(p.id) ?? 0,
}));
// Normalize Elo to [0,1] range for the blended sort score.
const eloValues = allTeams.map((t) => t.elo);
const minElo = Math.min(...eloValues);
const eloRange = (Math.max(...eloValues) - minElo) || 1;
const seededTeams = [...allTeams].sort((a, b) => {
const aScore = ELO_WEIGHT * ((a.elo - minElo) / eloRange) + ODDS_WEIGHT * a.oddsProb;
const bScore = ELO_WEIGHT * ((b.elo - minElo) / eloRange) + ODDS_WEIGHT * b.oddsProb;
return bScore - aScore;
});
if (seededTeams.length < BRACKET_SIZE) {
throw new Error(
`CFP simulator requires ${BRACKET_SIZE} participants, found ${seededTeams.length}. ` +
`Add all teams to the sports season before running simulation.`
);
}
const bracketTeams = seededTeams.slice(0, BRACKET_SIZE);
// 5. Initialise placement count accumulators for all participants.
// Teams outside the top 12 keep all zeros (0 EV).
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++) {
simulateBracket(bracketTeams, counts);
}
// 7. Convert counts to probability distributions and return.
// SF losers: 2 per sim, so each team's share = sfLoser / (2 * N).
// QF losers: 4 per sim, so 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)!;
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",
};
});
}
}