brackt/app/services/simulations/world-cup-simulator.ts
chrisp 03726f1a66
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m27s
🚀 Deploy / ʦ TypeScript (push) Successful in 1m18s
🚀 Deploy / 🔍 Lint (push) Successful in 47s
🚀 Deploy / 🐳 Build (push) Successful in 15m5s
🚀 Deploy / 🚀 Deploy (push) Successful in 32s
Fix simulator correctness bugs and reduce test iteration counts (#61)
## Summary

- **Bug fix**: EPL Elo/odds fallback was gated on \`dbEloMap.size === 0\` — if any team had a direct \`sourceElo\`, teams with only \`sourceOdds\` were never populated and the simulator threw "Missing Elo" instead of using their odds data
- **Iterations**: WorldCupSimulator default 50k→10k; manifest \`defaultConfig\` for \`world_cup\` and \`epl_standings\` now explicitly override \`BASE_CONFIG\` with 10k so the production fallback matches; test iterations reduced (WorldCup 500→100, EPL 10k→500) to fix timeout failures on slower CI
- **Cleanup**: Extracted \`runKnockoutRound\` out of the 10k-iteration simulation loop; replaced two hot-loop \`toSorted()\` calls on 2-element arrays with a conditional string comparison; removed local \`normalizeProbabilities\` in favour of the shared \`normalizeSimulationResultColumns\`

## Test plan

- [ ] \`npm run test:run\` — all simulator unit tests pass without timeout
- [ ] Typecheck passes (\`npm run typecheck\`)
- [ ] EPL simulate with mixed Elo+odds data no longer throws

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #61
2026-05-31 17:39:43 +00:00

587 lines
22 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.

/**
* FIFA World Cup Simulator (2026+ 48-team format)
*
* Monte Carlo simulation covering both the group stage and knockout bracket.
*
* Algorithm:
* 1. Load participants, futures odds, and bracket/group data from DB
* 2. Build per-team Elo: from futures odds if available (blended 70/30 Elo+odds),
* otherwise from hardcoded 2026 national team Elo ratings, fallback 1500.
* 3. For each of NUM_SIMULATIONS iterations:
* a. GROUP STAGE (12 groups × 6 matches each = 72 matches)
* - Each match: compute P(win)/P(draw)/P(loss) from Elo difference
* - Track pts / GD / GF per team; sort to determine group positions
* - Group winner (pos 1) and runner-up (pos 2) always advance
* - 3rd-place teams ranked across all 12 groups; top 8 also advance
* b. KNOCKOUT (R32 → R16 → QF → SF)
* - Standard single-elimination using Elo win probabilities
* - Completed knockout matches use actual results
* c. THIRD PLACE GAME
* - SF loser 1 vs SF loser 2; winner = 3rd, loser = 4th
* d. FINAL
* - SF winner 1 vs SF winner 2; winner = 1st, loser = 2nd
* 4. Accumulate integer placement counts; convert to probabilities.
* Placement buckets → SimulationProbabilities mapping:
* probFirst = P(champion)
* probSecond = P(runner-up)
* probThird = P(3rd place game winner)
* probFourth = P(3rd place game loser)
* probFifthprobEighth = P(QF elimination) / 4
* R32/R16 losers → 0 (scoringStartsAtRound = "Quarterfinals")
*
* Group stage W/D/L probabilities:
* pDraw = 0.28 × exp(0.002 × |eloDiff|) (≈28% when equal, decreases with skill gap)
* pWin = (1 pDraw) × eloWinProb(eloA, eloB)
* pLoss = 1 pWin pDraw
*/
import { database } from "~/database/context";
import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema";
import { convertFuturesToElo, eloWinProbability } from "~/services/probability-engine";
import { simulateEloSoccerMatch } from "./soccer-helpers";
import { normalizeSimulationResultColumns } from "./simulation-probabilities";
import { logger } from "~/lib/logger";
import type { Simulator, SimulationResult } from "./types";
// ─── Name normalisation (same logic as elo-ratings bulk-import fuzzy match) ──
function normalizeTeamName(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9\s]/g, "").replace(/\s+/g, " ").trim();
}
// ─── Parameters ──────────────────────────────────────────────────────────────
const NUM_SIMULATIONS = 10_000;
const ELO_WEIGHT = 0.7;
const ODDS_WEIGHT = 1 - ELO_WEIGHT;
/** Base draw rate when teams are evenly matched. Decays with Elo difference. */
const BASE_DRAW_RATE = 0.28;
const DRAW_DECAY = 0.002;
// ─── National team Elo ratings (eloratings.net, pre-2026 World Cup) ──────────
// These are used when no futures odds are stored for a team.
// Update before the tournament starts.
const NATIONAL_TEAM_ELO: Record<string, number> = {
"france": 2083,
"england": 2047,
"brazil": 2038,
"spain": 2031,
"belgium": 2003,
"argentina": 1997,
"portugal": 1993,
"netherlands": 1988,
"germany": 1978,
"italy": 1966,
"croatia": 1961,
"uruguay": 1955,
"colombia": 1950,
"mexico": 1945,
"usa": 1935,
"united states": 1935,
"senegal": 1930,
"denmark": 1928,
"switzerland": 1925,
"austria": 1918,
"morocco": 1915,
"japan": 1912,
"south korea": 1905,
"ecuador": 1900,
"poland": 1898,
"australia": 1893,
"nigeria": 1888,
"iran": 1883,
"cameroon": 1878,
"ghana": 1875,
"canada": 1870,
"peru": 1865,
"chile": 1862,
"venezuela": 1858,
"costa rica": 1853,
"cote d'ivoire": 1850,
"ivory coast": 1850,
"egypt": 1848,
"hungary": 1845,
"turkey": 1842,
"ukraine": 1838,
"serbia": 1835,
"czech republic": 1830,
"romania": 1825,
"slovakia": 1820,
"new zealand": 1790,
"saudi arabia": 1785,
"qatar": 1780,
"honduras": 1778,
"panama": 1775,
"el salvador": 1770,
"guatemala": 1765,
"cuba": 1760,
};
/**
* Look up a national team's Elo rating using fuzzy name matching.
* Priority:
* 1. Exact normalized match
* 2. Substring containment (either direction)
* 3. Word-overlap (≥50% shared significant words)
* Logs a warning when no match is found so mismatches are visible in server logs.
*/
function getTeamElo(name: string, fallback = 1500): number {
const normalized = normalizeTeamName(name);
const entries = Object.entries(NATIONAL_TEAM_ELO);
// 1. Exact match
const exact = entries.find(([k]) => k === normalized);
if (exact) return exact[1];
// 2. Substring containment (handles "Ivory Coast" ↔ "Cote d'Ivoire" aliases already in map,
// and things like "United States" matching "usa")
const contains = entries.find(([k]) => k.includes(normalized) || normalized.includes(k));
if (contains) return contains[1];
// 3. Word-overlap (≥50% shared words longer than 2 chars)
const inputWords = normalized.split(" ").filter((w) => w.length > 2);
if (inputWords.length > 0) {
const overlap = entries.find(([k]) => {
const kWords = k.split(" ").filter((w) => w.length > 2);
const shared = inputWords.filter((w) => kWords.includes(w));
return shared.length > 0 && shared.length >= Math.min(inputWords.length, kWords.length) * 0.5;
});
if (overlap) return overlap[1];
}
logger.warn(`[WorldCupSimulator] No Elo found for team "${name}" (normalized: "${normalized}") — using fallback ${fallback}`);
return fallback;
}
// ─── Group stage helpers ──────────────────────────────────────────────────────
/**
* Simulate a single group stage match.
* Returns "win" (team A wins), "draw", or "loss" (team B wins).
*/
export function simGroupMatch(eloA: number, eloB: number): "win" | "draw" | "loss" {
return simulateEloSoccerMatch(eloA, eloB, {
baseDrawRate: BASE_DRAW_RATE,
drawDecay: DRAW_DECAY,
});
}
interface TeamStats {
id: string;
pts: number;
gd: number;
gf: number;
}
interface GroupMatchResult {
participant1Id: string;
participant2Id: string;
participant1Score: number | null;
participant2Score: number | null;
isComplete: boolean;
}
/**
* Simulate a full round-robin group of 4 teams.
* Completed matches (isComplete=true with real scores) are replayed with their
* actual results; remaining matches are simulated via Elo.
* This correctly handles partial group completion (e.g. 4/6 matches done).
*/
function simGroup(
teamIds: string[],
eloFn: (id: string) => number,
completedMatches?: GroupMatchResult[]
): TeamStats[] {
const stats = new Map<string, TeamStats>(
teamIds.map((id) => [id, { id, pts: 0, gd: 0, gf: 0 }])
);
// Build set of completed pairings keyed "minId:maxId" so we can skip them
const completedPairs = new Set<string>();
if (completedMatches) {
for (const m of completedMatches) {
if (!m.isComplete || m.participant1Score === null || m.participant2Score === null) continue;
const sa = stats.get(m.participant1Id);
const sb = stats.get(m.participant2Id);
if (!sa || !sb) continue;
const s1 = m.participant1Score;
const s2 = m.participant2Score;
sa.gf += s1; sa.gd += s1 - s2;
sb.gf += s2; sb.gd += s2 - s1;
if (s1 > s2) { sa.pts += 3; }
else if (s2 > s1) { sb.pts += 3; }
else { sa.pts += 1; sb.pts += 1; }
// Mark this pairing as done so we don't re-simulate it
const [p1, p2] = m.participant1Id < m.participant2Id
? [m.participant1Id, m.participant2Id]
: [m.participant2Id, m.participant1Id];
completedPairs.add(`${p1}:${p2}`);
}
}
// Simulate remaining (not-yet-played) matches
for (let i = 0; i < teamIds.length; i++) {
for (let j = i + 1; j < teamIds.length; j++) {
const a = teamIds[i];
const b = teamIds[j];
const key = a < b ? `${a}:${b}` : `${b}:${a}`;
if (completedPairs.has(key)) continue; // already applied above
const result = simGroupMatch(eloFn(a), eloFn(b));
const sa = stats.get(a);
const sb = stats.get(b);
if (!sa || !sb) continue;
// Simple goal simulation: winner scores 1-2, loser 0-1, draw both 1
let ga: number, gb: number;
if (result === "win") {
ga = Math.random() < 0.5 ? 2 : 1;
gb = ga === 2 && Math.random() < 0.3 ? 1 : 0;
sa.pts += 3;
} else if (result === "loss") {
gb = Math.random() < 0.5 ? 2 : 1;
ga = gb === 2 && Math.random() < 0.3 ? 1 : 0;
sb.pts += 3;
} else {
ga = gb = Math.random() < 0.5 ? 1 : 0;
sa.pts += 1;
sb.pts += 1;
}
sa.gf += ga; sa.gd += ga - gb;
sb.gf += gb; sb.gd += gb - ga;
}
}
return [...stats.values()].toSorted(sortTeams);
}
function sortTeams(a: TeamStats, b: TeamStats): number {
if (b.pts !== a.pts) return b.pts - a.pts;
if (b.gd !== a.gd) return b.gd - a.gd;
return b.gf - a.gf;
}
// ─── Knockout helpers ─────────────────────────────────────────────────────────
function simKnockout(
teamA: string,
teamB: string,
eloFn: (id: string) => number,
normalizedProb: (id: string) => number
): { winner: string; loser: string } {
const eloProb = eloWinProbability(eloFn(teamA), eloFn(teamB));
const oddsProb = normalizedProb(teamA);
const blended = ELO_WEIGHT * eloProb + ODDS_WEIGHT * (0.5 + (oddsProb - 0.5));
const winner = Math.random() < blended ? teamA : teamB;
return { winner, loser: winner === teamA ? teamB : teamA };
}
// ─── Main simulator ───────────────────────────────────────────────────────────
export class WorldCupSimulator implements Simulator {
private readonly numSimulations: number;
constructor(numSimulations = NUM_SIMULATIONS) {
this.numSimulations = numSimulations;
}
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
// 1. Load all participants for this season
const participantRows = await db.query.seasonParticipants.findMany({
where: eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId),
});
if (participantRows.length === 0) {
throw new Error(`No participants found for sports season ${sportsSeasonId}`);
}
const participantIds = participantRows.map((p) => p.id);
const participantNames = new Map(participantRows.map((p) => [p.id, p.name]));
// 2. Load stored ratings (sourceElo from Elo page, sourceOdds from futures page)
const evRows = await db
.select({
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
})
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
const evMap = new Map(evRows.map((r) => [r.participantId, r]));
const participantSet = new Set(participantIds);
// 3. Build Elo map — priority: sourceElo (direct) > sourceOdds (converted) > hardcoded
const sourceEloMap = new Map<string, number>();
for (const r of evRows) {
if (r.sourceElo !== null && r.sourceElo !== undefined && participantSet.has(r.participantId)) {
sourceEloMap.set(r.participantId, r.sourceElo);
}
}
const hasOdds = evRows.some(
(r) => r.sourceOdds !== null && participantSet.has(r.participantId)
);
let eloFromOdds: Map<string, number>;
if (hasOdds) {
const oddsInput = evRows
.filter((r) => r.sourceOdds !== null && participantSet.has(r.participantId))
.map((r) => ({ participantId: r.participantId, odds: r.sourceOdds ?? 0 }));
eloFromOdds = convertFuturesToElo(oddsInput, "american");
} else {
eloFromOdds = new Map();
}
const eloFn = (id: string): number => {
if (sourceEloMap.has(id)) return sourceEloMap.get(id) ?? 1500;
if (eloFromOdds.has(id)) return eloFromOdds.get(id) ?? 1500;
const name = participantNames.get(id) ?? "";
return getTeamElo(name, 1500);
};
// 4. Build normalized futures win-probability map (vig removed)
const rawProbs = new Map<string, number>();
for (const id of participantIds) {
const ev = evMap.get(id);
if (ev?.sourceOdds !== null && ev?.sourceOdds !== undefined) {
rawProbs.set(id, Math.abs(ev.sourceOdds) > 0
? ev.sourceOdds > 0
? 100 / (ev.sourceOdds + 100)
: Math.abs(ev.sourceOdds) / (Math.abs(ev.sourceOdds) + 100)
: 1 / participantIds.length);
} else {
rawProbs.set(id, 1 / participantIds.length);
}
}
const totalRawProb = [...rawProbs.values()].reduce((s, p) => s + p, 0);
const normalizedProb = (id: string): number =>
totalRawProb > 0 ? (rawProbs.get(id) ?? 0) / totalRawProb : 1 / participantIds.length;
// 5. Load group stage data from DB
const bracketEvent = await db.query.scoringEvents.findFirst({
where: and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.eventType, "playoff_game")
),
});
const tournamentGroups = bracketEvent
? await db.query.tournamentGroups.findMany({
where: eq(schema.tournamentGroups.scoringEventId, bracketEvent.id),
with: {
members: true,
matches: true,
},
})
: [];
// Build group definitions: list of teams + their completed match data per group.
// If no groups set up yet, distribute all participants into 12 synthetic groups of 4.
const groupDefs: Array<{
groupName: string;
teamIds: string[];
completedMatches: GroupMatchResult[];
}> = [];
if (tournamentGroups.length > 0) {
for (const group of tournamentGroups) {
const teamIds = group.members.map((m) => m.participantId);
const completedMatches: GroupMatchResult[] = group.matches
.filter((m) => m.isComplete)
.map((m) => ({
participant1Id: m.participant1Id,
participant2Id: m.participant2Id,
participant1Score: m.participant1Score,
participant2Score: m.participant2Score,
isComplete: m.isComplete,
}));
groupDefs.push({ groupName: group.groupName, teamIds, completedMatches });
}
} else {
// No groups set up — distribute participants into 12 synthetic groups of 4
const chunkSize = 4;
const labels = ["A","B","C","D","E","F","G","H","I","J","K","L"];
for (let i = 0; i < Math.min(12, labels.length); i++) {
const teamIds = participantIds.slice(i * chunkSize, (i + 1) * chunkSize);
if (teamIds.length > 0) groupDefs.push({ groupName: labels[i], teamIds, completedMatches: [] });
}
}
// 6. Load completed knockout matches (to fix results in simulation)
const completedKnockoutMatches = bracketEvent
? await db.query.playoffMatches.findMany({
where: and(
eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
eq(schema.playoffMatches.isComplete, true)
),
})
: [];
const completedByRoundAndNumber = new Map<string, { winnerId: string; loserId: string }>();
for (const m of completedKnockoutMatches) {
if (m.winnerId && m.loserId) {
completedByRoundAndNumber.set(`${m.round}:${m.matchNumber}`, {
winnerId: m.winnerId,
loserId: m.loserId,
});
}
}
// 7. Monte Carlo simulation
const counts = {
champion: new Map<string, number>(),
runnerUp: new Map<string, number>(),
thirdPlace: new Map<string, number>(),
fourthPlace: new Map<string, number>(),
qfLoser: new Map<string, number>(),
};
for (const id of participantIds) {
counts.champion.set(id, 0);
counts.runnerUp.set(id, 0);
counts.thirdPlace.set(id, 0);
counts.fourthPlace.set(id, 0);
counts.qfLoser.set(id, 0);
}
function runKnockoutRound(
teams: string[],
roundName: string
): { winners: string[]; losers: string[] } {
const winners: string[] = [];
const losers: string[] = [];
for (let i = 0; i < teams.length; i += 2) {
const a = teams[i];
const b = teams[i + 1];
if (!a || !b) continue;
const matchNum = Math.floor(i / 2) + 1;
const fixed = completedByRoundAndNumber.get(`${roundName}:${matchNum}`);
if (fixed) {
winners.push(fixed.winnerId);
losers.push(fixed.loserId);
} else {
const { winner, loser } = simKnockout(a, b, eloFn, normalizedProb);
winners.push(winner);
losers.push(loser);
}
}
return { winners, losers };
}
for (let sim = 0; sim < this.numSimulations; sim++) {
// ── Group stage ──────────────────────────────────────────────
const advancingFromGroup: string[] = []; // group winners + runners-up (24 teams)
const thirdPlaceTeams: TeamStats[] = []; // 12 third-place teams
for (const group of groupDefs) {
if (group.teamIds.length < 3) continue;
const sorted = simGroup(group.teamIds, eloFn, group.completedMatches);
advancingFromGroup.push(sorted[0].id, sorted[1].id); // 1st and 2nd
if (sorted[2]) thirdPlaceTeams.push(sorted[2]); // 3rd place
}
// Pick best 8 third-place teams
const best8Third = thirdPlaceTeams
.toSorted(sortTeams)
.slice(0, 8)
.map((t) => t.id);
// Build R32 pool: 24 group advancers + 8 best 3rd-place = 32 teams
const r32Pool = [...advancingFromGroup, ...best8Third];
// ── Knockout rounds ──────────────────────────────────────────
// R32 → R16 → QF → SF → 3PG + Final
//
// SEEDING NOTE: We pair teams sequentially (1v2, 3v4, …) in arrival order
// (group A winner, group A runner-up, group B winner, …, best-8 3rd-place teams).
// Real FIFA uses a pre-determined bracket path (e.g. Group A winner vs Group B
// runner-up), which varies by edition. This simplified pairing produces correct
// aggregate probabilities for fantasy purposes even though simulated bracket paths
// may not match the actual draw.
//
// Completed knockout matches are honoured by round+matchNumber, so as the real
// bracket plays out the simulation locks in actual results automatically.
const r32 = runKnockoutRound(r32Pool, "Round of 32");
const r16 = runKnockoutRound(r32.winners, "Round of 16");
const qf = runKnockoutRound(r16.winners, "Quarterfinals");
const sf = runKnockoutRound(qf.winners, "Semifinals");
// QF losers
for (const id of qf.losers) {
counts.qfLoser.set(id, (counts.qfLoser.get(id) ?? 0) + 1);
}
// Third place game (SF losers)
const [sf1Loser, sf2Loser] = sf.losers;
if (sf1Loser && sf2Loser) {
const fixed3pg = completedByRoundAndNumber.get("Third Place Game:1");
if (fixed3pg) {
counts.thirdPlace.set(fixed3pg.winnerId, (counts.thirdPlace.get(fixed3pg.winnerId) ?? 0) + 1);
counts.fourthPlace.set(fixed3pg.loserId, (counts.fourthPlace.get(fixed3pg.loserId) ?? 0) + 1);
} else {
const { winner: thirdWinner, loser: thirdLoser } = simKnockout(
sf1Loser, sf2Loser, eloFn, normalizedProb
);
counts.thirdPlace.set(thirdWinner, (counts.thirdPlace.get(thirdWinner) ?? 0) + 1);
counts.fourthPlace.set(thirdLoser, (counts.fourthPlace.get(thirdLoser) ?? 0) + 1);
}
}
// Final (SF winners)
const [sfWinner1, sfWinner2] = sf.winners;
if (sfWinner1 && sfWinner2) {
const fixedFinal = completedByRoundAndNumber.get("Finals:1");
if (fixedFinal) {
counts.champion.set(fixedFinal.winnerId, (counts.champion.get(fixedFinal.winnerId) ?? 0) + 1);
counts.runnerUp.set(fixedFinal.loserId, (counts.runnerUp.get(fixedFinal.loserId) ?? 0) + 1);
} else {
const { winner: champion, loser: runnerUp } = simKnockout(
sfWinner1, sfWinner2, eloFn, normalizedProb
);
counts.champion.set(champion, (counts.champion.get(champion) ?? 0) + 1);
counts.runnerUp.set(runnerUp, (counts.runnerUp.get(runnerUp) ?? 0) + 1);
}
}
}
// 8. Convert counts to probabilities
const N = this.numSimulations;
const numQfLosers = 4; // 4 QF losers per sim
const results: SimulationResult[] = participantIds.map((id) => {
const qfProb = (counts.qfLoser.get(id) ?? 0) / (numQfLosers * N);
return {
participantId: id,
probabilities: {
probFirst: (counts.champion.get(id) ?? 0) / N,
probSecond: (counts.runnerUp.get(id) ?? 0) / N,
probThird: (counts.thirdPlace.get(id) ?? 0) / N,
probFourth: (counts.fourthPlace.get(id) ?? 0) / N,
probFifth: qfProb,
probSixth: qfProb,
probSeventh: qfProb,
probEighth: qfProb,
},
source: "World Cup Monte Carlo (group stage + knockout)",
};
});
normalizeSimulationResultColumns(results);
return results;
}
}