import { convertFuturesToElo } from "~/services/probability-engine"; import { simulatorInputLabel, type SimulatorManifestProfile } from "./manifest"; export type MissingEloStrategy = "block" | "fallbackElo" | "averageKnown" | "worstKnownMinus"; export interface SimulatorInputPolicy { missingEloStrategy: MissingEloStrategy; fallbackElo: number; fallbackEloDelta: number; eloMin: number; eloMax: 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" | MissingEloStrategy; } export interface ResolvedRating { participantId: string; rating: number; method: "direct" | "sourceOdds"; } const DEFAULT_POLICY: SimulatorInputPolicy = { missingEloStrategy: "block", fallbackElo: 1400, fallbackEloDelta: 25, eloMin: 1100, eloMax: 1900, 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); } export function getSimulatorInputPolicy(config: Record): SimulatorInputPolicy { const rawPolicy = isRecord(config.inputPolicy) ? config.inputPolicy : {}; const strategy = rawPolicy.missingEloStrategy; return { missingEloStrategy: strategy === "fallbackElo" || strategy === "averageKnown" || strategy === "worstKnownMinus" ? strategy : "block", 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), 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(" / "); } 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); 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", }); } } if (alternatives.has("sourceOdds")) { const oddsInputs = inputs .filter((input) => !resolved.has(input.participantId) && input.sourceOdds !== null && input.sourceOdds !== undefined) .map((input) => ({ participantId: input.participantId, odds: input.sourceOdds as number })); 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", }); } } 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 ?? []); for (const input of inputs) { if (input.rating !== null && input.rating !== undefined) { resolved.set(input.participantId, { participantId: input.participantId, rating: input.rating, method: "direct", }); } } if (alternatives.has("sourceOdds")) { const oddsInputs = inputs .filter((input) => !resolved.has(input.participantId) && input.sourceOdds !== null && input.sourceOdds !== undefined) .map((input) => ({ participantId: input.participantId, odds: input.sourceOdds as number })); 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, method: "sourceOdds", }); } } return resolved; }