Fix futures odds being ignored when stale Elo exists
All checks were successful
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m58s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m23s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped

When an admin entered futures (preseason) odds for a season that already
had Elo ratings stored, the simulator kept using the old Elo and silently
ignored the new odds. This affected any Elo-based simulator (e.g. NHL).

Root cause: resolveSourceElos() ranks a direct sourceElo above the
sourceOdds -> convertFuturesToElo branch, but batchSaveFuturesOddsForSimulator()
only cleared the bracket-seeding `rating`/`ratingMethod` — never the stale
`sourceElo`/`sourceEloMethod`. A manually entered Elo (method "direct") is not
treated as generated, so it survived and short-circuited the resolver.

Fix:
- batchSaveFuturesOddsForSimulator now also nulls sourceElo and strips
  sourceEloMethod (both the pre-update and upsert-conflict paths), so the
  existing futures -> Elo conversion drives the run.
- resolveSourceElos' sourceOdds branch now guards for >= 2 participants
  (mirroring resolveRatings), so a lone-odds season falls through to the
  configured missing-Elo strategy instead of getting a flat ~1500.
- batchSaveSourceOdds clears the legacy EV sourceElo and marks source as
  futures_odds so the elo-ratings page won't resurrect a stale rating.

Adds unit coverage for odds-derived Elo, the single-participant guard, the
post-clear regression, generated-vs-direct sourceElo suppression, and the
new clearing behavior in batchSaveFuturesOddsForSimulator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YNfUEd9RzD3zm84oLHBHUH
This commit is contained in:
Claude 2026-06-25 17:43:31 +00:00
parent 6772079e86
commit 88248e349c
No known key found for this signature in database
6 changed files with 188 additions and 12 deletions

View file

@ -0,0 +1,66 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock the database context before importing any model
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
import { database } from "~/database/context";
import { batchSaveFuturesOddsForSimulator } from "../simulator";
type SetPayload = Record<string, unknown>;
const setCalls: SetPayload[] = [];
const conflictSetCalls: SetPayload[] = [];
const tx = {
update: vi.fn(() => ({
set: vi.fn((arg: SetPayload) => {
setCalls.push(arg);
return { where: vi.fn().mockResolvedValue(undefined) };
}),
})),
insert: vi.fn(() => ({
values: vi.fn(() => ({
onConflictDoUpdate: vi.fn((arg: { set: SetPayload }) => {
conflictSetCalls.push(arg.set);
return Promise.resolve(undefined);
}),
})),
})),
};
const mockDb = {
transaction: vi.fn(async (cb: (t: typeof tx) => Promise<void>) => cb(tx)),
};
beforeEach(() => {
vi.clearAllMocks();
setCalls.length = 0;
conflictSetCalls.length = 0;
(database as ReturnType<typeof vi.fn>).mockReturnValue(mockDb);
});
describe("batchSaveFuturesOddsForSimulator", () => {
it("clears rating and sourceElo so odds drive the next run", async () => {
await batchSaveFuturesOddsForSimulator([
{ participantId: "team-1", sportsSeasonId: "season-1", sourceOdds: 550 },
]);
// Pre-update clears the stale rating and sourceElo for these participants.
expect(setCalls).toHaveLength(1);
expect(setCalls[0]).toMatchObject({ rating: null, sourceElo: null });
// Metadata is rewritten (an SQL fragment that strips ratingMethod/sourceEloMethod).
expect(setCalls[0].metadata).toBeDefined();
// The upsert conflict path also nulls both so re-entering odds stays clean.
expect(conflictSetCalls).toHaveLength(1);
expect(conflictSetCalls[0]).toMatchObject({ rating: null, sourceElo: null });
expect(conflictSetCalls[0].metadata).toBeDefined();
});
it("is a no-op when given no inputs", async () => {
await batchSaveFuturesOddsForSimulator([]);
expect(mockDb.transaction).not.toHaveBeenCalled();
});
});

View file

@ -71,4 +71,44 @@ describe("simulator input model", () => {
expect(byParticipant.get("generated-rating")?.rating).toBeNull(); expect(byParticipant.get("generated-rating")?.rating).toBeNull();
expect(byParticipant.get("legacy-direct-rating")?.rating).toBe(27.5); expect(byParticipant.get("legacy-direct-rating")?.rating).toBe(27.5);
}); });
it("keeps direct sourceElo but suppresses generated sourceElo", async () => {
mockDb.query.seasonParticipants.findMany.mockResolvedValue([
{ id: "direct-elo" },
{ id: "generated-elo" },
]);
mockDb.query.seasonParticipantSimulatorInputs.findMany.mockResolvedValue([
{
participantId: "direct-elo",
sourceOdds: null,
sourceElo: 1600,
worldRanking: null,
rating: null,
projectedWins: null,
projectedTablePoints: null,
seed: null,
region: null,
metadata: { sourceEloMethod: "direct" },
},
{
participantId: "generated-elo",
sourceOdds: 750,
sourceElo: 1480,
worldRanking: null,
rating: null,
projectedWins: null,
projectedTablePoints: null,
seed: null,
region: null,
metadata: { sourceEloMethod: "sourceOdds" },
},
]);
mockDb.query.seasonParticipantExpectedValues.findMany.mockResolvedValue([]);
const inputs = await getParticipantSimulatorInputs("season-1");
const byParticipant = new Map(inputs.map((input) => [input.participantId, input]));
expect(byParticipant.get("direct-elo")?.sourceElo).toBe(1600);
expect(byParticipant.get("generated-elo")?.sourceElo).toBeNull();
});
}); });

View file

