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
279 lines
10 KiB
TypeScript
279 lines
10 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,
|
|
};
|
|
|
|
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, oddsWeight: 0.3 },
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["sourceOdds"],
|
|
derivableInputs: { sourceElo: ["sourceOdds"] },
|
|
setupSections: ["participants", "futuresOdds", "bracket"],
|
|
},
|
|
ncaam_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG, ratingScaleFactor: 7.5, inputPolicy: { ratingMin: -10, ratingMax: 35, fallbackRatingDelta: 5 } },
|
|
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 } },
|
|
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, 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, 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: {
|
|
// College hockey already blends odds into Elo internally (and also uses NPI
|
|
// rank, which the central resolver can't), so the central blend is disabled
|
|
// (oddsWeight 0) to avoid double-counting the odds.
|
|
defaultConfig: { ...BASE_CONFIG, bracketSize: 16, parityFactor: 850, oddsWeight: 0 },
|
|
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];
|
|
}
|