From 9684ce6b9283c361b277d9cb255d39a1026a25ca Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 05:36:03 +0000 Subject: [PATCH] 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 --- app/services/simulations/nba-simulator.ts | 55 ++++++++++++--- app/services/simulations/nhl-simulator.ts | 81 +++++++++++++--------- app/services/simulations/wnba-simulator.ts | 19 +++-- 3 files changed, 108 insertions(+), 47 deletions(-) diff --git a/app/services/simulations/nba-simulator.ts b/app/services/simulations/nba-simulator.ts index 1e2884f..cb4dacd 100644 --- a/app/services/simulations/nba-simulator.ts +++ b/app/services/simulations/nba-simulator.ts @@ -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(); + 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(); 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 { 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(); + 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 { diff --git a/app/services/simulations/nhl-simulator.ts b/app/services/simulations/nhl-simulator.ts index aa54784..af175dd 100644 --- a/app/services/simulations/nhl-simulator.ts +++ b/app/services/simulations/nhl-simulator.ts @@ -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; } /** @@ -273,13 +275,21 @@ export class NHLSimulator implements Simulator { } } - // 1. Load participants and standings in parallel. - const [participantRows, standings] = await Promise.all([ + // 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) { @@ -291,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(); + 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}. ` + @@ -320,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, @@ -329,7 +347,8 @@ export class NHLSimulator implements Simulator { division, currentPoints, remainingGames, - winProb: eloWinProbability(data?.elo ?? 1400, 1500), + winProb: eloWinProbability(resolvedElo, 1500), + resolvedElo, }; }); @@ -364,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) @@ -596,29 +607,37 @@ export class NHLSimulator implements Simulator { ); } - // Load all participants so we can build the Elo map and return results for everyone. - const participantRows = await db - .select({ id: schema.participants.id, name: schema.participants.name }) - .from(schema.participants) - .where(eq(schema.participants.sportsSeasonId, sportsSeasonId)); + // 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 nameById = new Map(participantRows.map((r) => [r.id, r.name])); - // Build Elo map: TEAMS_DATA lookup by name, fallback 1400. - const eloMap = new Map(); - for (const r of participantRows) { - eloMap.set(r.id, getTeamData(r.name)?.elo ?? 1400); + const dbEloMap = new Map(); + for (const row of evRows) { + if (row.sourceElo !== null && row.sourceElo !== undefined) { + dbEloMap.set(row.participantId, row.sourceElo); + } } - // Load futures odds for blending. - const evRows = await db - .select({ - participantId: schema.participantExpectedValues.participantId, - sourceOdds: schema.participantExpectedValues.sourceOdds, - }) - .from(schema.participantExpectedValues) - .where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)); + // Build Elo map: sourceElo > TEAMS_DATA > fallback 1400. + const eloMap = new Map(); + 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( diff --git a/app/services/simulations/wnba-simulator.ts b/app/services/simulations/wnba-simulator.ts index d8b5c0b..a820202 100644 --- a/app/services/simulations/wnba-simulator.ts +++ b/app/services/simulations/wnba-simulator.ts @@ -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(); + 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,