From dc4efe87cf7eb7d87b70f459580a9f024539cbfb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 22:11:32 +0000 Subject: [PATCH] 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);