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
This commit is contained in:
Claude 2026-04-30 05:36:03 +00:00
parent 6f5920eb2b
commit 9684ce6b92
No known key found for this signature in database
3 changed files with 108 additions and 47 deletions

View file

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

View file

@ -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;
} }
/** /**
@ -273,13 +275,21 @@ export class NHLSimulator implements Simulator {
} }
} }
// 1. Load participants and standings in parallel. // 1. Load participants, standings, and sourceElo values in parallel.
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,
sourceOdds: schema.participantExpectedValues.sourceOdds,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)),
]); ]);
if (participantRows.length === 0) { if (participantRows.length === 0) {
@ -291,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}. ` +
@ -320,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,
@ -329,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,
}; };
}); });
@ -364,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)
@ -596,29 +607,37 @@ export class NHLSimulator implements Simulator {
); );
} }
// Load all participants so we can build the Elo map and return results for everyone. // Load all participants and EV data 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,
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 allParticipantIds = participantRows.map((r) => r.id);
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>();
const eloMap = new Map<string, number>(); for (const row of evRows) {
for (const r of participantRows) { if (row.sourceElo !== null && row.sourceElo !== undefined) {
eloMap.set(r.id, getTeamData(r.name)?.elo ?? 1400); dbEloMap.set(row.participantId, row.sourceElo);
}
} }
// Load futures odds for blending. // Build Elo map: sourceElo > TEAMS_DATA > fallback 1400.
const evRows = await db const eloMap = new Map<string, number>();
.select({ for (const r of participantRows) {
participantId: schema.participantExpectedValues.participantId, eloMap.set(r.id, dbEloMap.get(r.id) ?? getTeamData(r.name)?.elo ?? 1400);
sourceOdds: schema.participantExpectedValues.sourceOdds, }
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId));
const participantIdSet = new Set(allParticipantIds); const participantIdSet = new Set(allParticipantIds);
const oddsRows = evRows.filter( const oddsRows = evRows.filter(

View file

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