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];
// 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 {

View file

@ -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<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}. ` +
@ -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<string, number>();
for (const r of participantRows) {
eloMap.set(r.id, getTeamData(r.name)?.elo ?? 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);
}
}
// 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<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(

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.
*
* 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,