Fix NHL bracket-aware simulation and MLB sourceElo support

NHL: NHLSimulator now checks for a populated playoff_game bracket before
falling back to season-projection mode. When a bracket exists (e.g. via
simple_16 template), it simulates the actual bracket structure, respecting
completed matches and using ELO+odds blending for the rest — matching the
pattern used by Snooker, NBA, and UCL simulators.

MLB: MLBSimulator now reads sourceElo from participantExpectedValues and
converts it to an effective RDif (via eloToRDif), overriding the hardcoded
TEAMS_DATA.rdif when present. This wires up the expected-wins admin form
(which saves sourceElo) to the simulation engine.

https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
This commit is contained in:
Claude 2026-04-30 05:22:11 +00:00
parent 380b0786ca
commit 6f5920eb2b
No known key found for this signature in database
3 changed files with 290 additions and 7 deletions

View file

@ -4,6 +4,7 @@ import {
getTeamData,
winRateFromRDif,
rdifWinProbability,
eloToRDif,
simBo3,
simBo5,
simBo7,
@ -237,3 +238,29 @@ describe("simBo7 (LCS / World Series — first to 4 wins)", () => {
expect(result.winner).not.toBe(result.loser);
});
});
// ─── eloToRDif ────────────────────────────────────────────────────────────────
describe("eloToRDif", () => {
it("maps Elo 1500 (average) to RDif 0", () => {
expect(eloToRDif(1500)).toBeCloseTo(0, 5);
});
it("maps Elo above 1500 to a positive RDif", () => {
expect(eloToRDif(1600)).toBeGreaterThan(0);
});
it("maps Elo below 1500 to a negative RDif", () => {
expect(eloToRDif(1400)).toBeLessThan(0);
});
it("is symmetric: eloToRDif(1500 + d) = -eloToRDif(1500 - d)", () => {
expect(eloToRDif(1600)).toBeCloseTo(-eloToRDif(1400), 5);
});
it("round-trips through winRateFromRDif: winRate(eloToRDif(elo)) ≈ eloWinProb(elo, 1500)", () => {
const elo = 1620;
const expectedWinRate = 1 / (1 + Math.pow(10, (1500 - elo) / 400));
expect(winRateFromRDif(eloToRDif(elo))).toBeCloseTo(expectedWinRate, 4);
});
});

View file

