Fix simulator brackets: NHL bracket-aware mode, MLB/NBA/WNBA sourceElo support (#358)

* 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

* Wire sourceElo from projected wins form into NBA, NHL, and WNBA simulators

All three simulators had entries in simulator-config.ts (enabling the projected
wins form in the admin UI) but silently ignored the sourceElo that was saved.

NBA: Both bracket-aware and season-projection modes now load sourceElo and
prefer it over hardcoded TEAMS_DATA elo. Added resolvedElo field to TeamEntry
so eloOfEntry() uses the correct value throughout.

NHL: Season-projection mode now fetches sourceElo + sourceOdds in a single
parallel query (removing the duplicate evRows query). Bracket-aware mode also
loads sourceElo alongside sourceOdds. Both modes use sourceElo > TEAMS_DATA > 1400.

WNBA: resolveElo() now accepts sourceElo as an optional highest-priority
argument. The evRows query fetches sourceElo alongside sourceOdds and passes
it through when building team entries.

https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn

* Add review fixes: NHL R1 validation, WNBA sourceElo tests, NHL bracket unit tests

- NHLSimulator.simulateBracket: remove unused nameById variable
- NHLSimulator.simulateBracket: validate all R1 participants before entering
  Monte Carlo loop; throws with a clear message if any slot is missing
- wnba-simulator.test.ts: add 5 tests covering the sourceElo override parameter
  on resolveElo() (overrides SRS, overrides futures, overrides fallback,
  null/undefined fall through to normal priority chain)
- nhl-simulator.test.ts: add full bracket-aware integration suite (10 tests)
  covering fallback to season-projection, structure validation errors, probability
  distributions, fully-decided champion, and source tag

https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn

* Fix TS error: add resolvedElo to makeEntry in nhl-simulator.test.ts

TeamEntry requires resolvedElo after the simulator refactor.

https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn

* Fix lint: replace non-null assertions with null-safe alternatives in NHL test

https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-04-30 08:33:57 -07:00 committed by GitHub
parent 380b0786ca
commit ef0ddeff39
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 689 additions and 38 deletions

View file

@ -4,6 +4,7 @@ import {
getTeamData, getTeamData,
winRateFromRDif, winRateFromRDif,
rdifWinProbability, rdifWinProbability,
eloToRDif,
simBo3, simBo3,
simBo5, simBo5,
simBo7, simBo7,
@ -237,3 +238,29 @@ describe("simBo7 (LCS / World Series — first to 4 wins)", () => {
expect(result.winner).not.toBe(result.loser); 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

@ -1,9 +1,10 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
import { import {
normalizeTeamName, normalizeTeamName,
getTeamData, getTeamData,
eloWinProbability, eloWinProbability,
simulateProjectedPoints, simulateProjectedPoints,
NHLSimulator,
} from "../nhl-simulator"; } from "../nhl-simulator";
// ─── normalizeTeamName ──────────────────────────────────────────────────────── // ─── normalizeTeamName ────────────────────────────────────────────────────────
@ -135,6 +136,7 @@ const makeEntry = (overrides: Partial<{
currentPoints: overrides.currentPoints ?? 80, currentPoints: overrides.currentPoints ?? 80,
remainingGames: overrides.remainingGames ?? 10, remainingGames: overrides.remainingGames ?? 10,
winProb: overrides.winProb ?? 0.55, winProb: overrides.winProb ?? 0.55,
resolvedElo: 1500,
}); });
describe("simulateProjectedPoints", () => { describe("simulateProjectedPoints", () => {
@ -171,3 +173,279 @@ describe("simulateProjectedPoints", () => {
expect(avg(0.7)).toBeGreaterThan(avg(0.4)); expect(avg(0.7)).toBeGreaterThan(avg(0.4));
}); });
}); });
// ─── NHLSimulator — bracket-aware mode ───────────────────────────────────────
const TEAM_IDS = Array.from({ length: 16 }, (_, i) => `team-${i + 1}`);
function makeMatch(
round: string,
matchNumber: number,
opts: {
p1?: string | null;
p2?: string | null;
winnerId?: string | null;
loserId?: string | null;
isComplete?: boolean;
} = {}
) {
return {
id: `${round.replace(/\s/g, "-").toLowerCase()}-m${matchNumber}`,
scoringEventId: "event-1",
round,
matchNumber,
participant1Id: opts.p1 ?? null,
participant2Id: opts.p2 ?? null,
winnerId: opts.winnerId ?? null,
loserId: opts.loserId ?? null,
isComplete: opts.isComplete ?? false,
isScoring: false,
templateRound: round,
seedInfo: null,
participant1Score: null,
participant2Score: null,
createdAt: new Date(),
updatedAt: new Date(),
};
}
/**
* Builds a full 16-team NHL bracket fixture:
* R1: 8 matches (Wild Card, matchNumbers 18)
* R2: 4 matches (Divisional, matchNumbers 14) participants null (filled from R1 winners)
* R3: 2 matches (Conference Finals, matchNumbers 12)
* Final: 1 match (Stanley Cup, matchNumber 1)
*/
function buildFullNHLBracket() {
const t = TEAM_IDS;
return [
makeMatch("Wild Card", 1, { p1: t[0], p2: t[15] }),
makeMatch("Wild Card", 2, { p1: t[1], p2: t[14] }),
makeMatch("Wild Card", 3, { p1: t[2], p2: t[13] }),
makeMatch("Wild Card", 4, { p1: t[3], p2: t[12] }),
makeMatch("Wild Card", 5, { p1: t[4], p2: t[11] }),
makeMatch("Wild Card", 6, { p1: t[5], p2: t[10] }),
makeMatch("Wild Card", 7, { p1: t[6], p2: t[9] }),
makeMatch("Wild Card", 8, { p1: t[7], p2: t[8] }),
makeMatch("Divisional", 1),
makeMatch("Divisional", 2),
makeMatch("Divisional", 3),
makeMatch("Divisional", 4),
makeMatch("Conference Finals", 1),
makeMatch("Conference Finals", 2),
makeMatch("Stanley Cup", 1),
];
}
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
vi.mock("~/models/regular-season-standings", () => ({
getRegularSeasonStandings: vi.fn().mockResolvedValue([]),
}));
describe("NHLSimulator — bracket-aware mode", () => {
let mockDb: {
query: {
scoringEvents: { findFirst: MockInstance };
playoffMatches: { findMany: MockInstance };
};
select: MockInstance;
};
beforeEach(async () => {
const { database } = await import("~/database/context");
mockDb = {
query: {
scoringEvents: { findFirst: vi.fn() },
playoffMatches: { findMany: vi.fn() },
},
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(
TEAM_IDS.map((id) => ({ id, name: id }))
),
}),
}),
};
(database as unknown as MockInstance).mockReturnValue(mockDb);
mockDb.query.scoringEvents.findFirst.mockResolvedValue({
id: "event-1",
sportsSeasonId: "season-1",
eventType: "playoff_game",
});
});
it("falls back to season-projection when no bracket event exists", async () => {
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
const sim = new NHLSimulator();
// Season-projection fails: unknown team IDs → no division data → division check throws
await expect(sim.simulate("season-1")).rejects.toThrow(/Each division needs at least 3/);
});
it("falls back to season-projection when bracket matches have no participants", async () => {
const emptyMatches = buildFullNHLBracket().map((m) => ({
...m,
participant1Id: null,
participant2Id: null,
}));
mockDb.query.playoffMatches.findMany.mockResolvedValue(emptyMatches);
const sim = new NHLSimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/Each division needs at least 3/);
});
it("throws if bracket has fewer than 4 rounds", async () => {
const oneRound = Array.from({ length: 8 }, (_, i) =>
makeMatch("Wild Card", i + 1, { p1: TEAM_IDS[i], p2: TEAM_IDS[i + 8] ?? TEAM_IDS[0] })
);
mockDb.query.playoffMatches.findMany.mockResolvedValue(oneRound);
const sim = new NHLSimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/unexpected structure/);
});
it("throws if R1 has wrong match count", async () => {
const shortR1 = [
...Array.from({ length: 4 }, (_, i) =>
makeMatch("Wild Card", i + 1, { p1: TEAM_IDS[i], p2: TEAM_IDS[i + 4] })
),
makeMatch("Divisional", 1),
makeMatch("Divisional", 2),
makeMatch("Conference Finals", 1),
makeMatch("Stanley Cup", 1),
];
mockDb.query.playoffMatches.findMany.mockResolvedValue(shortR1);
const sim = new NHLSimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/Expected 8 first-round matches/);
});
it("throws if an R1 participant slot is missing", async () => {
const matches = buildFullNHLBracket();
const r1m3 = matches.find((m) => m.round === "Wild Card" && m.matchNumber === 3);
if (!r1m3) throw new Error("R1 M3 not found in test fixture");
r1m3.participant2Id = null;
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
const sim = new NHLSimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/missing participants/);
});
it("returns results for all 16 participants", async () => {
mockDb.query.playoffMatches.findMany.mockResolvedValue(buildFullNHLBracket());
const sim = new NHLSimulator();
const results = await sim.simulate("season-1");
expect(results).toHaveLength(16);
});
it("each probability column sums to 1.0 across all 16 participants", async () => {
mockDb.query.playoffMatches.findMany.mockResolvedValue(buildFullNHLBracket());
const sim = new NHLSimulator();
const results = await sim.simulate("season-1");
const keys = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
] as const;
for (const key of keys) {
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
expect(colSum, `column ${key} should sum to 1`).toBeCloseTo(1.0, 1);
}
});
it("all probabilities are non-negative", async () => {
mockDb.query.playoffMatches.findMany.mockResolvedValue(buildFullNHLBracket());
const sim = new NHLSimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
for (const v of Object.values(r.probabilities)) {
expect(v).toBeGreaterThanOrEqual(0);
}
}
});
it("champion has probFirst=1 when bracket is fully decided", async () => {
const t = TEAM_IDS;
const matches = buildFullNHLBracket();
// R1: p1 always wins
const r1 = matches.filter((m) => m.round === "Wild Card");
for (const m of r1) {
m.winnerId = m.participant1Id;
m.loserId = m.participant2Id;
m.isComplete = true;
}
const r1Winners = r1.map((m) => m.winnerId ?? "");
// R2: fill from R1 winners, p1 always wins
const r2 = matches.filter((m) => m.round === "Divisional");
for (let i = 0; i < 4; i++) {
r2[i].participant1Id = r1Winners[i * 2];
r2[i].participant2Id = r1Winners[i * 2 + 1];
r2[i].winnerId = r2[i].participant1Id;
r2[i].loserId = r2[i].participant2Id;
r2[i].isComplete = true;
}
const r2Winners = r2.map((m) => m.winnerId ?? "");
// R3: fill from R2 winners, p1 always wins
const r3 = matches.filter((m) => m.round === "Conference Finals");
for (let i = 0; i < 2; i++) {
r3[i].participant1Id = r2Winners[i * 2];
r3[i].participant2Id = r2Winners[i * 2 + 1];
r3[i].winnerId = r3[i].participant1Id;
r3[i].loserId = r3[i].participant2Id;
r3[i].isComplete = true;
}
const r3Winners = r3.map((m) => m.winnerId ?? "");
// Final: t[0] vs t[1], t[0] wins
const champion = t[0];
const final = matches.find((m) => m.round === "Stanley Cup");
if (!final) throw new Error("Stanley Cup match not found in test fixture");
final.participant1Id = r3Winners[0];
final.participant2Id = r3Winners[1];
final.winnerId = champion;
final.loserId = r3Winners[1];
final.isComplete = true;
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
const sim = new NHLSimulator();
const results = await sim.simulate("season-1");
const champResult = results.find((r) => r.participantId === champion);
expect(champResult, "champion result should exist").toBeDefined();
expect(champResult?.probabilities.probFirst).toBeCloseTo(1.0, 3);
for (const r of results) {
if (r.participantId !== champion) {
expect(r.probabilities.probFirst).toBeCloseTo(0, 3);
}
}
});
it("uses source 'nhl_bracket_monte_carlo' on all results", async () => {
mockDb.query.playoffMatches.findMany.mockResolvedValue(buildFullNHLBracket());
const sim = new NHLSimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
expect(r.source).toBe("nhl_bracket_monte_carlo");
}
});
});

