From dc4efe87cf7eb7d87b70f459580a9f024539cbfb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 22:11:32 +0000 Subject: [PATCH 1/4] Unify futures odds with the simulation system Make the futures-vs-Elo relationship an explicit, configurable rule and fix futures odds failing to override stored Elo. Root causes addressed: - resolveSourceElos/resolveRatings used a hardcoded precedence (direct Elo -> projectedWins -> projectedTablePoints -> odds). The prior fix nulled sourceElo on futures entry but not projections, which still outranked odds. - The override relied on a destructive null-on-save hack that also wiped manually entered Elo. - Blended simulators buried their Elo/odds weight in module constants. - Futures odds were not surfaced on the /admin/simulators inventory. Changes: - Add a configurable source policy: sourceEloPriority (ordered) and oddsWeight, parsed/clamped in getSimulatorInputPolicy. resolveSourceElos/resolveRatings now resolve each participant by the configured priority instead of a fixed order. Futures-centric simulators (ncaam, ncaaw, world_cup, ncaa_football, college_hockey) default to a futures-override priority. - Drop the destructive nulling in batchSaveFuturesOddsForSimulator and batchSaveSourceOdds; override is now governed by policy, preserving stored Elo. - Thread an optional SimulationContext (oddsWeight) through the Simulator interface so blended sims (UCL, World Cup, NCAA FB, MLB) read the blend weight from the season policy; defaults preserve prior calibration when no context is passed. - Add a "Futures vs. Elo" strategy control and Odds Blend Weight input to the Simulator Setup input-policy card, persisted via save-input-policy. - Surface futures on /admin/simulators: a source badge and a Futures Odds quick link; extend listSportsSeasonSimulatorSummaries with odds source info. - Tests: configurable priority override (Elo/projections/rating), oddsWeight parsing/clamping, prefersFuturesOdds, manifest defaults, an NCAA Football context-blend behavioral test, and an updated non-destructive save test. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QhNeB7gN6VKene7sdbBVQT --- .../__tests__/futures-odds-simulator.test.ts | 46 ++-- app/models/participant-expected-value.ts | 10 +- app/models/simulator.ts | 105 +++---- app/routes/admin.simulators.tsx | 14 +- .../admin.sports-seasons.$id.simulator.tsx | 37 ++- .../__tests__/input-policy.test.ts | 89 +++++- .../simulations/__tests__/manifest.test.ts | 16 ++ .../__tests__/ncaa-football-simulator.test.ts | 29 ++ .../simulations/__tests__/runner.test.ts | 9 +- app/services/simulations/input-policy.ts | 259 ++++++++++++------ app/services/simulations/manifest.ts | 19 +- app/services/simulations/mlb-simulator.ts | 10 +- .../simulations/ncaa-football-simulator.ts | 41 +-- app/services/simulations/runner.ts | 6 +- app/services/simulations/types.ts | 15 +- app/services/simulations/ucl-simulator.ts | 10 +- .../simulations/world-cup-simulator.ts | 29 +- 17 files changed, 523 insertions(+), 221 deletions(-) diff --git a/app/models/__tests__/futures-odds-simulator.test.ts b/app/models/__tests__/futures-odds-simulator.test.ts index 1b4669d..aed3c1f 100644 --- a/app/models/__tests__/futures-odds-simulator.test.ts +++ b/app/models/__tests__/futures-odds-simulator.test.ts @@ -10,57 +10,43 @@ 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 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)), + insert: vi.fn(() => ({ + values: vi.fn(() => ({ onConflictDoUpdate })), + })), }; 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 () => { + it("persists odds without destroying a stored Elo/rating", 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. + // The upsert writes the odds and leaves Elo/rating untouched — whether the + // odds override them is now decided by the configurable source priority, + // not by nulling stored values here. + expect(mockDb.insert).toHaveBeenCalledTimes(1); expect(conflictSetCalls).toHaveLength(1); - expect(conflictSetCalls[0]).toMatchObject({ rating: null, sourceElo: null }); - expect(conflictSetCalls[0].metadata).toBeDefined(); + expect(conflictSetCalls[0]).toHaveProperty("sourceOdds"); + expect(conflictSetCalls[0]).not.toHaveProperty("sourceElo"); + expect(conflictSetCalls[0]).not.toHaveProperty("rating"); }); it("is a no-op when given no inputs", async () => { await batchSaveFuturesOddsForSimulator([]); - expect(mockDb.transaction).not.toHaveBeenCalled(); + expect(mockDb.insert).not.toHaveBeenCalled(); }); }); diff --git a/app/models/participant-expected-value.ts b/app/models/participant-expected-value.ts index 7c9bf4d..c642fd0 100644 --- a/app/models/participant-expected-value.ts +++ b/app/models/participant-expected-value.ts @@ -393,10 +393,12 @@ export async function batchSaveSourceOdds( if (existing.length > 0) { await tx .update(seasonParticipantExpectedValues) - // 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 }) + // 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. + .set({ sourceOdds, 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 48ae1c3..b561ce9 100644 --- a/app/models/simulator.ts +++ b/app/models/simulator.ts @@ -1,4 +1,4 @@ -import { and, eq, inArray, sql } from "drizzle-orm"; +import { and, eq, inArray, isNotNull, sql } from "drizzle-orm"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { @@ -8,6 +8,8 @@ import { type SimulatorManifestProfile, } from "~/services/simulations/manifest"; import { + getSimulatorInputPolicy, + prefersFuturesOdds, ratingRequirementLabel, resolveRatings, resolveSourceElos, @@ -82,6 +84,12 @@ export interface SportsSeasonSimulatorSummary { participantInputCount: number; lastSimulatedDate: string | null; readiness: SimulatorReadiness; + /** Whether this simulator exposes a Futures Odds setup section. */ + supportsFuturesOdds: boolean; + /** Number of participants that currently have stored futures odds. */ + oddsParticipantCount: number; + /** Whether the season's input policy makes futures odds override Elo/projections. */ + prefersFuturesOdds: boolean; } function parseDecimal(value: string | null | undefined): number | null { @@ -359,57 +367,32 @@ export async function batchSaveFuturesOddsForSimulator( if (inputs.length === 0) return; const db = database(); const now = new Date(); - const participantIds = inputs.map((input) => input.participantId); - const seasonIds = [...new Set(inputs.map((input) => input.sportsSeasonId))]; - await db.transaction(async (tx) => { - - // 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, - sourceElo: null, - metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod' - 'sourceEloMethod'`, + // 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. + await db + .insert(schema.seasonParticipantSimulatorInputs) + .values( + inputs.map((input) => ({ + participantId: input.participantId, + sportsSeasonId: input.sportsSeasonId, + sourceOdds: input.sourceOdds, + createdAt: now, updatedAt: now, - }) - .where( - and( - inArray(schema.seasonParticipantSimulatorInputs.sportsSeasonId, seasonIds), - inArray(schema.seasonParticipantSimulatorInputs.participantId, participantIds) - ) - ); - - await tx - .insert(schema.seasonParticipantSimulatorInputs) - .values( - inputs.map((input) => ({ - participantId: input.participantId, - sportsSeasonId: input.sportsSeasonId, - sourceOdds: input.sourceOdds, - createdAt: now, - updatedAt: now, - })) - ) - .onConflictDoUpdate({ - target: [ - schema.seasonParticipantSimulatorInputs.participantId, - schema.seasonParticipantSimulatorInputs.sportsSeasonId, - ], - set: { - sourceOdds: sql`excluded.source_odds`, - rating: null, - sourceElo: null, - metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod' - 'sourceEloMethod'`, - updatedAt: now, - }, - }); - }); + })) + ) + .onConflictDoUpdate({ + target: [ + schema.seasonParticipantSimulatorInputs.participantId, + schema.seasonParticipantSimulatorInputs.sportsSeasonId, + ], + set: { + sourceOdds: sql`excluded.source_odds`, + updatedAt: now, + }, + }); } export async function batchUpsertParticipantSimulatorInputs( @@ -669,6 +652,23 @@ export async function listSportsSeasonSimulatorSummaries(): Promise [row.sportsSeasonId, row.lastSimulatedDate])); + const oddsCountRows = seasonIds.length > 0 + ? await db + .select({ + sportsSeasonId: schema.seasonParticipantSimulatorInputs.sportsSeasonId, + oddsCount: sql`count(*)::int`, + }) + .from(schema.seasonParticipantSimulatorInputs) + .where( + and( + inArray(schema.seasonParticipantSimulatorInputs.sportsSeasonId, seasonIds), + isNotNull(schema.seasonParticipantSimulatorInputs.sourceOdds) + ) + ) + .groupBy(schema.seasonParticipantSimulatorInputs.sportsSeasonId) + : []; + const oddsCountBySeason = new Map(oddsCountRows.map((row) => [row.sportsSeasonId, Number(row.oddsCount)])); + // Each season calls getSimulatorProfile + validateSimulatorReadiness (~4 queries each). // Acceptable for an admin-only page; revisit if season counts grow past ~50. const summaries = await Promise.all( @@ -676,6 +676,8 @@ export async function listSportsSeasonSimulatorSummaries(): Promise - {sim.participantInputCount}/{sim.participantCount} +
{sim.participantInputCount}/{sim.participantCount}
+ {sim.supportsFuturesOdds && ( + + {sim.oddsParticipantCount > 0 + ? `Futures: ${sim.oddsParticipantCount}${sim.prefersFuturesOdds ? " (overrides Elo)" : " (fallback)"}` + : "No futures odds"} + + )}
{sim.lastSimulatedDate ?? "Never"} @@ -258,6 +265,11 @@ export default function AdminSimulators({ loaderData }: Route.ComponentProps) { Setup + {sim.supportsFuturesOdds && ( + + )} diff --git a/app/routes/admin.sports-seasons.$id.simulator.tsx b/app/routes/admin.sports-seasons.$id.simulator.tsx index 8a8e4c2..2618a81 100644 --- a/app/routes/admin.sports-seasons.$id.simulator.tsx +++ b/app/routes/admin.sports-seasons.$id.simulator.tsx @@ -25,7 +25,14 @@ import { type UpsertParticipantSimulatorInput, } from "~/models/simulator"; import { normalizeName } from "~/lib/fuzzy-match"; -import { getSimulatorInputPolicy, type MissingEloStrategy, type MissingRatingStrategy } from "~/services/simulations/input-policy"; +import { + getSimulatorInputPolicy, + prefersFuturesOdds, + ELO_FIRST_SOURCE_PRIORITY, + FUTURES_FIRST_SOURCE_PRIORITY, + type MissingEloStrategy, + type MissingRatingStrategy, +} from "~/services/simulations/input-policy"; import { runSportsSeasonSimulation } from "~/services/simulations/runner"; export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { @@ -205,6 +212,11 @@ export async function action({ request, params }: Route.ActionArgs): Promise
+
+ + +

+ Controls which signal wins when a participant has both. Futures-first makes freshly + entered odds drive the run without discarding stored Elo. +

+
+
+ + +

+ For simulators that blend Elo and odds per match. 1.0 = pure odds; 0 = pure Elo. +

+
{config.profile.requiredInputs.includes("sourceElo") && ( <>
diff --git a/app/services/simulations/__tests__/input-policy.test.ts b/app/services/simulations/__tests__/input-policy.test.ts index 3d8a770..4a45e0f 100644 --- a/app/services/simulations/__tests__/input-policy.test.ts +++ b/app/services/simulations/__tests__/input-policy.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { resolveRatings, resolveSourceElos } from "../input-policy"; +import { + getSimulatorInputPolicy, + prefersFuturesOdds, + resolveRatings, + resolveSourceElos, + ELO_FIRST_SOURCE_PRIORITY, + FUTURES_FIRST_SOURCE_PRIORITY, +} from "../input-policy"; import type { SimulatorManifestProfile } from "../manifest"; const profile = { @@ -183,6 +190,86 @@ describe("simulator input policy", () => { expect(resolved.get("missing-tail")).toMatchObject({ rating: -10, method: "worstKnownMinus" }); }); + it("lets a futures-first priority override a stored direct Elo", () => { + const inputs = [ + { participantId: "favorite", sourceElo: 1800, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null }, + { participantId: "longshot", sourceElo: 1200, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null }, + ]; + + // Default (Elo-first): the stored direct Elo wins. + const eloFirst = resolveSourceElos(inputs, profile, {}); + expect(eloFirst.get("favorite")).toMatchObject({ sourceElo: 1800, method: "direct" }); + + // Futures-first: odds win even though a direct Elo is present. + const futuresFirst = resolveSourceElos(inputs, profile, { + inputPolicy: { sourceEloPriority: FUTURES_FIRST_SOURCE_PRIORITY }, + }); + expect(futuresFirst.get("favorite")?.method).toBe("sourceOdds"); + expect(futuresFirst.get("longshot")?.method).toBe("sourceOdds"); + }); + + it("lets a futures-first priority override stored projected wins", () => { + const inputs = [ + { participantId: "team-1", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: 60, projectedTablePoints: null }, + { participantId: "team-2", sourceElo: null, rating: null, sourceOdds: 20000, projectedWins: 20, projectedTablePoints: null }, + ]; + + // Default (Elo-first): projectedWins outranks odds. + expect(resolveSourceElos(inputs, profile, { seasonGames: 82 }).get("team-1")?.method).toBe("projectedWins"); + + // Futures-first: odds win over projections. + const futuresFirst = resolveSourceElos(inputs, profile, { + seasonGames: 82, + inputPolicy: { sourceEloPriority: FUTURES_FIRST_SOURCE_PRIORITY }, + }); + expect(futuresFirst.get("team-1")?.method).toBe("sourceOdds"); + }); + + it("lets a futures-first priority override a stored direct rating", () => { + const ratingProfile = { derivableInputs: { rating: ["sourceOdds"] } } as Pick; + const inputs = [ + { participantId: "favorite", sourceElo: null, rating: 30, sourceOdds: 300, projectedWins: null, projectedTablePoints: null }, + { participantId: "longshot", sourceElo: null, rating: -5, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null }, + ]; + + // Default: direct rating wins. + expect(resolveRatings(inputs, ratingProfile, { inputPolicy: { ratingMin: -10, ratingMax: 35 } }).get("favorite")) + .toMatchObject({ rating: 30, method: "direct" }); + + // Futures-first: odds win. + expect( + resolveRatings(inputs, ratingProfile, { + inputPolicy: { ratingMin: -10, ratingMax: 35, sourceEloPriority: FUTURES_FIRST_SOURCE_PRIORITY }, + }).get("favorite")?.method + ).toBe("sourceOdds"); + }); + + it("parses and clamps the input policy source priority and odds weight", () => { + const defaults = getSimulatorInputPolicy({}); + expect(defaults.sourceEloPriority).toEqual(ELO_FIRST_SOURCE_PRIORITY); + expect(defaults.oddsWeight).toBe(0.3); + + // oddsWeight can be sourced from the top-level config or the input policy, + // and is clamped to [0, 1]; junk priority entries are dropped. + expect(getSimulatorInputPolicy({ oddsWeight: 0.6 }).oddsWeight).toBe(0.6); + expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: 5 } }).oddsWeight).toBe(1); + expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: -2 } }).oddsWeight).toBe(0); + expect( + getSimulatorInputPolicy({ inputPolicy: { sourceEloPriority: ["sourceOdds", "bogus", "sourceElo"] } }).sourceEloPriority + ).toEqual(["sourceOdds", "sourceElo"]); + // An empty/all-invalid priority falls back to the default. + expect(getSimulatorInputPolicy({ inputPolicy: { sourceEloPriority: ["nope"] } }).sourceEloPriority).toEqual( + ELO_FIRST_SOURCE_PRIORITY + ); + }); + + it("detects whether a priority prefers futures odds", () => { + expect(prefersFuturesOdds(ELO_FIRST_SOURCE_PRIORITY)).toBe(false); + expect(prefersFuturesOdds(FUTURES_FIRST_SOURCE_PRIORITY)).toBe(true); + expect(prefersFuturesOdds(["projectedWins", "sourceElo"])).toBe(false); + expect(prefersFuturesOdds(["sourceOdds"])).toBe(true); + }); + it("uses NCAAW-style rating scale when deriving and falling back", () => { const resolved = resolveRatings( [ diff --git a/app/services/simulations/__tests__/manifest.test.ts b/app/services/simulations/__tests__/manifest.test.ts index 2bf7b3d..a8afc9e 100644 --- a/app/services/simulations/__tests__/manifest.test.ts +++ b/app/services/simulations/__tests__/manifest.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import * as schema from "~/database/schema"; import { SIMULATOR_MANIFEST } from "../manifest"; import { SIMULATOR_TYPES } from "../registry"; +import { getSimulatorInputPolicy, prefersFuturesOdds } from "../input-policy"; import { assertRegistrySchemaDriftFree } from "~/models/simulator"; describe("simulator manifest", () => { @@ -40,6 +41,21 @@ describe("simulator manifest", () => { } }); + it("defaults futures-centric simulators to a futures-override source priority", () => { + const futuresCentric = ["ncaam_bracket", "ncaaw_bracket", "world_cup", "ncaa_football_bracket", "college_hockey_bracket"] as const; + for (const simulatorType of futuresCentric) { + const policy = getSimulatorInputPolicy(SIMULATOR_MANIFEST[simulatorType].defaultConfig); + expect(prefersFuturesOdds(policy.sourceEloPriority)).toBe(true); + } + }); + + it("exposes the blended Elo/odds weight through the input policy", () => { + expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.ncaa_football_bracket.defaultConfig).oddsWeight).toBe(0.4); + expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.world_cup.defaultConfig).oddsWeight).toBe(0.3); + // A non-futures team sport keeps the Elo-first default. + expect(prefersFuturesOdds(getSimulatorInputPolicy(SIMULATOR_MANIFEST.nba_bracket.defaultConfig).sourceEloPriority)).toBe(false); + }); + it("keeps EPL projection and match parity as separate config knobs", () => { expect(SIMULATOR_MANIFEST.epl_standings.defaultConfig).toMatchObject({ parityFactor: 400, diff --git a/app/services/simulations/__tests__/ncaa-football-simulator.test.ts b/app/services/simulations/__tests__/ncaa-football-simulator.test.ts index 4e3d4d6..a8354f8 100644 --- a/app/services/simulations/__tests__/ncaa-football-simulator.test.ts +++ b/app/services/simulations/__tests__/ncaa-football-simulator.test.ts @@ -150,6 +150,35 @@ describe("NCAAFootballSimulator", () => { const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0); expect(total).toBeCloseTo(1.0, 1); }); + + it("honors the oddsWeight context when odds and Elo disagree", async () => { + // team-12 has the worst Elo but by far the best futures odds. + function setupConflicting() { + const participants = makeParticipants(TEAM_IDS); + const evRows = TEAM_IDS.map((participantId, i) => ({ + participantId, + sourceElo: 1700 - i * 50, + sourceOdds: i === 11 ? -100000 : 50000, + })); + mockDb.select.mockImplementation(() => { + const callIndex = selectCallCount++; + const data = callIndex === 0 ? participants : evRows; + return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) }; + }); + } + + setupConflicting(); + const pureOdds = await new NCAAFootballSimulator().simulate("season-1", { config: {}, oddsWeight: 1 }); + selectCallCount = 0; + setupConflicting(); + const pureElo = await new NCAAFootballSimulator().simulate("season-1", { config: {}, oddsWeight: 0 }); + + const champByOdds = pureOdds.find((r) => r.participantId === "team-12")?.probabilities.probFirst ?? 0; + const champByElo = pureElo.find((r) => r.participantId === "team-12")?.probabilities.probFirst ?? 0; + // With the odds signal fully weighted, the odds-favored team wins far more + // often than when only its (worst) Elo drives the result. + expect(champByOdds).toBeGreaterThan(champByElo); + }); }); // ── Pre-bracket mode (>12 teams — probabilistic selection) ─────────────── diff --git a/app/services/simulations/__tests__/runner.test.ts b/app/services/simulations/__tests__/runner.test.ts index bdf543c..f7eb9be 100644 --- a/app/services/simulations/__tests__/runner.test.ts +++ b/app/services/simulations/__tests__/runner.test.ts @@ -21,9 +21,12 @@ vi.mock("~/models/ev-snapshot", () => ({ vi.mock("~/models/scoring-calculator", () => ({ recalculateStandings: vi.fn(), })); -vi.mock("~/services/simulations/registry", () => ({ - getSimulator: vi.fn(), -})); +vi.mock("~/services/simulations/registry", async (importOriginal) => { + // Keep the real SIMULATOR_TYPES / getSimulatorInfo so the manifest (pulled in + // transitively via input-policy) can build; only stub getSimulator. + const actual = await importOriginal(); + return { ...actual, getSimulator: vi.fn() }; +}); vi.mock("~/services/simulations/simulation-probabilities", () => ({ normalizeSimulationResultColumns: vi.fn(), })); diff --git a/app/services/simulations/input-policy.ts b/app/services/simulations/input-policy.ts index 62ac3da..ef9c4a6 100644 --- a/app/services/simulations/input-policy.ts +++ b/app/services/simulations/input-policy.ts @@ -5,9 +5,57 @@ import { logger } from "~/lib/logger"; export type MissingEloStrategy = "block" | "fallbackElo" | "averageKnown" | "worstKnownMinus"; export type MissingRatingStrategy = "block" | "fallbackRating" | "averageKnown" | "worstKnownMinus"; +/** + * Sources a participant's strength input can be derived from, in the order the + * resolver should try them. `"sourceElo"` represents the simulator's primary + * direct input — a manually entered Elo for Elo-based sims, or the direct + * `rating` for rating-based sims. Placing `"sourceOdds"` ahead of `"sourceElo"` + * is how an admin makes futures odds override stored Elo/ratings. + */ +export type SourceEloKey = "sourceElo" | "projectedWins" | "projectedTablePoints" | "sourceOdds"; + +export const SOURCE_ELO_KEYS: readonly SourceEloKey[] = [ + "sourceElo", + "projectedWins", + "projectedTablePoints", + "sourceOdds", +]; + +/** Default priority: stored Elo/projections win, futures odds are the fallback. */ +export const ELO_FIRST_SOURCE_PRIORITY: SourceEloKey[] = [ + "sourceElo", + "projectedWins", + "projectedTablePoints", + "sourceOdds", +]; + +/** Futures-override priority: freshly entered odds win over stored Elo/projections. */ +export const FUTURES_FIRST_SOURCE_PRIORITY: SourceEloKey[] = [ + "sourceOdds", + "sourceElo", + "projectedWins", + "projectedTablePoints", +]; + +/** Whether a priority list prefers futures odds over the direct Elo/rating input. */ +export function prefersFuturesOdds(priority: SourceEloKey[]): boolean { + const oddsIndex = priority.indexOf("sourceOdds"); + const eloIndex = priority.indexOf("sourceElo"); + if (oddsIndex === -1) return false; + return eloIndex === -1 || oddsIndex < eloIndex; +} + export interface SimulatorInputPolicy { missingEloStrategy: MissingEloStrategy; missingRatingStrategy: MissingRatingStrategy; + /** Ordered preference for deriving a participant's strength input. */ + sourceEloPriority: SourceEloKey[]; + /** + * Weight (0–1) given to the futures-odds signal in simulators that blend Elo + * and odds (UCL, World Cup, NCAA Football, MLB, college hockey). The Elo + * signal gets `1 - oddsWeight`. Set to 1.0 to make futures fully override Elo. + */ + oddsWeight: number; fallbackElo: number; fallbackEloDelta: number; eloMin: number; @@ -39,9 +87,13 @@ export interface ResolvedRating { method: "direct" | "sourceOdds" | MissingRatingStrategy; } +const DEFAULT_ODDS_WEIGHT = 0.3; + const DEFAULT_POLICY: SimulatorInputPolicy = { missingEloStrategy: "block", missingRatingStrategy: "block", + sourceEloPriority: ELO_FIRST_SOURCE_PRIORITY, + oddsWeight: DEFAULT_ODDS_WEIGHT, fallbackElo: 1400, fallbackEloDelta: 25, eloMin: 1100, @@ -80,6 +132,19 @@ function projectionToElo( return clamp(Math.round(elo), policy.eloMin, policy.eloMax); } +function parseSourceEloPriority(value: unknown, fallback: SourceEloKey[]): SourceEloKey[] { + if (!Array.isArray(value)) return fallback; + const seen = new Set(); + const parsed: SourceEloKey[] = []; + for (const entry of value) { + if (typeof entry === "string" && (SOURCE_ELO_KEYS as readonly string[]).includes(entry) && !seen.has(entry as SourceEloKey)) { + seen.add(entry as SourceEloKey); + parsed.push(entry as SourceEloKey); + } + } + return parsed.length > 0 ? parsed : fallback; +} + export function getSimulatorInputPolicy(config: Record): SimulatorInputPolicy { const rawPolicy = isRecord(config.inputPolicy) ? config.inputPolicy : {}; const eloStrategy = rawPolicy.missingEloStrategy; @@ -94,6 +159,14 @@ export function getSimulatorInputPolicy(config: Record): Simula ratingStrategy === "fallbackRating" || ratingStrategy === "averageKnown" || ratingStrategy === "worstKnownMinus" ? ratingStrategy : "block", + sourceEloPriority: parseSourceEloPriority(rawPolicy.sourceEloPriority, DEFAULT_POLICY.sourceEloPriority), + // Allow oddsWeight to live either in inputPolicy (the unified knob) or at the + // top level of config (legacy per-profile defaultConfig), preferring the policy. + oddsWeight: clamp( + optionalNumber(rawPolicy.oddsWeight, optionalNumber(config.oddsWeight, DEFAULT_POLICY.oddsWeight)), + 0, + 1 + ), fallbackElo: optionalNumber(rawPolicy.fallbackElo, DEFAULT_POLICY.fallbackElo), fallbackEloDelta: optionalNumber(rawPolicy.fallbackEloDelta, DEFAULT_POLICY.fallbackEloDelta), eloMin: optionalNumber(rawPolicy.eloMin, DEFAULT_POLICY.eloMin), @@ -128,65 +201,70 @@ export function resolveSourceElos( const alternatives = new Set(profile.derivableInputs?.sourceElo ?? []); const parityFactor = optionalNumber(config.parityFactor, 400); - for (const input of inputs) { - if (input.sourceElo !== null && input.sourceElo !== undefined) { - resolved.set(input.participantId, { - participantId: input.participantId, - sourceElo: input.sourceElo, - method: "direct", - }); - } - } + // Resolve each participant from the first source in the configured priority + // that (a) is permitted for this simulator and (b) has a value. The direct + // `sourceElo` input is always permitted; the rest must be declared in the + // profile's `derivableInputs.sourceElo`. Reordering the priority so that + // `sourceOdds` precedes `sourceElo` makes freshly entered futures odds + // override a stale Elo (and projections) without destroying the stored value. + for (const source of policy.sourceEloPriority) { + if (source !== "sourceElo" && !alternatives.has(source)) continue; - if (alternatives.has("projectedWins")) { - const seasonGames = optionalNumber(config.seasonGames, Math.max(...inputs.map((input) => input.projectedWins ?? 0), 1)); - for (const input of inputs) { - if (resolved.has(input.participantId) || input.projectedWins === null || input.projectedWins === undefined) continue; - resolved.set(input.participantId, { - participantId: input.participantId, - sourceElo: projectionToElo(input.projectedWins, seasonGames, parityFactor, policy), - method: "projectedWins", - }); - } - } - - if (alternatives.has("projectedTablePoints")) { - const seasonGames = optionalNumber(config.seasonGames, 38); - const maxPoints = optionalNumber(config.maxTablePoints, seasonGames * 3); - for (const input of inputs) { - if (resolved.has(input.participantId) || input.projectedTablePoints === null || input.projectedTablePoints === undefined) continue; - resolved.set(input.participantId, { - participantId: input.participantId, - sourceElo: projectionToElo(input.projectedTablePoints, maxPoints, parityFactor, policy), - method: "projectedTablePoints", - }); - } - } - - 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 })); - 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", + if (source === "sourceElo") { + for (const input of inputs) { + if (resolved.has(input.participantId)) continue; + if (input.sourceElo !== null && input.sourceElo !== undefined) { + resolved.set(input.participantId, { + participantId: input.participantId, + sourceElo: input.sourceElo, + method: "direct", + }); + } + } + } else if (source === "projectedWins") { + const seasonGames = optionalNumber(config.seasonGames, Math.max(...inputs.map((input) => input.projectedWins ?? 0), 1)); + for (const input of inputs) { + if (resolved.has(input.participantId) || input.projectedWins === null || input.projectedWins === undefined) continue; + resolved.set(input.participantId, { + participantId: input.participantId, + sourceElo: projectionToElo(input.projectedWins, seasonGames, parityFactor, policy), + method: "projectedWins", }); } + } else if (source === "projectedTablePoints") { + const seasonGames = optionalNumber(config.seasonGames, 38); + const maxPoints = optionalNumber(config.maxTablePoints, seasonGames * 3); + for (const input of inputs) { + if (resolved.has(input.participantId) || input.projectedTablePoints === null || input.projectedTablePoints === undefined) continue; + resolved.set(input.participantId, { + participantId: input.participantId, + sourceElo: projectionToElo(input.projectedTablePoints, maxPoints, parityFactor, policy), + method: "projectedTablePoints", + }); + } + } else if (source === "sourceOdds") { + // Odds-derived Elo maps onto eloMin/eloMax via convertFuturesToElo; when + // mixed with direct Elo for other participants the two scales are not + // jointly calibrated. That mixed case is 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 })); + 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 the next source.` + ); + } + 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", + }); + } + } } } @@ -227,38 +305,45 @@ export function resolveRatings( const resolved = new Map(); const alternatives = new Set(profile.derivableInputs?.rating ?? []); - for (const input of inputs) { - if (input.rating !== null && input.rating !== undefined) { - resolved.set(input.participantId, { - participantId: input.participantId, - rating: input.rating, - method: "direct", - }); - } - } - - if (alternatives.has("sourceOdds")) { - 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 })); - if (oddsInputs.length === 1) { - logger.warn( - `resolveRatings: only 1 participant has sourceOdds (${oddsInputs[0].participantId}). ` + - `convertFuturesToElo requires at least 2 to derive a meaningful rating — falling through to missing-rating strategy.` - ); - } - if (oddsInputs.length >= 2) { - const converted = convertFuturesToElo(oddsInputs, "american", { - exponent: 0.33, - eloMin: policy.ratingMin, - eloMax: policy.ratingMax, - }); - for (const [participantId, rating] of converted) { - resolved.set(participantId, { - participantId, - rating: clamp(rating, policy.ratingMin, policy.ratingMax), - method: "sourceOdds", + // Ratings share the configured priority list: the `"sourceElo"` slot stands + // for this simulator's direct strength input (here, `rating`), so ordering + // `"sourceOdds"` ahead of `"sourceElo"` makes futures override the rating too. + // Projection sources do not apply to rating-based simulators and are skipped. + for (const source of policy.sourceEloPriority) { + if (source === "sourceElo") { + for (const input of inputs) { + if (resolved.has(input.participantId)) continue; + if (input.rating !== null && input.rating !== undefined) { + resolved.set(input.participantId, { + participantId: input.participantId, + rating: input.rating, + method: "direct", + }); + } + } + } else if (source === "sourceOdds" && alternatives.has("sourceOdds")) { + 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 })); + if (oddsInputs.length === 1) { + logger.warn( + `resolveRatings: only 1 participant has sourceOdds (${oddsInputs[0].participantId}). ` + + `convertFuturesToElo requires at least 2 to derive a meaningful rating — falling through to the next source.` + ); + } + if (oddsInputs.length >= 2) { + const converted = convertFuturesToElo(oddsInputs, "american", { + exponent: 0.33, + eloMin: policy.ratingMin, + eloMax: policy.ratingMax, }); + for (const [participantId, rating] of converted) { + resolved.set(participantId, { + participantId, + rating: clamp(rating, policy.ratingMin, policy.ratingMax), + method: "sourceOdds", + }); + } } } } diff --git a/app/services/simulations/manifest.ts b/app/services/simulations/manifest.ts index e2e7f9f..3c0877e 100644 --- a/app/services/simulations/manifest.ts +++ b/app/services/simulations/manifest.ts @@ -40,6 +40,15 @@ const BASE_CONFIG = { iterations: 50_000, }; +// Source-resolution priority that makes freshly entered futures odds win over a +// stored Elo/rating (and projections). Used as the default for simulators where +// futures odds are a deliberate, primary input (those exposing a Futures Odds +// setup section). Admins can still override this per season in the Input Policy. +// Kept as a local literal (not imported from input-policy) to avoid a circular +// module dependency that would be evaluated before input-policy's consts exist. +// Mirrors FUTURES_FIRST_SOURCE_PRIORITY in input-policy.ts. +const FUTURES_FIRST_PRIORITY = ["sourceOdds", "sourceElo", "projectedWins", "projectedTablePoints"] as const; + const PROFILES: Record> = { f1_standings: { defaultConfig: { iterations: 10_000, raceNoise: 0.5, participantVolatility: 1.5 }, @@ -72,14 +81,14 @@ const PROFILES: Record { + async simulate(sportsSeasonId: string, context?: SimulationContext): Promise { + // RDif-vs-odds blend weight: configurable via the season input policy, with + // the historical 70/30 split as the default when invoked without context. + const oddsWeight = context?.oddsWeight ?? ODDS_WEIGHT; + const rdifWeight = 1 - oddsWeight; const db = database(); // 1. Load all participants for this sports season. @@ -587,7 +591,7 @@ export class MLBSimulator implements Simulator { // Fall back to RDif if either team lacks odds — avoids inflating one side to 100%. if (o1 === null || o1 === undefined || o2 === null || o2 === undefined) return rdifProb; const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5; - return RDIF_WEIGHT * rdifProb + ODDS_WEIGHT * oddsProb; + return rdifWeight * rdifProb + oddsWeight * oddsProb; }; // ─── Placement count maps ────────────────────────────────────────────────── diff --git a/app/services/simulations/ncaa-football-simulator.ts b/app/services/simulations/ncaa-football-simulator.ts index 4ba53db..77537a4 100644 --- a/app/services/simulations/ncaa-football-simulator.ts +++ b/app/services/simulations/ncaa-football-simulator.ts @@ -64,7 +64,7 @@ import { convertFuturesToElo, eloWinProbability, } from "~/services/probability-engine"; -import type { Simulator, SimulationResult } from "./types"; +import type { Simulator, SimulationResult, SimulationContext } from "./types"; // ─── Simulation parameters ──────────────────────────────────────────────────── @@ -106,7 +106,7 @@ interface Team { * Blended win probability for team1 vs team2. * Falls back to pure Elo when no futures data is present. */ -function blendedWinProb(team1: Team, team2: Team): number { +function blendedWinProb(team1: Team, team2: Team, oddsWeight: number = ODDS_WEIGHT): number { const eloProbValue = eloWinProbability(team1.elo, team2.elo); if (team1.oddsProb === 0 && team2.oddsProb === 0) { @@ -116,11 +116,11 @@ function blendedWinProb(team1: Team, team2: Team): number { const oddsSum = team1.oddsProb + team2.oddsProb; const oddsProbValue = oddsSum > 0 ? team1.oddsProb / oddsSum : 0.5; - return ELO_WEIGHT * eloProbValue + ODDS_WEIGHT * oddsProbValue; + return (1 - oddsWeight) * eloProbValue + oddsWeight * oddsProbValue; } -function simGame(team1: Team, team2: Team): { winner: Team; loser: Team } { - const p1Wins = Math.random() < blendedWinProb(team1, team2); +function simGame(team1: Team, team2: Team, oddsWeight: number = ODDS_WEIGHT): { winner: Team; loser: Team } { + const p1Wins = Math.random() < blendedWinProb(team1, team2, oddsWeight); return p1Wins ? { winner: team1, loser: team2 } : { winner: team2, loser: team1 }; @@ -169,18 +169,18 @@ interface PlacementCounts { * Semifinals: qf1w vs qf2w, qf3w vs qf4w * Championship: sf1w vs sf2w */ -function simulateBracket(teams: Team[], counts: Map): void { +function simulateBracket(teams: Team[], counts: Map, oddsWeight: number = ODDS_WEIGHT): void { // ── First Round (seeds 5–12) ─────────────────────────────────────────────── - const fr1 = simGame(teams[4], teams[11]); // 5 vs 12 - const fr2 = simGame(teams[5], teams[10]); // 6 vs 11 - const fr3 = simGame(teams[6], teams[9]); // 7 vs 10 - const fr4 = simGame(teams[7], teams[8]); // 8 vs 9 + const fr1 = simGame(teams[4], teams[11], oddsWeight); // 5 vs 12 + const fr2 = simGame(teams[5], teams[10], oddsWeight); // 6 vs 11 + const fr3 = simGame(teams[6], teams[9], oddsWeight); // 7 vs 10 + const fr4 = simGame(teams[7], teams[8], oddsWeight); // 8 vs 9 // ── Quarterfinals (seeds 1–4 get byes) ──────────────────────────────────── - const qf1 = simGame(teams[0], fr4.winner); // 1 vs 8/9 winner - const qf2 = simGame(teams[3], fr1.winner); // 4 vs 5/12 winner - const qf3 = simGame(teams[2], fr2.winner); // 3 vs 6/11 winner - const qf4 = simGame(teams[1], fr3.winner); // 2 vs 7/10 winner + const qf1 = simGame(teams[0], fr4.winner, oddsWeight); // 1 vs 8/9 winner + const qf2 = simGame(teams[3], fr1.winner, oddsWeight); // 4 vs 5/12 winner + const qf3 = simGame(teams[2], fr2.winner, oddsWeight); // 3 vs 6/11 winner + const qf4 = simGame(teams[1], fr3.winner, oddsWeight); // 2 vs 7/10 winner const bump = (id: string, key: keyof PlacementCounts) => { const entry = counts.get(id); @@ -193,14 +193,14 @@ function simulateBracket(teams: Team[], counts: Map): v bump(qf4.loser.participantId, "qfLoser"); // ── Semifinals ──────────────────────────────────────────────────────────── - const sf1 = simGame(qf1.winner, qf2.winner); - const sf2 = simGame(qf3.winner, qf4.winner); + const sf1 = simGame(qf1.winner, qf2.winner, oddsWeight); + const sf2 = simGame(qf3.winner, qf4.winner, oddsWeight); bump(sf1.loser.participantId, "sfLoser"); bump(sf2.loser.participantId, "sfLoser"); // ── National Championship ───────────────────────────────────────────────── - const final = simGame(sf1.winner, sf2.winner); + const final = simGame(sf1.winner, sf2.winner, oddsWeight); bump(final.winner.participantId, "champion"); bump(final.loser.participantId, "finalist"); @@ -209,8 +209,11 @@ function simulateBracket(teams: Team[], counts: Map): v // ─── Simulator ──────────────────────────────────────────────────────────────── export class NCAAFootballSimulator implements Simulator { - async simulate(sportsSeasonId: string): Promise { + async simulate(sportsSeasonId: string, context?: SimulationContext): Promise { const db = database(); + // Elo-vs-odds blend weight: configurable via the season input policy, with + // the historical 60/40 split as the default when invoked without context. + const oddsWeight = context?.oddsWeight ?? ODDS_WEIGHT; // 1. Load all participants for this sports season. const participants = await db @@ -318,7 +321,7 @@ export class NCAAFootballSimulator implements Simulator { const field = preBracketMode ? sampleBracketField(allTeams, BRACKET_SIZE) : (deterministicField ?? []); - simulateBracket(field, counts); + simulateBracket(field, counts, oddsWeight); } // 7. Convert counts to probability distributions. diff --git a/app/services/simulations/runner.ts b/app/services/simulations/runner.ts index beb8688..71d9e98 100644 --- a/app/services/simulations/runner.ts +++ b/app/services/simulations/runner.ts @@ -16,6 +16,7 @@ import { prepareSimulatorInputsForRun, validateSimulatorReadiness, } from "~/models/simulator"; +import { getSimulatorInputPolicy } from "~/services/simulations/input-policy"; const ZERO_PROBS = { probFirst: 0, @@ -105,7 +106,10 @@ export async function runSportsSeasonSimulation( try { const simulator = getSimulator(simulatorConfig.simulatorType); - const results = await simulator.simulate(sportsSeasonId); + const results = await simulator.simulate(sportsSeasonId, { + config: simulatorConfig.config, + oddsWeight: getSimulatorInputPolicy(simulatorConfig.config).oddsWeight, + }); if (results.length === 0) { throw new Error("Simulation returned no results. Check that participants have simulator input data."); diff --git a/app/services/simulations/types.ts b/app/services/simulations/types.ts index 19ef0bf..caf744a 100644 --- a/app/services/simulations/types.ts +++ b/app/services/simulations/types.ts @@ -27,9 +27,22 @@ export interface SimulationResult { source: string; } +/** + * Optional run-time context handed to a simulator by the runner. Carries the + * resolved season config and input policy so simulators can honor configurable + * knobs (e.g. the Elo-vs-odds blend weight) without re-querying. It is optional + * so simulators can still be invoked directly (e.g. in unit tests) with just a + * season id, in which case they fall back to their built-in defaults. + */ +export interface SimulationContext { + config: Record; + /** Weight (0–1) for the futures-odds signal in blended simulators. */ + oddsWeight: number; +} + /** * Common interface all sport simulators must implement. */ export interface Simulator { - simulate(sportsSeasonId: string): Promise; + simulate(sportsSeasonId: string, context?: SimulationContext): Promise; } diff --git a/app/services/simulations/ucl-simulator.ts b/app/services/simulations/ucl-simulator.ts index 698431c..bcf494e 100644 --- a/app/services/simulations/ucl-simulator.ts +++ b/app/services/simulations/ucl-simulator.ts @@ -38,7 +38,7 @@ import { database } from "~/database/context"; import { eq, and } from "drizzle-orm"; import * as schema from "~/database/schema"; import { convertFuturesToElo, eloWinProbability } from "~/services/probability-engine"; -import type { Simulator, SimulationResult } from "./types"; +import type { Simulator, SimulationResult, SimulationContext } from "./types"; // ─── Simulation parameters ──────────────────────────────────────────────────── @@ -62,8 +62,12 @@ export function americanToImpliedProb(odds: number): number { // ─── Simulator ──────────────────────────────────────────────────────────────── export class UCLSimulator implements Simulator { - async simulate(sportsSeasonId: string): Promise { + async simulate(sportsSeasonId: string, context?: SimulationContext): Promise { const db = database(); + // Elo-vs-odds blend weight: configurable via the season input policy, with + // the historical 70/30 split as the default when invoked without context. + const oddsWeight = context?.oddsWeight ?? ODDS_WEIGHT; + const eloWeight = 1 - oddsWeight; // 1. Find the bracket scoring event for this sports season. // UCL has exactly one playoff_game event per season. @@ -205,7 +209,7 @@ export class UCLSimulator implements Simulator { const o2 = normalizedOddsMap.get(p2) ?? fallbackProb; const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5; - return ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb; + return eloWeight * eloProb + oddsWeight * oddsProb; }; const simMatch = (p1: string, p2: string): { winner: string; loser: string } => { diff --git a/app/services/simulations/world-cup-simulator.ts b/app/services/simulations/world-cup-simulator.ts index 86d0c90..ff0baee 100644 --- a/app/services/simulations/world-cup-simulator.ts +++ b/app/services/simulations/world-cup-simulator.ts @@ -44,7 +44,7 @@ import { normalizeSimulationResultColumns } from "./simulation-probabilities"; import { logger } from "~/lib/logger"; import { FIFA_48 } from "~/lib/bracket-templates"; import { FIFA_2026_R32_TEMPLATE, assignThirdPlaceSlots } from "~/lib/fifa-2026-bracket"; -import type { Simulator, SimulationResult } from "./types"; +import type { Simulator, SimulationResult, SimulationContext } from "./types"; // ─── Name normalisation (same logic as elo-ratings bulk-import fuzzy match) ── @@ -275,11 +275,12 @@ function simKnockout( teamA: string, teamB: string, eloFn: (id: string) => number, - normalizedProb: (id: string) => number + normalizedProb: (id: string) => number, + oddsWeight: number = ODDS_WEIGHT ): { winner: string; loser: string } { const eloProb = eloWinProbability(eloFn(teamA), eloFn(teamB)); const oddsProb = normalizedProb(teamA); - const blended = ELO_WEIGHT * eloProb + ODDS_WEIGHT * (0.5 + (oddsProb - 0.5)); + const blended = (1 - oddsWeight) * eloProb + oddsWeight * (0.5 + (oddsProb - 0.5)); const winner = Math.random() < blended ? teamA : teamB; return { winner, loser: winner === teamA ? teamB : teamA }; } @@ -319,7 +320,8 @@ function runKnockoutRoundByNumber( matches: Map, completed: Map, eloFn: (id: string) => number, - normalizedProb: (id: string) => number + normalizedProb: (id: string) => number, + oddsWeight: number = ODDS_WEIGHT ): { winnersByNumber: Map; losersByNumber: Map; @@ -348,7 +350,7 @@ function runKnockoutRoundByNumber( winnerId = fixed.winnerId; loserId = fixed.loserId; } else if (m.p1 && m.p2) { - const result = simKnockout(m.p1, m.p2, eloFn, normalizedProb); + const result = simKnockout(m.p1, m.p2, eloFn, normalizedProb, oddsWeight); winnerId = result.winner; loserId = result.loser; } else if (m.p1 || m.p2) { @@ -446,7 +448,10 @@ export class WorldCupSimulator implements Simulator { this.numSimulations = numSimulations; } - async simulate(sportsSeasonId: string): Promise { + async simulate(sportsSeasonId: string, context?: SimulationContext): Promise { + // Elo-vs-odds blend weight for knockout matches: configurable via the season + // input policy, defaulting to the historical 70/30 split without context. + const oddsWeight = context?.oddsWeight ?? ODDS_WEIGHT; const db = database(); // 1. Load all participants for this season @@ -714,10 +719,10 @@ export class WorldCupSimulator implements Simulator { // ── Knockout rounds ────────────────────────────────────────── // R32 → R16 → QF → SF → Third Place Game + Final, advancing via the // canonical ceil(n/2) tree. Completed matches lock in real results. - const r32 = runKnockoutRoundByNumber(R32, buildR32(), completedByRoundAndNumber, eloFn, normalizedProb); - const r16 = runKnockoutRoundByNumber(R16, r32.nextRound, completedByRoundAndNumber, eloFn, normalizedProb); - const qf = runKnockoutRoundByNumber(QF, r16.nextRound, completedByRoundAndNumber, eloFn, normalizedProb); - const sf = runKnockoutRoundByNumber(SF, qf.nextRound, completedByRoundAndNumber, eloFn, normalizedProb); + const r32 = runKnockoutRoundByNumber(R32, buildR32(), completedByRoundAndNumber, eloFn, normalizedProb, oddsWeight); + const r16 = runKnockoutRoundByNumber(R16, r32.nextRound, completedByRoundAndNumber, eloFn, normalizedProb, oddsWeight); + const qf = runKnockoutRoundByNumber(QF, r16.nextRound, completedByRoundAndNumber, eloFn, normalizedProb, oddsWeight); + const sf = runKnockoutRoundByNumber(SF, qf.nextRound, completedByRoundAndNumber, eloFn, normalizedProb, oddsWeight); // QF losers (placements 5–8) for (const id of qf.losersByNumber.values()) { @@ -734,7 +739,7 @@ export class WorldCupSimulator implements Simulator { counts.fourthPlace.set(fixed3pg.loserId, (counts.fourthPlace.get(fixed3pg.loserId) ?? 0) + 1); } else { const { winner: thirdWinner, loser: thirdLoser } = simKnockout( - sf1Loser, sf2Loser, eloFn, normalizedProb + sf1Loser, sf2Loser, eloFn, normalizedProb, oddsWeight ); counts.thirdPlace.set(thirdWinner, (counts.thirdPlace.get(thirdWinner) ?? 0) + 1); counts.fourthPlace.set(thirdLoser, (counts.fourthPlace.get(thirdLoser) ?? 0) + 1); @@ -751,7 +756,7 @@ export class WorldCupSimulator implements Simulator { counts.runnerUp.set(fixedFinal.loserId, (counts.runnerUp.get(fixedFinal.loserId) ?? 0) + 1); } else { const { winner: champion, loser: runnerUp } = simKnockout( - sfWinner1, sfWinner2, eloFn, normalizedProb + sfWinner1, sfWinner2, eloFn, normalizedProb, oddsWeight ); counts.champion.set(champion, (counts.champion.get(champion) ?? 0) + 1); counts.runnerUp.set(runnerUp, (counts.runnerUp.get(runnerUp) ?? 0) + 1); -- 2.45.3 From 24de966b3b83aa0bd0849e7b84c416ee99cc5887 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 01:39:12 +0000 Subject: [PATCH 2/4] Unify simulator strength on a single blended Elo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the two parallel odds/Elo mechanisms with one model: every strength source (raw Elo, projected wins, projected table points, futures odds) converts to an Elo, those blend by weight into a single Elo, and that one Elo feeds every simulator. This supersedes the earlier source-priority + per-match probability blend approach. Resolution (app/services/simulations/input-policy.ts): - SimulatorInputPolicy gains baseEloPriority (order among the substitutable base sources: raw Elo / projected wins / projected table points) and oddsWeight (0–1). resolveSourceElos/resolveRatings now compute baseElo, derive an odds Elo via convertFuturesToElo, and blend: 0 = base only, 1 = futures override, between = weighted blend (method "blend"). - Drops the odds-inclusive sourceEloPriority and the prefersFuturesOdds helper. Simulators consume the single resolved Elo: - UCL, World Cup, NCAA Football, MLB drop their separate normalized-odds signal, convertFuturesToElo calls, and per-match probability blend; they read the resolved sourceElo (preserving each sim's hardcoded fallback Elo table). The optional SimulationContext oddsWeight plumbing (types.ts/runner.ts) is removed. - UCL is routed through the central blend (requiredInputs sourceElo, derivable from sourceOdds) so any entered Elo and futures blend uniformly. - College hockey already blends odds into Elo internally (and uses NPI rank the central resolver can't), so its central oddsWeight is set to 0 to avoid double-counting; the simulator is unchanged. Manifest: per-profile oddsWeight defaults (World Cup/UCL/MLB 0.3, NCAA FB 0.4, college hockey 0; global default 0.3). UI: the Input Policy card exposes one "Futures vs. Elo — Odds Blend Weight" control; the /admin/simulators inventory badge shows the effective blend ("Elo only" / "NN% blend" / "overrides Elo") with the odds participant count. Tests: input-policy blend math (0/0.5/1) for Elo and ratings, baseEloPriority and oddsWeight parsing/clamping, manifest per-profile weights; obsolete source-priority and oddsWeight-context tests removed/replaced. Note: this intentionally shifts the calibrated EV outputs of the four sims that previously blended at the probability level (accepted in design discussion). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QhNeB7gN6VKene7sdbBVQT --- app/models/simulator.ts | 11 +- app/routes/admin.simulators.tsx | 10 +- .../admin.sports-seasons.$id.simulator.tsx | 33 +- .../__tests__/input-policy.test.ts | 89 +++--- .../simulations/__tests__/manifest.test.ts | 20 +- .../__tests__/ncaa-football-simulator.test.ts | 34 +-- app/services/simulations/input-policy.ts | 284 ++++++++++-------- app/services/simulations/manifest.ts | 29 +- app/services/simulations/mlb-simulator.ts | 57 +--- .../simulations/ncaa-football-simulator.ts | 140 ++------- app/services/simulations/runner.ts | 6 +- app/services/simulations/types.ts | 19 +- app/services/simulations/ucl-simulator.ts | 73 +---- .../simulations/world-cup-simulator.ts | 82 ++--- 14 files changed, 328 insertions(+), 559 deletions(-) diff --git a/app/models/simulator.ts b/app/models/simulator.ts index b561ce9..d4e1c3f 100644 --- a/app/models/simulator.ts +++ b/app/models/simulator.ts @@ -9,7 +9,6 @@ import { } from "~/services/simulations/manifest"; import { getSimulatorInputPolicy, - prefersFuturesOdds, ratingRequirementLabel, resolveRatings, resolveSourceElos, @@ -88,8 +87,8 @@ export interface SportsSeasonSimulatorSummary { supportsFuturesOdds: boolean; /** Number of participants that currently have stored futures odds. */ oddsParticipantCount: number; - /** Whether the season's input policy makes futures odds override Elo/projections. */ - prefersFuturesOdds: boolean; + /** Weight (0–1) the season's policy gives futures odds when blending into Elo. */ + oddsWeight: number; } function parseDecimal(value: string | null | undefined): number | null { @@ -133,8 +132,8 @@ function isFallbackMethod(method: string): boolean { return method === "fallbackElo" || method === "fallbackRating" || method === "averageKnown" || method === "worstKnownMinus"; } -const GENERATED_RATING_METHODS = ["sourceOdds", "fallbackRating", "averageKnown", "worstKnownMinus"] as const; -const GENERATED_SOURCE_ELO_METHODS = ["projectedWins", "projectedTablePoints", "sourceOdds", "fallbackElo", "averageKnown", "worstKnownMinus"] as const; +const GENERATED_RATING_METHODS = ["sourceOdds", "blend", "fallbackRating", "averageKnown", "worstKnownMinus"] as const; +const GENERATED_SOURCE_ELO_METHODS = ["projectedWins", "projectedTablePoints", "sourceOdds", "blend", "fallbackElo", "averageKnown", "worstKnownMinus"] as const; function isGeneratedRatingMethod(method: unknown): boolean { return typeof method === "string" && GENERATED_RATING_METHODS.includes(method as (typeof GENERATED_RATING_METHODS)[number]); @@ -697,7 +696,7 @@ export async function listSportsSeasonSimulatorSummaries(): Promise }; } +function futuresBlendLabel(oddsWeight: number): string { + if (oddsWeight >= 1) return "overrides Elo"; + if (oddsWeight <= 0) return "Elo only"; + return `${Math.round(oddsWeight * 100)}% blend`; +} + function statusBadge(status: string) { if (status === "ready") return Ready; return Needs setup; @@ -247,9 +253,9 @@ export default function AdminSimulators({ loaderData }: Route.ComponentProps) {
{sim.participantInputCount}/{sim.participantCount}
{sim.supportsFuturesOdds && ( - + 0 ? "default" : "outline"} className="mt-1 text-xs font-normal"> {sim.oddsParticipantCount > 0 - ? `Futures: ${sim.oddsParticipantCount}${sim.prefersFuturesOdds ? " (overrides Elo)" : " (fallback)"}` + ? `Futures: ${sim.oddsParticipantCount} · ${futuresBlendLabel(sim.oddsWeight)}` : "No futures odds"} )} diff --git a/app/routes/admin.sports-seasons.$id.simulator.tsx b/app/routes/admin.sports-seasons.$id.simulator.tsx index 2618a81..c9f4417 100644 --- a/app/routes/admin.sports-seasons.$id.simulator.tsx +++ b/app/routes/admin.sports-seasons.$id.simulator.tsx @@ -27,9 +27,6 @@ import { import { normalizeName } from "~/lib/fuzzy-match"; import { getSimulatorInputPolicy, - prefersFuturesOdds, - ELO_FIRST_SOURCE_PRIORITY, - FUTURES_FIRST_SOURCE_PRIORITY, type MissingEloStrategy, type MissingRatingStrategy, } from "~/services/simulations/input-policy"; @@ -212,10 +209,7 @@ export async function action({ request, params }: Route.ActionArgs): Promise -
- - -

- Controls which signal wins when a participant has both. Futures-first makes freshly - entered odds drive the run without discarding stored Elo. -

-
-
- +
+

- For simulators that blend Elo and odds per match. 1.0 = pure odds; 0 = pure Elo. + Every source (raw Elo, projections, futures odds) becomes an Elo, then they blend into + the single Elo that feeds the simulator. This is the weight given to futures odds: + 0 = Elo / projections only, 1 = futures fully override, + in between = blend (e.g. 0.3 = 70% Elo / 30% futures).

{config.profile.requiredInputs.includes("sourceElo") && ( diff --git a/app/services/simulations/__tests__/input-policy.test.ts b/app/services/simulations/__tests__/input-policy.test.ts index 4a45e0f..ed49a7a 100644 --- a/app/services/simulations/__tests__/input-policy.test.ts +++ b/app/services/simulations/__tests__/input-policy.test.ts @@ -1,11 +1,9 @@ import { describe, expect, it } from "vitest"; import { getSimulatorInputPolicy, - prefersFuturesOdds, resolveRatings, resolveSourceElos, - ELO_FIRST_SOURCE_PRIORITY, - FUTURES_FIRST_SOURCE_PRIORITY, + DEFAULT_BASE_ELO_PRIORITY, } from "../input-policy"; import type { SimulatorManifestProfile } from "../manifest"; @@ -190,86 +188,95 @@ describe("simulator input policy", () => { expect(resolved.get("missing-tail")).toMatchObject({ rating: -10, method: "worstKnownMinus" }); }); - it("lets a futures-first priority override a stored direct Elo", () => { + 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 }, { participantId: "longshot", sourceElo: 1200, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null }, ]; - // Default (Elo-first): the stored direct Elo wins. - const eloFirst = resolveSourceElos(inputs, profile, {}); - expect(eloFirst.get("favorite")).toMatchObject({ sourceElo: 1800, method: "direct" }); + // oddsWeight 0: the stored base Elo wins outright. + const eloOnly = resolveSourceElos(inputs, profile, { inputPolicy: { oddsWeight: 0 } }); + expect(eloOnly.get("favorite")).toMatchObject({ sourceElo: 1800, method: "direct" }); - // Futures-first: odds win even though a direct Elo is present. - const futuresFirst = resolveSourceElos(inputs, profile, { - inputPolicy: { sourceEloPriority: FUTURES_FIRST_SOURCE_PRIORITY }, - }); - expect(futuresFirst.get("favorite")?.method).toBe("sourceOdds"); - expect(futuresFirst.get("longshot")?.method).toBe("sourceOdds"); + // oddsWeight 1: futures fully override the base Elo. + const oddsOnly = resolveSourceElos(inputs, profile, { inputPolicy: { oddsWeight: 1 } }); + expect(oddsOnly.get("favorite")?.method).toBe("sourceOdds"); + expect(oddsOnly.get("longshot")?.method).toBe("sourceOdds"); + + // Between: a blend that sits strictly between the base and the odds-derived Elo. + const oddsElo = oddsOnly.get("favorite")!.sourceElo; + const blended = resolveSourceElos(inputs, profile, { inputPolicy: { oddsWeight: 0.5 } }); + expect(blended.get("favorite")?.method).toBe("blend"); + const blendedElo = blended.get("favorite")!.sourceElo; + expect(blendedElo).toBeGreaterThan(Math.min(1800, oddsElo)); + expect(blendedElo).toBeLessThan(Math.max(1800, oddsElo)); + expect(blendedElo).toBeCloseTo(Math.round(0.5 * 1800 + 0.5 * oddsElo), 0); }); - it("lets a futures-first priority override stored projected wins", () => { + it("blends projected wins with futures odds when both are present", () => { const inputs = [ { participantId: "team-1", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: 60, projectedTablePoints: null }, { participantId: "team-2", sourceElo: null, rating: null, sourceOdds: 20000, projectedWins: 20, projectedTablePoints: null }, ]; - // Default (Elo-first): projectedWins outranks odds. - expect(resolveSourceElos(inputs, profile, { seasonGames: 82 }).get("team-1")?.method).toBe("projectedWins"); + // oddsWeight 0: projectedWins is the base. + expect(resolveSourceElos(inputs, profile, { seasonGames: 82, inputPolicy: { oddsWeight: 0 } }).get("team-1")?.method) + .toBe("projectedWins"); - // Futures-first: odds win over projections. - const futuresFirst = resolveSourceElos(inputs, profile, { - seasonGames: 82, - inputPolicy: { sourceEloPriority: FUTURES_FIRST_SOURCE_PRIORITY }, - }); - expect(futuresFirst.get("team-1")?.method).toBe("sourceOdds"); + // oddsWeight 1: futures override the projection. + expect(resolveSourceElos(inputs, profile, { seasonGames: 82, inputPolicy: { oddsWeight: 1 } }).get("team-1")?.method) + .toBe("sourceOdds"); + + // Between: blended. + expect(resolveSourceElos(inputs, profile, { seasonGames: 82, inputPolicy: { oddsWeight: 0.4 } }).get("team-1")?.method) + .toBe("blend"); }); - it("lets a futures-first priority override a stored direct rating", () => { + it("blends a direct rating with futures odds by oddsWeight", () => { const ratingProfile = { derivableInputs: { rating: ["sourceOdds"] } } as Pick; const inputs = [ { participantId: "favorite", sourceElo: null, rating: 30, sourceOdds: 300, projectedWins: null, projectedTablePoints: null }, { participantId: "longshot", sourceElo: null, rating: -5, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null }, ]; - // Default: direct rating wins. - expect(resolveRatings(inputs, ratingProfile, { inputPolicy: { ratingMin: -10, ratingMax: 35 } }).get("favorite")) + // oddsWeight 0: direct rating wins. + expect(resolveRatings(inputs, ratingProfile, { inputPolicy: { ratingMin: -10, ratingMax: 35, oddsWeight: 0 } }).get("favorite")) .toMatchObject({ rating: 30, method: "direct" }); - // Futures-first: odds win. + // oddsWeight 1: odds override the rating. expect( resolveRatings(inputs, ratingProfile, { - inputPolicy: { ratingMin: -10, ratingMax: 35, sourceEloPriority: FUTURES_FIRST_SOURCE_PRIORITY }, + inputPolicy: { ratingMin: -10, ratingMax: 35, oddsWeight: 1 }, }).get("favorite")?.method ).toBe("sourceOdds"); + + // Between: blended. + expect( + resolveRatings(inputs, ratingProfile, { + inputPolicy: { ratingMin: -10, ratingMax: 35, oddsWeight: 0.5 }, + }).get("favorite")?.method + ).toBe("blend"); }); - it("parses and clamps the input policy source priority and odds weight", () => { + it("parses and clamps the base Elo priority and odds weight", () => { const defaults = getSimulatorInputPolicy({}); - expect(defaults.sourceEloPriority).toEqual(ELO_FIRST_SOURCE_PRIORITY); + expect(defaults.baseEloPriority).toEqual(DEFAULT_BASE_ELO_PRIORITY); expect(defaults.oddsWeight).toBe(0.3); // oddsWeight can be sourced from the top-level config or the input policy, - // and is clamped to [0, 1]; junk priority entries are dropped. + // and is clamped to [0, 1]; junk base-priority entries are dropped. expect(getSimulatorInputPolicy({ oddsWeight: 0.6 }).oddsWeight).toBe(0.6); expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: 5 } }).oddsWeight).toBe(1); expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: -2 } }).oddsWeight).toBe(0); expect( - getSimulatorInputPolicy({ inputPolicy: { sourceEloPriority: ["sourceOdds", "bogus", "sourceElo"] } }).sourceEloPriority - ).toEqual(["sourceOdds", "sourceElo"]); + getSimulatorInputPolicy({ inputPolicy: { baseEloPriority: ["projectedWins", "sourceOdds", "sourceElo"] } }).baseEloPriority + ).toEqual(["projectedWins", "sourceElo"]); // An empty/all-invalid priority falls back to the default. - expect(getSimulatorInputPolicy({ inputPolicy: { sourceEloPriority: ["nope"] } }).sourceEloPriority).toEqual( - ELO_FIRST_SOURCE_PRIORITY + expect(getSimulatorInputPolicy({ inputPolicy: { baseEloPriority: ["nope"] } }).baseEloPriority).toEqual( + DEFAULT_BASE_ELO_PRIORITY ); }); - it("detects whether a priority prefers futures odds", () => { - expect(prefersFuturesOdds(ELO_FIRST_SOURCE_PRIORITY)).toBe(false); - expect(prefersFuturesOdds(FUTURES_FIRST_SOURCE_PRIORITY)).toBe(true); - expect(prefersFuturesOdds(["projectedWins", "sourceElo"])).toBe(false); - expect(prefersFuturesOdds(["sourceOdds"])).toBe(true); - }); - it("uses NCAAW-style rating scale when deriving and falling back", () => { const resolved = resolveRatings( [ diff --git a/app/services/simulations/__tests__/manifest.test.ts b/app/services/simulations/__tests__/manifest.test.ts index a8afc9e..be36fa7 100644 --- a/app/services/simulations/__tests__/manifest.test.ts +++ b/app/services/simulations/__tests__/manifest.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import * as schema from "~/database/schema"; import { SIMULATOR_MANIFEST } from "../manifest"; import { SIMULATOR_TYPES } from "../registry"; -import { getSimulatorInputPolicy, prefersFuturesOdds } from "../input-policy"; +import { getSimulatorInputPolicy } from "../input-policy"; import { assertRegistrySchemaDriftFree } from "~/models/simulator"; describe("simulator manifest", () => { @@ -41,19 +41,15 @@ describe("simulator manifest", () => { } }); - it("defaults futures-centric simulators to a futures-override source priority", () => { - const futuresCentric = ["ncaam_bracket", "ncaaw_bracket", "world_cup", "ncaa_football_bracket", "college_hockey_bracket"] as const; - for (const simulatorType of futuresCentric) { - const policy = getSimulatorInputPolicy(SIMULATOR_MANIFEST[simulatorType].defaultConfig); - expect(prefersFuturesOdds(policy.sourceEloPriority)).toBe(true); - } - }); - - it("exposes the blended Elo/odds weight through the input policy", () => { + it("exposes the per-profile futures blend weight through the input policy", () => { expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.ncaa_football_bracket.defaultConfig).oddsWeight).toBe(0.4); expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.world_cup.defaultConfig).oddsWeight).toBe(0.3); - // A non-futures team sport keeps the Elo-first default. - expect(prefersFuturesOdds(getSimulatorInputPolicy(SIMULATOR_MANIFEST.nba_bracket.defaultConfig).sourceEloPriority)).toBe(false); + expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.ucl_bracket.defaultConfig).oddsWeight).toBe(0.3); + // College hockey blends odds internally, so the central blend is disabled. + expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.college_hockey_bracket.defaultConfig).oddsWeight).toBe(0); + // Every profile resolves to the default base-Elo priority unless overridden. + expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.nba_bracket.defaultConfig).baseEloPriority) + .toEqual(["sourceElo", "projectedWins", "projectedTablePoints"]); }); it("keeps EPL projection and match parity as separate config knobs", () => { diff --git a/app/services/simulations/__tests__/ncaa-football-simulator.test.ts b/app/services/simulations/__tests__/ncaa-football-simulator.test.ts index a8354f8..a63cce3 100644 --- a/app/services/simulations/__tests__/ncaa-football-simulator.test.ts +++ b/app/services/simulations/__tests__/ncaa-football-simulator.test.ts @@ -143,42 +143,14 @@ describe("NCAAFootballSimulator", () => { } }); - it("uses futures odds when sourceOdds present", async () => { - setupMockDb(TEAM_IDS, { includeOdds: true }); + it("runs from the resolved Elo (futures already blended upstream)", async () => { + // sourceElo is the single resolved Elo; the sim no longer reads sourceOdds. + setupMockDb(TEAM_IDS); const results = await new NCAAFootballSimulator().simulate("season-1"); expect(results).toHaveLength(12); const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0); expect(total).toBeCloseTo(1.0, 1); }); - - it("honors the oddsWeight context when odds and Elo disagree", async () => { - // team-12 has the worst Elo but by far the best futures odds. - function setupConflicting() { - const participants = makeParticipants(TEAM_IDS); - const evRows = TEAM_IDS.map((participantId, i) => ({ - participantId, - sourceElo: 1700 - i * 50, - sourceOdds: i === 11 ? -100000 : 50000, - })); - mockDb.select.mockImplementation(() => { - const callIndex = selectCallCount++; - const data = callIndex === 0 ? participants : evRows; - return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) }; - }); - } - - setupConflicting(); - const pureOdds = await new NCAAFootballSimulator().simulate("season-1", { config: {}, oddsWeight: 1 }); - selectCallCount = 0; - setupConflicting(); - const pureElo = await new NCAAFootballSimulator().simulate("season-1", { config: {}, oddsWeight: 0 }); - - const champByOdds = pureOdds.find((r) => r.participantId === "team-12")?.probabilities.probFirst ?? 0; - const champByElo = pureElo.find((r) => r.participantId === "team-12")?.probabilities.probFirst ?? 0; - // With the odds signal fully weighted, the odds-favored team wins far more - // often than when only its (worst) Elo drives the result. - expect(champByOdds).toBeGreaterThan(champByElo); - }); }); // ── Pre-bracket mode (>12 teams — probabilistic selection) ─────────────── diff --git a/app/services/simulations/input-policy.ts b/app/services/simulations/input-policy.ts index ef9c4a6..a05dfdf 100644 --- a/app/services/simulations/input-policy.ts +++ b/app/services/simulations/input-policy.ts @@ -6,54 +6,36 @@ export type MissingEloStrategy = "block" | "fallbackElo" | "averageKnown" | "wor export type MissingRatingStrategy = "block" | "fallbackRating" | "averageKnown" | "worstKnownMinus"; /** - * Sources a participant's strength input can be derived from, in the order the - * resolver should try them. `"sourceElo"` represents the simulator's primary - * direct input — a manually entered Elo for Elo-based sims, or the direct - * `rating` for rating-based sims. Placing `"sourceOdds"` ahead of `"sourceElo"` - * is how an admin makes futures odds override stored Elo/ratings. + * "Base" strength sources — the ways to produce a participant's underlying Elo + * before any futures-odds blend. These are *substitutes* (a season rarely has + * more than one), so they are tried in order and the first available one wins. + * Futures odds are not in this list: they blend on top of the base Elo via + * `oddsWeight`, rather than being one more interchangeable base. */ -export type SourceEloKey = "sourceElo" | "projectedWins" | "projectedTablePoints" | "sourceOdds"; +export type BaseEloKey = "sourceElo" | "projectedWins" | "projectedTablePoints"; -export const SOURCE_ELO_KEYS: readonly SourceEloKey[] = [ - "sourceElo", - "projectedWins", - "projectedTablePoints", - "sourceOdds", -]; - -/** Default priority: stored Elo/projections win, futures odds are the fallback. */ -export const ELO_FIRST_SOURCE_PRIORITY: SourceEloKey[] = [ - "sourceElo", - "projectedWins", - "projectedTablePoints", - "sourceOdds", -]; - -/** Futures-override priority: freshly entered odds win over stored Elo/projections. */ -export const FUTURES_FIRST_SOURCE_PRIORITY: SourceEloKey[] = [ - "sourceOdds", +export const BASE_ELO_KEYS: readonly BaseEloKey[] = [ "sourceElo", "projectedWins", "projectedTablePoints", ]; -/** Whether a priority list prefers futures odds over the direct Elo/rating input. */ -export function prefersFuturesOdds(priority: SourceEloKey[]): boolean { - const oddsIndex = priority.indexOf("sourceOdds"); - const eloIndex = priority.indexOf("sourceElo"); - if (oddsIndex === -1) return false; - return eloIndex === -1 || oddsIndex < eloIndex; -} +/** Default order for choosing the base Elo: raw Elo, then projections. */ +export const DEFAULT_BASE_ELO_PRIORITY: BaseEloKey[] = [ + "sourceElo", + "projectedWins", + "projectedTablePoints", +]; export interface SimulatorInputPolicy { missingEloStrategy: MissingEloStrategy; missingRatingStrategy: MissingRatingStrategy; - /** Ordered preference for deriving a participant's strength input. */ - sourceEloPriority: SourceEloKey[]; + /** Order for choosing the base strength Elo from its substitutable sources. */ + baseEloPriority: BaseEloKey[]; /** - * Weight (0–1) given to the futures-odds signal in simulators that blend Elo - * and odds (UCL, World Cup, NCAA Football, MLB, college hockey). The Elo - * signal gets `1 - oddsWeight`. Set to 1.0 to make futures fully override Elo. + * Weight (0–1) of the futures-odds-derived Elo when blending with the base Elo + * into the single Elo that feeds every simulator. `0` = base only (Elo / + * projections), `1` = futures fully override the base, in between = blend. */ oddsWeight: number; fallbackElo: number; @@ -78,13 +60,13 @@ export interface ParticipantInputForPolicy { export interface ResolvedSourceElo { participantId: string; sourceElo: number; - method: "direct" | "projectedWins" | "projectedTablePoints" | "sourceOdds" | MissingEloStrategy; + method: "direct" | "projectedWins" | "projectedTablePoints" | "sourceOdds" | "blend" | MissingEloStrategy; } export interface ResolvedRating { participantId: string; rating: number; - method: "direct" | "sourceOdds" | MissingRatingStrategy; + method: "direct" | "sourceOdds" | "blend" | MissingRatingStrategy; } const DEFAULT_ODDS_WEIGHT = 0.3; @@ -92,7 +74,7 @@ const DEFAULT_ODDS_WEIGHT = 0.3; const DEFAULT_POLICY: SimulatorInputPolicy = { missingEloStrategy: "block", missingRatingStrategy: "block", - sourceEloPriority: ELO_FIRST_SOURCE_PRIORITY, + baseEloPriority: DEFAULT_BASE_ELO_PRIORITY, oddsWeight: DEFAULT_ODDS_WEIGHT, fallbackElo: 1400, fallbackEloDelta: 25, @@ -132,14 +114,14 @@ function projectionToElo( return clamp(Math.round(elo), policy.eloMin, policy.eloMax); } -function parseSourceEloPriority(value: unknown, fallback: SourceEloKey[]): SourceEloKey[] { +function parseBaseEloPriority(value: unknown, fallback: BaseEloKey[]): BaseEloKey[] { if (!Array.isArray(value)) return fallback; - const seen = new Set(); - const parsed: SourceEloKey[] = []; + const seen = new Set(); + const parsed: BaseEloKey[] = []; for (const entry of value) { - if (typeof entry === "string" && (SOURCE_ELO_KEYS as readonly string[]).includes(entry) && !seen.has(entry as SourceEloKey)) { - seen.add(entry as SourceEloKey); - parsed.push(entry as SourceEloKey); + if (typeof entry === "string" && (BASE_ELO_KEYS as readonly string[]).includes(entry) && !seen.has(entry as BaseEloKey)) { + seen.add(entry as BaseEloKey); + parsed.push(entry as BaseEloKey); } } return parsed.length > 0 ? parsed : fallback; @@ -159,7 +141,7 @@ export function getSimulatorInputPolicy(config: Record): Simula ratingStrategy === "fallbackRating" || ratingStrategy === "averageKnown" || ratingStrategy === "worstKnownMinus" ? ratingStrategy : "block", - sourceEloPriority: parseSourceEloPriority(rawPolicy.sourceEloPriority, DEFAULT_POLICY.sourceEloPriority), + baseEloPriority: parseBaseEloPriority(rawPolicy.baseEloPriority, DEFAULT_POLICY.baseEloPriority), // Allow oddsWeight to live either in inputPolicy (the unified knob) or at the // top level of config (legacy per-profile defaultConfig), preferring the policy. oddsWeight: clamp( @@ -201,33 +183,26 @@ export function resolveSourceElos( const alternatives = new Set(profile.derivableInputs?.sourceElo ?? []); const parityFactor = optionalNumber(config.parityFactor, 400); - // Resolve each participant from the first source in the configured priority - // that (a) is permitted for this simulator and (b) has a value. The direct - // `sourceElo` input is always permitted; the rest must be declared in the - // profile's `derivableInputs.sourceElo`. Reordering the priority so that - // `sourceOdds` precedes `sourceElo` makes freshly entered futures odds - // override a stale Elo (and projections) without destroying the stored value. - for (const source of policy.sourceEloPriority) { + // 1. Base Elo: the first available *substitutable* source per baseEloPriority. + // `sourceElo` (raw) is always permitted; projections must be declared in + // the profile's derivableInputs.sourceElo. + const baseElo = new Map(); + for (const source of policy.baseEloPriority) { if (source !== "sourceElo" && !alternatives.has(source)) continue; if (source === "sourceElo") { for (const input of inputs) { - if (resolved.has(input.participantId)) continue; + if (baseElo.has(input.participantId)) continue; if (input.sourceElo !== null && input.sourceElo !== undefined) { - resolved.set(input.participantId, { - participantId: input.participantId, - sourceElo: input.sourceElo, - method: "direct", - }); + baseElo.set(input.participantId, { elo: input.sourceElo, method: "direct" }); } } } else if (source === "projectedWins") { const seasonGames = optionalNumber(config.seasonGames, Math.max(...inputs.map((input) => input.projectedWins ?? 0), 1)); for (const input of inputs) { - if (resolved.has(input.participantId) || input.projectedWins === null || input.projectedWins === undefined) continue; - resolved.set(input.participantId, { - participantId: input.participantId, - sourceElo: projectionToElo(input.projectedWins, seasonGames, parityFactor, policy), + if (baseElo.has(input.participantId) || input.projectedWins === null || input.projectedWins === undefined) continue; + baseElo.set(input.participantId, { + elo: projectionToElo(input.projectedWins, seasonGames, parityFactor, policy), method: "projectedWins", }); } @@ -235,39 +210,69 @@ export function resolveSourceElos( const seasonGames = optionalNumber(config.seasonGames, 38); const maxPoints = optionalNumber(config.maxTablePoints, seasonGames * 3); for (const input of inputs) { - if (resolved.has(input.participantId) || input.projectedTablePoints === null || input.projectedTablePoints === undefined) continue; - resolved.set(input.participantId, { - participantId: input.participantId, - sourceElo: projectionToElo(input.projectedTablePoints, maxPoints, parityFactor, policy), + if (baseElo.has(input.participantId) || input.projectedTablePoints === null || input.projectedTablePoints === undefined) continue; + baseElo.set(input.participantId, { + elo: projectionToElo(input.projectedTablePoints, maxPoints, parityFactor, policy), method: "projectedTablePoints", }); } - } else if (source === "sourceOdds") { - // Odds-derived Elo maps onto eloMin/eloMax via convertFuturesToElo; when - // mixed with direct Elo for other participants the two scales are not - // jointly calibrated. That mixed case is 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 })); - 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 the next source.` - ); - } - 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", - }); - } + } + } + + // 2. Odds-derived Elo (field-relative): only when futures odds are a permitted + // source and at least two participants have odds (a spread is required). + const oddsElo = new Map(); + if (alternatives.has("sourceOdds")) { + const oddsInputs = inputs + .filter((input) => input.sourceOdds !== null && input.sourceOdds !== undefined) + .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 — ignoring the lone odds value.` + ); + } + if (oddsInputs.length >= 2) { + for (const [participantId, elo] of convertFuturesToElo(oddsInputs)) { + oddsElo.set(participantId, elo); } } } + // 3. Blend base + odds into the single Elo that feeds the simulator. + const w = policy.oddsWeight; + for (const input of inputs) { + const base = baseElo.get(input.participantId); + const odds = oddsElo.get(input.participantId); + let elo: number; + let method: ResolvedSourceElo["method"]; + if (base !== undefined && odds !== undefined) { + if (w <= 0) { + elo = base.elo; + method = base.method; + } else if (w >= 1) { + elo = odds; + method = "sourceOdds"; + } else { + elo = (1 - w) * base.elo + w * odds; + method = "blend"; + } + } else if (base !== undefined) { + elo = base.elo; + method = base.method; + } else if (odds !== undefined) { + elo = odds; + method = "sourceOdds"; + } else { + continue; + } + resolved.set(input.participantId, { + participantId: input.participantId, + sourceElo: clamp(Math.round(elo), policy.eloMin, policy.eloMax), + method, + }); + } + const knownElos = [...resolved.values()].map((value) => value.sourceElo); const averageKnown = knownElos.length > 0 ? knownElos.reduce((sum, elo) => sum + elo, 0) / knownElos.length @@ -305,49 +310,72 @@ export function resolveRatings( const resolved = new Map(); const alternatives = new Set(profile.derivableInputs?.rating ?? []); - // Ratings share the configured priority list: the `"sourceElo"` slot stands - // for this simulator's direct strength input (here, `rating`), so ordering - // `"sourceOdds"` ahead of `"sourceElo"` makes futures override the rating too. - // Projection sources do not apply to rating-based simulators and are skipped. - for (const source of policy.sourceEloPriority) { - if (source === "sourceElo") { - for (const input of inputs) { - if (resolved.has(input.participantId)) continue; - if (input.rating !== null && input.rating !== undefined) { - resolved.set(input.participantId, { - participantId: input.participantId, - rating: input.rating, - method: "direct", - }); - } - } - } else if (source === "sourceOdds" && alternatives.has("sourceOdds")) { - 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 })); - if (oddsInputs.length === 1) { - logger.warn( - `resolveRatings: only 1 participant has sourceOdds (${oddsInputs[0].participantId}). ` + - `convertFuturesToElo requires at least 2 to derive a meaningful rating — falling through to the next source.` - ); - } - if (oddsInputs.length >= 2) { - const converted = convertFuturesToElo(oddsInputs, "american", { - exponent: 0.33, - eloMin: policy.ratingMin, - eloMax: policy.ratingMax, - }); - for (const [participantId, rating] of converted) { - resolved.set(participantId, { - participantId, - rating: clamp(rating, policy.ratingMin, policy.ratingMax), - method: "sourceOdds", - }); - } + // Ratings follow the same single-value blend as Elo: the base is the direct + // `rating`; futures odds (when permitted, scaled onto the rating range) blend + // on top by oddsWeight. `0` = rating only, `1` = futures override. + const baseRating = new Map(); + for (const input of inputs) { + if (input.rating !== null && input.rating !== undefined) { + baseRating.set(input.participantId, input.rating); + } + } + + const oddsRating = new Map(); + if (alternatives.has("sourceOdds")) { + const oddsInputs = inputs + .filter((input) => input.sourceOdds !== null && input.sourceOdds !== undefined) + .map((input) => ({ participantId: input.participantId, odds: input.sourceOdds as number })); + if (oddsInputs.length === 1) { + logger.warn( + `resolveRatings: only 1 participant has sourceOdds (${oddsInputs[0].participantId}). ` + + `convertFuturesToElo requires at least 2 to derive a meaningful rating — ignoring the lone odds value.` + ); + } + if (oddsInputs.length >= 2) { + const converted = convertFuturesToElo(oddsInputs, "american", { + exponent: 0.33, + eloMin: policy.ratingMin, + eloMax: policy.ratingMax, + }); + for (const [participantId, rating] of converted) { + oddsRating.set(participantId, rating); } } } + const w = policy.oddsWeight; + for (const input of inputs) { + const base = baseRating.get(input.participantId); + const odds = oddsRating.get(input.participantId); + let rating: number; + let method: ResolvedRating["method"]; + if (base !== undefined && odds !== undefined) { + if (w <= 0) { + rating = base; + method = "direct"; + } else if (w >= 1) { + rating = odds; + method = "sourceOdds"; + } else { + rating = (1 - w) * base + w * odds; + method = "blend"; + } + } else if (base !== undefined) { + rating = base; + method = "direct"; + } else if (odds !== undefined) { + rating = odds; + method = "sourceOdds"; + } else { + continue; + } + resolved.set(input.participantId, { + participantId: input.participantId, + rating: clamp(rating, policy.ratingMin, policy.ratingMax), + method, + }); + } + const knownRatings = [...resolved.values()].map((value) => value.rating); const averageKnown = knownRatings.length > 0 ? knownRatings.reduce((sum, rating) => sum + rating, 0) / knownRatings.length diff --git a/app/services/simulations/manifest.ts b/app/services/simulations/manifest.ts index 3c0877e..4a18e68 100644 --- a/app/services/simulations/manifest.ts +++ b/app/services/simulations/manifest.ts @@ -40,15 +40,6 @@ const BASE_CONFIG = { iterations: 50_000, }; -// Source-resolution priority that makes freshly entered futures odds win over a -// stored Elo/rating (and projections). Used as the default for simulators where -// futures odds are a deliberate, primary input (those exposing a Futures Odds -// setup section). Admins can still override this per season in the Input Policy. -// Kept as a local literal (not imported from input-policy) to avoid a circular -// module dependency that would be evaluated before input-policy's consts exist. -// Mirrors FUTURES_FIRST_SOURCE_PRIORITY in input-policy.ts. -const FUTURES_FIRST_PRIORITY = ["sourceOdds", "sourceElo", "projectedWins", "projectedTablePoints"] as const; - const PROFILES: Record> = { f1_standings: { defaultConfig: { iterations: 10_000, raceNoise: 0.5, participantVolatility: 1.5 }, @@ -75,20 +66,21 @@ const PROFILES: Record { - // RDif-vs-odds blend weight: configurable via the season input policy, with - // the historical 70/30 split as the default when invoked without context. - const oddsWeight = context?.oddsWeight ?? ODDS_WEIGHT; - const rdifWeight = 1 - oddsWeight; + async simulate(sportsSeasonId: string): Promise { const db = database(); // 1. Load all participants for this sports season. @@ -525,33 +511,19 @@ export class MLBSimulator implements Simulator { ); } - // ─── Futures odds blending ───────────────────────────────────────────────── + // ─── Strength from the resolved Elo ──────────────────────────────────────── + // sourceElo is the single Elo produced by the input policy — already a blend + // of any raw Elo / projections / futures odds — so the simulator just reads it + // and no longer blends odds itself. const evRows = await db .select({ participantId: schema.seasonParticipantExpectedValues.participantId, - sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds, sourceElo: schema.seasonParticipantExpectedValues.sourceElo, }) .from(schema.seasonParticipantExpectedValues) .where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)); - const oddsRows = evRows.filter( - (r) => r.sourceOdds !== null && participantIdSet.has(r.participantId) - ); - const hasOdds = oddsRows.length > 0; - - const normalizedOddsMap = new Map(); - if (hasOdds) { - const rawProbs = oddsRows.map((r) => - convertAmericanOddsToProbability(r.sourceOdds ?? 0) - ); - const normalized = normalizeProbabilities(rawProbs); - oddsRows.forEach(({ participantId }, i) => { - normalizedOddsMap.set(participantId, normalized[i]); - }); - } - // Build a map of sourceElo-derived RDif values (overrides hardcoded TEAMS_DATA.rdif) // for playoff series win probability. const sourceEloRDifMap = new Map(); @@ -579,20 +551,11 @@ export class MLBSimulator implements Simulator { rawWinRateMap.get(entry.id) ?? rawWinRateFromRDif(getEntryRDif(entry)); /** - * Blended per-game win probability for team A over team B in a playoff series. - * When odds are available: 70% RDif log5 + 30% vig-removed futures head-to-head. + * Per-game win probability for team A over team B in a playoff series, from + * the (resolved-Elo-derived) run differential. */ - const gameWinProb = (a: TeamEntry, b: TeamEntry): number => { - const rdifProb = rdifWinProbability(effectiveRDif(a), effectiveRDif(b)); - if (!hasOdds) return rdifProb; - - const o1 = normalizedOddsMap.get(a.id); - const o2 = normalizedOddsMap.get(b.id); - // Fall back to RDif if either team lacks odds — avoids inflating one side to 100%. - if (o1 === null || o1 === undefined || o2 === null || o2 === undefined) return rdifProb; - const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5; - return rdifWeight * rdifProb + oddsWeight * oddsProb; - }; + const gameWinProb = (a: TeamEntry, b: TeamEntry): number => + rdifWinProbability(effectiveRDif(a), effectiveRDif(b)); // ─── Placement count maps ────────────────────────────────────────────────── diff --git a/app/services/simulations/ncaa-football-simulator.ts b/app/services/simulations/ncaa-football-simulator.ts index 77537a4..6141165 100644 --- a/app/services/simulations/ncaa-football-simulator.ts +++ b/app/services/simulations/ncaa-football-simulator.ts @@ -59,12 +59,8 @@ import { database } from "~/database/context"; import { eq } from "drizzle-orm"; import * as schema from "~/database/schema"; -import { - convertAmericanOddsToProbability, - convertFuturesToElo, - eloWinProbability, -} from "~/services/probability-engine"; -import type { Simulator, SimulationResult, SimulationContext } from "./types"; +import { eloWinProbability } from "~/services/probability-engine"; +import type { Simulator, SimulationResult } from "./types"; // ─── Simulation parameters ──────────────────────────────────────────────────── @@ -72,15 +68,7 @@ const NUM_SIMULATIONS = 50_000; const BRACKET_SIZE = 12; /** - * Blend weights for per-game win probability when sourceOdds are present. - * Lower Elo weight than other sports (0.7) gives more influence to Vegas - * championship odds, which are highly informative in college football. - */ -const ELO_WEIGHT = 0.6; -const ODDS_WEIGHT = 1 - ELO_WEIGHT; - -/** - * Softmax temperature for Elo-based selection weights (pre-bracket mode, no odds). + * Softmax temperature for Elo-based selection weights (pre-bracket mode). * At T=100, a 200-point Elo gap produces ~7× weight difference — enough to strongly * favour the top teams while still giving bubble teams meaningful selection probability. */ @@ -90,37 +78,21 @@ const SELECTION_TEMP = 100; interface Team { participantId: string; + /** Single resolved Elo (already a blend of any raw Elo/FPI and futures odds). */ elo: number; - /** Normalized futures win probability (0–1). Used for blending per-game win prob. */ - oddsProb: number; - /** - * Weight used for probabilistic CFP field selection (pre-bracket mode only). - * Derived from oddsProb when available; otherwise softmax on Elo. - */ + /** Weight used for probabilistic CFP field selection (pre-bracket mode only). */ selectionWeight: number; } // ─── Helpers ───────────────────────────────────────────────────────────────── -/** - * Blended win probability for team1 vs team2. - * Falls back to pure Elo when no futures data is present. - */ -function blendedWinProb(team1: Team, team2: Team, oddsWeight: number = ODDS_WEIGHT): number { - const eloProbValue = eloWinProbability(team1.elo, team2.elo); - - if (team1.oddsProb === 0 && team2.oddsProb === 0) { - return eloProbValue; - } - - const oddsSum = team1.oddsProb + team2.oddsProb; - const oddsProbValue = oddsSum > 0 ? team1.oddsProb / oddsSum : 0.5; - - return (1 - oddsWeight) * eloProbValue + oddsWeight * oddsProbValue; +/** Win probability for team1 vs team2 from the single resolved Elo. */ +function gameWinProb(team1: Team, team2: Team): number { + return eloWinProbability(team1.elo, team2.elo); } -function simGame(team1: Team, team2: Team, oddsWeight: number = ODDS_WEIGHT): { winner: Team; loser: Team } { - const p1Wins = Math.random() < blendedWinProb(team1, team2, oddsWeight); +function simGame(team1: Team, team2: Team): { winner: Team; loser: Team } { + const p1Wins = Math.random() < gameWinProb(team1, team2); return p1Wins ? { winner: team1, loser: team2 } : { winner: team2, loser: team1 }; @@ -169,18 +141,18 @@ interface PlacementCounts { * Semifinals: qf1w vs qf2w, qf3w vs qf4w * Championship: sf1w vs sf2w */ -function simulateBracket(teams: Team[], counts: Map, oddsWeight: number = ODDS_WEIGHT): void { +function simulateBracket(teams: Team[], counts: Map): void { // ── First Round (seeds 5–12) ─────────────────────────────────────────────── - const fr1 = simGame(teams[4], teams[11], oddsWeight); // 5 vs 12 - const fr2 = simGame(teams[5], teams[10], oddsWeight); // 6 vs 11 - const fr3 = simGame(teams[6], teams[9], oddsWeight); // 7 vs 10 - const fr4 = simGame(teams[7], teams[8], oddsWeight); // 8 vs 9 + const fr1 = simGame(teams[4], teams[11]); // 5 vs 12 + const fr2 = simGame(teams[5], teams[10]); // 6 vs 11 + const fr3 = simGame(teams[6], teams[9]); // 7 vs 10 + const fr4 = simGame(teams[7], teams[8]); // 8 vs 9 // ── Quarterfinals (seeds 1–4 get byes) ──────────────────────────────────── - const qf1 = simGame(teams[0], fr4.winner, oddsWeight); // 1 vs 8/9 winner - const qf2 = simGame(teams[3], fr1.winner, oddsWeight); // 4 vs 5/12 winner - const qf3 = simGame(teams[2], fr2.winner, oddsWeight); // 3 vs 6/11 winner - const qf4 = simGame(teams[1], fr3.winner, oddsWeight); // 2 vs 7/10 winner + const qf1 = simGame(teams[0], fr4.winner); // 1 vs 8/9 winner + const qf2 = simGame(teams[3], fr1.winner); // 4 vs 5/12 winner + const qf3 = simGame(teams[2], fr2.winner); // 3 vs 6/11 winner + const qf4 = simGame(teams[1], fr3.winner); // 2 vs 7/10 winner const bump = (id: string, key: keyof PlacementCounts) => { const entry = counts.get(id); @@ -193,14 +165,14 @@ function simulateBracket(teams: Team[], counts: Map, od bump(qf4.loser.participantId, "qfLoser"); // ── Semifinals ──────────────────────────────────────────────────────────── - const sf1 = simGame(qf1.winner, qf2.winner, oddsWeight); - const sf2 = simGame(qf3.winner, qf4.winner, oddsWeight); + const sf1 = simGame(qf1.winner, qf2.winner); + const sf2 = simGame(qf3.winner, qf4.winner); bump(sf1.loser.participantId, "sfLoser"); bump(sf2.loser.participantId, "sfLoser"); // ── National Championship ───────────────────────────────────────────────── - const final = simGame(sf1.winner, sf2.winner, oddsWeight); + const final = simGame(sf1.winner, sf2.winner); bump(final.winner.participantId, "champion"); bump(final.loser.participantId, "finalist"); @@ -209,12 +181,8 @@ function simulateBracket(teams: Team[], counts: Map, od // ─── Simulator ──────────────────────────────────────────────────────────────── export class NCAAFootballSimulator implements Simulator { - async simulate(sportsSeasonId: string, context?: SimulationContext): Promise { + async simulate(sportsSeasonId: string): Promise { const db = database(); - // Elo-vs-odds blend weight: configurable via the season input policy, with - // the historical 60/40 split as the default when invoked without context. - const oddsWeight = context?.oddsWeight ?? ODDS_WEIGHT; - // 1. Load all participants for this sports season. const participants = await db .select({ id: schema.seasonParticipants.id }) @@ -228,79 +196,35 @@ export class NCAAFootballSimulator implements Simulator { ); } - // 2. Load Elo/FPI ratings and optional futures odds in a single query. + // 2. Load the resolved single Elo (the input policy already blended any + // raw Elo/FPI and futures odds into sourceElo before the run). const evRows = await db .select({ participantId: schema.seasonParticipantExpectedValues.participantId, sourceElo: schema.seasonParticipantExpectedValues.sourceElo, - sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds, }) .from(schema.seasonParticipantExpectedValues) .where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)); - // Build Elo and raw odds maps in a single pass. const eloFromDb = new Map(); - const rawOddsProbs = new Map(); - for (const row of evRows) { if (row.sourceElo !== null) { eloFromDb.set(row.participantId, row.sourceElo); } - if (row.sourceOdds !== null) { - rawOddsProbs.set(row.participantId, convertAmericanOddsToProbability(row.sourceOdds)); - } } - // 3. Build normalized odds probability map (vig removed). - const normalizedOddsMap = new Map(); - - if (rawOddsProbs.size > 0) { - const rawSum = [...rawOddsProbs.values()].reduce((a, b) => a + b, 0); - for (const [id, prob] of rawOddsProbs) { - normalizedOddsMap.set(id, rawSum > 0 ? prob / rawSum : 0); - } - - // Backfill Elo from futures for any team missing sourceElo. - if (eloFromDb.size < participants.length) { - const oddsInput = evRows - .filter((r) => r.sourceOdds !== null && !eloFromDb.has(r.participantId)) - .map((r) => ({ participantId: r.participantId, odds: r.sourceOdds ?? 0 })); - - if (oddsInput.length > 0) { - const oddsEloMap = convertFuturesToElo(oddsInput, "american"); - for (const [id, elo] of oddsEloMap) { - eloFromDb.set(id, elo); - } - } - } - } - - // 4. Build team list with Elo, oddsProb, and selectionWeight. - const hasOdds = normalizedOddsMap.size > 0; - + // 3. Build team list; field-selection weight is a softmax on the resolved Elo. const allTeams: Team[] = participants.map((p) => ({ participantId: p.id, elo: eloFromDb.get(p.id) ?? 1500, - oddsProb: normalizedOddsMap.get(p.id) ?? 0, selectionWeight: 0, // computed below })); - if (hasOdds) { - // Selection weight = normalized championship implied probability. - // This encodes both "probability of making the field" and "strength once there." - for (const team of allTeams) { - team.selectionWeight = normalizedOddsMap.get(team.participantId) ?? 0; - } - } else { - // No odds: softmax on Elo so top-rated teams are strongly favoured. - const eloValues = allTeams.map((t) => t.elo); - const maxElo = Math.max(...eloValues); - // Subtract max for numerical stability before exp(). - const expWeights = allTeams.map((t) => Math.exp((t.elo - maxElo) / SELECTION_TEMP)); - const expSum = expWeights.reduce((a, b) => a + b, 0); - for (let i = 0; i < allTeams.length; i++) { - allTeams[i].selectionWeight = expWeights[i] / expSum; - } + const maxElo = Math.max(...allTeams.map((t) => t.elo)); + const expWeights = allTeams.map((t) => Math.exp((t.elo - maxElo) / SELECTION_TEMP)); + const expSum = expWeights.reduce((a, b) => a + b, 0); + for (let i = 0; i < allTeams.length; i++) { + allTeams[i].selectionWeight = expSum > 0 ? expWeights[i] / expSum : 1 / allTeams.length; } const preBracketMode = participants.length > BRACKET_SIZE; @@ -321,7 +245,7 @@ export class NCAAFootballSimulator implements Simulator { const field = preBracketMode ? sampleBracketField(allTeams, BRACKET_SIZE) : (deterministicField ?? []); - simulateBracket(field, counts, oddsWeight); + simulateBracket(field, counts); } // 7. Convert counts to probability distributions. diff --git a/app/services/simulations/runner.ts b/app/services/simulations/runner.ts index 71d9e98..beb8688 100644 --- a/app/services/simulations/runner.ts +++ b/app/services/simulations/runner.ts @@ -16,7 +16,6 @@ import { prepareSimulatorInputsForRun, validateSimulatorReadiness, } from "~/models/simulator"; -import { getSimulatorInputPolicy } from "~/services/simulations/input-policy"; const ZERO_PROBS = { probFirst: 0, @@ -106,10 +105,7 @@ export async function runSportsSeasonSimulation( try { const simulator = getSimulator(simulatorConfig.simulatorType); - const results = await simulator.simulate(sportsSeasonId, { - config: simulatorConfig.config, - oddsWeight: getSimulatorInputPolicy(simulatorConfig.config).oddsWeight, - }); + const results = await simulator.simulate(sportsSeasonId); if (results.length === 0) { throw new Error("Simulation returned no results. Check that participants have simulator input data."); diff --git a/app/services/simulations/types.ts b/app/services/simulations/types.ts index caf744a..d2b3ed0 100644 --- a/app/services/simulations/types.ts +++ b/app/services/simulations/types.ts @@ -27,22 +27,13 @@ export interface SimulationResult { source: string; } -/** - * Optional run-time context handed to a simulator by the runner. Carries the - * resolved season config and input policy so simulators can honor configurable - * knobs (e.g. the Elo-vs-odds blend weight) without re-querying. It is optional - * so simulators can still be invoked directly (e.g. in unit tests) with just a - * season id, in which case they fall back to their built-in defaults. - */ -export interface SimulationContext { - config: Record; - /** Weight (0–1) for the futures-odds signal in blended simulators. */ - oddsWeight: number; -} - /** * Common interface all sport simulators must implement. + * + * Simulators consume a single resolved Elo per participant (already blended from + * its sources — raw Elo, projections, futures odds — by the input policy and + * persisted before the run), so they need only the season id. */ export interface Simulator { - simulate(sportsSeasonId: string, context?: SimulationContext): Promise; + simulate(sportsSeasonId: string): Promise; } diff --git a/app/services/simulations/ucl-simulator.ts b/app/services/simulations/ucl-simulator.ts index bcf494e..6836b3f 100644 --- a/app/services/simulations/ucl-simulator.ts +++ b/app/services/simulations/ucl-simulator.ts @@ -37,20 +37,13 @@ import { database } from "~/database/context"; import { eq, and } from "drizzle-orm"; import * as schema from "~/database/schema"; -import { convertFuturesToElo, eloWinProbability } from "~/services/probability-engine"; -import type { Simulator, SimulationResult, SimulationContext } from "./types"; +import { eloWinProbability } from "~/services/probability-engine"; +import type { Simulator, SimulationResult } from "./types"; // ─── Simulation parameters ──────────────────────────────────────────────────── const NUM_SIMULATIONS = 50000; -/** - * Weight given to the Elo-based win probability (derived from futures). - * Remaining weight (1 - ELO_WEIGHT) goes to the normalized futures odds component. - */ -const ELO_WEIGHT = 0.7; -const ODDS_WEIGHT = 1 - ELO_WEIGHT; - // ─── Odds helper ────────────────────────────────────────────────────────────── /** Convert American odds to implied probability (with vig). Exported for testing. */ @@ -62,12 +55,8 @@ export function americanToImpliedProb(odds: number): number { // ─── Simulator ──────────────────────────────────────────────────────────────── export class UCLSimulator implements Simulator { - async simulate(sportsSeasonId: string, context?: SimulationContext): Promise { + async simulate(sportsSeasonId: string): Promise { const db = database(); - // Elo-vs-odds blend weight: configurable via the season input policy, with - // the historical 70/30 split as the default when invoked without context. - const oddsWeight = context?.oddsWeight ?? ODDS_WEIGHT; - const eloWeight = 1 - oddsWeight; // 1. Find the bracket scoring event for this sports season. // UCL has exactly one playoff_game event per season. @@ -146,52 +135,26 @@ export class UCLSimulator implements Simulator { participantIds.push(m.participant1Id ?? "", m.participant2Id ?? ""); } const participantSet = new Set(participantIds); - const fallbackProb = 1 / participantIds.length; - // 5. Load futures odds from participantExpectedValues. + // 5. Load the resolved single Elo (input policy already blended any raw Elo + // and futures odds into sourceElo before the run). const evRows = await db .select({ participantId: schema.seasonParticipantExpectedValues.participantId, - sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds, + sourceElo: schema.seasonParticipantExpectedValues.sourceElo, }) .from(schema.seasonParticipantExpectedValues) .where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)); - const evMap = new Map(evRows.map((r) => [r.participantId, r])); - - // 6. Build Elo map via the futures → Elo pipeline. - const hasOdds = evRows.some( - (r) => r.sourceOdds !== null && participantSet.has(r.participantId) - ); - - let eloMap: Map; - if (hasOdds) { - const oddsInput = evRows - .filter((r) => r.sourceOdds !== null && participantSet.has(r.participantId)) - .map((r) => ({ participantId: r.participantId, odds: r.sourceOdds ?? 0 })); - eloMap = convertFuturesToElo(oddsInput, "american"); - } else { - // No odds stored — all teams get equal Elo (coin-flip bracket) - eloMap = new Map(participantIds.map((id) => [id, 1500])); + // 6. Build the Elo map; teams without a resolved Elo fall back to 1500. + const eloMap = new Map(participantIds.map((id) => [id, 1500])); + for (const r of evRows) { + if (r.sourceElo !== null && r.sourceElo !== undefined && participantSet.has(r.participantId)) { + eloMap.set(r.participantId, r.sourceElo); + } } - // 7. Build normalized futures win-probability map (vig removed). - // Used as the second signal in the blended per-match probability. - const rawProbs = new Map(); - for (const id of participantIds) { - const ev = evMap.get(id); - rawProbs.set( - id, - ev !== undefined && ev.sourceOdds !== null && ev.sourceOdds !== undefined ? americanToImpliedProb(ev.sourceOdds) : fallbackProb - ); - } - const rawSum = [...rawProbs.values()].reduce((a, b) => a + b, 0); - const normalizedOddsMap = new Map(); - for (const [id, prob] of rawProbs) { - normalizedOddsMap.set(id, prob / rawSum); - } - - // 8. Build per-round lookup maps keyed by matchNumber for O(1) access in the hot loop. + // 7. Build per-round lookup maps keyed by matchNumber for O(1) access in the hot loop. const r16ByNum = new Map(r16Matches.map((m) => [m.matchNumber, m])); const qfByNum = new Map(qfMatches.map((m) => [m.matchNumber, m])); const sfByNum = new Map(sfMatches.map((m) => [m.matchNumber, m])); @@ -199,17 +162,11 @@ export class UCLSimulator implements Simulator { // ─── Helpers ────────────────────────────────────────────────────────────── - /** Blended Elo + normalized-odds win probability for p1 vs p2. */ + /** Pure-Elo win probability for p1 vs p2 (single resolved Elo per team). */ const blendedWinProb = (p1: string, p2: string): number => { const elo1 = eloMap.get(p1) ?? 1500; const elo2 = eloMap.get(p2) ?? 1500; - const eloProb = eloWinProbability(elo1, elo2); - - const o1 = normalizedOddsMap.get(p1) ?? fallbackProb; - const o2 = normalizedOddsMap.get(p2) ?? fallbackProb; - const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5; - - return eloWeight * eloProb + oddsWeight * oddsProb; + return eloWinProbability(elo1, elo2); }; const simMatch = (p1: string, p2: string): { winner: string; loser: string } => { diff --git a/app/services/simulations/world-cup-simulator.ts b/app/services/simulations/world-cup-simulator.ts index ff0baee..2927eb1 100644 --- a/app/services/simulations/world-cup-simulator.ts +++ b/app/services/simulations/world-cup-simulator.ts @@ -38,13 +38,13 @@ import { database } from "~/database/context"; import { eq, and } from "drizzle-orm"; import * as schema from "~/database/schema"; -import { convertFuturesToElo, eloWinProbability } from "~/services/probability-engine"; +import { eloWinProbability } from "~/services/probability-engine"; import { simulateEloSoccerMatch } from "./soccer-helpers"; import { normalizeSimulationResultColumns } from "./simulation-probabilities"; import { logger } from "~/lib/logger"; import { FIFA_48 } from "~/lib/bracket-templates"; import { FIFA_2026_R32_TEMPLATE, assignThirdPlaceSlots } from "~/lib/fifa-2026-bracket"; -import type { Simulator, SimulationResult, SimulationContext } from "./types"; +import type { Simulator, SimulationResult } from "./types"; // ─── Name normalisation (same logic as elo-ratings bulk-import fuzzy match) ── @@ -55,8 +55,6 @@ function normalizeTeamName(name: string): string { // ─── Parameters ────────────────────────────────────────────────────────────── const NUM_SIMULATIONS = 10_000; -const ELO_WEIGHT = 0.7; -const ODDS_WEIGHT = 1 - ELO_WEIGHT; /** Base draw rate when teams are evenly matched. Decays with Elo difference. */ const BASE_DRAW_RATE = 0.28; @@ -274,14 +272,10 @@ function sortTeams(a: TeamStats, b: TeamStats): number { function simKnockout( teamA: string, teamB: string, - eloFn: (id: string) => number, - normalizedProb: (id: string) => number, - oddsWeight: number = ODDS_WEIGHT + eloFn: (id: string) => number ): { winner: string; loser: string } { - const eloProb = eloWinProbability(eloFn(teamA), eloFn(teamB)); - const oddsProb = normalizedProb(teamA); - const blended = (1 - oddsWeight) * eloProb + oddsWeight * (0.5 + (oddsProb - 0.5)); - const winner = Math.random() < blended ? teamA : teamB; + const winProb = eloWinProbability(eloFn(teamA), eloFn(teamB)); + const winner = Math.random() < winProb ? teamA : teamB; return { winner, loser: winner === teamA ? teamB : teamA }; } @@ -319,9 +313,7 @@ function runKnockoutRoundByNumber( roundName: string, matches: Map, completed: Map, - eloFn: (id: string) => number, - normalizedProb: (id: string) => number, - oddsWeight: number = ODDS_WEIGHT + eloFn: (id: string) => number ): { winnersByNumber: Map; losersByNumber: Map; @@ -350,7 +342,7 @@ function runKnockoutRoundByNumber( winnerId = fixed.winnerId; loserId = fixed.loserId; } else if (m.p1 && m.p2) { - const result = simKnockout(m.p1, m.p2, eloFn, normalizedProb, oddsWeight); + const result = simKnockout(m.p1, m.p2, eloFn); winnerId = result.winner; loserId = result.loser; } else if (m.p1 || m.p2) { @@ -448,10 +440,7 @@ export class WorldCupSimulator implements Simulator { this.numSimulations = numSimulations; } - async simulate(sportsSeasonId: string, context?: SimulationContext): Promise { - // Elo-vs-odds blend weight for knockout matches: configurable via the season - // input policy, defaulting to the historical 70/30 split without context. - const oddsWeight = context?.oddsWeight ?? ODDS_WEIGHT; + async simulate(sportsSeasonId: string): Promise { const db = database(); // 1. Load all participants for this season @@ -466,20 +455,19 @@ export class WorldCupSimulator implements Simulator { const participantIds = participantRows.map((p) => p.id); const participantNames = new Map(participantRows.map((p) => [p.id, p.name])); - // 2. Load stored ratings (sourceElo from Elo page, sourceOdds from futures page) + // 2. Load the resolved single Elo (the input policy already blended any raw + // Elo and futures odds into sourceElo before the run). const evRows = await db .select({ participantId: schema.seasonParticipantExpectedValues.participantId, - sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds, sourceElo: schema.seasonParticipantExpectedValues.sourceElo, }) .from(schema.seasonParticipantExpectedValues) .where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)); - const evMap = new Map(evRows.map((r) => [r.participantId, r])); const participantSet = new Set(participantIds); - // 3. Build Elo map — priority: sourceElo (direct) > sourceOdds (converted) > hardcoded + // 3. Build the Elo map — resolved sourceElo, else hardcoded national-team Elo. const sourceEloMap = new Map(); for (const r of evRows) { if (r.sourceElo !== null && r.sourceElo !== undefined && participantSet.has(r.participantId)) { @@ -487,47 +475,13 @@ export class WorldCupSimulator implements Simulator { } } - const hasOdds = evRows.some( - (r) => r.sourceOdds !== null && participantSet.has(r.participantId) - ); - - let eloFromOdds: Map; - if (hasOdds) { - const oddsInput = evRows - .filter((r) => r.sourceOdds !== null && participantSet.has(r.participantId)) - .map((r) => ({ participantId: r.participantId, odds: r.sourceOdds ?? 0 })); - eloFromOdds = convertFuturesToElo(oddsInput, "american"); - } else { - eloFromOdds = new Map(); - } - const eloFn = (id: string): number => { if (sourceEloMap.has(id)) return sourceEloMap.get(id) ?? 1500; - if (eloFromOdds.has(id)) return eloFromOdds.get(id) ?? 1500; const name = participantNames.get(id) ?? ""; return getTeamElo(name, 1500); }; - // 4. Build normalized futures win-probability map (vig removed) - const rawProbs = new Map(); - for (const id of participantIds) { - const ev = evMap.get(id); - if (ev?.sourceOdds !== null && ev?.sourceOdds !== undefined) { - rawProbs.set(id, Math.abs(ev.sourceOdds) > 0 - ? ev.sourceOdds > 0 - ? 100 / (ev.sourceOdds + 100) - : Math.abs(ev.sourceOdds) / (Math.abs(ev.sourceOdds) + 100) - : 1 / participantIds.length); - } else { - rawProbs.set(id, 1 / participantIds.length); - } - } - - const totalRawProb = [...rawProbs.values()].reduce((s, p) => s + p, 0); - const normalizedProb = (id: string): number => - totalRawProb > 0 ? (rawProbs.get(id) ?? 0) / totalRawProb : 1 / participantIds.length; - - // 5. Resolve the bracket scoring event. Prefer a fifa_48 playoff event; if + // 4. Resolve the bracket scoring event. Prefer a fifa_48 playoff event; if // several playoff_game events exist, take the most recent so a re-created // event wins over a stale one. const playoffEvents = await db.query.scoringEvents.findMany({ @@ -719,10 +673,10 @@ export class WorldCupSimulator implements Simulator { // ── Knockout rounds ────────────────────────────────────────── // R32 → R16 → QF → SF → Third Place Game + Final, advancing via the // canonical ceil(n/2) tree. Completed matches lock in real results. - const r32 = runKnockoutRoundByNumber(R32, buildR32(), completedByRoundAndNumber, eloFn, normalizedProb, oddsWeight); - const r16 = runKnockoutRoundByNumber(R16, r32.nextRound, completedByRoundAndNumber, eloFn, normalizedProb, oddsWeight); - const qf = runKnockoutRoundByNumber(QF, r16.nextRound, completedByRoundAndNumber, eloFn, normalizedProb, oddsWeight); - const sf = runKnockoutRoundByNumber(SF, qf.nextRound, completedByRoundAndNumber, eloFn, normalizedProb, oddsWeight); + const r32 = runKnockoutRoundByNumber(R32, buildR32(), completedByRoundAndNumber, eloFn); + const r16 = runKnockoutRoundByNumber(R16, r32.nextRound, completedByRoundAndNumber, eloFn); + const qf = runKnockoutRoundByNumber(QF, r16.nextRound, completedByRoundAndNumber, eloFn); + const sf = runKnockoutRoundByNumber(SF, qf.nextRound, completedByRoundAndNumber, eloFn); // QF losers (placements 5–8) for (const id of qf.losersByNumber.values()) { @@ -739,7 +693,7 @@ export class WorldCupSimulator implements Simulator { counts.fourthPlace.set(fixed3pg.loserId, (counts.fourthPlace.get(fixed3pg.loserId) ?? 0) + 1); } else { const { winner: thirdWinner, loser: thirdLoser } = simKnockout( - sf1Loser, sf2Loser, eloFn, normalizedProb, oddsWeight + sf1Loser, sf2Loser, eloFn ); counts.thirdPlace.set(thirdWinner, (counts.thirdPlace.get(thirdWinner) ?? 0) + 1); counts.fourthPlace.set(thirdLoser, (counts.fourthPlace.get(thirdLoser) ?? 0) + 1); @@ -756,7 +710,7 @@ export class WorldCupSimulator implements Simulator { counts.runnerUp.set(fixedFinal.loserId, (counts.runnerUp.get(fixedFinal.loserId) ?? 0) + 1); } else { const { winner: champion, loser: runnerUp } = simKnockout( - sfWinner1, sfWinner2, eloFn, normalizedProb, oddsWeight + sfWinner1, sfWinner2, eloFn ); counts.champion.set(champion, (counts.champion.get(champion) ?? 0) + 1); counts.runnerUp.set(runnerUp, (counts.runnerUp.get(runnerUp) ?? 0) + 1); -- 2.45.3 From 8fff8d2715046130af0bc3f9558522c9d81b36e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 04:09:48 +0000 Subject: [PATCH 3/4] Fix: don't clamp directly entered Elo/rating in the blend resolver Code review caught a regression in the single-Elo blend: resolveSourceElos / resolveRatings clamped every resolved value to [eloMin, eloMax] / [ratingMin, ratingMax], including a directly entered base Elo. The original resolver only clamped derived values (projections, odds, fallbacks) and passed direct inputs through untouched. With the default 1900 Elo ceiling this truncated manual Elos for sports that enter higher ratings (e.g. snooker ~2450), collapsing their spread. Clamp only odds-derived / blended / fallback values now; direct Elo and rating inputs pass through as entered. Adds a regression test, and fixes two comments that still referenced the removed `sourceEloPriority` field (now `oddsWeight`). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QhNeB7gN6VKene7sdbBVQT --- app/models/participant-expected-value.ts | 8 ++--- app/models/simulator.ts | 8 ++--- .../__tests__/input-policy.test.ts | 17 ++++++++++ app/services/simulations/input-policy.ts | 31 ++++++++++++------- 4 files changed, 45 insertions(+), 19 deletions(-) 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, }); } -- 2.45.3 From a77485d5a57713d986d568e580b0930532cde8bc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 05:10:35 +0000 Subject: [PATCH 4/4] Cleanup: dedupe blend logic, single-home oddsWeight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address code-review cleanup notes: - Extract the base/odds blend ladder (duplicated between resolveSourceElos and resolveRatings) into one generic `blendBaseAndOdds` helper. - Move the futures blend weight to a single home: oddsWeight now lives only under inputPolicy. Manifest per-profile defaults moved from top-level config into inputPolicy.oddsWeight, and getSimulatorInputPolicy no longer reads a legacy top-level config.oddsWeight. Drops the now-dead mlb rDiffWeight config knob. - Clarify the college_hockey oddsWeight 0 rationale (it disables the central blend so its internal odds blend isn't double-counted, while still letting odds resolve an Elo when they're the only source — a separate centralBlend flag would wrongly drop that sole-source path and break readiness). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QhNeB7gN6VKene7sdbBVQT --- .../__tests__/input-policy.test.ts | 7 +- app/services/simulations/input-policy.ts | 117 ++++++++---------- app/services/simulations/manifest.ts | 18 +-- 3 files changed, 65 insertions(+), 77 deletions(-) diff --git a/app/services/simulations/__tests__/input-policy.test.ts b/app/services/simulations/__tests__/input-policy.test.ts index 2927145..152f24c 100644 --- a/app/services/simulations/__tests__/input-policy.test.ts +++ b/app/services/simulations/__tests__/input-policy.test.ts @@ -280,9 +280,10 @@ describe("simulator input policy", () => { expect(defaults.baseEloPriority).toEqual(DEFAULT_BASE_ELO_PRIORITY); expect(defaults.oddsWeight).toBe(0.3); - // oddsWeight can be sourced from the top-level config or the input policy, - // and is clamped to [0, 1]; junk base-priority entries are dropped. - expect(getSimulatorInputPolicy({ oddsWeight: 0.6 }).oddsWeight).toBe(0.6); + // oddsWeight lives under inputPolicy and is clamped to [0, 1]; a top-level + // config.oddsWeight is ignored (single home); junk base-priority entries drop. + expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: 0.6 } }).oddsWeight).toBe(0.6); + expect(getSimulatorInputPolicy({ oddsWeight: 0.6 }).oddsWeight).toBe(0.3); expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: 5 } }).oddsWeight).toBe(1); expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: -2 } }).oddsWeight).toBe(0); expect( diff --git a/app/services/simulations/input-policy.ts b/app/services/simulations/input-policy.ts index c6800ee..8e08b89 100644 --- a/app/services/simulations/input-policy.ts +++ b/app/services/simulations/input-policy.ts @@ -142,13 +142,8 @@ export function getSimulatorInputPolicy(config: Record): Simula ? ratingStrategy : "block", baseEloPriority: parseBaseEloPriority(rawPolicy.baseEloPriority, DEFAULT_POLICY.baseEloPriority), - // Allow oddsWeight to live either in inputPolicy (the unified knob) or at the - // top level of config (legacy per-profile defaultConfig), preferring the policy. - oddsWeight: clamp( - optionalNumber(rawPolicy.oddsWeight, optionalNumber(config.oddsWeight, DEFAULT_POLICY.oddsWeight)), - 0, - 1 - ), + // The futures blend weight lives only under inputPolicy (single home). + oddsWeight: clamp(optionalNumber(rawPolicy.oddsWeight, DEFAULT_POLICY.oddsWeight), 0, 1), fallbackElo: optionalNumber(rawPolicy.fallbackElo, DEFAULT_POLICY.fallbackElo), fallbackEloDelta: optionalNumber(rawPolicy.fallbackEloDelta, DEFAULT_POLICY.fallbackEloDelta), eloMin: optionalNumber(rawPolicy.eloMin, DEFAULT_POLICY.eloMin), @@ -173,6 +168,32 @@ export function ratingRequirementLabel(profile: SimulatorManifestProfile): strin return ["rating", ...alternatives.map(simulatorInputLabel)].join(" / "); } +/** + * Combine a participant's base strength value and its odds-derived value into the + * single value that feeds the simulator, by `oddsWeight`: 0 = base only, + * 1 = odds only, in between = weighted blend. Odds-derived and blended results + * pass through `clampDerived` (they are mapped onto the configured band by + * design); a base value is returned untouched (a directly entered Elo/rating is + * trusted as-is, a projection-derived Elo was already clamped upstream). Returns + * undefined when neither source is present (the caller falls back to its + * missing-input strategy). Shared by Elo and rating resolution. + */ +function blendBaseAndOdds( + base: { value: number; method: M } | undefined, + odds: number | undefined, + oddsWeight: number, + clampDerived: (value: number) => number +): { value: number; method: M | "sourceOdds" | "blend" } | undefined { + if (base !== undefined && odds !== undefined) { + if (oddsWeight <= 0) return { value: base.value, method: base.method }; + if (oddsWeight >= 1) return { value: clampDerived(odds), method: "sourceOdds" }; + return { value: clampDerived((1 - oddsWeight) * base.value + oddsWeight * odds), method: "blend" }; + } + if (base !== undefined) return { value: base.value, method: base.method }; + if (odds !== undefined) return { value: clampDerived(odds), method: "sourceOdds" }; + return undefined; +} + export function resolveSourceElos( inputs: ParticipantInputForPolicy[], profile: Pick, @@ -240,42 +261,23 @@ 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; + // Directly entered Elos (some sports use ratings above the default ceiling, + // e.g. snooker ~2450) pass through unclamped; only derived values are + // clamped onto [eloMin, eloMax]. 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 sourceElo: number; - let method: ResolvedSourceElo["method"]; - if (base !== undefined && odds !== undefined) { - if (w <= 0) { - sourceElo = base.elo; - method = base.method; - } else if (w >= 1) { - sourceElo = clampDerived(odds); - method = "sourceOdds"; - } else { - sourceElo = clampDerived((1 - w) * base.elo + w * odds); - method = "blend"; - } - } else if (base !== undefined) { - sourceElo = base.elo; - method = base.method; - } else if (odds !== undefined) { - sourceElo = clampDerived(odds); - method = "sourceOdds"; - } else { - continue; - } + const blended = blendBaseAndOdds( + base ? { value: base.elo, method: base.method } : undefined, + oddsElo.get(input.participantId), + policy.oddsWeight, + clampDerived + ); + if (!blended) continue; resolved.set(input.participantId, { participantId: input.participantId, - sourceElo, - method, + sourceElo: blended.value, + method: blended.method, }); } @@ -349,39 +351,22 @@ 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; + // As with Elo: a directly entered rating passes through untouched; only + // derived values are clamped onto the rating band. 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); - let rating: number; - let method: ResolvedRating["method"]; - if (base !== undefined && odds !== undefined) { - if (w <= 0) { - rating = base; - method = "direct"; - } else if (w >= 1) { - rating = clampDerived(odds); - method = "sourceOdds"; - } else { - rating = clampDerived((1 - w) * base + w * odds); - method = "blend"; - } - } else if (base !== undefined) { - rating = base; - method = "direct"; - } else if (odds !== undefined) { - rating = clampDerived(odds); - method = "sourceOdds"; - } else { - continue; - } + const blended = blendBaseAndOdds( + base !== undefined ? { value: base, method: "direct" as const } : undefined, + oddsRating.get(input.participantId), + policy.oddsWeight, + clampDerived + ); + if (!blended) continue; resolved.set(input.participantId, { participantId: input.participantId, - rating, - method, + rating: blended.value, + method: blended.method, }); } diff --git a/app/services/simulations/manifest.ts b/app/services/simulations/manifest.ts index 4a18e68..8d9c862 100644 --- a/app/services/simulations/manifest.ts +++ b/app/services/simulations/manifest.ts @@ -66,7 +66,7 @@ const PROFILES: Record