Replace the two parallel odds/Elo mechanisms with one model: every strength
source (raw Elo, projected wins, projected table points, futures odds) converts
to an Elo, those blend by weight into a single Elo, and that one Elo feeds every
simulator. This supersedes the earlier source-priority + per-match probability
blend approach.
Resolution (app/services/simulations/input-policy.ts):
- SimulatorInputPolicy gains baseEloPriority (order among the substitutable base
sources: raw Elo / projected wins / projected table points) and oddsWeight
(0–1). resolveSourceElos/resolveRatings now compute baseElo, derive an
odds Elo via convertFuturesToElo, and blend: 0 = base only, 1 = futures
override, between = weighted blend (method "blend").
- Drops the odds-inclusive sourceEloPriority and the prefersFuturesOdds helper.
Simulators consume the single resolved Elo:
- UCL, World Cup, NCAA Football, MLB drop their separate normalized-odds signal,
convertFuturesToElo calls, and per-match probability blend; they read the
resolved sourceElo (preserving each sim's hardcoded fallback Elo table). The
optional SimulationContext oddsWeight plumbing (types.ts/runner.ts) is removed.
- UCL is routed through the central blend (requiredInputs sourceElo, derivable
from sourceOdds) so any entered Elo and futures blend uniformly.
- College hockey already blends odds into Elo internally (and uses NPI rank the
central resolver can't), so its central oddsWeight is set to 0 to avoid
double-counting; the simulator is unchanged.
Manifest: per-profile oddsWeight defaults (World Cup/UCL/MLB 0.3, NCAA FB 0.4,
college hockey 0; global default 0.3).
UI: the Input Policy card exposes one "Futures vs. Elo — Odds Blend Weight"
control; the /admin/simulators inventory badge shows the effective blend
("Elo only" / "NN% blend" / "overrides Elo") with the odds participant count.
Tests: input-policy blend math (0/0.5/1) for Elo and ratings, baseEloPriority
and oddsWeight parsing/clamping, manifest per-profile weights; obsolete
source-priority and oddsWeight-context tests removed/replaced.
Note: this intentionally shifts the calibrated EV outputs of the four sims that
previously blended at the probability level (accepted in design discussion).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QhNeB7gN6VKene7sdbBVQT
407 lines
15 KiB
TypeScript
407 lines
15 KiB
TypeScript
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<string, unknown> {
|
||
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<BaseEloKey>();
|
||
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<string, unknown>): 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),
|
||
// Allow oddsWeight to live either in inputPolicy (the unified knob) or at the
|
||
// top level of config (legacy per-profile defaultConfig), preferring the policy.
|
||
oddsWeight: clamp(
|
||
optionalNumber(rawPolicy.oddsWeight, optionalNumber(config.oddsWeight, DEFAULT_POLICY.oddsWeight)),
|
||
0,
|
||
1
|
||
),
|
||
fallbackElo: optionalNumber(rawPolicy.fallbackElo, DEFAULT_POLICY.fallbackElo),
|
||
fallbackEloDelta: optionalNumber(rawPolicy.fallbackEloDelta, DEFAULT_POLICY.fallbackEloDelta),
|
||
eloMin: optionalNumber(rawPolicy.eloMin, DEFAULT_POLICY.eloMin),
|
||
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(" / ");
|
||
}
|
||
|
||
export function resolveSourceElos(
|
||
inputs: ParticipantInputForPolicy[],
|
||
profile: Pick<SimulatorManifestProfile, "derivableInputs">,
|
||
config: Record<string, unknown>
|
||
): Map<string, ResolvedSourceElo> {
|
||
const policy = getSimulatorInputPolicy(config);
|
||
const resolved = new Map<string, ResolvedSourceElo>();
|
||
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<string, { elo: number; method: ResolvedSourceElo["method"] }>();
|
||
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<string, number>();
|
||
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
|
||
: 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<SimulatorManifestProfile, "derivableInputs">,
|
||
config: Record<string, unknown>
|
||
): Map<string, ResolvedRating> {
|
||
const policy = getSimulatorInputPolicy(config);
|
||
const resolved = new Map<string, ResolvedRating>();
|
||
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<string, number>();
|
||
for (const input of inputs) {
|
||
if (input.rating !== null && input.rating !== undefined) {
|
||
baseRating.set(input.participantId, input.rating);
|
||
}
|
||
}
|
||
|
||
const oddsRating = new Map<string, number>();
|
||
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
|
||
: 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;
|
||
}
|