brackt/app/services/simulations/input-policy.ts
Claude dc4efe87cf
Unify futures odds with the simulation system
Make the futures-vs-Elo relationship an explicit, configurable rule and fix
futures odds failing to override stored Elo.

Root causes addressed:
- resolveSourceElos/resolveRatings used a hardcoded precedence (direct Elo ->
  projectedWins -> projectedTablePoints -> odds). The prior fix nulled sourceElo
  on futures entry but not projections, which still outranked odds.
- The override relied on a destructive null-on-save hack that also wiped
  manually entered Elo.
- Blended simulators buried their Elo/odds weight in module constants.
- Futures odds were not surfaced on the /admin/simulators inventory.

Changes:
- Add a configurable source policy: sourceEloPriority (ordered) and oddsWeight,
  parsed/clamped in getSimulatorInputPolicy. resolveSourceElos/resolveRatings now
  resolve each participant by the configured priority instead of a fixed order.
  Futures-centric simulators (ncaam, ncaaw, world_cup, ncaa_football,
  college_hockey) default to a futures-override priority.
- Drop the destructive nulling in batchSaveFuturesOddsForSimulator and
  batchSaveSourceOdds; override is now governed by policy, preserving stored Elo.
- Thread an optional SimulationContext (oddsWeight) through the Simulator
  interface so blended sims (UCL, World Cup, NCAA FB, MLB) read the blend weight
  from the season policy; defaults preserve prior calibration when no context is
  passed.
- Add a "Futures vs. Elo" strategy control and Odds Blend Weight input to the
  Simulator Setup input-policy card, persisted via save-input-policy.
- Surface futures on /admin/simulators: a source badge and a Futures Odds quick
  link; extend listSportsSeasonSimulatorSummaries with odds source info.
- Tests: configurable priority override (Elo/projections/rating), oddsWeight
  parsing/clamping, prefersFuturesOdds, manifest defaults, an NCAA Football
  context-blend behavioral test, and an updated non-destructive save test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QhNeB7gN6VKene7sdbBVQT
2026-06-25 22:11:32 +00:00

379 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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";
/**
* 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.
*/
export type SourceEloKey = "sourceElo" | "projectedWins" | "projectedTablePoints" | "sourceOdds";
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",
"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;
}
export interface SimulatorInputPolicy {
missingEloStrategy: MissingEloStrategy;
missingRatingStrategy: MissingRatingStrategy;
/** Ordered preference for deriving a participant's strength input. */
sourceEloPriority: SourceEloKey[];
/**
* Weight (01) 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.
*/
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" | MissingEloStrategy;
}
export interface ResolvedRating {
participantId: string;
rating: number;
method: "direct" | "sourceOdds" | MissingRatingStrategy;
}
const DEFAULT_ODDS_WEIGHT = 0.3;
const DEFAULT_POLICY: SimulatorInputPolicy = {
missingEloStrategy: "block",
missingRatingStrategy: "block",
sourceEloPriority: ELO_FIRST_SOURCE_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 parseSourceEloPriority(value: unknown, fallback: SourceEloKey[]): SourceEloKey[] {
if (!Array.isArray(value)) return fallback;
const seen = new Set<SourceEloKey>();
const parsed: SourceEloKey[] = [];
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);
}
}
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",
sourceEloPriority: parseSourceEloPriority(rawPolicy.sourceEloPriority, DEFAULT_POLICY.sourceEloPriority),
// 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);
// 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) {
if (source !== "sourceElo" && !alternatives.has(source)) continue;
if (source === "sourceElo") {
for (const input of inputs) {
if (resolved.has(input.participantId)) continue;
if (input.sourceElo !== null && input.sourceElo !== undefined) {
resolved.set(input.participantId, {
participantId: input.participantId,
sourceElo: 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),
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 (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",
});
}
} 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",
});
}
}
}
}
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 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",
});
}
}
}
}
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;
}