281 lines
12 KiB
TypeScript
281 lines
12 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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, 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 1–12
|
|||
|
|
* 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)
|
|||
|
|
* probFifth–probEighth = 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 (0–1). 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 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
|
|||
|
|
|
|||
|
|
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",
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|