Add NFL season + playoffs Monte Carlo simulator (#272)
Implements a new `nfl_bracket` simulator that projects the NFL regular season and full playoff bracket using nfelo Elo ratings. - Simulates remaining regular season games (17 total) per team using Elo win probability vs an average opponent, then seeds both conferences (division winners = seeds 1–4, wildcards = 5–7) per simulation - Simulates Wild Card, Divisional, Conference Championship, and Super Bowl rounds with correct NFL bracket structure (seed 1 bye) - Applies +48 Elo home-field advantage (~57% win rate) for all rounds except the neutral-site Super Bowl - Pre-season mode (no standings in DB): simulates all 17 games from scratch; falls back to futures odds → Elo conversion if no sourceElo - Validates all 8 NFL divisions have Elo-rated teams before simulating, with a clear error listing missing divisions - Registers as `nfl_bracket` simulator type in the registry and schema - Adds migration to extend the `simulator_type` enum - Also updates drizzle.config.ts to prefer DIRECT_DATABASE_URL when set Fixes #129 Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
787b817fa7
commit
102cb781ad
7 changed files with 5260 additions and 1 deletions
305
app/services/simulations/__tests__/nfl-simulator.test.ts
Normal file
305
app/services/simulations/__tests__/nfl-simulator.test.ts
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
getTeamData,
|
||||
seedConference,
|
||||
simulateConferenceBracket,
|
||||
} from "../nfl-simulator";
|
||||
|
||||
// ─── Team data ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("getTeamData", () => {
|
||||
it("finds teams by full name", () => {
|
||||
expect(getTeamData("Kansas City Chiefs")).toMatchObject({
|
||||
conference: "AFC",
|
||||
division: "West",
|
||||
});
|
||||
expect(getTeamData("Philadelphia Eagles")).toMatchObject({
|
||||
conference: "NFC",
|
||||
division: "East",
|
||||
});
|
||||
});
|
||||
|
||||
it("finds teams by short name", () => {
|
||||
expect(getTeamData("Chiefs")).toMatchObject({ conference: "AFC", division: "West" });
|
||||
expect(getTeamData("Eagles")).toMatchObject({ conference: "NFC", division: "East" });
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
expect(getTeamData("kansas city chiefs")).toBeDefined();
|
||||
expect(getTeamData("PATRIOTS")).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns undefined for unknown teams", () => {
|
||||
expect(getTeamData("Springfield Isotopes")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("covers all 32 NFL teams across both conferences", () => {
|
||||
const allTeams = [
|
||||
// AFC East
|
||||
"Buffalo Bills", "Miami Dolphins", "New England Patriots", "New York Jets",
|
||||
// AFC North
|
||||
"Baltimore Ravens", "Cincinnati Bengals", "Cleveland Browns", "Pittsburgh Steelers",
|
||||
// AFC South
|
||||
"Houston Texans", "Indianapolis Colts", "Jacksonville Jaguars", "Tennessee Titans",
|
||||
// AFC West
|
||||
"Denver Broncos", "Kansas City Chiefs", "Las Vegas Raiders", "Los Angeles Chargers",
|
||||
// NFC East
|
||||
"Dallas Cowboys", "New York Giants", "Philadelphia Eagles", "Washington Commanders",
|
||||
// NFC North
|
||||
"Chicago Bears", "Detroit Lions", "Green Bay Packers", "Minnesota Vikings",
|
||||
// NFC South
|
||||
"Atlanta Falcons", "Carolina Panthers", "New Orleans Saints", "Tampa Bay Buccaneers",
|
||||
// NFC West
|
||||
"Arizona Cardinals", "Los Angeles Rams", "San Francisco 49ers", "Seattle Seahawks",
|
||||
];
|
||||
|
||||
for (const name of allTeams) {
|
||||
expect(getTeamData(name), `${name} not found`).toBeDefined();
|
||||
}
|
||||
expect(allTeams).toHaveLength(32);
|
||||
});
|
||||
|
||||
it("has exactly 16 AFC and 16 NFC teams", () => {
|
||||
const allTeams = [
|
||||
"Buffalo Bills", "Miami Dolphins", "New England Patriots", "New York Jets",
|
||||
"Baltimore Ravens", "Cincinnati Bengals", "Cleveland Browns", "Pittsburgh Steelers",
|
||||
"Houston Texans", "Indianapolis Colts", "Jacksonville Jaguars", "Tennessee Titans",
|
||||
"Denver Broncos", "Kansas City Chiefs", "Las Vegas Raiders", "Los Angeles Chargers",
|
||||
"Dallas Cowboys", "New York Giants", "Philadelphia Eagles", "Washington Commanders",
|
||||
"Chicago Bears", "Detroit Lions", "Green Bay Packers", "Minnesota Vikings",
|
||||
"Atlanta Falcons", "Carolina Panthers", "New Orleans Saints", "Tampa Bay Buccaneers",
|
||||
"Arizona Cardinals", "Los Angeles Rams", "San Francisco 49ers", "Seattle Seahawks",
|
||||
];
|
||||
|
||||
const afcCount = allTeams.filter((n) => getTeamData(n)?.conference === "AFC").length;
|
||||
const nfcCount = allTeams.filter((n) => getTeamData(n)?.conference === "NFC").length;
|
||||
expect(afcCount).toBe(16);
|
||||
expect(nfcCount).toBe(16);
|
||||
});
|
||||
|
||||
it("has exactly 4 teams per division per conference", () => {
|
||||
const allTeams = [
|
||||
"Buffalo Bills", "Miami Dolphins", "New England Patriots", "New York Jets",
|
||||
"Baltimore Ravens", "Cincinnati Bengals", "Cleveland Browns", "Pittsburgh Steelers",
|
||||
"Houston Texans", "Indianapolis Colts", "Jacksonville Jaguars", "Tennessee Titans",
|
||||
"Denver Broncos", "Kansas City Chiefs", "Las Vegas Raiders", "Los Angeles Chargers",
|
||||
"Dallas Cowboys", "New York Giants", "Philadelphia Eagles", "Washington Commanders",
|
||||
"Chicago Bears", "Detroit Lions", "Green Bay Packers", "Minnesota Vikings",
|
||||
"Atlanta Falcons", "Carolina Panthers", "New Orleans Saints", "Tampa Bay Buccaneers",
|
||||
"Arizona Cardinals", "Los Angeles Rams", "San Francisco 49ers", "Seattle Seahawks",
|
||||
];
|
||||
const divisions = ["East", "North", "South", "West"] as const;
|
||||
const conferences = ["AFC", "NFC"] as const;
|
||||
|
||||
for (const conf of conferences) {
|
||||
for (const div of divisions) {
|
||||
const count = allTeams.filter((n) => {
|
||||
const d = getTeamData(n);
|
||||
return d?.conference === conf && d?.division === div;
|
||||
}).length;
|
||||
expect(count, `${conf} ${div}`).toBe(4);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── seedConference ───────────────────────────────────────────────────────────
|
||||
|
||||
function makeTeam(
|
||||
id: string,
|
||||
division: "East" | "North" | "South" | "West",
|
||||
projectedWins: number,
|
||||
elo = 1500
|
||||
) {
|
||||
return { participantId: id, elo, division, projectedWins };
|
||||
}
|
||||
|
||||
describe("seedConference", () => {
|
||||
// Build a deterministic 16-team AFC where wins are all unique (no tiebreaks needed)
|
||||
const afcTeams = [
|
||||
// East: Bills win
|
||||
makeTeam("bills", "East", 13),
|
||||
makeTeam("dolphins", "East", 9),
|
||||
makeTeam("patriots", "East", 5),
|
||||
makeTeam("jets", "East", 4),
|
||||
// North: Ravens win
|
||||
makeTeam("ravens", "North", 12),
|
||||
makeTeam("bengals", "North", 10),
|
||||
makeTeam("steelers", "North", 8),
|
||||
makeTeam("browns", "North", 6),
|
||||
// South: Texans win
|
||||
makeTeam("texans", "South", 11),
|
||||
makeTeam("colts", "South", 7),
|
||||
makeTeam("jaguars", "South", 3),
|
||||
makeTeam("titans", "South", 2),
|
||||
// West: Chiefs win
|
||||
makeTeam("chiefs", "West", 14),
|
||||
makeTeam("chargers", "West", 10.5), // non-integer to stay unique
|
||||
makeTeam("broncos", "West", 8.5),
|
||||
makeTeam("raiders", "West", 1),
|
||||
];
|
||||
|
||||
it("returns 7 seeds", () => {
|
||||
const seeds = seedConference(afcTeams);
|
||||
expect(seeds).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("seed 1 is the team with the most wins (Chiefs, 14)", () => {
|
||||
const seeds = seedConference(afcTeams);
|
||||
const seed1 = seeds.find((s) => s.seed === 1);
|
||||
expect(seed1?.participantId).toBe("chiefs");
|
||||
});
|
||||
|
||||
it("division winners occupy seeds 1-4", () => {
|
||||
const seeds = seedConference(afcTeams);
|
||||
const divWinnerIds = new Set(["chiefs", "bills", "ravens", "texans"]);
|
||||
const seedsOneToFour = seeds.filter((s) => s.seed <= 4).map((s) => s.participantId);
|
||||
expect(new Set(seedsOneToFour)).toEqual(divWinnerIds);
|
||||
});
|
||||
|
||||
it("wildcards occupy seeds 5-7", () => {
|
||||
const seeds = seedConference(afcTeams);
|
||||
// Wildcards = top 3 non-division-winners: bengals(10), chargers(10.5), colts(7)
|
||||
const wcIds = seeds.filter((s) => s.seed >= 5).map((s) => s.participantId);
|
||||
expect(wcIds).toHaveLength(3);
|
||||
// Top 3 non-winners by wins: chargers(10.5), bengals(10), dolphins(9)
|
||||
expect(new Set(wcIds)).toEqual(new Set(["chargers", "bengals", "dolphins"]));
|
||||
});
|
||||
|
||||
it("seed 5 is the best wildcard", () => {
|
||||
const seeds = seedConference(afcTeams);
|
||||
const seed5 = seeds.find((s) => s.seed === 5);
|
||||
// chargers has 10.5 wins, highest non-division-winner
|
||||
expect(seed5?.participantId).toBe("chargers");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── simulateConferenceBracket ────────────────────────────────────────────────
|
||||
|
||||
describe("simulateConferenceBracket", () => {
|
||||
// Dominant team: one team has overwhelming Elo advantage
|
||||
const dominantSeeds = [
|
||||
{ participantId: "king", elo: 2500, seed: 1 }, // bye, nearly unbeatable
|
||||
{ participantId: "s2", elo: 1500, seed: 2 },
|
||||
{ participantId: "s3", elo: 1500, seed: 3 },
|
||||
{ participantId: "s4", elo: 1500, seed: 4 },
|
||||
{ participantId: "s5", elo: 1500, seed: 5 },
|
||||
{ participantId: "s6", elo: 1500, seed: 6 },
|
||||
{ participantId: "s7", elo: 1500, seed: 7 },
|
||||
];
|
||||
|
||||
it("returns a finalist, confChampLoser, and two divisionalLosers", () => {
|
||||
const result = simulateConferenceBracket(dominantSeeds);
|
||||
expect(result.finalist).toBeDefined();
|
||||
expect(result.confChampLoser).toBeDefined();
|
||||
expect(result.divisionalLosers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("finalist and confChampLoser are different teams", () => {
|
||||
const result = simulateConferenceBracket(dominantSeeds);
|
||||
expect(result.finalist.participantId).not.toBe(result.confChampLoser.participantId);
|
||||
});
|
||||
|
||||
it("all returned participant IDs are distinct", () => {
|
||||
const result = simulateConferenceBracket(dominantSeeds);
|
||||
const ids = [
|
||||
result.finalist.participantId,
|
||||
result.confChampLoser.participantId,
|
||||
...result.divisionalLosers.map((l) => l.participantId),
|
||||
];
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("dominant seed 1 team reaches the conference championship with high frequency", () => {
|
||||
// Run 200 trials; seed 1 with Elo 2500 should reach conf champ almost always
|
||||
let reachedConfChamp = 0;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const result = simulateConferenceBracket(dominantSeeds);
|
||||
if (
|
||||
result.finalist.participantId === "king" ||
|
||||
result.confChampLoser.participantId === "king"
|
||||
) {
|
||||
reachedConfChamp++;
|
||||
}
|
||||
}
|
||||
// With Elo 2500 vs 1500, P(win single game) ≈ 0.9997; reaching conf champ ≈ near certain
|
||||
expect(reachedConfChamp).toBeGreaterThan(190);
|
||||
});
|
||||
|
||||
it("higher seed wins more often than lower seed due to home-field advantage", () => {
|
||||
// Evenly matched teams (same Elo). Seed 2 should beat seed 7 more than 50%
|
||||
// because seed 2 is the home team (+48 Elo ≈ 57% expected win rate).
|
||||
const evenSeeds = [
|
||||
{ participantId: "s1", elo: 1500, seed: 1 },
|
||||
{ participantId: "s2", elo: 1500, seed: 2 },
|
||||
{ participantId: "s3", elo: 1500, seed: 3 },
|
||||
{ participantId: "s4", elo: 1500, seed: 4 },
|
||||
{ participantId: "s5", elo: 1500, seed: 5 },
|
||||
{ participantId: "s6", elo: 1500, seed: 6 },
|
||||
{ participantId: "s7", elo: 1500, seed: 7 },
|
||||
];
|
||||
let seed2WonWildCard = 0;
|
||||
const trials = 500;
|
||||
for (let i = 0; i < trials; i++) {
|
||||
const result = simulateConferenceBracket(evenSeeds);
|
||||
// If s7 is absent from all scored positions, it lost in Wild Card to seed 2
|
||||
const s7Survived =
|
||||
result.divisionalLosers.some((l) => l.participantId === "s7") ||
|
||||
result.confChampLoser.participantId === "s7" ||
|
||||
result.finalist.participantId === "s7";
|
||||
if (!s7Survived) seed2WonWildCard++;
|
||||
}
|
||||
// Seed 2 (home, +48 Elo) vs seed 7 (away) with equal base Elo → ~57% win rate
|
||||
expect(seed2WonWildCard).toBeGreaterThan(trials * 0.50);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Smoke test ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("seedConference + simulateConferenceBracket integration", () => {
|
||||
it("produces valid probability-like fractions when run many times", () => {
|
||||
const afcTeams = [
|
||||
makeTeam("bills", "East", 13, 1650),
|
||||
makeTeam("dolphins", "East", 9, 1480),
|
||||
makeTeam("patriots", "East", 5, 1400),
|
||||
makeTeam("jets", "East", 4, 1380),
|
||||
makeTeam("ravens", "North", 12, 1620),
|
||||
makeTeam("bengals", "North", 10, 1520),
|
||||
makeTeam("steelers", "North", 8, 1490),
|
||||
makeTeam("browns", "North", 6, 1440),
|
||||
makeTeam("texans", "South", 11, 1560),
|
||||
makeTeam("colts", "South", 7, 1460),
|
||||
makeTeam("jaguars", "South", 3, 1390),
|
||||
makeTeam("titans", "South", 2, 1370),
|
||||
makeTeam("chiefs", "West", 14, 1700),
|
||||
makeTeam("chargers", "West", 10.5, 1530),
|
||||
makeTeam("broncos", "West", 8.5, 1500),
|
||||
makeTeam("raiders", "West", 1, 1360),
|
||||
];
|
||||
|
||||
const finalistCounts: Record<string, number> = {};
|
||||
|
||||
const N = 500;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const seeds = seedConference(afcTeams);
|
||||
if (seeds.length < 7) continue;
|
||||
const result = simulateConferenceBracket(seeds);
|
||||
const id = result.finalist.participantId;
|
||||
finalistCounts[id] = (finalistCounts[id] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Every team that appeared as finalist should have a non-negative count
|
||||
for (const count of Object.values(finalistCounts)) {
|
||||
expect(count).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// Total finalist appearances = N
|
||||
const total = Object.values(finalistCounts).reduce((a, b) => a + b, 0);
|
||||
expect(total).toBe(N);
|
||||
|
||||
// Strong teams (Chiefs, Ravens, Bills) should make conference finals more often
|
||||
const chiefsFinalist = finalistCounts["chiefs"] ?? 0;
|
||||
const jetsFinalist = finalistCounts["jets"] ?? 0;
|
||||
expect(chiefsFinalist).toBeGreaterThan(jetsFinalist);
|
||||
});
|
||||
});
|
||||
465
app/services/simulations/nfl-simulator.ts
Normal file
465
app/services/simulations/nfl-simulator.ts
Normal file
|
|
@ -0,0 +1,465 @@
|
|||
/**
|
||||
* 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 { eloWinProbability, convertFuturesToElo } from "~/services/probability-engine";
|
||||
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
||||
|
||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||
|
||||
const NUM_SIMULATIONS = 50_000;
|
||||
|
||||
/** NFL regular season games per team. */
|
||||
const NFL_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
|
||||
): number {
|
||||
const remaining = Math.max(0, NFL_REGULAR_SEASON_GAMES - 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): { winner: SeededTeam; loser: SeededTeam } {
|
||||
const [home, away] = a.seed < b.seed ? [a, b] : [b, a];
|
||||
const winProbHome = eloWinProbability(
|
||||
home.elo + NFL_HOME_FIELD_ELO_ADVANTAGE,
|
||||
away.elo
|
||||
);
|
||||
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): { winner: SeededTeam; loser: SeededTeam } {
|
||||
const winProbA = eloWinProbability(a.elo, b.elo);
|
||||
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[]): ConferenceBracketResult {
|
||||
const byIndex = new Map(seeds.map((t) => [t.seed, t]));
|
||||
const s = (seed: number) => byIndex.get(seed) as SeededTeam;
|
||||
|
||||
// Wild Card (seed 1 bye; lower seed = home)
|
||||
const wc27 = playGame(s(2), s(7));
|
||||
const wc36 = playGame(s(3), s(6));
|
||||
const wc45 = playGame(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 = playGame(s(1), wcWinners[wcWinners.length - 1]);
|
||||
const div2 = playGame(wcWinners[0], wcWinners[1]);
|
||||
|
||||
// Conference Championship (higher remaining seed is home)
|
||||
const conf = playGame(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): Promise<SimulationResult[]> {
|
||||
const db = database();
|
||||
|
||||
// Load participants
|
||||
const participants = await db.query.participants.findMany({
|
||||
where: eq(schema.participants.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.participantExpectedValues.participantId,
|
||||
sourceElo: schema.participantExpectedValues.sourceElo,
|
||||
sourceOdds: schema.participantExpectedValues.sourceOdds,
|
||||
})
|
||||
.from(schema.participantExpectedValues)
|
||||
.where(eq(schema.participantExpectedValues.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, eloWinProbability(t.elo, AVG_OPPONENT_ELO));
|
||||
}
|
||||
|
||||
// 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 < NUM_SIMULATIONS; 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
|
||||
),
|
||||
}));
|
||||
|
||||
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);
|
||||
const nfcResult = simulateConferenceBracket(nfcSeeds);
|
||||
|
||||
// Super Bowl (neutral site)
|
||||
const sb = playNeutralGame(afcResult.finalist, nfcResult.finalist);
|
||||
|
||||
// 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 = NUM_SIMULATIONS;
|
||||
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",
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ import { TennisSimulator } from "./tennis-simulator";
|
|||
import { DartsSimulator } from "./darts-simulator";
|
||||
import { CSMajorSimulator } from "./cs-major-simulator";
|
||||
import { MLBSimulator } from "./mlb-simulator";
|
||||
import { NFLSimulator } from "./nfl-simulator";
|
||||
import { WNBASimulator } from "./wnba-simulator";
|
||||
import { WorldCupSimulator } from "./world-cup-simulator";
|
||||
|
||||
|
|
@ -34,6 +35,7 @@ export const SIMULATOR_TYPES = [
|
|||
"ncaaw_bracket",
|
||||
"nba_bracket",
|
||||
"nhl_bracket",
|
||||
"nfl_bracket",
|
||||
"afl_bracket",
|
||||
"snooker_bracket",
|
||||
"tennis_qualifying_points",
|
||||
|
|
@ -102,6 +104,13 @@ const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => Simul
|
|||
info: { name: "NHL Playoff Monte Carlo", description: "Simulates NHL playoff seedings (divisional format, via seed probabilities) and full bracket (best-of-7 series) using Elo ratings" },
|
||||
create: () => new NHLSimulator(),
|
||||
},
|
||||
nfl_bracket: {
|
||||
info: {
|
||||
name: "NFL Season + Playoffs Monte Carlo",
|
||||
description: "Projects NFL regular season standings (via nfelo Elo ratings) to determine the 14-team playoff field, then simulates the full bracket (Wild Card → Divisional → Conference Championship → Super Bowl). Elo ratings maintained via admin sourceElo field.",
|
||||
},
|
||||
create: () => new NFLSimulator(),
|
||||
},
|
||||
afl_bracket: {
|
||||
info: { name: "AFL Season + Finals Monte Carlo", description: "Projects AFL regular season standings via Elo, then simulates the 10-team finals series (Wildcard → QF/EF → SF → PF → Grand Final). Reads current standings from DB; falls back to full-season projection pre-season." },
|
||||
create: () => new AFLSimulator(),
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ export const simulatorTypeEnum = pgEnum("simulator_type", [
|
|||
"ncaaw_bracket",
|
||||
"nba_bracket",
|
||||
"nhl_bracket",
|
||||
"nfl_bracket",
|
||||
"afl_bracket",
|
||||
"snooker_bracket",
|
||||
"tennis_qualifying_points",
|
||||
|
|
|
|||
1
drizzle/0071_many_nico_minoru.sql
Normal file
1
drizzle/0071_many_nico_minoru.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TYPE "public"."simulator_type" ADD VALUE 'nfl_bracket' BEFORE 'afl_bracket';
|
||||
4471
drizzle/meta/0071_snapshot.json
Normal file
4471
drizzle/meta/0071_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -498,6 +498,13 @@
|
|||
"when": 1775485630297,
|
||||
"tag": "0070_verify_migrations",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 71,
|
||||
"version": "7",
|
||||
"when": 1775528386356,
|
||||
"tag": "0071_many_nico_minoru",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue