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:
parent
380b0786ca
commit
ef0ddeff39
7 changed files with 689 additions and 38 deletions
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 1–8)
|
||||||
|
* R2: 4 matches (Divisional, matchNumbers 1–4) — participants null (filled from R1 winners)
|
||||||
|
* R3: 2 matches (Conference Finals, matchNumbers 1–2)
|
||||||
|
* 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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 ───────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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 1–3 by RDif descending (best RDif = seed 1)
|
// 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)
|
// 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];
|
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++;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 → 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue