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
284 lines
11 KiB
TypeScript
284 lines
11 KiB
TypeScript
import { getSimulatorInfo, SIMULATOR_TYPES, type SimulatorType } from "./registry";
|
|
|
|
export type SimulatorInputKey =
|
|
| "sourceOdds"
|
|
| "sourceElo"
|
|
| "worldRanking"
|
|
| "rating"
|
|
| "projectedWins"
|
|
| "projectedTablePoints"
|
|
| "seed"
|
|
| "region"
|
|
| "metadata";
|
|
|
|
export type SimulatorSetupSection =
|
|
| "futuresOdds"
|
|
| "eloRatings"
|
|
| "rankings"
|
|
| "ratings"
|
|
| "regularStandings"
|
|
| "bracket"
|
|
| "events"
|
|
| "golfSkills"
|
|
| "surfaceElo"
|
|
| "cs2Setup"
|
|
| "participants";
|
|
|
|
export interface SimulatorManifestProfile {
|
|
simulatorType: SimulatorType;
|
|
displayName: string;
|
|
description: string;
|
|
defaultConfig: Record<string, unknown>;
|
|
requiredInputs: SimulatorInputKey[];
|
|
optionalInputs: SimulatorInputKey[];
|
|
derivableInputs?: Partial<Record<SimulatorInputKey, SimulatorInputKey[]>>;
|
|
setupSections: SimulatorSetupSection[];
|
|
minParticipantInputs?: number;
|
|
}
|
|
|
|
const BASE_CONFIG = {
|
|
iterations: 50_000,
|
|
};
|
|
|
|
// Source-resolution priority that makes freshly entered futures odds win over a
|
|
// stored Elo/rating (and projections). Used as the default for simulators where
|
|
// futures odds are a deliberate, primary input (those exposing a Futures Odds
|
|
// setup section). Admins can still override this per season in the Input Policy.
|
|
// Kept as a local literal (not imported from input-policy) to avoid a circular
|
|
// module dependency that would be evaluated before input-policy's consts exist.
|
|
// Mirrors FUTURES_FIRST_SOURCE_PRIORITY in input-policy.ts.
|
|
const FUTURES_FIRST_PRIORITY = ["sourceOdds", "sourceElo", "projectedWins", "projectedTablePoints"] as const;
|
|
|
|
const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorType" | "displayName" | "description">> = {
|
|
f1_standings: {
|
|
defaultConfig: { iterations: 10_000, raceNoise: 0.5, participantVolatility: 1.5 },
|
|
requiredInputs: ["sourceOdds"],
|
|
optionalInputs: [],
|
|
setupSections: ["participants", "futuresOdds", "events"],
|
|
},
|
|
indycar_standings: {
|
|
defaultConfig: { iterations: 10_000, raceNoise: 0.5, participantVolatility: 1.5 },
|
|
requiredInputs: ["sourceOdds"],
|
|
optionalInputs: [],
|
|
setupSections: ["participants", "futuresOdds", "events"],
|
|
},
|
|
golf_qualifying_points: {
|
|
defaultConfig: { iterations: 10_000, fieldSize: 156, plBeta: 1.5 },
|
|
requiredInputs: [],
|
|
optionalInputs: ["sourceOdds", "rating"],
|
|
setupSections: ["participants", "events", "golfSkills"],
|
|
},
|
|
playoff_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG },
|
|
requiredInputs: ["sourceOdds"],
|
|
optionalInputs: ["sourceElo"],
|
|
setupSections: ["participants", "futuresOdds", "bracket"],
|
|
},
|
|
ucl_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG, eloWeight: 0.7, oddsWeight: 0.3 },
|
|
requiredInputs: ["sourceOdds"],
|
|
optionalInputs: ["sourceElo"],
|
|
setupSections: ["participants", "futuresOdds", "bracket"],
|
|
},
|
|
ncaam_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG, ratingScaleFactor: 7.5, inputPolicy: { ratingMin: -10, ratingMax: 35, fallbackRatingDelta: 5, sourceEloPriority: FUTURES_FIRST_PRIORITY } },
|
|
requiredInputs: ["rating"],
|
|
optionalInputs: ["sourceOdds", "sourceElo", "seed", "region"],
|
|
derivableInputs: { rating: ["sourceOdds"] },
|
|
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
|
|
},
|
|
ncaaw_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG, inputPolicy: { ratingMin: 0.70, ratingMax: 0.97, missingRatingStrategy: "worstKnownMinus", fallbackRatingDelta: 0.01, sourceEloPriority: FUTURES_FIRST_PRIORITY } },
|
|
requiredInputs: ["rating"],
|
|
optionalInputs: ["sourceOdds", "seed", "region"],
|
|
derivableInputs: { rating: ["sourceOdds"] },
|
|
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
|
|
},
|
|
nba_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 82 },
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["sourceOdds", "projectedWins"],
|
|
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
|
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
|
},
|
|
nhl_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG, parityFactor: 1000, seasonGames: 82, overtimeRate: 0.23 },
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["sourceOdds", "projectedWins"],
|
|
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
|
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
|
},
|
|
nfl_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 17, homeFieldElo: 48 },
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["sourceOdds", "projectedWins"],
|
|
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
|
setupSections: ["participants", "eloRatings", "regularStandings"],
|
|
},
|
|
afl_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG, parityFactor: 450, seasonGames: 23 },
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["projectedWins"],
|
|
derivableInputs: { sourceElo: ["projectedWins"] },
|
|
setupSections: ["participants", "eloRatings", "regularStandings"],
|
|
},
|
|
epl_standings: {
|
|
defaultConfig: {
|
|
...BASE_CONFIG,
|
|
iterations: 10_000,
|
|
seasonGames: 38,
|
|
parityFactor: 400,
|
|
matchParityFactor: 400,
|
|
averageOpponentElo: 1500,
|
|
baseDrawRate: 0.26,
|
|
drawDecay: 0.002,
|
|
},
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["sourceOdds", "projectedTablePoints"],
|
|
derivableInputs: { sourceElo: ["projectedTablePoints", "sourceOdds"] },
|
|
setupSections: ["participants", "eloRatings", "regularStandings"],
|
|
},
|
|
snooker_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG, eloDivisor: 400 },
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["worldRanking", "seed"],
|
|
setupSections: ["participants", "eloRatings", "rankings", "bracket"],
|
|
},
|
|
tennis_qualifying_points: {
|
|
defaultConfig: { iterations: 10_000, eloDivisor: 400, fallbackElo: 1500 },
|
|
requiredInputs: [],
|
|
optionalInputs: ["worldRanking"],
|
|
setupSections: ["participants", "surfaceElo", "events"],
|
|
},
|
|
mlb_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG, rDiffWeight: 0.7, oddsWeight: 0.3, seasonGames: 162 },
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["sourceOdds", "projectedWins"],
|
|
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
|
setupSections: ["participants", "eloRatings", "regularStandings"],
|
|
},
|
|
wnba_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG, seasonGames: 44, srsEloScale: 30 },
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["sourceOdds", "projectedWins"],
|
|
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
|
setupSections: ["participants", "eloRatings", "regularStandings"],
|
|
},
|
|
world_cup: {
|
|
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, eloWeight: 0.7, oddsWeight: 0.3, inputPolicy: { sourceEloPriority: FUTURES_FIRST_PRIORITY, oddsWeight: 0.3 } },
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["sourceOdds", "worldRanking"],
|
|
derivableInputs: { sourceElo: ["sourceOdds"] },
|
|
setupSections: ["participants", "eloRatings", "futuresOdds", "events"],
|
|
},
|
|
darts_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, eloDivisor: 400 },
|
|
requiredInputs: ["sourceElo", "worldRanking"],
|
|
optionalInputs: ["seed"],
|
|
setupSections: ["participants", "eloRatings", "rankings"],
|
|
},
|
|
cs2_major_qualifying_points: {
|
|
defaultConfig: { iterations: 10_000, fieldSize: 32, guaranteedCount: 12 },
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["worldRanking", "metadata"],
|
|
setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"],
|
|
},
|
|
ncaa_football_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG, bracketSize: 12, eloWeight: 0.6, oddsWeight: 0.4, inputPolicy: { sourceEloPriority: FUTURES_FIRST_PRIORITY, oddsWeight: 0.4 } },
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["sourceOdds", "worldRanking"],
|
|
derivableInputs: { sourceElo: ["sourceOdds"] },
|
|
setupSections: ["participants", "eloRatings", "futuresOdds", "bracket"],
|
|
},
|
|
llws_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG, usTeamCount: 10, internationalTeamCount: 10, poolSize: 5 },
|
|
requiredInputs: ["sourceOdds"],
|
|
optionalInputs: ["metadata"],
|
|
setupSections: ["participants", "futuresOdds"],
|
|
},
|
|
college_hockey_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG, bracketSize: 16, parityFactor: 850, inputPolicy: { sourceEloPriority: FUTURES_FIRST_PRIORITY } },
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["sourceOdds", "worldRanking"],
|
|
derivableInputs: { sourceElo: ["sourceOdds"] },
|
|
setupSections: ["participants", "eloRatings", "rankings", "futuresOdds", "bracket"],
|
|
},
|
|
brackt: {
|
|
defaultConfig: { iterations: 20_000 },
|
|
requiredInputs: [],
|
|
optionalInputs: [],
|
|
setupSections: ["participants"],
|
|
},
|
|
nll_bracket: {
|
|
defaultConfig: {
|
|
...BASE_CONFIG,
|
|
seasonGames: 18,
|
|
parityFactor: 400,
|
|
bracketSize: 8,
|
|
playoffTeams: 8,
|
|
regularSeasonTeamCount: 14,
|
|
homeFieldElo: 0,
|
|
regularSeasonMode: "project_remaining_games",
|
|
regularSeasonNoise: 0.9,
|
|
},
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["projectedWins", "sourceOdds", "seed"],
|
|
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
|
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
|
},
|
|
mls_bracket: {
|
|
defaultConfig: {
|
|
...BASE_CONFIG,
|
|
seasonGames: 34,
|
|
parityFactor: 400,
|
|
matchParityFactor: 400,
|
|
averageOpponentElo: 1500,
|
|
baseDrawRate: 0.26,
|
|
drawDecay: 0.002,
|
|
playoffTeamsPerConference: 9,
|
|
},
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["sourceOdds", "projectedTablePoints", "seed", "region"],
|
|
derivableInputs: { sourceElo: ["projectedTablePoints", "sourceOdds"] },
|
|
setupSections: ["participants", "eloRatings", "regularStandings"],
|
|
},
|
|
};
|
|
|
|
export const SIMULATOR_MANIFEST: Record<SimulatorType, SimulatorManifestProfile> =
|
|
Object.fromEntries(
|
|
SIMULATOR_TYPES.map((simulatorType) => {
|
|
const info = getSimulatorInfo(simulatorType);
|
|
const profile = PROFILES[simulatorType];
|
|
return [
|
|
simulatorType,
|
|
{
|
|
simulatorType,
|
|
displayName: info?.name ?? simulatorType,
|
|
description: info?.description ?? "",
|
|
...profile,
|
|
minParticipantInputs: profile.minParticipantInputs ?? 1,
|
|
},
|
|
];
|
|
})
|
|
) as Record<SimulatorType, SimulatorManifestProfile>;
|
|
|
|
export function getManifestSimulatorProfile(
|
|
simulatorType: SimulatorType
|
|
): SimulatorManifestProfile {
|
|
return SIMULATOR_MANIFEST[simulatorType];
|
|
}
|
|
|
|
export function simulatorInputLabel(key: SimulatorInputKey): string {
|
|
const labels: Record<SimulatorInputKey, string> = {
|
|
sourceOdds: "futures odds",
|
|
sourceElo: "Elo rating",
|
|
worldRanking: "ranking",
|
|
rating: "rating",
|
|
projectedWins: "projected wins",
|
|
projectedTablePoints: "projected table points",
|
|
seed: "seed",
|
|
region: "region",
|
|
metadata: "metadata",
|
|
};
|
|
return labels[key];
|
|
}
|