476 lines
19 KiB
TypeScript
476 lines
19 KiB
TypeScript
/**
|
||
* NFL Season + Playoffs Simulator
|
||
*
|
||
* Monte Carlo simulation of the NFL regular season and playoffs.
|
||
*
|
||
* Algorithm:
|
||
* 1. Load all participants for the sports season from DB
|
||
* 2. Load Elo ratings from participantExpectedValues.sourceElo (user-maintained)
|
||
* Falls back to sourceOdds → convertFuturesToElo() if no sourceElo set.
|
||
* 3. Validate that all 8 NFL divisions have at least one Elo-rated team.
|
||
* 4. Load current regular season standings (wins, gamesPlayed, conference, division)
|
||
* If no standings in DB → pre-season mode (all teams start 0-0).
|
||
* 5. For each simulation:
|
||
* a. Simulate remaining games (17 total) for each team using Elo vs average (1500)
|
||
* → projectedWins = currentWins + Binomial(remainingGames, winProb)
|
||
* b. Per conference (AFC, NFC):
|
||
* - Division winners: best projectedWins in each of 4 divisions (random tiebreak)
|
||
* - Wildcards: top 3 non-division-winners by projectedWins
|
||
* - Seed 1: best division winner (gets bye)
|
||
* - Seeds 2-4: remaining division winners, sorted by projectedWins desc
|
||
* - Seeds 5-7: wildcards, sorted by projectedWins desc
|
||
* c. Simulate Wild Card (seed 1 has bye): 2v7, 3v6, 4v5 per conference
|
||
* Higher seed has home-field advantage (+48 Elo ≈ 57% baseline win rate).
|
||
* d. Simulate Divisional: seed 1 vs lowest remaining, seed 2 vs next
|
||
* (higher seed is home)
|
||
* e. Simulate Conference Championships (higher remaining seed is home)
|
||
* f. Simulate Super Bowl at a neutral site (no home-field adjustment)
|
||
* 6. Track placements:
|
||
* - probFirst / probSecond: Super Bowl winner / loser
|
||
* - probThird / probFourth: Conference Championship losers (1 per conf, split evenly)
|
||
* - probFifth–probEighth: Divisional losers (4 total, split evenly)
|
||
* - Wild Card losers and missed playoffs → 0
|
||
*
|
||
* Elo ratings are maintained by the admin via the sourceElo field on
|
||
* participantExpectedValues. Update before each simulation run.
|
||
* Source: nfelo.app (nfelo ratings system)
|
||
*
|
||
* Home-field advantage:
|
||
* NFL playoff home field is worth ~+48 Elo points (≈57% baseline win rate for
|
||
* the higher seed). This applies to Wild Card, Divisional, and Conference
|
||
* Championship rounds. The Super Bowl is played at a neutral site.
|
||
*/
|
||
|
||
import { database } from "~/database/context";
|
||
import { eq } from "drizzle-orm";
|
||
import * as schema from "~/database/schema";
|
||
import type { Simulator, SimulationResult } from "./types";
|
||
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
||
import { eloWinProbabilityWithParity, convertFuturesToElo } from "~/services/probability-engine";
|
||
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
||
import { positiveConfigNumber } from "./config-access";
|
||
|
||
// ─── Simulation parameters (defaults; overridable via season config) ───────────
|
||
|
||
const DEFAULT_NUM_SIMULATIONS = 50_000;
|
||
|
||
/** Elo parity factor. NFL defaults to 400 (standard formula). */
|
||
const DEFAULT_PARITY_FACTOR = 400;
|
||
|
||
/** NFL regular season games per team. */
|
||
const DEFAULT_REGULAR_SEASON_GAMES = 17;
|
||
|
||
/** Average opponent Elo for regular season game projection. */
|
||
const AVG_OPPONENT_ELO = 1500;
|
||
|
||
/**
|
||
* Home-field Elo advantage for NFL playoff games.
|
||
* Adds to the higher seed's Elo before computing win probability.
|
||
* +48 Elo ≈ 57% baseline win rate for the home team, consistent with
|
||
* NFL regular-season home-field data (~2.5 point spread advantage).
|
||
*/
|
||
const NFL_HOME_FIELD_ELO_ADVANTAGE = 48;
|
||
|
||
type Conference = "AFC" | "NFC";
|
||
type Division = "East" | "North" | "South" | "West";
|
||
|
||
const CONFERENCES: Conference[] = ["AFC", "NFC"];
|
||
const DIVISIONS: Division[] = ["East", "North", "South", "West"];
|
||
|
||
// ─── Team data ────────────────────────────────────────────────────────────────
|
||
|
||
interface NflTeamData {
|
||
names: string[];
|
||
conference: Conference;
|
||
division: Division;
|
||
}
|
||
|
||
const TEAMS_DATA: NflTeamData[] = [
|
||
// AFC East
|
||
{ names: ["Buffalo Bills", "Bills"], conference: "AFC", division: "East" },
|
||
{ names: ["Miami Dolphins", "Dolphins"], conference: "AFC", division: "East" },
|
||
{ names: ["New England Patriots", "Patriots"], conference: "AFC", division: "East" },
|
||
{ names: ["New York Jets", "Jets"], conference: "AFC", division: "East" },
|
||
|
||
// AFC North
|
||
{ names: ["Baltimore Ravens", "Ravens"], conference: "AFC", division: "North" },
|
||
{ names: ["Cincinnati Bengals", "Bengals"], conference: "AFC", division: "North" },
|
||
{ names: ["Cleveland Browns", "Browns"], conference: "AFC", division: "North" },
|
||
{ names: ["Pittsburgh Steelers", "Steelers"], conference: "AFC", division: "North" },
|
||
|
||
// AFC South
|
||
{ names: ["Houston Texans", "Texans"], conference: "AFC", division: "South" },
|
||
{ names: ["Indianapolis Colts", "Colts"], conference: "AFC", division: "South" },
|
||
{ names: ["Jacksonville Jaguars", "Jaguars"], conference: "AFC", division: "South" },
|
||
{ names: ["Tennessee Titans", "Titans"], conference: "AFC", division: "South" },
|
||
|
||
// AFC West
|
||
{ names: ["Denver Broncos", "Broncos"], conference: "AFC", division: "West" },
|
||
{ names: ["Kansas City Chiefs", "Chiefs"], conference: "AFC", division: "West" },
|
||
{ names: ["Las Vegas Raiders", "Raiders"], conference: "AFC", division: "West" },
|
||
{ names: ["Los Angeles Chargers", "Chargers"], conference: "AFC", division: "West" },
|
||
|
||
// NFC East
|
||
{ names: ["Dallas Cowboys", "Cowboys"], conference: "NFC", division: "East" },
|
||
{ names: ["New York Giants", "Giants"], conference: "NFC", division: "East" },
|
||
{ names: ["Philadelphia Eagles", "Eagles"], conference: "NFC", division: "East" },
|
||
{ names: ["Washington Commanders", "Commanders"], conference: "NFC", division: "East" },
|
||
|
||
// NFC North
|
||
{ names: ["Chicago Bears", "Bears"], conference: "NFC", division: "North" },
|
||
{ names: ["Detroit Lions", "Lions"], conference: "NFC", division: "North" },
|
||
{ names: ["Green Bay Packers", "Packers"], conference: "NFC", division: "North" },
|
||
{ names: ["Minnesota Vikings", "Vikings"], conference: "NFC", division: "North" },
|
||
|
||
// NFC South
|
||
{ names: ["Atlanta Falcons", "Falcons"], conference: "NFC", division: "South" },
|
||
{ names: ["Carolina Panthers", "Panthers"], conference: "NFC", division: "South" },
|
||
{ names: ["New Orleans Saints", "Saints"], conference: "NFC", division: "South" },
|
||
{ names: ["Tampa Bay Buccaneers", "Buccaneers"], conference: "NFC", division: "South" },
|
||
|
||
// NFC West
|
||
{ names: ["Arizona Cardinals", "Cardinals"], conference: "NFC", division: "West" },
|
||
{ names: ["Los Angeles Rams", "Rams"], conference: "NFC", division: "West" },
|
||
{ names: ["San Francisco 49ers", "49ers"], conference: "NFC", division: "West" },
|
||
{ names: ["Seattle Seahawks", "Seahawks"], conference: "NFC", division: "West" },
|
||
];
|
||
|
||
// ─── Internal types ───────────────────────────────────────────────────────────
|
||
|
||
interface TeamState {
|
||
participantId: string;
|
||
conference: Conference;
|
||
division: Division;
|
||
elo: number;
|
||
currentWins: number;
|
||
gamesPlayed: number;
|
||
}
|
||
|
||
interface SeededTeam {
|
||
participantId: string;
|
||
elo: number;
|
||
seed: number;
|
||
}
|
||
|
||
interface ConferenceBracketResult {
|
||
finalist: SeededTeam;
|
||
confChampLoser: SeededTeam;
|
||
divisionalLosers: [SeededTeam, SeededTeam];
|
||
}
|
||
|
||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Look up team data by participant name.
|
||
*/
|
||
export function getTeamData(name: string): NflTeamData | undefined {
|
||
const normalized = normalizeTeamName(name);
|
||
return TEAMS_DATA.find((t) =>
|
||
t.names.some((n) => normalizeTeamName(n) === normalized)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Simulate projected wins for a team over its remaining games.
|
||
* Each game is an independent Bernoulli trial.
|
||
*/
|
||
function simulateProjectedWins(
|
||
currentWins: number,
|
||
gamesPlayed: number,
|
||
winProb: number,
|
||
seasonGames = DEFAULT_REGULAR_SEASON_GAMES
|
||
): number {
|
||
const remaining = Math.max(0, seasonGames - gamesPlayed);
|
||
let additional = 0;
|
||
for (let g = 0; g < remaining; g++) {
|
||
if (Math.random() < winProb) additional++;
|
||
}
|
||
return currentWins + additional;
|
||
}
|
||
|
||
/**
|
||
* Determine the 7-team playoff seeding for one conference.
|
||
*
|
||
* Seeds 1-4 are division winners (sorted by projectedWins desc).
|
||
* Seeds 5-7 are wildcards (top 3 non-division-winners).
|
||
* Seed 1 receives the bye.
|
||
*
|
||
* Ties in projectedWins are broken by a pre-computed random jitter, which
|
||
* avoids the instability of using Math.random() directly inside a sort comparator.
|
||
*/
|
||
export function seedConference(
|
||
teams: Array<{ participantId: string; elo: number; division: Division; projectedWins: number }>
|
||
): SeededTeam[] {
|
||
// Pre-compute per-team jitter so each sort comparison is deterministic
|
||
const jitter = new Map(teams.map((t) => [t.participantId, Math.random()]));
|
||
const cmp = (a: typeof teams[0], b: typeof teams[0]) =>
|
||
b.projectedWins - a.projectedWins ||
|
||
(jitter.get(b.participantId) ?? 0) - (jitter.get(a.participantId) ?? 0);
|
||
|
||
const divisionWinners: typeof teams[0][] = [];
|
||
const nonWinners: typeof teams[0][] = [];
|
||
|
||
for (const div of DIVISIONS) {
|
||
const divTeams = teams.filter((t) => t.division === div).toSorted(cmp);
|
||
if (divTeams.length === 0) continue;
|
||
divisionWinners.push(divTeams[0]);
|
||
nonWinners.push(...divTeams.slice(1));
|
||
}
|
||
|
||
divisionWinners.sort(cmp);
|
||
nonWinners.sort(cmp);
|
||
const wildcards = nonWinners.slice(0, 3);
|
||
|
||
const seeded: SeededTeam[] = [];
|
||
divisionWinners.forEach((t, i) =>
|
||
seeded.push({ participantId: t.participantId, elo: t.elo, seed: i + 1 })
|
||
);
|
||
wildcards.forEach((t, i) =>
|
||
seeded.push({ participantId: t.participantId, elo: t.elo, seed: i + 5 })
|
||
);
|
||
|
||
return seeded;
|
||
}
|
||
|
||
/**
|
||
* Simulate a playoff game with home-field advantage for the higher seed.
|
||
* The team with the lower seed number (better seed) is the home team.
|
||
*/
|
||
function playGame(a: SeededTeam, b: SeededTeam, parityFactor = DEFAULT_PARITY_FACTOR): { winner: SeededTeam; loser: SeededTeam } {
|
||
const [home, away] = a.seed < b.seed ? [a, b] : [b, a];
|
||
const winProbHome = eloWinProbabilityWithParity(
|
||
home.elo + NFL_HOME_FIELD_ELO_ADVANTAGE,
|
||
away.elo,
|
||
parityFactor
|
||
);
|
||
if (Math.random() < winProbHome) return { winner: home, loser: away };
|
||
return { winner: away, loser: home };
|
||
}
|
||
|
||
/**
|
||
* Simulate a game at a neutral site (no home-field adjustment).
|
||
* Used for the Super Bowl.
|
||
*/
|
||
function playNeutralGame(a: SeededTeam, b: SeededTeam, parityFactor = DEFAULT_PARITY_FACTOR): { winner: SeededTeam; loser: SeededTeam } {
|
||
const winProbA = eloWinProbabilityWithParity(a.elo, b.elo, parityFactor);
|
||
if (Math.random() < winProbA) return { winner: a, loser: b };
|
||
return { winner: b, loser: a };
|
||
}
|
||
|
||
/**
|
||
* Simulate one conference's playoff bracket (seeds 1-7, seed 1 has bye).
|
||
*
|
||
* Wild Card: 2v7, 3v6, 4v5 (seed 1 bye; higher seed is home)
|
||
* Divisional: 1 vs lowest remaining, 2 vs next (higher seed is home)
|
||
* Conference Champ: divisional winners (higher seed is home)
|
||
*
|
||
* Returns the finalist, the conference championship loser, and the two
|
||
* divisional-round losers (for 5th-8th place EV).
|
||
*/
|
||
export function simulateConferenceBracket(seeds: SeededTeam[], parityFactor = DEFAULT_PARITY_FACTOR): ConferenceBracketResult {
|
||
const byIndex = new Map(seeds.map((t) => [t.seed, t]));
|
||
const s = (seed: number) => byIndex.get(seed) as SeededTeam;
|
||
const pg = (a: SeededTeam, b: SeededTeam) => playGame(a, b, parityFactor);
|
||
|
||
// Wild Card (seed 1 bye; lower seed = home)
|
||
const wc27 = pg(s(2), s(7));
|
||
const wc36 = pg(s(3), s(6));
|
||
const wc45 = pg(s(4), s(5));
|
||
|
||
// Divisional: sort WC winners by original seed (ascending = better seed = home)
|
||
// Seed 1 hosts the lowest-ranked (highest seed number) WC winner
|
||
const wcWinners = [wc27.winner, wc36.winner, wc45.winner].toSorted(
|
||
(a, b) => a.seed - b.seed
|
||
);
|
||
|
||
const div1 = pg(s(1), wcWinners[wcWinners.length - 1]);
|
||
const div2 = pg(wcWinners[0], wcWinners[1]);
|
||
|
||
// Conference Championship (higher remaining seed is home)
|
||
const conf = pg(div1.winner, div2.winner);
|
||
|
||
return {
|
||
finalist: conf.winner,
|
||
confChampLoser: conf.loser,
|
||
divisionalLosers: [div1.loser, div2.loser],
|
||
};
|
||
}
|
||
|
||
// ─── Simulator class ──────────────────────────────────────────────────────────
|
||
|
||
export class NFLSimulator 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));
|
||
const seasonGames = Math.round(positiveConfigNumber(config, "seasonGames", DEFAULT_REGULAR_SEASON_GAMES));
|
||
|
||
// Load participants
|
||
const participants = await db.query.seasonParticipants.findMany({
|
||
where: eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId),
|
||
});
|
||
|
||
if (participants.length === 0) {
|
||
throw new Error(`No participants found for sports season ${sportsSeasonId}.`);
|
||
}
|
||
|
||
// Load EV rows (sourceElo + sourceOdds)
|
||
const evRows = await db
|
||
.select({
|
||
participantId: schema.seasonParticipantExpectedValues.participantId,
|
||
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
|
||
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
|
||
})
|
||
.from(schema.seasonParticipantExpectedValues)
|
||
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
|
||
|
||
// Build Elo map: prefer sourceElo, fall back to converting sourceOdds
|
||
const eloMap = new Map<string, number>();
|
||
|
||
const hasSourceElo = evRows.some((r) => r.sourceElo !== null);
|
||
if (hasSourceElo) {
|
||
for (const r of evRows) {
|
||
if (r.sourceElo !== null) eloMap.set(r.participantId, r.sourceElo);
|
||
}
|
||
} else {
|
||
const hasOdds = evRows.some((r) => r.sourceOdds !== null);
|
||
if (!hasOdds) {
|
||
throw new Error(
|
||
`No Elo ratings or futures odds found for sports season ${sportsSeasonId}. ` +
|
||
`Enter sourceElo ratings via Admin → Expected Values before simulating.`
|
||
);
|
||
}
|
||
const oddsInput = evRows
|
||
.filter((r) => r.sourceOdds !== null)
|
||
.map((r) => ({ participantId: r.participantId, odds: r.sourceOdds as number }));
|
||
const converted = convertFuturesToElo(oddsInput);
|
||
for (const [id, elo] of converted) eloMap.set(id, elo);
|
||
}
|
||
|
||
// Load standings (empty pre-season)
|
||
const standingsRows = await getRegularSeasonStandings(sportsSeasonId, db);
|
||
const standingsMap = new Map(standingsRows.map((r) => [r.participantId, r]));
|
||
|
||
// Build team state list — skip participants with no Elo or unknown team name
|
||
const teams: TeamState[] = [];
|
||
for (const p of participants) {
|
||
const teamData = getTeamData(p.name);
|
||
if (!teamData) continue;
|
||
const elo = eloMap.get(p.id);
|
||
if (elo === undefined) continue;
|
||
const standing = standingsMap.get(p.id);
|
||
teams.push({
|
||
participantId: p.id,
|
||
conference: teamData.conference,
|
||
division: teamData.division,
|
||
elo,
|
||
currentWins: standing?.wins ?? 0,
|
||
gamesPlayed: standing?.gamesPlayed ?? 0,
|
||
});
|
||
}
|
||
|
||
if (teams.length === 0) {
|
||
throw new Error(
|
||
`Could not match any participants to NFL team data for season ${sportsSeasonId}. ` +
|
||
`Ensure participant names match (e.g. "Kansas City Chiefs").`
|
||
);
|
||
}
|
||
|
||
// Validate that every division in every conference has at least one Elo-rated team.
|
||
// A missing division means seeding is broken for the whole simulation.
|
||
const covered = new Set(teams.map((t) => `${t.conference} ${t.division}`));
|
||
const missing: string[] = [];
|
||
for (const conf of CONFERENCES) {
|
||
for (const div of DIVISIONS) {
|
||
if (!covered.has(`${conf} ${div}`)) missing.push(`${conf} ${div}`);
|
||
}
|
||
}
|
||
if (missing.length > 0) {
|
||
throw new Error(
|
||
`Missing Elo-rated teams in: ${missing.join(", ")}. ` +
|
||
`All 32 NFL teams must have Elo ratings set before simulating.`
|
||
);
|
||
}
|
||
|
||
// Pre-compute per-team win probability vs average opponent (constant across sims)
|
||
const winProbMap = new Map<string, number>();
|
||
for (const t of teams) {
|
||
winProbMap.set(t.participantId, eloWinProbabilityWithParity(t.elo, AVG_OPPONENT_ELO, parityFactor));
|
||
}
|
||
|
||
// Placement counters
|
||
const counts: Record<string, Record<1 | 2 | 3 | 4 | 5 | 6 | 7 | 8, number>> = {};
|
||
for (const t of teams) {
|
||
counts[t.participantId] = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0 };
|
||
}
|
||
|
||
// ─── Monte Carlo ──────────────────────────────────────────────────────────
|
||
|
||
for (let sim = 0; sim < numSimulations; sim++) {
|
||
// Project wins for each team this simulation
|
||
const projected = teams.map((t) => ({
|
||
participantId: t.participantId,
|
||
elo: t.elo,
|
||
conference: t.conference,
|
||
division: t.division,
|
||
projectedWins: simulateProjectedWins(
|
||
t.currentWins,
|
||
t.gamesPlayed,
|
||
winProbMap.get(t.participantId) as number,
|
||
seasonGames
|
||
),
|
||
}));
|
||
|
||
const afcTeams = projected.filter((t) => t.conference === "AFC");
|
||
const nfcTeams = projected.filter((t) => t.conference === "NFC");
|
||
const afcSeeds = seedConference(afcTeams);
|
||
const nfcSeeds = seedConference(nfcTeams);
|
||
|
||
// Simulate conference brackets
|
||
const afcResult = simulateConferenceBracket(afcSeeds, parityFactor);
|
||
const nfcResult = simulateConferenceBracket(nfcSeeds, parityFactor);
|
||
|
||
// Super Bowl (neutral site)
|
||
const sb = playNeutralGame(afcResult.finalist, nfcResult.finalist, parityFactor);
|
||
|
||
// 1st / 2nd
|
||
counts[sb.winner.participantId][1] += 1;
|
||
counts[sb.loser.participantId][2] += 1;
|
||
|
||
// 3rd / 4th — Conference Championship losers (1 per conference, split 50/50)
|
||
counts[afcResult.confChampLoser.participantId][3] += 0.5;
|
||
counts[afcResult.confChampLoser.participantId][4] += 0.5;
|
||
counts[nfcResult.confChampLoser.participantId][3] += 0.5;
|
||
counts[nfcResult.confChampLoser.participantId][4] += 0.5;
|
||
|
||
// 5th–8th — Divisional losers (4 total: 2 per conference, split evenly)
|
||
for (const loser of [...afcResult.divisionalLosers, ...nfcResult.divisionalLosers]) {
|
||
counts[loser.participantId][5] += 0.25;
|
||
counts[loser.participantId][6] += 0.25;
|
||
counts[loser.participantId][7] += 0.25;
|
||
counts[loser.participantId][8] += 0.25;
|
||
}
|
||
// Wild Card losers and missed playoffs remain at 0 (already initialized)
|
||
}
|
||
|
||
// Convert counts to probabilities
|
||
return teams.map((t) => {
|
||
const c = counts[t.participantId];
|
||
const N = numSimulations;
|
||
return {
|
||
participantId: t.participantId,
|
||
probabilities: {
|
||
probFirst: c[1] / N,
|
||
probSecond: c[2] / N,
|
||
probThird: c[3] / N,
|
||
probFourth: c[4] / N,
|
||
probFifth: c[5] / N,
|
||
probSixth: c[6] / N,
|
||
probSeventh: c[7] / N,
|
||
probEighth: c[8] / N,
|
||
},
|
||
source: "nfl_elo_simulation",
|
||
};
|
||
});
|
||
}
|
||
}
|