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:
parent
380b0786ca
commit
6f5920eb2b
3 changed files with 290 additions and 7 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 1–3 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 4–6 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++;
|
||||
|
|
|
|||
|
|
@ -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 → 5th–8th.
|
||||
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 → 3rd–4th.
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue