283 lines
13 KiB
TypeScript
283 lines
13 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. Field-selection weight is a softmax over the resolved Elo. Futures odds are
|
||
* not blended into per-game win probability — the input policy already folded
|
||
* them into sourceElo, so blending again would double-count them.
|
||
* 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): eloWinProbabilityWithParity(eloA, eloB, parityFactor),
|
||
* where parityFactor comes from the season config (default 400).
|
||
*
|
||
* 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 { eloWinProbabilityWithParity } from "~/services/probability-engine";
|
||
import type { Simulator, SimulationResult } from "./types";
|
||
import { positiveConfigNumber } from "./config-access";
|
||
|
||
// ─── Simulation parameters (defaults; overridable via season config) ───────────
|
||
|
||
const DEFAULT_NUM_SIMULATIONS = 50_000;
|
||
/** Elo parity factor. Defaults to 400 (standard formula). */
|
||
const DEFAULT_PARITY_FACTOR = 400;
|
||
const BRACKET_SIZE = 12;
|
||
|
||
/**
|
||
* Softmax temperature for Elo-based selection weights (pre-bracket mode).
|
||
* 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;
|
||
/** Single resolved Elo (already a blend of any raw Elo/FPI and futures odds). */
|
||
elo: number;
|
||
/** Weight used for probabilistic CFP field selection (pre-bracket mode only). */
|
||
selectionWeight: number;
|
||
}
|
||
|
||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||
|
||
/** Win probability for team1 vs team2 from the single resolved Elo. */
|
||
function gameWinProb(team1: Team, team2: Team, parityFactor = DEFAULT_PARITY_FACTOR): number {
|
||
return eloWinProbabilityWithParity(team1.elo, team2.elo, parityFactor);
|
||
}
|
||
|
||
function simGame(team1: Team, team2: Team, parityFactor = DEFAULT_PARITY_FACTOR): { winner: Team; loser: Team } {
|
||
const p1Wins = Math.random() < gameWinProb(team1, team2, parityFactor);
|
||
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>, parityFactor = DEFAULT_PARITY_FACTOR): void {
|
||
const game = (a: Team, b: Team) => simGame(a, b, parityFactor);
|
||
// ── First Round (seeds 5–12) ───────────────────────────────────────────────
|
||
const fr1 = game(teams[4], teams[11]); // 5 vs 12
|
||
const fr2 = game(teams[5], teams[10]); // 6 vs 11
|
||
const fr3 = game(teams[6], teams[9]); // 7 vs 10
|
||
const fr4 = game(teams[7], teams[8]); // 8 vs 9
|
||
|
||
// ── Quarterfinals (seeds 1–4 get byes) ────────────────────────────────────
|
||
const qf1 = game(teams[0], fr4.winner); // 1 vs 8/9 winner
|
||
const qf2 = game(teams[3], fr1.winner); // 4 vs 5/12 winner
|
||
const qf3 = game(teams[2], fr2.winner); // 3 vs 6/11 winner
|
||
const qf4 = game(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 = game(qf1.winner, qf2.winner);
|
||
const sf2 = game(qf3.winner, qf4.winner);
|
||
|
||
bump(sf1.loser.participantId, "sfLoser");
|
||
bump(sf2.loser.participantId, "sfLoser");
|
||
|
||
// ── National Championship ─────────────────────────────────────────────────
|
||
const final = game(sf1.winner, sf2.winner);
|
||
|
||
bump(final.winner.participantId, "champion");
|
||
bump(final.loser.participantId, "finalist");
|
||
}
|
||
|
||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||
|
||
export class NCAAFootballSimulator implements Simulator {
|
||
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||
const db = database();
|
||
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
|
||
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
||
// 1. Load all participants for this sports season.
|
||
const participants = await db
|
||
.select({ id: schema.seasonParticipants.id })
|
||
.from(schema.seasonParticipants)
|
||
.where(eq(schema.seasonParticipants.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 the resolved single Elo (the input policy already blended any
|
||
// raw Elo/FPI and futures odds into sourceElo before the run).
|
||
const evRows = await db
|
||
.select({
|
||
participantId: schema.seasonParticipantExpectedValues.participantId,
|
||
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
|
||
})
|
||
.from(schema.seasonParticipantExpectedValues)
|
||
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
|
||
|
||
const eloFromDb = new Map<string, number>();
|
||
for (const row of evRows) {
|
||
if (row.sourceElo !== null) {
|
||
eloFromDb.set(row.participantId, row.sourceElo);
|
||
}
|
||
}
|
||
|
||
// 3. Build team list; field-selection weight is a softmax on the resolved Elo.
|
||
const allTeams: Team[] = participants.map((p) => ({
|
||
participantId: p.id,
|
||
elo: eloFromDb.get(p.id) ?? 1500,
|
||
selectionWeight: 0, // computed below
|
||
}));
|
||
|
||
const maxElo = Math.max(...allTeams.map((t) => t.elo));
|
||
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 = expSum > 0 ? expWeights[i] / expSum : 1 / allTeams.length;
|
||
}
|
||
|
||
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 < numSimulations; s++) {
|
||
const field = preBracketMode
|
||
? sampleBracketField(allTeams, BRACKET_SIZE)
|
||
: (deterministicField ?? []);
|
||
simulateBracket(field, counts, parityFactor);
|
||
}
|
||
|
||
// 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 * numSimulations;
|
||
const qfDivisor = 4 * numSimulations;
|
||
|
||
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 / numSimulations,
|
||
probSecond: c.finalist / numSimulations,
|
||
probThird: sfProb,
|
||
probFourth: sfProb,
|
||
probFifth: qfProb,
|
||
probSixth: qfProb,
|
||
probSeventh: qfProb,
|
||
probEighth: qfProb,
|
||
},
|
||
source: "cfp_monte_carlo",
|
||
};
|
||
});
|
||
}
|
||
}
|