diff --git a/app/models/participant-expected-value.ts b/app/models/participant-expected-value.ts index c642fd0..132fcce 100644 --- a/app/models/participant-expected-value.ts +++ b/app/models/participant-expected-value.ts @@ -394,10 +394,10 @@ export async function batchSaveSourceOdds( await tx .update(seasonParticipantExpectedValues) // Persist the odds and label the source as odds-driven for display. - // We no longer null a stored Elo here: whether these odds override it - // is decided at run time by the configurable source priority - // (SimulatorInputPolicy.sourceEloPriority), and prepareSimulatorInputsForRun - // overwrites the derived Elo before the simulator reads it. + // We no longer null a stored Elo here: how much these odds move it is + // decided at run time by the season's blend weight + // (SimulatorInputPolicy.oddsWeight), and prepareSimulatorInputsForRun + // overwrites the resolved Elo before the simulator reads it. .set({ sourceOdds, source: "futures_odds", updatedAt: now }) .where(eq(seasonParticipantExpectedValues.id, existing[0].id)); } else { diff --git a/app/models/simulator.ts b/app/models/simulator.ts index d4e1c3f..3455a72 100644 --- a/app/models/simulator.ts +++ b/app/models/simulator.ts @@ -367,10 +367,10 @@ export async function batchSaveFuturesOddsForSimulator( const db = database(); const now = new Date(); - // Persist the odds non-destructively. Whether these odds override a stored - // Elo/rating is now governed by the season's configurable source priority - // (see resolveSourceElos / SimulatorInputPolicy.sourceEloPriority), so we no - // longer null out manually entered Elo here — that policy decides at run time. + // Persist the odds non-destructively. How much these odds move a stored + // Elo/rating is now governed by the season's blend weight (see resolveSourceElos + // / SimulatorInputPolicy.oddsWeight), so we no longer null out manually entered + // Elo here — that policy decides at run time. await db .insert(schema.seasonParticipantSimulatorInputs) .values( diff --git a/app/services/simulations/__tests__/input-policy.test.ts b/app/services/simulations/__tests__/input-policy.test.ts index ed49a7a..2927145 100644 --- a/app/services/simulations/__tests__/input-policy.test.ts +++ b/app/services/simulations/__tests__/input-policy.test.ts @@ -188,6 +188,23 @@ describe("simulator input policy", () => { expect(resolved.get("missing-tail")).toMatchObject({ rating: -10, method: "worstKnownMinus" }); }); + it("does not clamp a directly entered Elo above the policy ceiling", () => { + // Snooker/darts enter manual Elos well above the default 1900 ceiling and + // have no odds source — the direct value must pass through unclamped. + const eloOnlyProfile = {} as Pick; + const resolved = resolveSourceElos( + [ + { participantId: "ronnie", sourceElo: 2450, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null }, + { participantId: "judd", sourceElo: 2300, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null }, + ], + eloOnlyProfile, + {} + ); + + expect(resolved.get("ronnie")).toMatchObject({ sourceElo: 2450, method: "direct" }); + expect(resolved.get("judd")).toMatchObject({ sourceElo: 2300, method: "direct" }); + }); + it("blends base Elo and futures odds into a single Elo by oddsWeight", () => { const inputs = [ { participantId: "favorite", sourceElo: 1800, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null }, diff --git a/app/services/simulations/input-policy.ts b/app/services/simulations/input-policy.ts index a05dfdf..c6800ee 100644 --- a/app/services/simulations/input-policy.ts +++ b/app/services/simulations/input-policy.ts @@ -240,35 +240,41 @@ export function resolveSourceElos( } // 3. Blend base + odds into the single Elo that feeds the simulator. + // Only odds-derived / blended / fallback values are clamped+rounded onto the + // [eloMin, eloMax] band (they are mapped onto it by design). A base Elo is + // passed through untouched: a directly entered Elo is trusted as-is (some + // sports use ratings above the default ceiling, e.g. snooker ~2450) and a + // projection-derived Elo was already clamped by projectionToElo. const w = policy.oddsWeight; + const clampDerived = (elo: number): number => clamp(Math.round(elo), policy.eloMin, policy.eloMax); for (const input of inputs) { const base = baseElo.get(input.participantId); const odds = oddsElo.get(input.participantId); - let elo: number; + let sourceElo: number; let method: ResolvedSourceElo["method"]; if (base !== undefined && odds !== undefined) { if (w <= 0) { - elo = base.elo; + sourceElo = base.elo; method = base.method; } else if (w >= 1) { - elo = odds; + sourceElo = clampDerived(odds); method = "sourceOdds"; } else { - elo = (1 - w) * base.elo + w * odds; + sourceElo = clampDerived((1 - w) * base.elo + w * odds); method = "blend"; } } else if (base !== undefined) { - elo = base.elo; + sourceElo = base.elo; method = base.method; } else if (odds !== undefined) { - elo = odds; + sourceElo = clampDerived(odds); method = "sourceOdds"; } else { continue; } resolved.set(input.participantId, { participantId: input.participantId, - sourceElo: clamp(Math.round(elo), policy.eloMin, policy.eloMax), + sourceElo, method, }); } @@ -343,7 +349,10 @@ export function resolveRatings( } } + // As with Elo: only odds-derived / blended values are clamped onto the rating + // band; a directly entered rating is trusted as-is (matching prior behavior). const w = policy.oddsWeight; + const clampDerived = (rating: number): number => clamp(rating, policy.ratingMin, policy.ratingMax); for (const input of inputs) { const base = baseRating.get(input.participantId); const odds = oddsRating.get(input.participantId); @@ -354,24 +363,24 @@ export function resolveRatings( rating = base; method = "direct"; } else if (w >= 1) { - rating = odds; + rating = clampDerived(odds); method = "sourceOdds"; } else { - rating = (1 - w) * base + w * odds; + rating = clampDerived((1 - w) * base + w * odds); method = "blend"; } } else if (base !== undefined) { rating = base; method = "direct"; } else if (odds !== undefined) { - rating = odds; + rating = clampDerived(odds); method = "sourceOdds"; } else { continue; } resolved.set(input.participantId, { participantId: input.participantId, - rating: clamp(rating, policy.ratingMin, policy.ratingMax), + rating, method, }); }