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:
parent
6f5920eb2b
commit
9684ce6b92
3 changed files with 108 additions and 47 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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