@ -189,6 +189,17 @@ export function winRateFromRDif(rdif: number): number {
return Math.min(0.99, Math.max(0.01, 0.5 + rdif / RDIF_DIVISOR));
}
/**
* Convert an Elo rating to an equivalent projected run differential.
* Uses the standard Elo win probability formula (parity factor 400, average Elo 1500),
* then inverts the winRateFromRDif formula: rdif = (winRate 0.5) × RDIF_DIVISOR.
* Exported for unit testing.
*/
export function eloToRDif(elo: number): number {
const winRate = 1 / (1 + Math.pow(10, (1500 - elo) / 400));
return (winRate - 0.5) * RDIF_DIVISOR;
}
/**
* Bill James log5 head-to-head win probability for team A over team B,
* given their projected run differentials.
@ -303,7 +314,8 @@ export function simBo7(
* with positive weight).
*/
function drawLeaguePlayoffField(
leagueTeams: TeamEntry[]
leagueTeams: TeamEntry[],
getRDif: (t: TeamEntry) => number = getEntryRDif
): TeamEntry[] | undefined {
// Group by division
const divMap = new Map<string, TeamEntry[]>();
@ -336,10 +348,10 @@ function drawLeaguePlayoffField(
}
// Rank division winners 13 by RDif descending (best RDif = seed 1)
const sortedDivWinners = divisionWinners.toSorted((a, b) => getEntryRDif(b) - getEntryRDif(a));
const sortedDivWinners = divisionWinners.toSorted((a, b) => getRDif(b) - getRDif(a));
// Rank WC teams 46 by RDif descending (best RDif = seed 4)
const sortedWcTeams = wcTeams.toSorted((a, b) => getEntryRDif(b) - getEntryRDif(a));
const sortedWcTeams = wcTeams.toSorted((a, b) => getRDif(b) - getRDif(a));
const seeds = [...sortedDivWinners, ...sortedWcTeams];
return seeds.map((t, i) => ({ ...t, originalSeed: i + 1 }));
@ -442,6 +454,7 @@ export class MLBSimulator implements Simulator {
.select({
participantId: schema.participantExpectedValues.participantId,
sourceOdds: schema.participantExpectedValues.sourceOdds,
sourceElo: schema.participantExpectedValues.sourceElo,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId));
@ -463,14 +476,26 @@ export class MLBSimulator implements Simulator {
});
}
// Build a map of sourceElo-derived RDif values (overrides hardcoded TEAMS_DATA.rdif).
const sourceEloRDifMap = new Map<string, number>();
for (const r of evRows) {
if (r.sourceElo !== null && r.sourceElo !== undefined && participantIdSet.has(r.participantId)) {
sourceEloRDifMap.set(r.participantId, eloToRDif(r.sourceElo));
}
}
// ─── Helpers ──────────────────────────────────────────────────────────────
/** Effective RDif: prefer sourceElo-derived value over hardcoded TEAMS_DATA.rdif. */
const effectiveRDif = (entry: TeamEntry): number =>
sourceEloRDifMap.get(entry.id) ?? getEntryRDif(entry);
/**
* Blended per-game win probability for team A over team B.
* When odds are available: 70% RDif log5 + 30% vig-removed futures head-to-head.
*/
const gameWinProb = (a: TeamEntry, b: TeamEntry): number => {
const rdifProb = rdifWinProbability(getEntryRDif(a), getEntryRDif(b));
const rdifProb = rdifWinProbability(effectiveRDif(a), effectiveRDif(b));
if (!hasOdds) return rdifProb;
const o1 = normalizedOddsMap.get(a.id);
@ -494,8 +519,8 @@ export class MLBSimulator implements Simulator {
let effectiveN = 0;
for (let s = 0; s < NUM_SIMULATIONS; s++) {
const alField = drawLeaguePlayoffField(alTeams);
const nlField = drawLeaguePlayoffField(nlTeams);
const alField = drawLeaguePlayoffField(alTeams, effectiveRDif);
const nlField = drawLeaguePlayoffField(nlTeams, effectiveRDif);
if (!alField || !nlField) continue; // degenerate draw — skip
effectiveN++;

View file

@ -60,7 +60,7 @@
*/
import { database } from "~/database/context";
import { eq } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
import { logger } from "~/lib/logger";
@ -254,6 +254,25 @@ export class NHLSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
// Check for a populated playoff bracket; if found, use bracket-aware simulation.
const bracketEvent = await db.query.scoringEvents.findFirst({
where: and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.eventType, "playoff_game")
),
});
if (bracketEvent) {
const allMatches = await db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
orderBy: (m, { asc }) => [asc(m.matchNumber)],
});
const bracketPopulated = allMatches.some((m) => m.participant1Id && m.participant2Id);
if (bracketPopulated) {
return this.simulateBracket(sportsSeasonId, allMatches);
}
}
// 1. Load participants and standings in parallel.
const [participantRows, standings] = await Promise.all([
db
@ -539,4 +558,216 @@ export class NHLSimulator implements Simulator {
return results;
}
// ── Mode: Bracket-Aware ───────────────────────────────────────────────────────
private async simulateBracket(
sportsSeasonId: string,
allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>
): Promise<SimulationResult[]> {
const db = database();
// Group matches by round, sorted by match count descending (R1 first → Final last).
const byRound = new Map<string, typeof allMatches>();
for (const m of allMatches) {
if (!byRound.has(m.round)) byRound.set(m.round, []);
byRound.get(m.round)?.push(m);
}
const sortedRoundMatches = [...byRound.values()]
.toSorted((a, b) => b.length - a.length)
.map((matches) => matches.toSorted((a, b) => a.matchNumber - b.matchNumber));
if (sortedRoundMatches.length < 4) {
throw new Error(
`NHL bracket has unexpected structure: expected 4 rounds, found ${sortedRoundMatches.length}. ` +
`Check the bracket template.`
);
}
const r1Matches = sortedRoundMatches[0]; // 8 matches (Round of 16 / Wild Card)
const r2Matches = sortedRoundMatches[1]; // 4 matches (Quarterfinals / Divisional)
const r3Matches = sortedRoundMatches[2]; // 2 matches (Semifinals / Conf Finals)
const finalMatches = sortedRoundMatches[3]; // 1 match (Finals / Stanley Cup)
if (r1Matches.length !== 8) {
throw new Error(
`Expected 8 first-round matches for NHL bracket, found ${r1Matches.length}.`
);
}
// Load all participants so we can build the Elo map and return results for everyone.
const participantRows = await db
.select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId));
const allParticipantIds = participantRows.map((r) => r.id);
const nameById = new Map(participantRows.map((r) => [r.id, r.name]));
// Build Elo map: TEAMS_DATA lookup by name, fallback 1400.
const eloMap = new Map<string, number>();
for (const r of participantRows) {
eloMap.set(r.id, getTeamData(r.name)?.elo ?? 1400);
}
// Load futures odds for blending.
const evRows = await db
.select({
participantId: schema.participantExpectedValues.participantId,
sourceOdds: schema.participantExpectedValues.sourceOdds,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId));
const participantIdSet = new Set(allParticipantIds);
const oddsRows = evRows.filter(
(r) => r.sourceOdds !== null && participantIdSet.has(r.participantId)
);
const hasOdds = oddsRows.length > 0;
const normalizedOddsMap = new Map<string, number>();
if (hasOdds) {
const rawProbs = oddsRows.map((r) => convertAmericanOddsToProbability(r.sourceOdds ?? 0));
const normalized = normalizeProbabilities(rawProbs);
oddsRows.forEach(({ participantId }, i) => {
normalizedOddsMap.set(participantId, normalized[i]);
});
}
// Per-round lookup maps for O(1) access.
const r1ByNum = new Map(r1Matches.map((m) => [m.matchNumber, m]));
const r2ByNum = new Map(r2Matches.map((m) => [m.matchNumber, m]));
const r3ByNum = new Map(r3Matches.map((m) => [m.matchNumber, m]));
const finalMatch = finalMatches[0];
// ── Helpers ──────────────────────────────────────────────────────────────────
const gameWinProb = (aId: string, bId: string): number => {
const eloProb = eloWinProbability(eloMap.get(aId) ?? 1400, eloMap.get(bId) ?? 1400);
if (!hasOdds) return eloProb;
const o1 = normalizedOddsMap.get(aId) ?? 0;
const o2 = normalizedOddsMap.get(bId) ?? 0;
const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5;
return ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb;
};
const simSeries = (aId: string, bId: string): { winner: string; loser: string } => {
const winProb = gameWinProb(aId, bId);
let wA = 0;
let wB = 0;
while (wA < 4 && wB < 4) {
if (Math.random() < winProb) wA++; else wB++;
}
return wA === 4 ? { winner: aId, loser: bId } : { winner: bId, loser: aId };
};
const resolveSeries = (
match: (typeof allMatches)[0] | undefined,
p1Fallback?: string,
p2Fallback?: string
): { winner: string; loser: string } => {
if (match?.isComplete && match.winnerId && match.loserId) {
return { winner: match.winnerId, loser: match.loserId };
}
const p1 = match?.participant1Id ?? p1Fallback;
const p2 = match?.participant2Id ?? p2Fallback;
if (!p1 || !p2) {
throw new Error(
`Cannot resolve NHL bracket match ${match?.id ?? "(undefined)"}: ` +
`missing participants (p1=${p1 ?? "null"}, p2=${p2 ?? "null"}).`
);
}
return simSeries(p1, p2);
};
// ── Placement count maps ──────────────────────────────────────────────────────
const championCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
const finalistCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
const r3LoserCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
const r2LoserCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
// ── Monte Carlo simulation loop ───────────────────────────────────────────────
for (let s = 0; s < NUM_SIMULATIONS; s++) {
// Round 1: R1 losers score 0 points — not tracked.
const r1Winners: string[] = [];
for (let i = 1; i <= 8; i++) {
const { winner } = resolveSeries(r1ByNum.get(i));
r1Winners.push(winner);
}
// Round 2 (Quarterfinals / Divisional): losers → 5th8th.
const r2Winners: string[] = [];
for (let i = 1; i <= 4; i++) {
const { winner, loser } = resolveSeries(
r2ByNum.get(i),
r1Winners[(i - 1) * 2],
r1Winners[(i - 1) * 2 + 1]
);
r2Winners.push(winner);
r2LoserCounts.set(loser, (r2LoserCounts.get(loser) ?? 0) + 1);
}
// Round 3 (Semifinals / Conference Finals): losers → 3rd4th.
const r3Winners: string[] = [];
for (let i = 1; i <= 2; i++) {
const { winner, loser } = resolveSeries(
r3ByNum.get(i),
r2Winners[(i - 1) * 2],
r2Winners[(i - 1) * 2 + 1]
);
r3Winners.push(winner);
r3LoserCounts.set(loser, (r3LoserCounts.get(loser) ?? 0) + 1);
}
// Final (Stanley Cup): winner → 1st, loser → 2nd.
const { winner, loser } = resolveSeries(finalMatch, r3Winners[0], r3Winners[1]);
championCounts.set(winner, (championCounts.get(winner) ?? 0) + 1);
finalistCounts.set(loser, (finalistCounts.get(loser) ?? 0) + 1);
}
// ── Convert counts to probability distributions ───────────────────────────────
const N = NUM_SIMULATIONS;
const results: SimulationResult[] = allParticipantIds.map((participantId) => {
const c = championCounts.get(participantId) ?? 0;
const f = finalistCounts.get(participantId) ?? 0;
const r3 = r3LoserCounts.get(participantId) ?? 0;
const r2 = r2LoserCounts.get(participantId) ?? 0;
return {
participantId,
probabilities: {
probFirst: c / N,
probSecond: f / N,
probThird: r3 / (2 * N),
probFourth: r3 / (2 * N),
probFifth: r2 / (4 * N),
probSixth: r2 / (4 * N),
probSeventh: r2 / (4 * N),
probEighth: r2 / (4 * N),
},
source: "nhl_bracket_monte_carlo",
};
});
// Per-position normalization.
const positionKeys: Array<keyof (typeof results)[0]["probabilities"]> = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
];
for (const key of positionKeys) {
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
const residual = 1.0 - colSum;
if (residual !== 0) {
const maxResult = results.reduce((best, r) =>
r.probabilities[key] > best.probabilities[key] ? r : best
);
maxResult.probabilities[key] += residual;
}
}
return results;
}
}