View file

@ -80,6 +80,26 @@ describe("resolveElo", () => {
it("futures mode with no futures: falls back to 1500", () => { it("futures mode with no futures: falls back to 1500", () => {
expect(resolveElo(8, null, false)).toBe(1500); expect(resolveElo(8, null, false)).toBe(1500);
}); });
it("sourceElo overrides SRS when in SRS mode", () => {
expect(resolveElo(5, 1600, true, 1750)).toBe(1750);
});
it("sourceElo overrides futures when not in SRS mode", () => {
expect(resolveElo(null, 1600, false, 1750)).toBe(1750);
});
it("sourceElo overrides 1500 fallback when all other signals are absent", () => {
expect(resolveElo(null, null, false, 1700)).toBe(1700);
});
it("null sourceElo falls through to normal priority chain", () => {
expect(resolveElo(5, 1600, true, null)).toBe(srsToElo(5));
});
it("undefined sourceElo falls through to normal priority chain", () => {
expect(resolveElo(null, 1620, false, undefined)).toBe(1620);
});
}); });
// ─── simSeriesN ─────────────────────────────────────────────────────────────── // ─── simSeriesN ───────────────────────────────────────────────────────────────

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)); 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, * Bill James log5 head-to-head win probability for team A over team B,
* given their projected run differentials. * given their projected run differentials.
@ -303,7 +314,8 @@ export function simBo7(
* with positive weight). * with positive weight).
*/ */
function drawLeaguePlayoffField( function drawLeaguePlayoffField(
leagueTeams: TeamEntry[] leagueTeams: TeamEntry[],
getRDif: (t: TeamEntry) => number = getEntryRDif
): TeamEntry[] | undefined { ): TeamEntry[] | undefined {
// Group by division // Group by division
const divMap = new Map<string, TeamEntry[]>(); const divMap = new Map<string, TeamEntry[]>();
@ -336,10 +348,10 @@ function drawLeaguePlayoffField(
} }
// Rank division winners 13 by RDif descending (best RDif = seed 1) // 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) // 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]; const seeds = [...sortedDivWinners, ...sortedWcTeams];
return seeds.map((t, i) => ({ ...t, originalSeed: i + 1 })); return seeds.map((t, i) => ({ ...t, originalSeed: i + 1 }));
@ -442,6 +454,7 @@ export class MLBSimulator implements Simulator {
.select({ .select({
participantId: schema.participantExpectedValues.participantId, participantId: schema.participantExpectedValues.participantId,
sourceOdds: schema.participantExpectedValues.sourceOdds, sourceOdds: schema.participantExpectedValues.sourceOdds,
sourceElo: schema.participantExpectedValues.sourceElo,
}) })
.from(schema.participantExpectedValues) .from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)); .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 ────────────────────────────────────────────────────────────── // ─── 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. * 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. * When odds are available: 70% RDif log5 + 30% vig-removed futures head-to-head.
*/ */
const gameWinProb = (a: TeamEntry, b: TeamEntry): number => { 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; if (!hasOdds) return rdifProb;
const o1 = normalizedOddsMap.get(a.id); const o1 = normalizedOddsMap.get(a.id);
@ -494,8 +519,8 @@ export class MLBSimulator implements Simulator {
let effectiveN = 0; let effectiveN = 0;
for (let s = 0; s < NUM_SIMULATIONS; s++) { for (let s = 0; s < NUM_SIMULATIONS; s++) {
const alField = drawLeaguePlayoffField(alTeams); const alField = drawLeaguePlayoffField(alTeams, effectiveRDif);
const nlField = drawLeaguePlayoffField(nlTeams); const nlField = drawLeaguePlayoffField(nlTeams, effectiveRDif);
if (!alField || !nlField) continue; // degenerate draw — skip if (!alField || !nlField) continue; // degenerate draw — skip
effectiveN++; effectiveN++;

View file

@ -309,19 +309,35 @@ export class NBASimulator implements Simulator {
} }
const participantIds = [...participantIdSet]; const participantIds = [...participantIdSet];
// Load participant names to map IDs → Elo via TEAMS_DATA. // Load participant names and sourceElo values in parallel.
const participantRows = await db const [participantRows, evRows] = await Promise.all([
.select({ id: schema.participants.id, name: schema.participants.name }) db
.from(schema.participants) .select({ id: schema.participants.id, name: schema.participants.name })
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId)); .from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId)),
db
.select({
participantId: schema.participantExpectedValues.participantId,
sourceElo: schema.participantExpectedValues.sourceElo,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)),
]);
const nameById = new Map(participantRows.map((r) => [r.id, r.name])); const nameById = new Map(participantRows.map((r) => [r.id, r.name]));
// Build Elo map: TEAMS_DATA lookup by name, fallback 1400. const dbEloMap = new Map<string, number>();
for (const row of evRows) {
if (row.sourceElo !== null && row.sourceElo !== undefined) {
dbEloMap.set(row.participantId, row.sourceElo);
}
}
// Build Elo map: sourceElo > TEAMS_DATA > fallback 1400.
const eloMap = new Map<string, number>(); const eloMap = new Map<string, number>();
for (const id of participantIds) { for (const id of participantIds) {
const name = nameById.get(id) ?? ""; const name = nameById.get(id) ?? "";
eloMap.set(id, getTeamData(name)?.elo ?? 1400); eloMap.set(id, dbEloMap.get(id) ?? getTeamData(name)?.elo ?? 1400);
} }
// Build per-round lookup maps for O(1) access in the hot loop. // Build per-round lookup maps for O(1) access in the hot loop.
@ -454,12 +470,19 @@ export class NBASimulator implements Simulator {
private async simulateSeasonProjection(sportsSeasonId: string): Promise<SimulationResult[]> { private async simulateSeasonProjection(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database(); const db = database();
const [participantRows, standings] = await Promise.all([ const [participantRows, standings, evRows] = await Promise.all([
db db
.select({ id: schema.participants.id, name: schema.participants.name }) .select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants) .from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId)), .where(eq(schema.participants.sportsSeasonId, sportsSeasonId)),
getRegularSeasonStandings(sportsSeasonId), getRegularSeasonStandings(sportsSeasonId),
db
.select({
participantId: schema.participantExpectedValues.participantId,
sourceElo: schema.participantExpectedValues.sourceElo,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)),
]); ]);
if (participantRows.length === 0) { if (participantRows.length === 0) {
@ -472,6 +495,13 @@ export class NBASimulator implements Simulator {
const standingsMap = new Map(standings.map((s) => [s.participantId, s])); const standingsMap = new Map(standings.map((s) => [s.participantId, s]));
const participantIds = participantRows.map((r) => r.id); const participantIds = participantRows.map((r) => r.id);
const dbEloMap = new Map<string, number>();
for (const row of evRows) {
if (row.sourceElo !== null && row.sourceElo !== undefined) {
dbEloMap.set(row.participantId, row.sourceElo);
}
}
interface TeamEntry { interface TeamEntry {
id: string; id: string;
name: string; name: string;
@ -480,6 +510,7 @@ export class NBASimulator implements Simulator {
currentWins: number; currentWins: number;
remainingGames: number; remainingGames: number;
winProb: number; winProb: number;
resolvedElo: number;
} }
const teams: TeamEntry[] = participantRows.map((r) => { const teams: TeamEntry[] = participantRows.map((r) => {
@ -491,6 +522,7 @@ export class NBASimulator implements Simulator {
conf === "Eastern" || conf === "Western" conf === "Eastern" || conf === "Western"
? conf ? conf
: (data?.conference ?? "Eastern"); : (data?.conference ?? "Eastern");
const resolvedElo = dbEloMap.get(r.id) ?? data?.elo ?? 1400;
return { return {
id: r.id, id: r.id,
name: r.name, name: r.name,
@ -498,7 +530,8 @@ export class NBASimulator implements Simulator {
conference, conference,
currentWins: standing?.wins ?? 0, currentWins: standing?.wins ?? 0,
remainingGames: Math.max(0, NBA_REGULAR_SEASON_GAMES - gamesPlayed), remainingGames: Math.max(0, NBA_REGULAR_SEASON_GAMES - gamesPlayed),
winProb: eloWinProbability(data?.elo ?? 1400, 1500), winProb: eloWinProbability(resolvedElo, 1500),
resolvedElo,
}; };
}); });
@ -618,11 +651,11 @@ export class NBASimulator implements Simulator {
// simulateSeasonProjection). eloOfEntry is module-level so the linter doesn't flag it for // simulateSeasonProjection). eloOfEntry is module-level so the linter doesn't flag it for
// "consistent-function-scoping" (it doesn't close over any local variables). // "consistent-function-scoping" (it doesn't close over any local variables).
interface TeamEntryBase { interface TeamEntryBase {
data: { elo: number } | undefined; resolvedElo: number;
} }
function eloOfEntry(entry: TeamEntryBase): number { function eloOfEntry(entry: TeamEntryBase): number {
return entry.data?.elo ?? 1400; return entry.resolvedElo;
} }
interface TeamForProjection { interface TeamForProjection {

View file

@ -60,7 +60,7 @@
*/ */
import { database } from "~/database/context"; import { database } from "~/database/context";
import { eq } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
@ -200,11 +200,13 @@ interface TeamEntry {
remainingGames: number; remainingGames: number;
/** Win probability vs. a league-average opponent (Elo 1500). Pre-computed. */ /** Win probability vs. a league-average opponent (Elo 1500). Pre-computed. */
winProb: number; winProb: number;
/** Resolved Elo: sourceElo from DB > hardcoded TEAMS_DATA > fallback 1400. */
resolvedElo: number;
} }
/** Get Elo for a team entry. Fallback 1400 for unknown teams. */ /** Get Elo for a team entry. */
function elo(entry: TeamEntry): number { function elo(entry: TeamEntry): number {
return entry.data?.elo ?? 1400; return entry.resolvedElo;
} }
/** /**
@ -254,13 +256,40 @@ export class NHLSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database(); const db = database();
// 1. Load participants and standings in parallel. // Check for a populated playoff bracket; if found, use bracket-aware simulation.
const [participantRows, standings] = await Promise.all([ 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, standings, and sourceElo values in parallel.
const [participantRows, standings, evRows] = await Promise.all([
db db
.select({ id: schema.participants.id, name: schema.participants.name }) .select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants) .from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId)), .where(eq(schema.participants.sportsSeasonId, sportsSeasonId)),
getRegularSeasonStandings(sportsSeasonId), getRegularSeasonStandings(sportsSeasonId),
db
.select({
participantId: schema.participantExpectedValues.participantId,
sourceElo: schema.participantExpectedValues.sourceElo,
sourceOdds: schema.participantExpectedValues.sourceOdds,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)),
]); ]);
if (participantRows.length === 0) { if (participantRows.length === 0) {
@ -272,9 +301,16 @@ export class NHLSimulator implements Simulator {
const participantIds = participantRows.map((r) => r.id); const participantIds = participantRows.map((r) => r.id);
// 2. Build standings lookup and construct team entries. // 2. Build standings lookup, sourceElo map, and construct team entries.
const standingsMap = new Map(standings.map((s) => [s.participantId, s])); const standingsMap = new Map(standings.map((s) => [s.participantId, s]));
const dbEloMap = new Map<string, number>();
for (const row of evRows) {
if (row.sourceElo !== null && row.sourceElo !== undefined) {
dbEloMap.set(row.participantId, row.sourceElo);
}
}
if (standings.length === 0) { if (standings.length === 0) {
logger.warn( logger.warn(
`[NHLSimulator] No standings data found for sports season ${sportsSeasonId}. ` + `[NHLSimulator] No standings data found for sports season ${sportsSeasonId}. ` +
@ -301,6 +337,7 @@ export class NHLSimulator implements Simulator {
const otLosses = standing?.otLosses ?? 0; const otLosses = standing?.otLosses ?? 0;
const currentPoints = wins * 2 + otLosses; const currentPoints = wins * 2 + otLosses;
const remainingGames = Math.max(0, NHL_REGULAR_SEASON_GAMES - gamesPlayed); const remainingGames = Math.max(0, NHL_REGULAR_SEASON_GAMES - gamesPlayed);
const resolvedElo = dbEloMap.get(r.id) ?? data?.elo ?? 1400;
return { return {
id: r.id, id: r.id,
@ -310,7 +347,8 @@ export class NHLSimulator implements Simulator {
division, division,
currentPoints, currentPoints,
remainingGames, remainingGames,
winProb: eloWinProbability(data?.elo ?? 1400, 1500), winProb: eloWinProbability(resolvedElo, 1500),
resolvedElo,
}; };
}); });
@ -345,14 +383,6 @@ export class NHLSimulator implements Simulator {
// ─── Futures odds blending ───────────────────────────────────────────────── // ─── Futures odds 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(participantIds); const participantIdSet = new Set(participantIds);
const oddsRows = evRows.filter( const oddsRows = evRows.filter(
(r) => r.sourceOdds !== null && participantIdSet.has(r.participantId) (r) => r.sourceOdds !== null && participantIdSet.has(r.participantId)
@ -539,4 +569,233 @@ export class NHLSimulator implements Simulator {
return results; 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}.`
);
}
// Validate all R1 matches have participants before entering the hot loop.
for (const m of r1Matches) {
if (!m.participant1Id || !m.participant2Id) {
throw new Error(
`Round 1 match ${m.matchNumber} is missing participants. ` +
`Seed all 16 teams into the bracket before running simulation.`
);
}
}
// Load all participants and EV data in parallel.
const [participantRows, evRows] = await Promise.all([
db
.select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId)),
db
.select({
participantId: schema.participantExpectedValues.participantId,
sourceOdds: schema.participantExpectedValues.sourceOdds,
sourceElo: schema.participantExpectedValues.sourceElo,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)),
]);
const allParticipantIds = participantRows.map((r) => r.id);
const dbEloMap = new Map<string, number>();
for (const row of evRows) {
if (row.sourceElo !== null && row.sourceElo !== undefined) {
dbEloMap.set(row.participantId, row.sourceElo);
}
}
// Build Elo map: sourceElo > TEAMS_DATA > fallback 1400.
const eloMap = new Map<string, number>();
for (const r of participantRows) {
eloMap.set(r.id, dbEloMap.get(r.id) ?? getTeamData(r.name)?.elo ?? 1400);
}
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;
}
} }

View file

@ -96,14 +96,15 @@ export function srsToElo(srs: number): number {
/** /**
* Resolve the Elo to use for a team given available signals and the current mode. * Resolve the Elo to use for a team given available signals and the current mode.
* *
* In SRS mode (in-season): use SRS-derived Elo, fall back to futuresElo, then 1500. * Priority: sourceElo (admin-entered projected wins) > SRS (in-season) > futures odds > 1500.
* In futures mode (pre-season): use futuresElo, fall back to 1500.
*/ */
export function resolveElo( export function resolveElo(
srs: number | null, srs: number | null,
futuresElo: number | null, futuresElo: number | null,
useSRS: boolean useSRS: boolean,
sourceElo?: number | null
): number { ): number {
if (sourceElo !== null && sourceElo !== undefined) return sourceElo;
if (useSRS) { if (useSRS) {
if (srs !== null) return srsToElo(srs); if (srs !== null) return srsToElo(srs);
return futuresElo ?? 1500; return futuresElo ?? 1500;
@ -166,6 +167,7 @@ export class WNBASimulator implements Simulator {
.select({ .select({
participantId: schema.participantExpectedValues.participantId, participantId: schema.participantExpectedValues.participantId,
sourceOdds: schema.participantExpectedValues.sourceOdds, sourceOdds: schema.participantExpectedValues.sourceOdds,
sourceElo: schema.participantExpectedValues.sourceElo,
}) })
.from(schema.participantExpectedValues) .from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)), .where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)),
@ -185,7 +187,7 @@ export class WNBASimulator implements Simulator {
); );
} }
// 2. Build futures Elo map from championship odds. // 2. Build futures Elo map from championship odds and sourceElo map from projected wins.
const oddsInput = evRows const oddsInput = evRows
.filter((r) => r.sourceOdds !== null) .filter((r) => r.sourceOdds !== null)
.map((r) => ({ participantId: r.participantId, odds: r.sourceOdds ?? 0 })); .map((r) => ({ participantId: r.participantId, odds: r.sourceOdds ?? 0 }));
@ -194,6 +196,13 @@ export class WNBASimulator implements Simulator {
? convertFuturesToElo(oddsInput, "american") ? convertFuturesToElo(oddsInput, "american")
: new Map(); : new Map();
const sourceEloMap = new Map<string, number>();
for (const row of evRows) {
if (row.sourceElo !== null && row.sourceElo !== undefined) {
sourceEloMap.set(row.participantId, row.sourceElo);
}
}
// 3. Build standings lookup and determine simulation mode. // 3. Build standings lookup and determine simulation mode.
const standingsMap = new Map(standings.map((s) => [s.participantId, s])); const standingsMap = new Map(standings.map((s) => [s.participantId, s]));
const participantIds = participantRows.map((r) => r.id); const participantIds = participantRows.map((r) => r.id);
@ -212,7 +221,7 @@ export class WNBASimulator implements Simulator {
? parseFloat(standing.srs) ? parseFloat(standing.srs)
: null; : null;
const futuresElo = futuresEloMap.get(r.id) ?? null; const futuresElo = futuresEloMap.get(r.id) ?? null;
const elo = resolveElo(srs, futuresElo, useSRS); const elo = resolveElo(srs, futuresElo, useSRS, sourceEloMap.get(r.id) ?? null);
const gamesPlayed = standing?.gamesPlayed ?? 0; const gamesPlayed = standing?.gamesPlayed ?? 0;
return { return {
id: r.id, id: r.id,