import { convertFuturesToElo } from "~/services/probability-engine"; import { simulatorInputLabel, type SimulatorManifestProfile } from "./manifest"; 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; eloMax: number; fallbackRating: number; fallbackRatingDelta: number; ratingMin: number; ratingMax: number; } export interface ParticipantInputForPolicy { participantId: string; sourceOdds: number | null; sourceElo: number | null; rating: number | null; projectedWins: number | null; projectedTablePoints: number | null; } export interface ResolvedSourceElo { participantId: string; sourceElo: number; method: "direct" | "projectedWins" | "projectedTablePoints" | "sourceOdds" | "blend" | MissingEloStrategy; } export interface ResolvedRating { participantId: string; rating: number; 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, eloMax: 1900, fallbackRating: 0, fallbackRatingDelta: 5, ratingMin: -20, ratingMax: 40, }; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function optionalNumber(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) ? value : fallback; } function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } function toWinRate(projection: number, maxProjection: number): number { if (maxProjection <= 0) return 0.5; return clamp(projection / maxProjection, 0.01, 0.99); } function projectionToElo( projection: number, maxProjection: number, parityFactor: number, policy: SimulatorInputPolicy ): number { const rate = toWinRate(projection, maxProjection); const elo = 1500 + parityFactor * Math.log10(rate / (1 - rate)); 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; const ratingStrategy = rawPolicy.missingRatingStrategy; return { missingEloStrategy: eloStrategy === "fallbackElo" || eloStrategy === "averageKnown" || eloStrategy === "worstKnownMinus" ? eloStrategy : "block", missingRatingStrategy: 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), eloMax: optionalNumber(rawPolicy.eloMax, DEFAULT_POLICY.eloMax), fallbackRating: optionalNumber(rawPolicy.fallbackRating, DEFAULT_POLICY.fallbackRating), fallbackRatingDelta: optionalNumber(rawPolicy.fallbackRatingDelta, DEFAULT_POLICY.fallbackRatingDelta), ratingMin: optionalNumber(rawPolicy.ratingMin, DEFAULT_POLICY.ratingMin), ratingMax: optionalNumber(rawPolicy.ratingMax, DEFAULT_POLICY.ratingMax), }; } export function sourceEloRequirementLabel(profile: SimulatorManifestProfile): string { const alternatives = profile.derivableInputs?.sourceElo ?? []; // Omit "configured fallback" from the label: if a participant appears in // missingInputs it means no fallback resolved it, so mentioning fallback // as an option would be misleading. return ["Elo rating", ...alternatives.map(simulatorInputLabel)].join(" / "); } export function ratingRequirementLabel(profile: SimulatorManifestProfile): string { const alternatives = profile.derivableInputs?.rating ?? []; 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, config: Record ): Map { const policy = getSimulatorInputPolicy(config); const resolved = new Map(); const alternatives = new Set(profile.derivableInputs?.sourceElo ?? []); const parityFactor = optionalNumber(config.parityFactor, 400); // 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")) { 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. // 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 : policy.fallbackElo; const worstKnown = knownElos.length > 0 ? Math.min(...knownElos) : policy.fallbackElo; for (const input of inputs) { if (resolved.has(input.participantId) || policy.missingEloStrategy === "block") continue; let sourceElo: number; if (policy.missingEloStrategy === "averageKnown") { sourceElo = averageKnown; } else if (policy.missingEloStrategy === "worstKnownMinus") { sourceElo = worstKnown - policy.fallbackEloDelta; } else { sourceElo = policy.fallbackElo; } resolved.set(input.participantId, { participantId: input.participantId, sourceElo: clamp(Math.round(sourceElo), policy.eloMin, policy.eloMax), method: policy.missingEloStrategy, }); } return resolved; } export function resolveRatings( inputs: ParticipantInputForPolicy[], profile: Pick, config: Record ): Map { const policy = getSimulatorInputPolicy(config); 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) { 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); } } } // 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 : policy.fallbackRating; const worstKnown = knownRatings.length > 0 ? Math.min(...knownRatings) : policy.fallbackRating; for (const input of inputs) { if (resolved.has(input.participantId) || policy.missingRatingStrategy === "block") continue; let rating: number | null; if (policy.missingRatingStrategy === "averageKnown") { rating = averageKnown; } else if (policy.missingRatingStrategy === "worstKnownMinus") { rating = knownRatings.length > 0 ? worstKnown - policy.fallbackRatingDelta : null; } else { rating = policy.fallbackRating; } if (rating === null) continue; resolved.set(input.participantId, { participantId: input.participantId, rating: clamp(rating, policy.ratingMin, policy.ratingMax), method: policy.missingRatingStrategy, }); } return resolved; }