@ -393,7 +393,10 @@ export async function batchSaveSourceOdds(
if (existing.length > 0) { if (existing.length > 0) {
await tx await tx
.update(seasonParticipantExpectedValues) .update(seasonParticipantExpectedValues)
.set({ sourceOdds, updatedAt: now }) // Clear any stale Elo and mark the source as odds-driven so the
// simulator re-derives Elo from these odds and the elo-ratings page
// loader doesn't resurrect an outdated rating.
.set({ sourceOdds, sourceElo: null, source: "futures_odds", updatedAt: now })
.where(eq(seasonParticipantExpectedValues.id, existing[0].id)); .where(eq(seasonParticipantExpectedValues.id, existing[0].id));
} else { } else {
// Insert a stub record — probabilities/EV will be filled in by the simulator // Insert a stub record — probabilities/EV will be filled in by the simulator

View file

@ -364,13 +364,18 @@ export async function batchSaveFuturesOddsForSimulator(
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
// Clear ALL ratings (both manual and generated) so the simulation // Clear ALL ratings AND sourceElo (both manual and generated) so the
// re-derives ratings from the newly saved futures odds. // simulation re-derives them from the newly saved futures odds. For
// Elo-based simulators, a stale `sourceElo` (e.g. a manually entered
// "direct" Elo) would otherwise win the resolveSourceElos precedence and
// cause the futures odds to be silently ignored. Nulling it here lets the
// sourceOdds -> convertFuturesToElo branch drive the run.
await tx await tx
.update(schema.seasonParticipantSimulatorInputs) .update(schema.seasonParticipantSimulatorInputs)
.set({ .set({
rating: null, rating: null,
metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`, sourceElo: null,
metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod' - 'sourceEloMethod'`,
updatedAt: now, updatedAt: now,
}) })
.where( .where(
@ -399,7 +404,8 @@ export async function batchSaveFuturesOddsForSimulator(
set: { set: {
sourceOdds: sql`excluded.source_odds`, sourceOdds: sql`excluded.source_odds`,
rating: null, rating: null,
metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`, sourceElo: null,
metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod' - 'sourceEloMethod'`,
updatedAt: now, updatedAt: now,
}, },
}); });

View file

@ -77,6 +77,54 @@ describe("simulator input policy", () => {
expect(resolved.get("tail")).toMatchObject({ sourceElo: 1400, method: "worstKnownMinus" }); expect(resolved.get("tail")).toMatchObject({ sourceElo: 1400, method: "worstKnownMinus" });
}); });
it("derives Elo from futures odds when no direct Elo is present", () => {
const resolved = resolveSourceElos(
[
{ participantId: "favorite", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
{ participantId: "longshot", sourceElo: null, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
],
profile,
{}
);
expect(resolved.get("favorite")?.method).toBe("sourceOdds");
expect(resolved.get("longshot")?.method).toBe("sourceOdds");
expect(resolved.get("favorite")?.sourceElo).toBeGreaterThan(resolved.get("longshot")?.sourceElo ?? Infinity);
});
it("ignores a lone sourceOdds participant rather than assigning a flat Elo", () => {
const inputs = [
{ participantId: "only", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
];
// Needs at least 2 odds to derive a spread; with block strategy it stays unresolved.
expect(resolveSourceElos(inputs, profile, {}).has("only")).toBe(false);
// With a fallback strategy it resolves via that method, not "sourceOdds".
expect(
resolveSourceElos(inputs, profile, {
inputPolicy: { missingEloStrategy: "fallbackElo", fallbackElo: 1400 },
}).get("only")
).toMatchObject({ sourceElo: 1400, method: "fallbackElo" });
});
it("derives Elo from odds once a prior direct Elo has been cleared", () => {
// Regression: after entering futures odds the simulator inputs row keeps
// sourceOdds but has its sourceElo nulled. The resolver must pick "sourceOdds",
// not resurrect a "direct" Elo.
const resolved = resolveSourceElos(
[
{ participantId: "favorite", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
{ participantId: "longshot", sourceElo: null, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
],
profile,
{}
);
expect(resolved.get("favorite")?.method).toBe("sourceOdds");
expect(resolved.get("longshot")?.method).toBe("sourceOdds");
});
it("derives ratings from futures odds when the profile allows it", () => { it("derives ratings from futures odds when the profile allows it", () => {
const resolved = resolveRatings( const resolved = resolveRatings(
[ [

View file

@ -164,9 +164,21 @@ export function resolveSourceElos(
} }
if (alternatives.has("sourceOdds")) { if (alternatives.has("sourceOdds")) {
// Note: participants resolved above via direct Elo are excluded here, so a
// season can end up mixing direct Elo and odds-derived Elo. Those two
// scales are not jointly calibrated — odds map onto eloMin/eloMax via
// convertFuturesToElo independently of any direct values. This is
// pre-existing behavior of the mixed input case and intentionally left as-is.
const oddsInputs = inputs const oddsInputs = inputs
.filter((input) => !resolved.has(input.participantId) && input.sourceOdds !== null && input.sourceOdds !== undefined) .filter((input) => !resolved.has(input.participantId) && input.sourceOdds !== null && input.sourceOdds !== undefined)
.map((input) => ({ participantId: input.participantId, odds: input.sourceOdds as number })); .map((input) => ({ participantId: input.participantId, odds: input.sourceOdds as number }));
if (oddsInputs.length === 1) {
logger.warn(
`resolveSourceElos: only 1 participant has sourceOdds (${oddsInputs[0].participantId}). ` +
`convertFuturesToElo requires at least 2 to derive a meaningful Elo spread — falling through to missing-Elo strategy.`
);
}
if (oddsInputs.length >= 2) {
const converted = convertFuturesToElo(oddsInputs); const converted = convertFuturesToElo(oddsInputs);
for (const [participantId, elo] of converted) { for (const [participantId, elo] of converted) {
resolved.set(participantId, { resolved.set(participantId, {
@ -176,6 +188,7 @@ export function resolveSourceElos(
}); });
} }
} }
}
const knownElos = [...resolved.values()].map((value) => value.sourceElo); const knownElos = [...resolved.values()].map((value) => value.sourceElo);
const averageKnown = knownElos.length > 0 const averageKnown = knownElos.length > 0