diff --git a/app/models/simulator.ts b/app/models/simulator.ts index b561ce9..d4e1c3f 100644 --- a/app/models/simulator.ts +++ b/app/models/simulator.ts @@ -9,7 +9,6 @@ import { } from "~/services/simulations/manifest"; import { getSimulatorInputPolicy, - prefersFuturesOdds, ratingRequirementLabel, resolveRatings, resolveSourceElos, @@ -88,8 +87,8 @@ export interface SportsSeasonSimulatorSummary { supportsFuturesOdds: boolean; /** Number of participants that currently have stored futures odds. */ oddsParticipantCount: number; - /** Whether the season's input policy makes futures odds override Elo/projections. */ - prefersFuturesOdds: boolean; + /** Weight (0–1) the season's policy gives futures odds when blending into Elo. */ + oddsWeight: number; } function parseDecimal(value: string | null | undefined): number | null { @@ -133,8 +132,8 @@ function isFallbackMethod(method: string): boolean { return method === "fallbackElo" || method === "fallbackRating" || method === "averageKnown" || method === "worstKnownMinus"; } -const GENERATED_RATING_METHODS = ["sourceOdds", "fallbackRating", "averageKnown", "worstKnownMinus"] as const; -const GENERATED_SOURCE_ELO_METHODS = ["projectedWins", "projectedTablePoints", "sourceOdds", "fallbackElo", "averageKnown", "worstKnownMinus"] as const; +const GENERATED_RATING_METHODS = ["sourceOdds", "blend", "fallbackRating", "averageKnown", "worstKnownMinus"] as const; +const GENERATED_SOURCE_ELO_METHODS = ["projectedWins", "projectedTablePoints", "sourceOdds", "blend", "fallbackElo", "averageKnown", "worstKnownMinus"] as const; function isGeneratedRatingMethod(method: unknown): boolean { return typeof method === "string" && GENERATED_RATING_METHODS.includes(method as (typeof GENERATED_RATING_METHODS)[number]); @@ -697,7 +696,7 @@ export async function listSportsSeasonSimulatorSummaries(): Promise }; } +function futuresBlendLabel(oddsWeight: number): string { + if (oddsWeight >= 1) return "overrides Elo"; + if (oddsWeight <= 0) return "Elo only"; + return `${Math.round(oddsWeight * 100)}% blend`; +} + function statusBadge(status: string) { if (status === "ready") return Ready; return Needs setup; @@ -247,9 +253,9 @@ export default function AdminSimulators({ loaderData }: Route.ComponentProps) {
{sim.participantInputCount}/{sim.participantCount}
{sim.supportsFuturesOdds && ( - + 0 ? "default" : "outline"} className="mt-1 text-xs font-normal"> {sim.oddsParticipantCount > 0 - ? `Futures: ${sim.oddsParticipantCount}${sim.prefersFuturesOdds ? " (overrides Elo)" : " (fallback)"}` + ? `Futures: ${sim.oddsParticipantCount} · ${futuresBlendLabel(sim.oddsWeight)}` : "No futures odds"} )} diff --git a/app/routes/admin.sports-seasons.$id.simulator.tsx b/app/routes/admin.sports-seasons.$id.simulator.tsx index 2618a81..c9f4417 100644 --- a/app/routes/admin.sports-seasons.$id.simulator.tsx +++ b/app/routes/admin.sports-seasons.$id.simulator.tsx @@ -27,9 +27,6 @@ import { import { normalizeName } from "~/lib/fuzzy-match"; import { getSimulatorInputPolicy, - prefersFuturesOdds, - ELO_FIRST_SOURCE_PRIORITY, - FUTURES_FIRST_SOURCE_PRIORITY, type MissingEloStrategy, type MissingRatingStrategy, } from "~/services/simulations/input-policy"; @@ -212,10 +209,7 @@ export async function action({ request, params }: Route.ActionArgs): Promise
-
- - -

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

-
-
- +
+

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

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