- Revert ncaaw-simulator to Barthag win probability formula; set realistic rating bounds (ratingMin: 0.70, ratingMax: 0.97) so derived ratings stay in the range where the formula behaves well - Add batchSaveFuturesOddsForSimulator which clears all ratings (manual and generated) before upserting sourceOdds, so futures odds always drive the simulation rather than being silently overridden by existing Barthag ratings from Simulator Setup - Add clearSourceOddsForParticipants to zero out both tables for participants excluded from a bulk import - Add "Clear existing odds" checkbox to the bulk import card; applies client-side on match and server-side on submit - Fix missing sportsSeasonId filter in batchSaveFuturesOddsForSimulator pre-clear UPDATE (could have wiped ratings across other seasons) - Fix race condition: run batchSaveSourceOdds then batchSaveFuturesOddsForSimulator sequentially so the simulator inputs table always ends in the correct cleared state - Fix Math.round in convertFuturesToElo collapsing Barthag-scale ratings to 0 or 1; Elo callers already round after clamping - Handle all-identical-odds edge case in convertFuturesToElo (assign midpoint instead of throwing) - Add missingRatingStrategy: worstKnownMinus to ncaaw_bracket manifest so fallbackRatingDelta is live config, not dead - Log warning in resolveRatings when only 1 participant has odds - Reset clearExisting checkbox after applyMatches Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
281 lines
10 KiB
TypeScript
281 lines
10 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";
|
|
|
|
export interface SimulatorInputPolicy {
|
|
missingEloStrategy: MissingEloStrategy;
|
|
missingRatingStrategy: MissingRatingStrategy;
|
|
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_POLICY: SimulatorInputPolicy = {
|
|
missingEloStrategy: "block",
|
|
missingRatingStrategy: "block",
|
|
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);
|
|
}
|
|
|
|
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",
|
|
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);
|
|
|
|
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<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 ?? []);
|
|
|
|
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 }));
|
|
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 missing-rating strategy.`
|
|
);
|
|
}
|
|
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;
|
|
}
|