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,
|
||||
winRateFromRDif,
|
||||
rdifWinProbability,
|
||||
eloToRDif,
|
||||
simBo3,
|
||||
simBo5,
|
||||
simBo7,
|
||||
|
|
@ -237,3 +238,29 @@ describe("simBo7 (LCS / World Series — first to 4 wins)", () => {
|
|||
expect(result.winner).not.toBe(result.loser);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── eloToRDif ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("eloToRDif", () => {
|
||||
it("maps Elo 1500 (average) to RDif 0", () => {
|
||||
expect(eloToRDif(1500)).toBeCloseTo(0, 5);
|
||||
});
|
||||
|
||||
it("maps Elo above 1500 to a positive RDif", () => {
|
||||
expect(eloToRDif(1600)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("maps Elo below 1500 to a negative RDif", () => {
|
||||
expect(eloToRDif(1400)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it("is symmetric: eloToRDif(1500 + d) = -eloToRDif(1500 - d)", () => {
|
||||
expect(eloToRDif(1600)).toBeCloseTo(-eloToRDif(1400), 5);
|
||||
});
|
||||
|
||||
it("round-trips through winRateFromRDif: winRate(eloToRDif(elo)) ≈ eloWinProb(elo, 1500)", () => {
|
||||
const elo = 1620;
|
||||
const expectedWinRate = 1 / (1 + Math.pow(10, (1500 - elo) / 400));
|
||||
expect(winRateFromRDif(eloToRDif(elo))).toBeCloseTo(expectedWinRate, 4);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
||||
import {
|
||||
normalizeTeamName,
|
||||
getTeamData,
|
||||
eloWinProbability,
|
||||
simulateProjectedPoints,
|
||||
NHLSimulator,
|
||||
} from "../nhl-simulator";
|
||||
|
||||
// ─── normalizeTeamName ────────────────────────────────────────────────────────
|
||||
|
|
@ -135,6 +136,7 @@ const makeEntry = (overrides: Partial<{
|
|||
currentPoints: overrides.currentPoints ?? 80,
|
||||
remainingGames: overrides.remainingGames ?? 10,
|
||||
winProb: overrides.winProb ?? 0.55,
|
||||
resolvedElo: 1500,
|
||||
});
|
||||
|
||||
describe("simulateProjectedPoints", () => {
|
||||
|
|
@ -171,3 +173,279 @@ describe("simulateProjectedPoints", () => {
|
|||
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", () => {
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -189,6 +189,17 @@ export function winRateFromRDif(rdif: number): number {
|
|||
return Math.min(0.99, Math.max(0.01, 0.5 + rdif / RDIF_DIVISOR));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an Elo rating to an equivalent projected run differential.
|
||||
* Uses the standard Elo win probability formula (parity factor 400, average Elo 1500),
|
||||
* then inverts the winRateFromRDif formula: rdif = (winRate − 0.5) × RDIF_DIVISOR.
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export function eloToRDif(elo: number): number {
|
||||
const winRate = 1 / (1 + Math.pow(10, (1500 - elo) / 400));
|
||||
return (winRate - 0.5) * RDIF_DIVISOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bill James log5 head-to-head win probability for team A over team B,
|
||||
* given their projected run differentials.
|
||||
|
|
@ -303,7 +314,8 @@ export function simBo7(
|
|||
* with positive weight).
|
||||
*/
|
||||
function drawLeaguePlayoffField(
|
||||
leagueTeams: TeamEntry[]
|
||||
leagueTeams: TeamEntry[],
|
||||
getRDif: (t: TeamEntry) => number = getEntryRDif
|
||||
): TeamEntry[] | undefined {
|
||||
// Group by division
|
||||
const divMap = new Map<string, TeamEntry[]>();
|
||||
|
|
@ -336,10 +348,10 @@ function drawLeaguePlayoffField(
|
|||
}
|
||||
|
||||
// Rank division winners 1–3 by RDif descending (best RDif = seed 1)
|
||||
const sortedDivWinners = divisionWinners.toSorted((a, b) => getEntryRDif(b) - getEntryRDif(a));
|
||||
const sortedDivWinners = divisionWinners.toSorted((a, b) => getRDif(b) - getRDif(a));
|
||||
|
||||
// Rank WC teams 4–6 by RDif descending (best RDif = seed 4)
|
||||
const sortedWcTeams = wcTeams.toSorted((a, b) => getEntryRDif(b) - getEntryRDif(a));
|
||||
const sortedWcTeams = wcTeams.toSorted((a, b) => getRDif(b) - getRDif(a));
|
||||
|
||||
const seeds = [...sortedDivWinners, ...sortedWcTeams];
|
||||
return seeds.map((t, i) => ({ ...t, originalSeed: i + 1 }));
|
||||
|
|
@ -442,6 +454,7 @@ export class MLBSimulator implements Simulator {
|
|||
.select({
|
||||
participantId: schema.participantExpectedValues.participantId,
|
||||
sourceOdds: schema.participantExpectedValues.sourceOdds,
|
||||
sourceElo: schema.participantExpectedValues.sourceElo,
|
||||
})
|
||||
.from(schema.participantExpectedValues)
|
||||
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId));
|
||||
|
|
@ -463,14 +476,26 @@ export class MLBSimulator implements Simulator {
|
|||
});
|
||||
}
|
||||
|
||||
// Build a map of sourceElo-derived RDif values (overrides hardcoded TEAMS_DATA.rdif).
|
||||
const sourceEloRDifMap = new Map<string, number>();
|
||||
for (const r of evRows) {
|
||||
if (r.sourceElo !== null && r.sourceElo !== undefined && participantIdSet.has(r.participantId)) {
|
||||
sourceEloRDifMap.set(r.participantId, eloToRDif(r.sourceElo));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Effective RDif: prefer sourceElo-derived value over hardcoded TEAMS_DATA.rdif. */
|
||||
const effectiveRDif = (entry: TeamEntry): number =>
|
||||
sourceEloRDifMap.get(entry.id) ?? getEntryRDif(entry);
|
||||
|
||||
/**
|
||||
* Blended per-game win probability for team A over team B.
|
||||
* When odds are available: 70% RDif log5 + 30% vig-removed futures head-to-head.
|
||||
*/
|
||||
const gameWinProb = (a: TeamEntry, b: TeamEntry): number => {
|
||||
const rdifProb = rdifWinProbability(getEntryRDif(a), getEntryRDif(b));
|
||||
const rdifProb = rdifWinProbability(effectiveRDif(a), effectiveRDif(b));
|
||||
if (!hasOdds) return rdifProb;
|
||||
|
||||
const o1 = normalizedOddsMap.get(a.id);
|
||||
|
|
@ -494,8 +519,8 @@ export class MLBSimulator implements Simulator {
|
|||
let effectiveN = 0;
|
||||
|
||||
for (let s = 0; s < NUM_SIMULATIONS; s++) {
|
||||
const alField = drawLeaguePlayoffField(alTeams);
|
||||
const nlField = drawLeaguePlayoffField(nlTeams);
|
||||
const alField = drawLeaguePlayoffField(alTeams, effectiveRDif);
|
||||
const nlField = drawLeaguePlayoffField(nlTeams, effectiveRDif);
|
||||
if (!alField || !nlField) continue; // degenerate draw — skip
|
||||
|
||||
effectiveN++;
|
||||
|
|
|
|||
|
|
@ -309,19 +309,35 @@ export class NBASimulator implements Simulator {
|
|||
}
|
||||
const participantIds = [...participantIdSet];
|
||||
|
||||
// Load participant names to map IDs → Elo via TEAMS_DATA.
|
||||
const participantRows = await db
|
||||
.select({ id: schema.participants.id, name: schema.participants.name })
|
||||
.from(schema.participants)
|
||||
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId));
|
||||
// Load participant names and sourceElo values 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,
|
||||
sourceElo: schema.participantExpectedValues.sourceElo,
|
||||
})
|
||||
.from(schema.participantExpectedValues)
|
||||
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)),
|
||||
]);
|
||||
|
||||
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>();
|
||||
for (const id of participantIds) {
|
||||
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.
|
||||
|
|
@ -454,12 +470,19 @@ export class NBASimulator implements Simulator {
|
|||
private async simulateSeasonProjection(sportsSeasonId: string): Promise<SimulationResult[]> {
|
||||
const db = database();
|
||||
|
||||
const [participantRows, standings] = await Promise.all([
|
||||
const [participantRows, standings, evRows] = await Promise.all([
|
||||
db
|
||||
.select({ id: schema.participants.id, name: schema.participants.name })
|
||||
.from(schema.participants)
|
||||
.where(eq(schema.participants.sportsSeasonId, 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) {
|
||||
|
|
@ -472,6 +495,13 @@ export class NBASimulator implements Simulator {
|
|||
const standingsMap = new Map(standings.map((s) => [s.participantId, s]));
|
||||
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 {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -480,6 +510,7 @@ export class NBASimulator implements Simulator {
|
|||
currentWins: number;
|
||||
remainingGames: number;
|
||||
winProb: number;
|
||||
resolvedElo: number;
|
||||
}
|
||||
|
||||
const teams: TeamEntry[] = participantRows.map((r) => {
|
||||
|
|
@ -491,6 +522,7 @@ export class NBASimulator implements Simulator {
|
|||
conf === "Eastern" || conf === "Western"
|
||||
? conf
|
||||
: (data?.conference ?? "Eastern");
|
||||
const resolvedElo = dbEloMap.get(r.id) ?? data?.elo ?? 1400;
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
|
|
@ -498,7 +530,8 @@ export class NBASimulator implements Simulator {
|
|||
conference,
|
||||
currentWins: standing?.wins ?? 0,
|
||||
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
|
||||
// "consistent-function-scoping" (it doesn't close over any local variables).
|
||||
interface TeamEntryBase {
|
||||
data: { elo: number } | undefined;
|
||||
resolvedElo: number;
|
||||
}
|
||||
|
||||
function eloOfEntry(entry: TeamEntryBase): number {
|
||||
return entry.data?.elo ?? 1400;
|
||||
return entry.resolvedElo;
|
||||
}
|
||||
|
||||
interface TeamForProjection {
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@
|
|||
*/
|
||||
|
||||
import { database } from "~/database/context";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import * as schema from "~/database/schema";
|
||||
import type { Simulator, SimulationResult } from "./types";
|
||||
import { logger } from "~/lib/logger";
|
||||
|
|
@ -200,11 +200,13 @@ interface TeamEntry {
|
|||
remainingGames: number;
|
||||
/** Win probability vs. a league-average opponent (Elo 1500). Pre-computed. */
|
||||
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 {
|
||||
return entry.data?.elo ?? 1400;
|
||||
return entry.resolvedElo;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -254,13 +256,40 @@ export class NHLSimulator implements Simulator {
|
|||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
||||
const db = database();
|
||||
|
||||
// 1. Load participants and standings in parallel.
|
||||
const [participantRows, standings] = await Promise.all([
|
||||
// Check for a populated playoff bracket; if found, use bracket-aware simulation.
|
||||
const bracketEvent = await db.query.scoringEvents.findFirst({
|
||||
where: and(
|
||||
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||
eq(schema.scoringEvents.eventType, "playoff_game")
|
||||
),
|
||||
});
|
||||
|
||||
if (bracketEvent) {
|
||||
const allMatches = await db.query.playoffMatches.findMany({
|
||||
where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
|
||||
orderBy: (m, { asc }) => [asc(m.matchNumber)],
|
||||
});
|
||||
const bracketPopulated = allMatches.some((m) => m.participant1Id && m.participant2Id);
|
||||
if (bracketPopulated) {
|
||||
return this.simulateBracket(sportsSeasonId, allMatches);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Load participants, standings, and sourceElo values in parallel.
|
||||
const [participantRows, standings, evRows] = await Promise.all([
|
||||
db
|
||||
.select({ id: schema.participants.id, name: schema.participants.name })
|
||||
.from(schema.participants)
|
||||
.where(eq(schema.participants.sportsSeasonId, 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) {
|
||||
|
|
@ -272,9 +301,16 @@ export class NHLSimulator implements Simulator {
|
|||
|
||||
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 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) {
|
||||
logger.warn(
|
||||
`[NHLSimulator] No standings data found for sports season ${sportsSeasonId}. ` +
|
||||
|
|
@ -301,6 +337,7 @@ export class NHLSimulator implements Simulator {
|
|||
const otLosses = standing?.otLosses ?? 0;
|
||||
const currentPoints = wins * 2 + otLosses;
|
||||
const remainingGames = Math.max(0, NHL_REGULAR_SEASON_GAMES - gamesPlayed);
|
||||
const resolvedElo = dbEloMap.get(r.id) ?? data?.elo ?? 1400;
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
|
|
@ -310,7 +347,8 @@ export class NHLSimulator implements Simulator {
|
|||
division,
|
||||
currentPoints,
|
||||
remainingGames,
|
||||
winProb: eloWinProbability(data?.elo ?? 1400, 1500),
|
||||
winProb: eloWinProbability(resolvedElo, 1500),
|
||||
resolvedElo,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -345,14 +383,6 @@ export class NHLSimulator implements Simulator {
|
|||
|
||||
// ─── 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 oddsRows = evRows.filter(
|
||||
(r) => r.sourceOdds !== null && participantIdSet.has(r.participantId)
|
||||
|
|
@ -539,4 +569,233 @@ export class NHLSimulator implements Simulator {
|
|||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ── Mode: Bracket-Aware ───────────────────────────────────────────────────────
|
||||
|
||||
private async simulateBracket(
|
||||
sportsSeasonId: string,
|
||||
allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>
|
||||
): Promise<SimulationResult[]> {
|
||||
const db = database();
|
||||
|
||||
// Group matches by round, sorted by match count descending (R1 first → Final last).
|
||||
const byRound = new Map<string, typeof allMatches>();
|
||||
for (const m of allMatches) {
|
||||
if (!byRound.has(m.round)) byRound.set(m.round, []);
|
||||
byRound.get(m.round)?.push(m);
|
||||
}
|
||||
|
||||
const sortedRoundMatches = [...byRound.values()]
|
||||
.toSorted((a, b) => b.length - a.length)
|
||||
.map((matches) => matches.toSorted((a, b) => a.matchNumber - b.matchNumber));
|
||||
|
||||
if (sortedRoundMatches.length < 4) {
|
||||
throw new Error(
|
||||
`NHL bracket has unexpected structure: expected 4 rounds, found ${sortedRoundMatches.length}. ` +
|
||||
`Check the bracket template.`
|
||||
);
|
||||
}
|
||||
|
||||
const r1Matches = sortedRoundMatches[0]; // 8 matches (Round of 16 / Wild Card)
|
||||
const r2Matches = sortedRoundMatches[1]; // 4 matches (Quarterfinals / Divisional)
|
||||
const r3Matches = sortedRoundMatches[2]; // 2 matches (Semifinals / Conf Finals)
|
||||
const finalMatches = sortedRoundMatches[3]; // 1 match (Finals / Stanley Cup)
|
||||
|
||||
if (r1Matches.length !== 8) {
|
||||
throw new Error(
|
||||
`Expected 8 first-round matches for NHL bracket, found ${r1Matches.length}.`
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
*
|
||||
* In SRS mode (in-season): use SRS-derived Elo, fall back to futuresElo, then 1500.
|
||||
* In futures mode (pre-season): use futuresElo, fall back to 1500.
|
||||
* Priority: sourceElo (admin-entered projected wins) > SRS (in-season) > futures odds > 1500.
|
||||
*/
|
||||
export function resolveElo(
|
||||
srs: number | null,
|
||||
futuresElo: number | null,
|
||||
useSRS: boolean
|
||||
useSRS: boolean,
|
||||
sourceElo?: number | null
|
||||
): number {
|
||||
if (sourceElo !== null && sourceElo !== undefined) return sourceElo;
|
||||
if (useSRS) {
|
||||
if (srs !== null) return srsToElo(srs);
|
||||
return futuresElo ?? 1500;
|
||||
|
|
@ -166,6 +167,7 @@ export class WNBASimulator implements Simulator {
|
|||
.select({
|
||||
participantId: schema.participantExpectedValues.participantId,
|
||||
sourceOdds: schema.participantExpectedValues.sourceOdds,
|
||||
sourceElo: schema.participantExpectedValues.sourceElo,
|
||||
})
|
||||
.from(schema.participantExpectedValues)
|
||||
.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
|
||||
.filter((r) => r.sourceOdds !== null)
|
||||
.map((r) => ({ participantId: r.participantId, odds: r.sourceOdds ?? 0 }));
|
||||
|
|
@ -194,6 +196,13 @@ export class WNBASimulator implements Simulator {
|
|||
? convertFuturesToElo(oddsInput, "american")
|
||||
: 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.
|
||||
const standingsMap = new Map(standings.map((s) => [s.participantId, s]));
|
||||
const participantIds = participantRows.map((r) => r.id);
|
||||
|
|
@ -212,7 +221,7 @@ export class WNBASimulator implements Simulator {
|
|||
? parseFloat(standing.srs)
|
||||
: 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;
|
||||
return {
|
||||
id: r.id,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue