diff --git a/app/models/__tests__/futures-odds-simulator.test.ts b/app/models/__tests__/futures-odds-simulator.test.ts new file mode 100644 index 0000000..1b4669d --- /dev/null +++ b/app/models/__tests__/futures-odds-simulator.test.ts @@ -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; + +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) => cb(tx)), +}; + +beforeEach(() => { + vi.clearAllMocks(); + setCalls.length = 0; + conflictSetCalls.length = 0; + (database as ReturnType).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(); + }); +}); diff --git a/app/models/__tests__/simulator-inputs.test.ts b/app/models/__tests__/simulator-inputs.test.ts index e333d1f..d39afbe 100644 --- a/app/models/__tests__/simulator-inputs.test.ts +++ b/app/models/__tests__/simulator-inputs.test.ts @@ -71,4 +71,44 @@ describe("simulator input model", () => { expect(byParticipant.get("generated-rating")?.rating).toBeNull(); 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(); + }); }); diff --git a/app/models/participant-expected-value.ts b/app/models/participant-expected-value.ts index 4a4f4bf..7c9bf4d 100644 --- a/app/models/participant-expected-value.ts +++ b/app/models/participant-expected-value.ts @@ -393,7 +393,10 @@ export async function batchSaveSourceOdds( if (existing.length > 0) { await tx .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)); } else { // Insert a stub record — probabilities/EV will be filled in by the simulator diff --git a/app/models/simulator.ts b/app/models/simulator.ts index 81a48d0..48ae1c3 100644 --- a/app/models/simulator.ts +++ b/app/models/simulator.ts @@ -364,13 +364,18 @@ export async function batchSaveFuturesOddsForSimulator( await db.transaction(async (tx) => { - // Clear ALL ratings (both manual and generated) so the simulation - // re-derives ratings from the newly saved futures odds. + // Clear ALL ratings AND sourceElo (both manual and generated) so the + // 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 .update(schema.seasonParticipantSimulatorInputs) .set({ rating: null, - metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`, + sourceElo: null, + metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod' - 'sourceEloMethod'`, updatedAt: now, }) .where( @@ -399,7 +404,8 @@ export async function batchSaveFuturesOddsForSimulator( set: { sourceOdds: sql`excluded.source_odds`, rating: null, - metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`, + sourceElo: null, + metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod' - 'sourceEloMethod'`, updatedAt: now, }, }); diff --git a/app/services/simulations/__tests__/input-policy.test.ts b/app/services/simulations/__tests__/input-policy.test.ts index 2661d09..3d8a770 100644 --- a/app/services/simulations/__tests__/input-policy.test.ts +++ b/app/services/simulations/__tests__/input-policy.test.ts @@ -77,6 +77,54 @@ describe("simulator input policy", () => { 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", () => { const resolved = resolveRatings( [ diff --git a/app/services/simulations/input-policy.ts b/app/services/simulations/input-policy.ts index 5360ae3..62ac3da 100644 --- a/app/services/simulations/input-policy.ts +++ b/app/services/simulations/input-policy.ts @@ -164,16 +164,29 @@ export function resolveSourceElos( } 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 .filter((input) => !resolved.has(input.participantId) && input.sourceOdds !== null && input.sourceOdds !== undefined) .map((input) => ({ participantId: input.participantId, odds: input.sourceOdds as number })); - const converted = convertFuturesToElo(oddsInputs); - for (const [participantId, elo] of converted) { - resolved.set(participantId, { - participantId, - sourceElo: clamp(Math.round(elo), policy.eloMin, policy.eloMax), - method: "sourceOdds", - }); + 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); + for (const [participantId, elo] of converted) { + resolved.set(participantId, { + participantId, + sourceElo: clamp(Math.round(elo), policy.eloMin, policy.eloMax), + method: "sourceOdds", + }); + } } }