From ee099c64cd8d8147b37239a89fa20e46d59d3eb1 Mon Sep 17 00:00:00 2001 From: chrisp Date: Fri, 26 Jun 2026 05:16:54 +0000 Subject: [PATCH] claude/clever-archimedes-dvye42 (#110) Co-authored-by: Claude Reviewed-on: https://forge.brackt.com/chrisp/brackt/pulls/110 --- .../__tests__/futures-odds-simulator.test.ts | 46 ++-- app/models/participant-expected-value.ts | 10 +- app/models/simulator.ts | 108 +++++---- app/routes/admin.simulators.tsx | 20 +- .../admin.sports-seasons.$id.simulator.tsx | 18 +- .../__tests__/input-policy.test.ts | 114 ++++++++- .../simulations/__tests__/manifest.test.ts | 12 + .../__tests__/ncaa-football-simulator.test.ts | 5 +- .../simulations/__tests__/runner.test.ts | 9 +- app/services/simulations/input-policy.ts | 227 +++++++++++++----- app/services/simulations/manifest.ts | 20 +- app/services/simulations/mlb-simulator.ts | 49 +--- .../simulations/ncaa-football-simulator.ts | 105 ++------ app/services/simulations/types.ts | 4 + app/services/simulations/ucl-simulator.ts | 65 +---- .../simulations/world-cup-simulator.ts | 73 ++---- 16 files changed, 485 insertions(+), 400 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..132fcce 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: 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 { // 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..3455a72 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,7 @@ import { type SimulatorManifestProfile, } from "~/services/simulations/manifest"; import { + getSimulatorInputPolicy, ratingRequirementLabel, resolveRatings, resolveSourceElos, @@ -82,6 +83,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; + /** Weight (0–1) the season's policy gives futures odds when blending into Elo. */ + oddsWeight: number; } function parseDecimal(value: string | null | undefined): number | null { @@ -125,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]); @@ -359,57 +366,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. 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( + 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 +651,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 +675,8 @@ 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; @@ -245,7 +251,14 @@ export default function AdminSimulators({ loaderData }: Route.ComponentProps) { - {sim.participantInputCount}/{sim.participantCount} +
{sim.participantInputCount}/{sim.participantCount}
+ {sim.supportsFuturesOdds && ( + 0 ? "default" : "outline"} className="mt-1 text-xs font-normal"> + {sim.oddsParticipantCount > 0 + ? `Futures: ${sim.oddsParticipantCount} · ${futuresBlendLabel(sim.oddsWeight)}` + : "No futures odds"} + + )}
{sim.lastSimulatedDate ?? "Never"} @@ -258,6 +271,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..c9f4417 100644 --- a/app/routes/admin.sports-seasons.$id.simulator.tsx +++ b/app/routes/admin.sports-seasons.$id.simulator.tsx @@ -25,7 +25,11 @@ 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, + 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 +209,8 @@ export async function action({ request, params }: Route.ActionArgs): Promise
+
+ + +

+ 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 3d8a770..152f24c 100644 --- a/app/services/simulations/__tests__/input-policy.test.ts +++ b/app/services/simulations/__tests__/input-policy.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { resolveRatings, resolveSourceElos } from "../input-policy"; +import { + getSimulatorInputPolicy, + resolveRatings, + resolveSourceElos, + DEFAULT_BASE_ELO_PRIORITY, +} from "../input-policy"; import type { SimulatorManifestProfile } from "../manifest"; const profile = { @@ -183,6 +188,113 @@ 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 }, + { participantId: "longshot", sourceElo: 1200, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null }, + ]; + + // 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" }); + + // 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("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 }, + ]; + + // oddsWeight 0: projectedWins is the base. + expect(resolveSourceElos(inputs, profile, { seasonGames: 82, inputPolicy: { oddsWeight: 0 } }).get("team-1")?.method) + .toBe("projectedWins"); + + // 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("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 }, + ]; + + // oddsWeight 0: direct rating wins. + expect(resolveRatings(inputs, ratingProfile, { inputPolicy: { ratingMin: -10, ratingMax: 35, oddsWeight: 0 } }).get("favorite")) + .toMatchObject({ rating: 30, method: "direct" }); + + // oddsWeight 1: odds override the rating. + expect( + resolveRatings(inputs, ratingProfile, { + 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 base Elo priority and odds weight", () => { + const defaults = getSimulatorInputPolicy({}); + expect(defaults.baseEloPriority).toEqual(DEFAULT_BASE_ELO_PRIORITY); + expect(defaults.oddsWeight).toBe(0.3); + + // 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( + getSimulatorInputPolicy({ inputPolicy: { baseEloPriority: ["projectedWins", "sourceOdds", "sourceElo"] } }).baseEloPriority + ).toEqual(["projectedWins", "sourceElo"]); + // An empty/all-invalid priority falls back to the default. + expect(getSimulatorInputPolicy({ inputPolicy: { baseEloPriority: ["nope"] } }).baseEloPriority).toEqual( + DEFAULT_BASE_ELO_PRIORITY + ); + }); + 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..be36fa7 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 } from "../input-policy"; import { assertRegistrySchemaDriftFree } from "~/models/simulator"; describe("simulator manifest", () => { @@ -40,6 +41,17 @@ describe("simulator manifest", () => { } }); + 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); + 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", () => { 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..a63cce3 100644 --- a/app/services/simulations/__tests__/ncaa-football-simulator.test.ts +++ b/app/services/simulations/__tests__/ncaa-football-simulator.test.ts @@ -143,8 +143,9 @@ 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); 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..8e08b89 100644 --- a/app/services/simulations/input-policy.ts +++ b/app/services/simulations/input-policy.ts @@ -5,9 +5,39 @@ import { logger } from "~/lib/logger"; export type MissingEloStrategy = "block" | "fallbackElo" | "averageKnown" | "worstKnownMinus"; export type MissingRatingStrategy = "block" | "fallbackRating" | "averageKnown" | "worstKnownMinus"; +/** + * "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 BaseEloKey = "sourceElo" | "projectedWins" | "projectedTablePoints"; + +export const BASE_ELO_KEYS: readonly BaseEloKey[] = [ + "sourceElo", + "projectedWins", + "projectedTablePoints", +]; + +/** 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; + /** Order for choosing the base strength Elo from its substitutable sources. */ + baseEloPriority: BaseEloKey[]; + /** + * 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; fallbackEloDelta: number; eloMin: number; @@ -30,18 +60,22 @@ 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; + const DEFAULT_POLICY: SimulatorInputPolicy = { missingEloStrategy: "block", missingRatingStrategy: "block", + baseEloPriority: DEFAULT_BASE_ELO_PRIORITY, + oddsWeight: DEFAULT_ODDS_WEIGHT, fallbackElo: 1400, fallbackEloDelta: 25, eloMin: 1100, @@ -80,6 +114,19 @@ function projectionToElo( return clamp(Math.round(elo), policy.eloMin, policy.eloMax); } +function parseBaseEloPriority(value: unknown, fallback: BaseEloKey[]): BaseEloKey[] { + if (!Array.isArray(value)) return fallback; + const seen = new Set(); + const parsed: BaseEloKey[] = []; + for (const entry of value) { + 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; +} + export function getSimulatorInputPolicy(config: Record): SimulatorInputPolicy { const rawPolicy = isRecord(config.inputPolicy) ? config.inputPolicy : {}; const eloStrategy = rawPolicy.missingEloStrategy; @@ -94,6 +141,9 @@ export function getSimulatorInputPolicy(config: Record): Simula ratingStrategy === "fallbackRating" || ratingStrategy === "averageKnown" || ratingStrategy === "worstKnownMinus" ? ratingStrategy : "block", + baseEloPriority: parseBaseEloPriority(rawPolicy.baseEloPriority, DEFAULT_POLICY.baseEloPriority), + // 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), @@ -118,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, @@ -128,68 +204,83 @@ 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", - }); - } - } - - 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", - }); + // 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 (baseElo.has(input.participantId)) continue; + if (input.sourceElo !== null && input.sourceElo !== undefined) { + 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 (baseElo.has(input.participantId) || input.projectedWins === null || input.projectedWins === undefined) continue; + baseElo.set(input.participantId, { + elo: 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 (baseElo.has(input.participantId) || input.projectedTablePoints === null || input.projectedTablePoints === undefined) continue; + baseElo.set(input.participantId, { + elo: projectionToElo(input.projectedTablePoints, maxPoints, parityFactor, policy), + method: "projectedTablePoints", + }); + } } } + // 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")) { - // 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) + .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 — falling through to missing-Elo strategy.` + `convertFuturesToElo requires at least 2 to derive a meaningful Elo spread — ignoring the lone odds value.` ); } 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", - }); + for (const [participantId, elo] of convertFuturesToElo(oddsInputs)) { + oddsElo.set(participantId, elo); } } } + // 3. Blend base + odds into the single Elo that feeds the simulator. + // 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 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: blended.value, + method: blended.method, + }); + } + const knownElos = [...resolved.values()].map((value) => value.sourceElo); const averageKnown = knownElos.length > 0 ? knownElos.reduce((sum, elo) => sum + elo, 0) / knownElos.length @@ -227,24 +318,25 @@ export function resolveRatings( const resolved = new Map(); const alternatives = new Set(profile.derivableInputs?.rating ?? []); + // 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) { - resolved.set(input.participantId, { - participantId: input.participantId, - rating: input.rating, - method: "direct", - }); + baseRating.set(input.participantId, input.rating); } } + const oddsRating = new Map(); if (alternatives.has("sourceOdds")) { const oddsInputs = inputs - .filter((input) => !resolved.has(input.participantId) && input.sourceOdds !== null && input.sourceOdds !== undefined) + .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 — falling through to missing-rating strategy.` + `convertFuturesToElo requires at least 2 to derive a meaningful rating — ignoring the lone odds value.` ); } if (oddsInputs.length >= 2) { @@ -254,15 +346,30 @@ export function resolveRatings( eloMax: policy.ratingMax, }); for (const [participantId, rating] of converted) { - resolved.set(participantId, { - participantId, - rating: clamp(rating, policy.ratingMin, policy.ratingMax), - method: "sourceOdds", - }); + oddsRating.set(participantId, rating); } } } + // 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 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: blended.value, + method: blended.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 e2e7f9f..8d9c862 100644 --- a/app/services/simulations/manifest.ts +++ b/app/services/simulations/manifest.ts @@ -66,9 +66,10 @@ const PROFILES: Record 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(); @@ -575,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 RDIF_WEIGHT * rdifProb + ODDS_WEIGHT * 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 4ba53db..6141165 100644 --- a/app/services/simulations/ncaa-football-simulator.ts +++ b/app/services/simulations/ncaa-football-simulator.ts @@ -59,11 +59,7 @@ 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 { 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): 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 ELO_WEIGHT * eloProbValue + ODDS_WEIGHT * 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): { winner: Team; loser: Team } { - const p1Wins = Math.random() < blendedWinProb(team1, team2); + const p1Wins = Math.random() < gameWinProb(team1, team2); return p1Wins ? { winner: team1, loser: team2 } : { winner: team2, loser: team1 }; @@ -211,7 +183,6 @@ function simulateBracket(teams: Team[], counts: Map): v export class NCAAFootballSimulator implements Simulator { async simulate(sportsSeasonId: string): Promise { const db = database(); - // 1. Load all participants for this sports season. const participants = await db .select({ id: schema.seasonParticipants.id }) @@ -225,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; diff --git a/app/services/simulations/types.ts b/app/services/simulations/types.ts index 19ef0bf..d2b3ed0 100644 --- a/app/services/simulations/types.ts +++ b/app/services/simulations/types.ts @@ -29,6 +29,10 @@ export interface SimulationResult { /** * 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): Promise; diff --git a/app/services/simulations/ucl-simulator.ts b/app/services/simulations/ucl-simulator.ts index 698431c..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 { 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. */ @@ -142,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])); @@ -195,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 ELO_WEIGHT * eloProb + ODDS_WEIGHT * 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 86d0c90..2927eb1 100644 --- a/app/services/simulations/world-cup-simulator.ts +++ b/app/services/simulations/world-cup-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 { eloWinProbability } from "~/services/probability-engine"; import { simulateEloSoccerMatch } from "./soccer-helpers"; import { normalizeSimulationResultColumns } from "./simulation-probabilities"; import { logger } from "~/lib/logger"; @@ -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,13 +272,10 @@ function sortTeams(a: TeamStats, b: TeamStats): number { function simKnockout( teamA: string, teamB: string, - eloFn: (id: string) => number, - normalizedProb: (id: string) => number + eloFn: (id: string) => number ): { 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 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 }; } @@ -318,8 +313,7 @@ function runKnockoutRoundByNumber( roundName: string, matches: Map, completed: Map, - eloFn: (id: string) => number, - normalizedProb: (id: string) => number + eloFn: (id: string) => number ): { winnersByNumber: Map; losersByNumber: Map; @@ -348,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); + const result = simKnockout(m.p1, m.p2, eloFn); winnerId = result.winner; loserId = result.loser; } else if (m.p1 || m.p2) { @@ -461,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)) { @@ -482,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({ @@ -714,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); - 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); + 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()) { @@ -734,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 + sf1Loser, sf2Loser, eloFn ); counts.thirdPlace.set(thirdWinner, (counts.thirdPlace.get(thirdWinner) ?? 0) + 1); counts.fourthPlace.set(thirdLoser, (counts.fourthPlace.get(thirdLoser) ?? 0) + 1); @@ -751,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 + sfWinner1, sfWinner2, eloFn ); counts.champion.set(champion, (counts.champion.get(champion) ?? 0) + 1); counts.runnerUp.set(runnerUp, (counts.runnerUp.get(runnerUp) ?? 0) + 1);