claude/probability-config-review-1tdolw (#119)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m1s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m17s
🚀 Deploy / 🐳 Build (push) Successful in 1m9s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s

Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #119
This commit is contained in:
chrisp 2026-06-30 23:24:48 +00:00
parent 8efa4aab9e
commit 3e50619629
28 changed files with 763 additions and 592 deletions

View file

@ -69,6 +69,25 @@ interface ActionData {
message: string; message: string;
} }
/**
* Engine knobs that a simulator (or the shared input-policy resolver) actually
* reads from config. The structured Engine fields are limited to these so the UI
* never shows a control that silently does nothing bespoke per-sim constants
* (e.g. homeFieldElo, eloDivisor, srsEloScale, raceNoise) that live in a profile
* but are not read from config stay editable only via the raw-JSON escape hatch.
*/
const HONORED_ENGINE_KNOBS = new Set([
"iterations",
"parityFactor",
"seasonGames",
"overtimeRate",
"matchParityFactor",
"averageOpponentElo",
"baseDrawRate",
"drawDecay",
"ratingScaleFactor",
]);
function parseOptionalNumber(value: string | undefined): number | null { function parseOptionalNumber(value: string | undefined): number | null {
if (value === undefined || value.trim() === "") return null; if (value === undefined || value.trim() === "") return null;
const parsed = Number(value); const parsed = Number(value);
@ -232,17 +251,27 @@ export async function action({ request, params }: Route.ActionArgs): Promise<Act
} }
} }
if (intent === "save-input-policy") { if (intent === "save-configuration") {
const currentConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId); const currentConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId);
if (!currentConfig) return { success: false, message: "Simulator config not found." }; if (!currentConfig) return { success: false, message: "Simulator config not found." };
// Start from the current merged config so keys not exposed as structured
// fields (e.g. string knobs) are preserved untouched.
const next: Record<string, unknown> = { ...currentConfig.config };
// Engine knobs: every numeric field rendered as `engine.<key>`.
for (const [field, value] of formData.entries()) {
if (typeof value !== "string" || !field.startsWith("engine.")) continue;
const key = field.slice("engine.".length);
const parsed = Number(value);
if (value.trim() !== "" && Number.isFinite(parsed)) next[key] = parsed;
}
// Input-derivation policy (only when the simulator consumes Elo/ratings).
if (formData.get("hasInputPolicy") === "1") {
const currentPolicy = getSimulatorInputPolicy(currentConfig.config); const currentPolicy = getSimulatorInputPolicy(currentConfig.config);
await upsertSportsSeasonSimulatorConfig({ next.inputPolicy = {
sportsSeasonId, ...currentPolicy,
simulatorType: currentConfig.simulatorType,
config: {
...currentConfig.config,
inputPolicy: {
missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")), missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")),
missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")), missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")),
// Stored as-is; getSimulatorInputPolicy clamps to [0,1] on read. // Stored as-is; getSimulatorInputPolicy clamps to [0,1] on read.
@ -255,10 +284,15 @@ export async function action({ request, params }: Route.ActionArgs): Promise<Act
fallbackRatingDelta: parsePolicyNumber(formData, "fallbackRatingDelta", currentPolicy.fallbackRatingDelta), fallbackRatingDelta: parsePolicyNumber(formData, "fallbackRatingDelta", currentPolicy.fallbackRatingDelta),
ratingMin: parsePolicyNumber(formData, "ratingMin", currentPolicy.ratingMin), ratingMin: parsePolicyNumber(formData, "ratingMin", currentPolicy.ratingMin),
ratingMax: parsePolicyNumber(formData, "ratingMax", currentPolicy.ratingMax), ratingMax: parsePolicyNumber(formData, "ratingMax", currentPolicy.ratingMax),
}, };
}, }
await upsertSportsSeasonSimulatorConfig({
sportsSeasonId,
simulatorType: currentConfig.simulatorType,
config: next,
}); });
return { success: true, message: "Simulator input policy saved." }; return { success: true, message: "Simulator configuration saved." };
} }
if (intent === "save-inputs") { if (intent === "save-inputs") {
@ -316,6 +350,13 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
const showsInputPolicy = const showsInputPolicy =
config.profile.requiredInputs.includes("sourceElo") || config.profile.requiredInputs.includes("rating"); config.profile.requiredInputs.includes("sourceElo") || config.profile.requiredInputs.includes("rating");
// Structured engine knobs: every top-level numeric config key (inputPolicy is a
// nested object edited in its own section). Driving the fields from the merged
// config means each simulator shows exactly the knobs it actually reads.
const engineEntries = Object.entries(config.config)
.filter(([key, value]) => HONORED_ENGINE_KNOBS.has(key) && typeof value === "number")
.map(([key, value]) => [key, value as unknown as number] as [string, number]);
return ( return (
<div className="container mx-auto p-6 space-y-6"> <div className="container mx-auto p-6 space-y-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@ -407,20 +448,55 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
</CardContent> </CardContent>
</Card> </Card>
{showsInputPolicy && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Input Policy</CardTitle> <CardTitle>Simulator Configuration</CardTitle>
<CardDescription> <CardDescription>
One place for this season's settings. <strong>Engine</strong> controls how the Monte Carlo
runs; <strong>Input derivation</strong> controls how raw inputs become the single Elo/rating
the engine consumes. Defaults come from the simulator profile; values set here override them
for this season only. Both sections write the same stored config.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<Form method="post" className="space-y-6">
<input type="hidden" name="intent" value="save-configuration" />
{showsInputPolicy && <input type="hidden" name="hasInputPolicy" value="1" />}
<section className="space-y-3">
<div>
<h3 className="text-sm font-semibold">Engine</h3>
<p className="text-xs text-muted-foreground">
How the simulation runs. A higher <code>parityFactor</code> flattens the finish-position
distribution (favorites win less often); <code>iterations</code> trades speed for precision.
</p>
</div>
{engineEntries.length > 0 ? (
<div className="grid gap-4 md:grid-cols-3">
{engineEntries.map(([key, value]) => (
<div key={key} className="space-y-2">
<Label htmlFor={`engine.${key}`} className="font-mono text-xs">{key}</Label>
<Input id={`engine.${key}`} name={`engine.${key}`} type="number" step="any" defaultValue={value} />
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">This simulator exposes no numeric engine knobs.</p>
)}
</section>
{showsInputPolicy && (
<section className="space-y-3">
<div>
<h3 className="text-sm font-semibold">Input derivation</h3>
<p className="text-xs text-muted-foreground">
Direct inputs win. This simulator can derive Elo from{" "} Direct inputs win. This simulator can derive Elo from{" "}
{sourceEloAlternatives.length > 0 ? sourceEloAlternatives.join(", ") : "no alternate Elo inputs"} {sourceEloAlternatives.length > 0 ? sourceEloAlternatives.join(", ") : "no alternate Elo inputs"}
{ratingAlternatives.length > 0 ? ` and ratings from ${ratingAlternatives.join(", ")}` : ""}. {ratingAlternatives.length > 0 ? ` and ratings from ${ratingAlternatives.join(", ")}` : ""}.
Tail fallbacks are explicit so low-impact missing participants do not silently get invented ratings. Tail fallbacks are explicit so low-impact missing participants do not silently get invented ratings.
</CardDescription> </p>
</CardHeader> </div>
<CardContent> <div className="grid gap-4 md:grid-cols-5">
<Form method="post" className="grid gap-4 md:grid-cols-5">
<input type="hidden" name="intent" value="save-input-policy" />
<div className="space-y-2 md:col-span-5"> <div className="space-y-2 md:col-span-5">
<Label htmlFor="oddsWeight">Futures vs. Elo Odds Blend Weight (01)</Label> <Label htmlFor="oddsWeight">Futures vs. Elo Odds Blend Weight (01)</Label>
<Input id="oddsWeight" name="oddsWeight" type="number" step="0.05" min="0" max="1" defaultValue={inputPolicy.oddsWeight} /> <Input id="oddsWeight" name="oddsWeight" type="number" step="0.05" min="0" max="1" defaultValue={inputPolicy.oddsWeight} />
@ -428,7 +504,8 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
Every source (raw Elo, projections, futures odds) becomes an Elo, then they blend into Every source (raw Elo, projections, futures odds) becomes an Elo, then they blend into
the single Elo that feeds the simulator. This is the weight given to futures odds: the single Elo that feeds the simulator. This is the weight given to futures odds:
<strong> 0</strong> = Elo / projections only, <strong>1</strong> = futures fully override, <strong> 0</strong> = Elo / projections only, <strong>1</strong> = futures fully override,
in between = blend (e.g. 0.3 = 70% Elo / 30% futures). in between = blend (e.g. 0.3 = 70% Elo / 30% futures). Odds enter the engine only through
this Elo they are not blended again per game.
</p> </p>
</div> </div>
{config.profile.requiredInputs.includes("sourceElo") && ( {config.profile.requiredInputs.includes("sourceElo") && (
@ -499,26 +576,19 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
<Label htmlFor="ratingMax">Rating Ceiling</Label> <Label htmlFor="ratingMax">Rating Ceiling</Label>
<Input id="ratingMax" name="ratingMax" type="number" step="0.01" defaultValue={inputPolicy.ratingMax} /> <Input id="ratingMax" name="ratingMax" type="number" step="0.01" defaultValue={inputPolicy.ratingMax} />
</div> </div>
<div className="md:col-span-5">
<Button type="submit" disabled={isSubmitting}>
<Save className="mr-2 h-4 w-4" />
Save Input Policy
</Button>
</div> </div>
</Form> </section>
</CardContent>
</Card>
)} )}
<Card> <Button type="submit" disabled={isSubmitting}>
<CardHeader> <Save className="mr-2 h-4 w-4" />
<CardTitle>Season Config</CardTitle> Save Configuration
<CardDescription> </Button>
JSON overrides for this specific sports season. Defaults come from the simulator profile. </Form>
</CardDescription>
</CardHeader> <details>
<CardContent> <summary className="cursor-pointer text-sm font-medium">Advanced: edit raw config JSON</summary>
<Form method="post" className="space-y-4"> <Form method="post" className="space-y-3 mt-3">
<input type="hidden" name="intent" value="save-config" /> <input type="hidden" name="intent" value="save-config" />
<Textarea <Textarea
name="config" name="config"
@ -526,11 +596,16 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
className="font-mono text-sm" className="font-mono text-sm"
defaultValue={JSON.stringify(config.config, null, 2)} defaultValue={JSON.stringify(config.config, null, 2)}
/> />
<Button type="submit" disabled={isSubmitting}> <p className="text-xs text-muted-foreground">
Power-user escape hatch for keys not shown above. This is the same stored config the fields
edit; saving here writes the whole object.
</p>
<Button type="submit" variant="outline" disabled={isSubmitting}>
<Save className="mr-2 h-4 w-4" /> <Save className="mr-2 h-4 w-4" />
Save Config Save Raw JSON
</Button> </Button>
</Form> </Form>
</details>
</CardContent> </CardContent>
</Card> </Card>

View file

@ -119,6 +119,13 @@ describe("eloWinProbability (PARITY_FACTOR = 1000)", () => {
const p = eloWinProbability(1700, 1500); const p = eloWinProbability(1700, 1500);
expect(p).toBeCloseTo(0.613, 2); expect(p).toBeCloseTo(0.613, 2);
}); });
it("a higher parity factor flattens the per-game win probability toward 0.5", () => {
const standard = eloWinProbability(1700, 1500); // default parity 1000
const flatter = eloWinProbability(1700, 1500, 2500); // season config override
expect(flatter).toBeLessThan(standard);
expect(flatter).toBeGreaterThan(0.5);
});
}); });
// ─── simulateProjectedPoints ────────────────────────────────────────────────── // ─── simulateProjectedPoints ──────────────────────────────────────────────────

View file

@ -69,20 +69,22 @@ import { normalizeTeamName } from "~/lib/normalize-team-name";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import { getRegularSeasonStandings } from "~/models/regular-season-standings"; import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { eloWinProbabilityWithParity } from "~/services/probability-engine"; import { eloWinProbabilityWithParity } from "~/services/probability-engine";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters (defaults; overridable via season config) ───────────
const NUM_SIMULATIONS = 10_000; const DEFAULT_NUM_SIMULATIONS = 10_000;
/** /**
* Elo parity factor for AFL single-game win probability. * Elo parity factor for AFL single-game win probability.
* 450 reflects moderate variance lower than NHL (1000) to account for * 450 reflects moderate variance lower than NHL (1000) to account for
* AFL's relatively predictable results vs. basketball's coin-flip tendencies. * AFL's relatively predictable results vs. basketball's coin-flip tendencies.
* Overridable via the season config's `parityFactor`.
*/ */
const PARITY_FACTOR = 450; const DEFAULT_PARITY_FACTOR = 450;
/** Approximate total regular season games per AFL team (2026 season). */ /** Approximate total regular season games per AFL team (2026 season). */
const AFL_REGULAR_SEASON_GAMES = 23; const DEFAULT_REGULAR_SEASON_GAMES = 23;
/** Average opponent Elo used for regular season projections. */ /** Average opponent Elo used for regular season projections. */
const AVERAGE_OPPONENT_ELO = 1500; const AVERAGE_OPPONENT_ELO = 1500;
@ -161,8 +163,8 @@ export function getTeamData(name: string): AflTeamData | undefined {
* P(A) = 1 / (1 + 10^((eloB - eloA) / PARITY_FACTOR)) * P(A) = 1 / (1 + 10^((eloB - eloA) / PARITY_FACTOR))
* Exported for unit testing. * Exported for unit testing.
*/ */
export function eloWinProbability(eloA: number, eloB: number): number { export function eloWinProbability(eloA: number, eloB: number, parityFactor = DEFAULT_PARITY_FACTOR): number {
return eloWinProbabilityWithParity(eloA, eloB, PARITY_FACTOR); return eloWinProbabilityWithParity(eloA, eloB, parityFactor);
} }
// ─── Internal types ─────────────────────────────────────────────────────────── // ─── Internal types ───────────────────────────────────────────────────────────
@ -193,8 +195,11 @@ function simulateProjectedWins(entry: TeamEntry): number {
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class AFLSimulator implements Simulator { export class AFLSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const db = database(); const db = database();
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const seasonGames = Math.round(positiveConfigNumber(config, "seasonGames", DEFAULT_REGULAR_SEASON_GAMES));
// 1. Load participants, DB Elo, and standings in parallel. // 1. Load participants, DB Elo, and standings in parallel.
const [participantRows, evRows, standings] = await Promise.all([ const [participantRows, evRows, standings] = await Promise.all([
@ -260,8 +265,8 @@ export class AFLSimulator implements Simulator {
name: r.name, name: r.name,
elo: resolvedElo, elo: resolvedElo,
currentWins: standing?.wins ?? 0, currentWins: standing?.wins ?? 0,
remainingGames: Math.max(0, AFL_REGULAR_SEASON_GAMES - gamesPlayed), remainingGames: Math.max(0, seasonGames - gamesPlayed),
winProb: eloWinProbability(resolvedElo, AVERAGE_OPPONENT_ELO), winProb: eloWinProbability(resolvedElo, AVERAGE_OPPONENT_ELO, parityFactor),
}; };
}); });
@ -269,7 +274,7 @@ export class AFLSimulator implements Simulator {
/** Simulate a single AFL game. Returns the winner. */ /** Simulate a single AFL game. Returns the winner. */
const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry => const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry =>
Math.random() < eloWinProbability(a.elo, b.elo) ? a : b; Math.random() < eloWinProbability(a.elo, b.elo, parityFactor) ? a : b;
/** /**
* Project end-of-season ladder and return the top 10 finalists seeded 110. * Project end-of-season ladder and return the top 10 finalists seeded 110.
@ -364,7 +369,7 @@ export class AFLSimulator implements Simulator {
const efLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0])); const efLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
// 4. Monte Carlo simulation loop. // 4. Monte Carlo simulation loop.
for (let s = 0; s < NUM_SIMULATIONS; s++) { for (let s = 0; s < numSimulations; s++) {
const finalists = buildFinalsList(); const finalists = buildFinalsList();
const { gfWinner, gfLoser, pfLosers, sfLosers, efLosers } = simAFLFinals(finalists); const { gfWinner, gfLoser, pfLosers, sfLosers, efLosers } = simAFLFinals(finalists);
@ -393,7 +398,7 @@ export class AFLSimulator implements Simulator {
// //
// Within each pair (3rd/4th, 5th/6th, 7th/8th), both positions receive the // Within each pair (3rd/4th, 5th/6th, 7th/8th), both positions receive the
// same probability — matching the AFL_10 bracket's averaged point values. // same probability — matching the AFL_10 bracket's averaged point values.
const N = NUM_SIMULATIONS; const N = numSimulations;
const results: SimulationResult[] = participantIds.map((participantId) => { const results: SimulationResult[] = participantIds.map((participantId) => {
const c = championCounts.get(participantId) ?? 0; const c = championCounts.get(participantId) ?? 0;
const f = finalistCounts.get(participantId) ?? 0; const f = finalistCounts.get(participantId) ?? 0;

View file

@ -26,10 +26,11 @@ import * as schema from "~/database/schema";
import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value"; import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value";
import { getSeasonResults } from "~/models/participant-season-result"; import { getSeasonResults } from "~/models/participant-season-result";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters (mirrors Python constants) ──────────────────────── // ─── Simulation parameters (mirrors Python constants) ────────────────────────
const NUM_SIMULATIONS = 10000; const DEFAULT_NUM_SIMULATIONS = 10000;
/** Per-race performance variance. 0 = no noise, 1 = fully random each race. */ /** Per-race performance variance. 0 = no noise, 1 = fully random each race. */
const RACE_NOISE = 0.50; const RACE_NOISE = 0.50;
@ -107,7 +108,8 @@ export class AutoRacingSimulator implements Simulator {
private readonly source: string, private readonly source: string,
) {} ) {}
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const db = database(); const db = database();
// 1. Load all participants for this sports season // 1. Load all participants for this sports season
@ -186,7 +188,7 @@ export class AutoRacingSimulator implements Simulator {
// Pre-season: no races to simulate, derive placement probabilities // Pre-season: no races to simulate, derive placement probabilities
// from sourceOdds via pure weighted draws. // from sourceOdds via pure weighted draws.
const weights = ids.map((id) => baseProbs.get(id) ?? fallbackProb); const weights = ids.map((id) => baseProbs.get(id) ?? fallbackProb);
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) { for (let sim = 0; sim < numSimulations; sim++) {
const finishOrder = weightedDrawWithoutReplacement(ids, weights); const finishOrder = weightedDrawWithoutReplacement(ids, weights);
for (let rank = 0; rank < Math.min(8, finishOrder.length); rank++) { for (let rank = 0; rank < Math.min(8, finishOrder.length); rank++) {
const counts = rankCounts.get(finishOrder[rank]); const counts = rankCounts.get(finishOrder[rank]);
@ -225,7 +227,7 @@ export class AutoRacingSimulator implements Simulator {
// Volatility shrinks as the season progresses — late-season standings are // Volatility shrinks as the season progresses — late-season standings are
// much more predictive than early-season odds. // much more predictive than early-season odds.
const effectiveVolatility = PARTICIPANT_VOLATILITY * (1 - seasonProgress * VOLATILITY_DECAY_FACTOR); const effectiveVolatility = PARTICIPANT_VOLATILITY * (1 - seasonProgress * VOLATILITY_DECAY_FACTOR);
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) { for (let sim = 0; sim < numSimulations; sim++) {
// 7a. Season-long performance multiplier per driver (uses blended strength) // 7a. Season-long performance multiplier per driver (uses blended strength)
const seasonWeights = new Map<string, number>(); const seasonWeights = new Map<string, number>();
for (const id of ids) { for (const id of ids) {
@ -277,14 +279,14 @@ export class AutoRacingSimulator implements Simulator {
return { return {
participantId: p.id, participantId: p.id,
probabilities: { probabilities: {
probFirst: counts[0] / NUM_SIMULATIONS, probFirst: counts[0] / numSimulations,
probSecond: counts[1] / NUM_SIMULATIONS, probSecond: counts[1] / numSimulations,
probThird: counts[2] / NUM_SIMULATIONS, probThird: counts[2] / numSimulations,
probFourth: counts[3] / NUM_SIMULATIONS, probFourth: counts[3] / numSimulations,
probFifth: counts[4] / NUM_SIMULATIONS, probFifth: counts[4] / numSimulations,
probSixth: counts[5] / NUM_SIMULATIONS, probSixth: counts[5] / numSimulations,
probSeventh: counts[6] / NUM_SIMULATIONS, probSeventh: counts[6] / numSimulations,
probEighth: counts[7] / NUM_SIMULATIONS, probEighth: counts[7] / numSimulations,
}, },
source: this.source, source: this.source,
}; };

View file

@ -22,8 +22,9 @@ import {
eloWinProbabilityWithParity, eloWinProbabilityWithParity,
} from "~/services/probability-engine"; } from "~/services/probability-engine";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
const NUM_SIMULATIONS = 50_000; const DEFAULT_NUM_SIMULATIONS = 50_000;
const BRACKET_SIZE = 16; const BRACKET_SIZE = 16;
const HOCKEY_PARITY_FACTOR = 850; const HOCKEY_PARITY_FACTOR = 850;
const DEFAULT_ELO = 1500; const DEFAULT_ELO = 1500;
@ -156,8 +157,8 @@ export function buildCollegeHockeyTeams(
return teams; return teams;
} }
function simGame(team1: Team, team2: Team): { winner: Team; loser: Team } { function simGame(team1: Team, team2: Team, parityFactor = HOCKEY_PARITY_FACTOR): { winner: Team; loser: Team } {
const p1Wins = Math.random() < eloWinProbabilityWithParity(team1.elo, team2.elo, HOCKEY_PARITY_FACTOR); const p1Wins = Math.random() < eloWinProbabilityWithParity(team1.elo, team2.elo, parityFactor);
return p1Wins ? { winner: team1, loser: team2 } : { winner: team2, loser: team1 }; return p1Wins ? { winner: team1, loser: team2 } : { winner: team2, loser: team1 };
} }
@ -189,26 +190,27 @@ function seedField(teams: Team[]): Team[] {
return [...teams].toSorted((a, b) => b.seedScore - a.seedScore || b.elo - a.elo); return [...teams].toSorted((a, b) => b.seedScore - a.seedScore || b.elo - a.elo);
} }
function simulateSeeded16TeamBracket(teams: Team[], counts: Map<string, PlacementCounts>): void { function simulateSeeded16TeamBracket(teams: Team[], counts: Map<string, PlacementCounts>, parityFactor = HOCKEY_PARITY_FACTOR): void {
const r16Pairs: Array<[number, number]> = [ const r16Pairs: Array<[number, number]> = [
[0, 15], [7, 8], [4, 11], [3, 12], [5, 10], [2, 13], [6, 9], [1, 14], [0, 15], [7, 8], [4, 11], [3, 12], [5, 10], [2, 13], [6, 9], [1, 14],
]; ];
const game = (a: Team, b: Team) => simGame(a, b, parityFactor);
const r16Winners = r16Pairs.map(([a, b]) => simGame(teams[a], teams[b]).winner); const r16Winners = r16Pairs.map(([a, b]) => game(teams[a], teams[b]).winner);
const qfWinners: Team[] = []; const qfWinners: Team[] = [];
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
const result = simGame(r16Winners[i * 2], r16Winners[i * 2 + 1]); const result = game(r16Winners[i * 2], r16Winners[i * 2 + 1]);
qfWinners.push(result.winner); qfWinners.push(result.winner);
bump(counts, result.loser.participantId, "quarterfinalLoser"); bump(counts, result.loser.participantId, "quarterfinalLoser");
} }
const sf1 = simGame(qfWinners[0], qfWinners[1]); const sf1 = game(qfWinners[0], qfWinners[1]);
const sf2 = simGame(qfWinners[2], qfWinners[3]); const sf2 = game(qfWinners[2], qfWinners[3]);
bump(counts, sf1.loser.participantId, "semifinalLoser"); bump(counts, sf1.loser.participantId, "semifinalLoser");
bump(counts, sf2.loser.participantId, "semifinalLoser"); bump(counts, sf2.loser.participantId, "semifinalLoser");
const final = simGame(sf1.winner, sf2.winner); const final = game(sf1.winner, sf2.winner);
bump(counts, final.winner.participantId, "champion"); bump(counts, final.winner.participantId, "champion");
bump(counts, final.loser.participantId, "finalist"); bump(counts, final.loser.participantId, "finalist");
} }
@ -236,19 +238,21 @@ function simulateMatchFromIds(
p1: string, p1: string,
p2: string, p2: string,
match: PlayoffMatch | undefined, match: PlayoffMatch | undefined,
teamById: Map<string, Team> teamById: Map<string, Team>,
parityFactor = HOCKEY_PARITY_FACTOR
): { winner: Team; loser: Team } { ): { winner: Team; loser: Team } {
if (match?.isComplete && match.winnerId) { if (match?.isComplete && match.winnerId) {
const loserId = completedLoser(match); const loserId = completedLoser(match);
return { winner: getTeam(teamById, match.winnerId), loser: getTeam(teamById, loserId) }; return { winner: getTeam(teamById, match.winnerId), loser: getTeam(teamById, loserId) };
} }
return simGame(getTeam(teamById, p1), getTeam(teamById, p2)); return simGame(getTeam(teamById, p1), getTeam(teamById, p2), parityFactor);
} }
function simulateExistingBracket( function simulateExistingBracket(
rounds: { r16: PlayoffMatch[]; qf: PlayoffMatch[]; sf: PlayoffMatch[]; final: PlayoffMatch }, rounds: { r16: PlayoffMatch[]; qf: PlayoffMatch[]; sf: PlayoffMatch[]; final: PlayoffMatch },
teamById: Map<string, Team>, teamById: Map<string, Team>,
counts: Map<string, PlacementCounts> counts: Map<string, PlacementCounts>,
parityFactor = HOCKEY_PARITY_FACTOR
): void { ): void {
const r16Winners: Team[] = []; const r16Winners: Team[] = [];
for (let i = 0; i < 8; i++) { for (let i = 0; i < 8; i++) {
@ -256,7 +260,7 @@ function simulateExistingBracket(
if (!match.participant1Id || !match.participant2Id) { if (!match.participant1Id || !match.participant2Id) {
throw new Error(`${match.round} match ${match.matchNumber} is missing participants.`); throw new Error(`${match.round} match ${match.matchNumber} is missing participants.`);
} }
r16Winners.push(simulateMatchFromIds(match.participant1Id, match.participant2Id, match, teamById).winner); r16Winners.push(simulateMatchFromIds(match.participant1Id, match.participant2Id, match, teamById, parityFactor).winner);
} }
const qfWinners: Team[] = []; const qfWinners: Team[] = [];
@ -265,7 +269,7 @@ function simulateExistingBracket(
const p1 = r16Winners[i * 2]?.participantId; const p1 = r16Winners[i * 2]?.participantId;
const p2 = r16Winners[i * 2 + 1]?.participantId; const p2 = r16Winners[i * 2 + 1]?.participantId;
if (!p1 || !p2) throw new Error(`${match.round} match ${match.matchNumber} cannot resolve participants.`); if (!p1 || !p2) throw new Error(`${match.round} match ${match.matchNumber} cannot resolve participants.`);
const result = simulateMatchFromIds(p1, p2, match, teamById); const result = simulateMatchFromIds(p1, p2, match, teamById, parityFactor);
qfWinners.push(result.winner); qfWinners.push(result.winner);
bump(counts, result.loser.participantId, "quarterfinalLoser"); bump(counts, result.loser.participantId, "quarterfinalLoser");
} }
@ -276,7 +280,7 @@ function simulateExistingBracket(
const p1 = qfWinners[i * 2]?.participantId; const p1 = qfWinners[i * 2]?.participantId;
const p2 = qfWinners[i * 2 + 1]?.participantId; const p2 = qfWinners[i * 2 + 1]?.participantId;
if (!p1 || !p2) throw new Error(`${match.round} match ${match.matchNumber} cannot resolve participants.`); if (!p1 || !p2) throw new Error(`${match.round} match ${match.matchNumber} cannot resolve participants.`);
const result = simulateMatchFromIds(p1, p2, match, teamById); const result = simulateMatchFromIds(p1, p2, match, teamById, parityFactor);
sfWinners.push(result.winner); sfWinners.push(result.winner);
bump(counts, result.loser.participantId, "semifinalLoser"); bump(counts, result.loser.participantId, "semifinalLoser");
} }
@ -284,21 +288,21 @@ function simulateExistingBracket(
const p1 = sfWinners[0]?.participantId; const p1 = sfWinners[0]?.participantId;
const p2 = sfWinners[1]?.participantId; const p2 = sfWinners[1]?.participantId;
if (!p1 || !p2) throw new Error(`${rounds.final.round} match ${rounds.final.matchNumber} cannot resolve participants.`); if (!p1 || !p2) throw new Error(`${rounds.final.round} match ${rounds.final.matchNumber} cannot resolve participants.`);
const final = simulateMatchFromIds(p1, p2, rounds.final, teamById); const final = simulateMatchFromIds(p1, p2, rounds.final, teamById, parityFactor);
bump(counts, final.winner.participantId, "champion"); bump(counts, final.winner.participantId, "champion");
bump(counts, final.loser.participantId, "finalist"); bump(counts, final.loser.participantId, "finalist");
} }
function toResults(participantIds: string[], counts: Map<string, PlacementCounts>): SimulationResult[] { function toResults(participantIds: string[], counts: Map<string, PlacementCounts>, numSimulations: number): SimulationResult[] {
const results = participantIds.map((participantId) => { const results = participantIds.map((participantId) => {
const c = counts.get(participantId) ?? { champion: 0, finalist: 0, semifinalLoser: 0, quarterfinalLoser: 0 }; const c = counts.get(participantId) ?? { champion: 0, finalist: 0, semifinalLoser: 0, quarterfinalLoser: 0 };
const sf = c.semifinalLoser / (2 * NUM_SIMULATIONS); const sf = c.semifinalLoser / (2 * numSimulations);
const qf = c.quarterfinalLoser / (4 * NUM_SIMULATIONS); const qf = c.quarterfinalLoser / (4 * numSimulations);
return { return {
participantId, participantId,
probabilities: { probabilities: {
probFirst: c.champion / NUM_SIMULATIONS, probFirst: c.champion / numSimulations,
probSecond: c.finalist / NUM_SIMULATIONS, probSecond: c.finalist / numSimulations,
probThird: sf, probThird: sf,
probFourth: sf, probFourth: sf,
probFifth: qf, probFifth: qf,
@ -325,8 +329,10 @@ function toResults(participantIds: string[], counts: Map<string, PlacementCounts
} }
export class CollegeHockeySimulator implements Simulator { export class CollegeHockeySimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const db = database(); const db = database();
const parityFactor = positiveConfigNumber(config, "parityFactor", HOCKEY_PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const bracketEvent = await db.query.scoringEvents.findFirst({ const bracketEvent = await db.query.scoringEvents.findFirst({
where: and( where: and(
@ -373,19 +379,19 @@ export class CollegeHockeySimulator implements Simulator {
); );
if (existingBracket) { if (existingBracket) {
for (let i = 0; i < NUM_SIMULATIONS; i++) { for (let i = 0; i < numSimulations; i++) {
simulateExistingBracket(existingBracket, teamById, counts); simulateExistingBracket(existingBracket, teamById, counts, parityFactor);
} }
} else { } else {
const preBracketMode = participantIds.length > BRACKET_SIZE; const preBracketMode = participantIds.length > BRACKET_SIZE;
const deterministicField = preBracketMode ? null : seedField(teams); const deterministicField = preBracketMode ? null : seedField(teams);
for (let i = 0; i < NUM_SIMULATIONS; i++) { for (let i = 0; i < numSimulations; i++) {
const field = preBracketMode ? sampleField(teams, BRACKET_SIZE) : deterministicField ?? []; const field = preBracketMode ? sampleField(teams, BRACKET_SIZE) : deterministicField ?? [];
simulateSeeded16TeamBracket(field, counts); simulateSeeded16TeamBracket(field, counts, parityFactor);
} }
} }
return toResults(participantIds, counts); return toResults(participantIds, counts, numSimulations);
} }
private getExistingBracketRounds(matches: PlayoffMatch[]): { r16: PlayoffMatch[]; qf: PlayoffMatch[]; sf: PlayoffMatch[]; final: PlayoffMatch } | null { private getExistingBracketRounds(matches: PlayoffMatch[]): { r16: PlayoffMatch[]; qf: PlayoffMatch[]; sf: PlayoffMatch[]; final: PlayoffMatch } | null {

View file

@ -0,0 +1,43 @@
/**
* Shared accessors for reading numeric knobs out of a merged simulator config
* (`sports_season_simulator_configs.config` merged over the profile defaults).
*
* These were previously duplicated inside individual simulators (EPL, MLS,
* NCAA-M). Centralizing them keeps every simulator reading engine knobs the same
* way, so values set in the unified config screen reliably drive the math.
*
* Two zero-handling variants exist deliberately:
* - `configNumber` accepts 0 (`>= 0`) for knobs where 0 is meaningful
* (e.g. a draw decay or blend weight of 0).
* - `positiveConfigNumber` requires `> 0` for counts/divisors where 0 is
* nonsensical and should fall back to the default.
*/
type Config = Record<string, unknown> | undefined;
/** Read a finite, non-negative number from config, else the fallback. */
export function configNumber(config: Config, key: string, fallback: number): number {
const value = config?.[key];
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
}
/** Read a finite, strictly-positive number from config, else the fallback. */
export function positiveConfigNumber(config: Config, key: string, fallback: number): number {
const value = config?.[key];
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
}
/**
* Read a required finite, strictly-positive number from config. Throws when the
* key is missing or non-positive use only for knobs a profile always seeds.
*/
export function requiredConfigNumber(config: Config, key: string, label = "simulator"): number {
const value = config?.[key];
if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
throw new Error(`${label} config is missing positive numeric ${key}.`);
}
/** Coerce a raw value (already extracted from config) to a positive number. */
export function optionalPositiveNumber(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
}

View file

@ -59,10 +59,11 @@ import {
type StructureSource, type StructureSource,
} from "./shared-major"; } from "./shared-major";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters ────────────────────────────────────────────────────
const NUM_SIMULATIONS = 10_000; const DEFAULT_NUM_SIMULATIONS = 10_000;
/** Total field size per CS2 Major. */ /** Total field size per CS2 Major. */
const FIELD_SIZE = 32; const FIELD_SIZE = 32;
@ -545,7 +546,8 @@ export function simulateChampionsStage(
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class CSMajorSimulator implements Simulator { export class CSMajorSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const db = database(); const db = database();
// 1. Load all participants for this sports season. // 1. Load all participants for this sports season.
@ -800,7 +802,7 @@ export class CSMajorSimulator implements Simulator {
const counts: number[][] = Array.from({ length: participantIds.length }, () => Array(8).fill(0)); const counts: number[][] = Array.from({ length: participantIds.length }, () => Array(8).fill(0));
const idToIndex = new Map<string, number>(participantIds.map((id, i) => [id, i])); const idToIndex = new Map<string, number>(participantIds.map((id, i) => [id, i]));
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) { for (let sim = 0; sim < numSimulations; sim++) {
const simQP = new Map<string, number>(actualQPMap); const simQP = new Map<string, number>(actualQPMap);
for (const event of incompleteEvents) { for (const event of incompleteEvents) {
@ -831,14 +833,14 @@ export class CSMajorSimulator implements Simulator {
return participantIds.map((participantId, i) => ({ return participantIds.map((participantId, i) => ({
participantId, participantId,
probabilities: { probabilities: {
probFirst: counts[i][0] / NUM_SIMULATIONS, probFirst: counts[i][0] / numSimulations,
probSecond: counts[i][1] / NUM_SIMULATIONS, probSecond: counts[i][1] / numSimulations,
probThird: counts[i][2] / NUM_SIMULATIONS, probThird: counts[i][2] / numSimulations,
probFourth: counts[i][3] / NUM_SIMULATIONS, probFourth: counts[i][3] / numSimulations,
probFifth: counts[i][4] / NUM_SIMULATIONS, probFifth: counts[i][4] / numSimulations,
probSixth: counts[i][5] / NUM_SIMULATIONS, probSixth: counts[i][5] / numSimulations,
probSeventh: counts[i][6] / NUM_SIMULATIONS, probSeventh: counts[i][6] / numSimulations,
probEighth: counts[i][7] / NUM_SIMULATIONS, probEighth: counts[i][7] / numSimulations,
}, },
source: "cs2_major_qualifying_points_monte_carlo", source: "cs2_major_qualifying_points_monte_carlo",
})); }));

View file

@ -48,6 +48,7 @@ import { database } from "~/database/context";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters ────────────────────────────────────────────────────
@ -204,8 +205,9 @@ export class DartsSimulator implements Simulator {
this.numSimulations = numSimulations; this.numSimulations = numSimulations;
} }
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const db = database(); const db = database();
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", this.numSimulations));
// 1. Find the bracket scoring event (if it exists). // 1. Find the bracket scoring event (if it exists).
const bracketEvent = await db.query.scoringEvents.findFirst({ const bracketEvent = await db.query.scoringEvents.findFirst({
@ -248,9 +250,9 @@ export class DartsSimulator implements Simulator {
const bracketPopulated = allMatches.some((m) => m.participant1Id && m.participant2Id); const bracketPopulated = allMatches.some((m) => m.participant1Id && m.participant2Id);
if (bracketPopulated) { if (bracketPopulated) {
return this.simulateBracket(allMatches, eloMap); return this.simulateBracket(allMatches, eloMap, numSimulations);
} else { } else {
return this.simulatePreBracket(sportsSeasonId, eloMap, rankingMap, db); return this.simulatePreBracket(sportsSeasonId, eloMap, rankingMap, db, numSimulations);
} }
} }
@ -258,7 +260,8 @@ export class DartsSimulator implements Simulator {
private async simulateBracket( private async simulateBracket(
allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>, allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>,
eloMap: Map<string, number> eloMap: Map<string, number>,
numSimulations: number
): Promise<SimulationResult[]> { ): Promise<SimulationResult[]> {
// Group matches by round, sorted by match count descending (R1 first = most matches). // Group matches by round, sorted by match count descending (R1 first = most matches).
const byRound = new Map<string, typeof allMatches>(); const byRound = new Map<string, typeof allMatches>();
@ -331,7 +334,7 @@ export class DartsSimulator implements Simulator {
const sfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0])); const sfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const qfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0])); const qfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
for (let s = 0; s < this.numSimulations; s++) { for (let s = 0; s < numSimulations; s++) {
// R1 (64 matches) // R1 (64 matches)
const r1Winners: string[] = []; const r1Winners: string[] = [];
for (let i = 1; i <= 64; i++) { for (let i = 1; i <= 64; i++) {
@ -439,7 +442,7 @@ export class DartsSimulator implements Simulator {
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1); finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
} }
return buildResults(participantIds, this.numSimulations, { return buildResults(participantIds, numSimulations, {
championCounts, championCounts,
finalistCounts, finalistCounts,
sfLoserCounts, sfLoserCounts,
@ -455,7 +458,8 @@ export class DartsSimulator implements Simulator {
sportsSeasonId: string, sportsSeasonId: string,
eloMap: Map<string, number>, eloMap: Map<string, number>,
rankingMap: Map<string, number>, rankingMap: Map<string, number>,
db: ReturnType<typeof database> db: ReturnType<typeof database>,
numSimulations: number
): Promise<SimulationResult[]> { ): Promise<SimulationResult[]> {
const allParticipants = await db const allParticipants = await db
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name }) .select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
@ -520,7 +524,7 @@ export class DartsSimulator implements Simulator {
unseededPool.push(`__bye_${unseededPool.length}`); unseededPool.push(`__bye_${unseededPool.length}`);
} }
for (let s = 0; s < this.numSimulations; s++) { for (let s = 0; s < numSimulations; s++) {
// Draw: shuffle the unseeded pool — seeded positions are pre-computed. // Draw: shuffle the unseeded pool — seeded positions are pre-computed.
const drawnUnseeded = shuffle([...unseededPool]); const drawnUnseeded = shuffle([...unseededPool]);
@ -588,7 +592,7 @@ export class DartsSimulator implements Simulator {
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1); finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
} }
return buildResults(allParticipantIds, this.numSimulations, { return buildResults(allParticipantIds, numSimulations, {
championCounts, championCounts,
finalistCounts, finalistCounts,
sfLoserCounts, sfLoserCounts,

View file

@ -18,6 +18,7 @@ import { convertFuturesToElo, eloWinProbabilityWithParity } from "~/services/pro
import { normalizeSimulationResultColumns } from "./simulation-probabilities"; import { normalizeSimulationResultColumns } from "./simulation-probabilities";
import { simulateEloSoccerMatch, simulateSimpleSoccerGoals } from "./soccer-helpers"; import { simulateEloSoccerMatch, simulateSimpleSoccerGoals } from "./soccer-helpers";
import { getSportsSeasonSimulatorConfig } from "~/models/simulator"; import { getSportsSeasonSimulatorConfig } from "~/models/simulator";
import { positiveConfigNumber, requiredConfigNumber } from "./config-access";
const DEFAULT_NUM_SIMULATIONS = 10_000; const DEFAULT_NUM_SIMULATIONS = 10_000;
const DEFAULT_EPL_REGULAR_SEASON_GAMES = 38; const DEFAULT_EPL_REGULAR_SEASON_GAMES = 38;
@ -44,17 +45,6 @@ interface SimulatedTableRow {
tiebreaker: number; tiebreaker: number;
} }
function configNumber(config: Record<string, unknown> | undefined, key: string, fallback: number): number {
const value = config?.[key];
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
}
function requiredConfigNumber(config: Record<string, unknown> | undefined, key: string): number {
const value = config?.[key];
if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
throw new Error(`EPL simulator config is missing positive numeric ${key}.`);
}
/** EPL single-match win probability using the sport-specific parity factor. */ /** EPL single-match win probability using the sport-specific parity factor. */
export function eplWinProbability( export function eplWinProbability(
eloA: number, eloA: number,
@ -99,13 +89,13 @@ export class EPLSimulator implements Simulator {
]); ]);
const config = simulatorConfig?.config; const config = simulatorConfig?.config;
const numSimulations = Math.round(configNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS)); const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const seasonGames = Math.round(configNumber(config, "seasonGames", DEFAULT_EPL_REGULAR_SEASON_GAMES)); const seasonGames = Math.round(positiveConfigNumber(config, "seasonGames", DEFAULT_EPL_REGULAR_SEASON_GAMES));
const averageOpponentElo = configNumber(config, "averageOpponentElo", DEFAULT_AVERAGE_OPPONENT_ELO); const averageOpponentElo = positiveConfigNumber(config, "averageOpponentElo", DEFAULT_AVERAGE_OPPONENT_ELO);
const matchOptions = { const matchOptions = {
baseDrawRate: configNumber(config, "baseDrawRate", DEFAULT_BASE_DRAW_RATE), baseDrawRate: positiveConfigNumber(config, "baseDrawRate", DEFAULT_BASE_DRAW_RATE),
drawDecay: configNumber(config, "drawDecay", DEFAULT_DRAW_DECAY), drawDecay: positiveConfigNumber(config, "drawDecay", DEFAULT_DRAW_DECAY),
matchParityFactor: requiredConfigNumber(config, "matchParityFactor"), matchParityFactor: requiredConfigNumber(config, "matchParityFactor", "EPL simulator"),
}; };
if (participantRows.length === 0) { if (participantRows.length === 0) {

View file

@ -38,10 +38,11 @@ import { getGolfSkillsMap, type GolfSkillsRecord } from "~/models/golf-skills";
import { getQPConfig } from "~/models/qualifying-points"; import { getQPConfig } from "~/models/qualifying-points";
import { getExcludedByEventMap } from "~/models/event-result"; import { getExcludedByEventMap } from "~/models/event-result";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters ────────────────────────────────────────────────────
const NUM_SIMULATIONS = 10_000; const DEFAULT_NUM_SIMULATIONS = 10_000;
/** /**
* Simulated field size for each major. * Simulated field size for each major.
@ -180,7 +181,8 @@ export function simulateMajor(
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class GolfSimulator implements Simulator { export class GolfSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const db = database(); const db = database();
// Load participants, skills, QP config, and scoring events in parallel. // Load participants, skills, QP config, and scoring events in parallel.
@ -269,7 +271,7 @@ export class GolfSimulator implements Simulator {
); );
const idToIndex = new Map<string, number>(participantIds.map((id, i) => [id, i])); const idToIndex = new Map<string, number>(participantIds.map((id, i) => [id, i]));
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) { for (let sim = 0; sim < numSimulations; sim++) {
const simQP = new Map<string, number>(actualQPMap); const simQP = new Map<string, number>(actualQPMap);
for (const { players, restCount, restStrength } of majorConfigs) { for (const { players, restCount, restStrength } of majorConfigs) {
@ -292,14 +294,14 @@ export class GolfSimulator implements Simulator {
return participantIds.map((participantId, i) => ({ return participantIds.map((participantId, i) => ({
participantId, participantId,
probabilities: { probabilities: {
probFirst: counts[i][0] / NUM_SIMULATIONS, probFirst: counts[i][0] / numSimulations,
probSecond: counts[i][1] / NUM_SIMULATIONS, probSecond: counts[i][1] / numSimulations,
probThird: counts[i][2] / NUM_SIMULATIONS, probThird: counts[i][2] / numSimulations,
probFourth: counts[i][3] / NUM_SIMULATIONS, probFourth: counts[i][3] / numSimulations,
probFifth: counts[i][4] / NUM_SIMULATIONS, probFifth: counts[i][4] / numSimulations,
probSixth: counts[i][5] / NUM_SIMULATIONS, probSixth: counts[i][5] / numSimulations,
probSeventh: counts[i][6] / NUM_SIMULATIONS, probSeventh: counts[i][6] / numSimulations,
probEighth: counts[i][7] / NUM_SIMULATIONS, probEighth: counts[i][7] / numSimulations,
}, },
source: "golf_qualifying_points_monte_carlo", source: "golf_qualifying_points_monte_carlo",
})); }));

View file

@ -254,6 +254,10 @@ export function resolveSourceElos(
); );
} }
if (oddsInputs.length >= 2) { if (oddsInputs.length >= 2) {
// Odds map onto the calibrated band (probability-engine DEFAULT_CALIBRATION);
// the policy's eloMin/eloMax then clamp the result via clampDerived below, so
// a season's Elo floor/ceiling still narrows the odds-derived spread without
// widening it (and without clamping a high directly-entered base Elo's blend).
for (const [participantId, elo] of convertFuturesToElo(oddsInputs)) { for (const [participantId, elo] of convertFuturesToElo(oddsInputs)) {
oddsElo.set(participantId, elo); oddsElo.set(participantId, elo);
} }

View file

@ -61,6 +61,7 @@ import { eq } from "drizzle-orm";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { convertAmericanOddsToProbability } from "~/services/probability-engine"; import { convertAmericanOddsToProbability } from "~/services/probability-engine";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters ────────────────────────────────────────────────────
@ -261,7 +262,8 @@ function determineRandomized(sideTeams: Team[], sideName: string): boolean {
export class LLWSSimulator implements Simulator { export class LLWSSimulator implements Simulator {
constructor(private numSimulations = NUM_SIMULATIONS) {} constructor(private numSimulations = NUM_SIMULATIONS) {}
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", this.numSimulations));
const db = database(); const db = database();
// 1. Load all participants. // 1. Load all participants.
@ -347,7 +349,7 @@ export class LLWSSimulator implements Simulator {
}; };
// 6. Run Monte Carlo simulations. // 6. Run Monte Carlo simulations.
for (let s = 0; s < this.numSimulations; s++) { for (let s = 0; s < numSimulations; s++) {
// Assign pools for this simulation. // Assign pools for this simulation.
const [usPoolA, usPoolB] = assignPools(usTeams, usRandomized); const [usPoolA, usPoolB] = assignPools(usTeams, usRandomized);
const [intlPoolA, intlPoolB] = assignPools(intlTeams, intlRandomized); const [intlPoolA, intlPoolB] = assignPools(intlTeams, intlRandomized);
@ -378,7 +380,7 @@ export class LLWSSimulator implements Simulator {
// 7. Convert counts to probability distributions. // 7. Convert counts to probability distributions.
// bracketLosers: 4 per sim (2 US + 2 Intl) → split evenly. // bracketLosers: 4 per sim (2 US + 2 Intl) → split evenly.
const bracketLosersPerSim = 4; const bracketLosersPerSim = 4;
const bracketDivisor = bracketLosersPerSim * this.numSimulations; const bracketDivisor = bracketLosersPerSim * numSimulations;
const zeroCounts: PlacementCounts = { champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0, bracketLoser: 0 }; const zeroCounts: PlacementCounts = { champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0, bracketLoser: 0 };
return allIds.map((id) => { return allIds.map((id) => {
@ -387,10 +389,10 @@ export class LLWSSimulator implements Simulator {
return { return {
participantId: id, participantId: id,
probabilities: { probabilities: {
probFirst: c.champion / this.numSimulations, probFirst: c.champion / numSimulations,
probSecond: c.finalist / this.numSimulations, probSecond: c.finalist / numSimulations,
probThird: c.thirdPlace / this.numSimulations, probThird: c.thirdPlace / numSimulations,
probFourth: c.fourthPlace / this.numSimulations, probFourth: c.fourthPlace / numSimulations,
probFifth: bracketProb, probFifth: bracketProb,
probSixth: bracketProb, probSixth: bracketProb,
probSeventh: bracketProb, probSeventh: bracketProb,

View file

@ -66,7 +66,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "futuresOdds", "bracket"], setupSections: ["participants", "futuresOdds", "bracket"],
}, },
ucl_bracket: { ucl_bracket: {
defaultConfig: { ...BASE_CONFIG, inputPolicy: { oddsWeight: 0.3 } }, defaultConfig: { ...BASE_CONFIG, parityFactor: 400, inputPolicy: { oddsWeight: 0.3 } },
requiredInputs: ["sourceElo"], requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds"], optionalInputs: ["sourceOdds"],
derivableInputs: { sourceElo: ["sourceOdds"] }, derivableInputs: { sourceElo: ["sourceOdds"] },
@ -150,7 +150,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "eloRatings", "regularStandings"], setupSections: ["participants", "eloRatings", "regularStandings"],
}, },
wnba_bracket: { wnba_bracket: {
defaultConfig: { ...BASE_CONFIG, seasonGames: 44, srsEloScale: 30 }, defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 44, srsEloScale: 30 },
requiredInputs: ["sourceElo"], requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "projectedWins"], optionalInputs: ["sourceOdds", "projectedWins"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] }, derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
@ -176,7 +176,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"], setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"],
}, },
ncaa_football_bracket: { ncaa_football_bracket: {
defaultConfig: { ...BASE_CONFIG, bracketSize: 12, inputPolicy: { oddsWeight: 0.4 } }, defaultConfig: { ...BASE_CONFIG, parityFactor: 400, bracketSize: 12, inputPolicy: { oddsWeight: 0.4 } },
requiredInputs: ["sourceElo"], requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "worldRanking"], optionalInputs: ["sourceOdds", "worldRanking"],
derivableInputs: { sourceElo: ["sourceOdds"] }, derivableInputs: { sourceElo: ["sourceOdds"] },

View file

@ -74,12 +74,13 @@ import { database } from "~/database/context";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import { getRegularSeasonStandings } from "~/models/regular-season-standings"; import { getRegularSeasonStandings } from "~/models/regular-season-standings";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters ────────────────────────────────────────────────────
const NUM_SIMULATIONS = 50_000; const DEFAULT_NUM_SIMULATIONS = 50_000;
const TOTAL_SEASON_GAMES = 162; const TOTAL_SEASON_GAMES = 162;
/** /**
@ -440,7 +441,8 @@ function simLeagueBracket(
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class MLBSimulator implements Simulator { export class MLBSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const db = database(); const db = database();
// 1. Load all participants for this sports season. // 1. Load all participants for this sports season.
@ -569,7 +571,7 @@ export class MLBSimulator implements Simulator {
let effectiveN = 0; let effectiveN = 0;
for (let s = 0; s < NUM_SIMULATIONS; s++) { for (let s = 0; s < numSimulations; s++) {
const alField = drawLeaguePlayoffField(alTeams, seedingWinRate); const alField = drawLeaguePlayoffField(alTeams, seedingWinRate);
const nlField = drawLeaguePlayoffField(nlTeams, seedingWinRate); const nlField = drawLeaguePlayoffField(nlTeams, seedingWinRate);
if (!alField || !nlField) continue; // degenerate draw — skip if (!alField || !nlField) continue; // degenerate draw — skip

View file

@ -50,6 +50,7 @@ import { simulateEloSoccerMatch, simulateSimpleSoccerGoals } from "./soccer-help
import type { EloSoccerMatchOptions } from "./soccer-helpers"; import type { EloSoccerMatchOptions } from "./soccer-helpers";
import { normalizeSimulationResultColumns } from "./simulation-probabilities"; import { normalizeSimulationResultColumns } from "./simulation-probabilities";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import { configNumber } from "./config-access";
// ─── Default constants (overridable via season simulator config) ─────────────── // ─── Default constants (overridable via season simulator config) ───────────────
@ -62,12 +63,6 @@ const DEFAULT_DRAW_DECAY = 0.002;
const MLS_PLAYOFF_TEAMS_PER_CONF = 9; const MLS_PLAYOFF_TEAMS_PER_CONF = 9;
// ─── Config helpers ────────────────────────────────────────────────────────────
function configNumber(config: Record<string, unknown> | undefined, key: string, fallback: number): number {
const value = config?.[key];
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
}
/** /**
* Infer MLS conference from seasonParticipant.externalId. * Infer MLS conference from seasonParticipant.externalId.

View file

@ -53,19 +53,21 @@ import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { normalizeTeamName } from "~/lib/normalize-team-name"; import { normalizeTeamName } from "~/lib/normalize-team-name";
import { getRegularSeasonStandings } from "~/models/regular-season-standings"; import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters (defaults; overridable via season config) ───────────
const NUM_SIMULATIONS = 50_000; const DEFAULT_NUM_SIMULATIONS = 50_000;
/** /**
* Elo parity factor. NBA uses 400 (standard formula). * Elo parity factor. NBA defaults to 400 (standard formula).
* A 400-point Elo difference ~90.9% win probability per game. * A 400-point Elo difference ~90.9% win probability per game.
* Raising it (via the season config's `parityFactor`) flattens the distribution.
*/ */
const PARITY_FACTOR = 400; const DEFAULT_PARITY_FACTOR = 400;
/** NBA regular season games per team. */ /** NBA regular season games per team. */
const NBA_REGULAR_SEASON_GAMES = 82; const DEFAULT_REGULAR_SEASON_GAMES = 82;
// ─── Team data (2025-26 season, as of March 2026) ───────────────────────────── // ─── Team data (2025-26 season, as of March 2026) ─────────────────────────────
@ -129,8 +131,8 @@ export function getTeamData(name: string): NbaTeamData | undefined {
* Elo win probability for team A over team B. * Elo win probability for team A over team B.
* P(A) = 1 / (1 + 10^((eloB - eloA) / PARITY_FACTOR)) * P(A) = 1 / (1 + 10^((eloB - eloA) / PARITY_FACTOR))
*/ */
export function eloWinProbability(eloA: number, eloB: number): number { export function eloWinProbability(eloA: number, eloB: number, parityFactor = DEFAULT_PARITY_FACTOR): number {
return 1 / (1 + Math.pow(10, (eloB - eloA) / PARITY_FACTOR)); return 1 / (1 + Math.pow(10, (eloB - eloA) / parityFactor));
} }
// ─── Per-position normalization ─────────────────────────────────────────────── // ─── Per-position normalization ───────────────────────────────────────────────
@ -157,6 +159,12 @@ function normalizeColumns(results: SimulationResult[]): void {
type DbMatch = typeof schema.playoffMatches.$inferSelect; type DbMatch = typeof schema.playoffMatches.$inferSelect;
/**
* Build the bracket match helpers bound to a given `parityFactor`. Returning them
* from a factory keeps every call site unchanged while letting the season config
* tune per-game variance.
*/
function makeBracketHelpers(parityFactor: number) {
/** Simulate a single-game matchup. Returns winner and loser. */ /** Simulate a single-game matchup. Returns winner and loser. */
function simGame( function simGame(
eloMap: Map<string, number>, eloMap: Map<string, number>,
@ -165,7 +173,7 @@ function simGame(
): { winner: string; loser: string } { ): { winner: string; loser: string } {
const eloA = eloMap.get(a) ?? 1400; const eloA = eloMap.get(a) ?? 1400;
const eloB = eloMap.get(b) ?? 1400; const eloB = eloMap.get(b) ?? 1400;
const winner = Math.random() < eloWinProbability(eloA, eloB) ? a : b; const winner = Math.random() < eloWinProbability(eloA, eloB, parityFactor) ? a : b;
return { winner, loser: winner === a ? b : a }; return { winner, loser: winner === a ? b : a };
} }
@ -175,7 +183,7 @@ function simSeries(
a: string, a: string,
b: string b: string
): { winner: string; loser: string } { ): { winner: string; loser: string } {
const winProb = eloWinProbability(eloMap.get(a) ?? 1400, eloMap.get(b) ?? 1400); const winProb = eloWinProbability(eloMap.get(a) ?? 1400, eloMap.get(b) ?? 1400, parityFactor);
let wA = 0; let wA = 0;
let wB = 0; let wB = 0;
while (wA < 4 && wB < 4) { while (wA < 4 && wB < 4) {
@ -237,10 +245,13 @@ function resolveSeries(
return simSeries(eloMap, p1, p2); return simSeries(eloMap, p1, p2);
} }
return { simGame, simSeries, resolveGame, resolveSeries };
}
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class NBASimulator implements Simulator { export class NBASimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const db = database(); const db = database();
// ── Mode detection: bracket-aware if a playoff event with matches exists ── // ── Mode detection: bracket-aware if a playoff event with matches exists ──
@ -258,21 +269,25 @@ export class NBASimulator implements Simulator {
}); });
if (bracketMatches.length > 0) { if (bracketMatches.length > 0) {
return this.simulateBracketAware(sportsSeasonId, bracketMatches); return this.simulateBracketAware(sportsSeasonId, bracketMatches, config);
} }
} }
// ── Fall back to season-projection mode ─────────────────────────────────── // ── Fall back to season-projection mode ───────────────────────────────────
return this.simulateSeasonProjection(sportsSeasonId); return this.simulateSeasonProjection(sportsSeasonId, config);
} }
// ── Mode 1: Bracket-Aware ────────────────────────────────────────────────── // ── Mode 1: Bracket-Aware ──────────────────────────────────────────────────
private async simulateBracketAware( private async simulateBracketAware(
sportsSeasonId: string, sportsSeasonId: string,
allMatches: DbMatch[] allMatches: DbMatch[],
config: Record<string, unknown> = {}
): Promise<SimulationResult[]> { ): Promise<SimulationResult[]> {
const db = database(); const db = database();
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const { resolveGame, resolveSeries } = makeBracketHelpers(parityFactor);
// Group matches by round. // Group matches by round.
const pir1 = allMatches.filter((m) => m.round === "Play-In Round 1") const pir1 = allMatches.filter((m) => m.round === "Play-In Round 1")
@ -355,7 +370,7 @@ export class NBASimulator implements Simulator {
const csLoserCounts = new Map(participantIds.map((id) => [id, 0])); const csLoserCounts = new Map(participantIds.map((id) => [id, 0]));
// ── Monte Carlo loop ────────────────────────────────────────────────────── // ── Monte Carlo loop ──────────────────────────────────────────────────────
for (let s = 0; s < NUM_SIMULATIONS; s++) { for (let s = 0; s < numSimulations; s++) {
// ── Play-In Round 1 (4 single games) ───────────────────────────────── // ── Play-In Round 1 (4 single games) ─────────────────────────────────
// M1: East 7 vs 8 → winner = E7 seed; loser enters PIR2 as p1 // M1: East 7 vs 8 → winner = E7 seed; loser enters PIR2 as p1
@ -439,7 +454,7 @@ export class NBASimulator implements Simulator {
// probFirst/Second → N total (1 per sim) // probFirst/Second → N total (1 per sim)
// probThird/Fourth → cfLoserCounts / (2×N) — 2 CF losers per sim // probThird/Fourth → cfLoserCounts / (2×N) — 2 CF losers per sim
// probFifthEighth → csLoserCounts / (4×N) — 4 CS losers per sim // probFifthEighth → csLoserCounts / (4×N) — 4 CS losers per sim
const N = NUM_SIMULATIONS; const N = numSimulations;
const results: SimulationResult[] = participantIds.map((participantId) => { const results: SimulationResult[] = participantIds.map((participantId) => {
const c = championCounts.get(participantId) ?? 0; const c = championCounts.get(participantId) ?? 0;
const f = finalistCounts.get(participantId) ?? 0; const f = finalistCounts.get(participantId) ?? 0;
@ -467,8 +482,11 @@ export class NBASimulator implements Simulator {
// ── Mode 2: Season Projection ────────────────────────────────────────────── // ── Mode 2: Season Projection ──────────────────────────────────────────────
private async simulateSeasonProjection(sportsSeasonId: string): Promise<SimulationResult[]> { private async simulateSeasonProjection(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const db = database(); const db = database();
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const seasonGames = Math.round(positiveConfigNumber(config, "seasonGames", DEFAULT_REGULAR_SEASON_GAMES));
const [participantRows, standings, evRows] = await Promise.all([ const [participantRows, standings, evRows] = await Promise.all([
db db
@ -529,8 +547,8 @@ export class NBASimulator implements Simulator {
data, data,
conference, conference,
currentWins: standing?.wins ?? 0, currentWins: standing?.wins ?? 0,
remainingGames: Math.max(0, NBA_REGULAR_SEASON_GAMES - gamesPlayed), remainingGames: Math.max(0, seasonGames - gamesPlayed),
winProb: eloWinProbability(resolvedElo, 1500), winProb: eloWinProbability(resolvedElo, 1500, parityFactor),
resolvedElo, resolvedElo,
}; };
}); });
@ -546,10 +564,10 @@ export class NBASimulator implements Simulator {
} }
const simTeamGame = (a: TeamEntry, b: TeamEntry): TeamEntry => const simTeamGame = (a: TeamEntry, b: TeamEntry): TeamEntry =>
Math.random() < eloWinProbability(eloOfEntry(a), eloOfEntry(b)) ? a : b; Math.random() < eloWinProbability(eloOfEntry(a), eloOfEntry(b), parityFactor) ? a : b;
const simTeamSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => { const simTeamSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => {
const winProb = eloWinProbability(eloOfEntry(a), eloOfEntry(b)); const winProb = eloWinProbability(eloOfEntry(a), eloOfEntry(b), parityFactor);
let wA = 0; let wA = 0;
let wB = 0; let wB = 0;
while (wA < 4 && wB < 4) { while (wA < 4 && wB < 4) {
@ -597,7 +615,7 @@ export class NBASimulator implements Simulator {
const confFinalLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0])); const confFinalLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const confSemiLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0])); const confSemiLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
for (let s = 0; s < NUM_SIMULATIONS; s++) { for (let s = 0; s < numSimulations; s++) {
const eastBracket = buildConferenceBracket(easternTeams); const eastBracket = buildConferenceBracket(easternTeams);
const westBracket = buildConferenceBracket(westernTeams); const westBracket = buildConferenceBracket(westernTeams);
@ -618,7 +636,7 @@ export class NBASimulator implements Simulator {
} }
} }
const N = NUM_SIMULATIONS; const N = numSimulations;
const results: SimulationResult[] = participantIds.map((participantId) => { const results: SimulationResult[] = participantIds.map((participantId) => {
const c = championCounts.get(participantId) ?? 0; const c = championCounts.get(participantId) ?? 0;
const f = finalistCounts.get(participantId) ?? 0; const f = finalistCounts.get(participantId) ?? 0;

View file

@ -7,9 +7,9 @@
* 1. Load all participants for the sports season from DB * 1. Load all participants for the sports season from DB
* 2. Load Elo/FPI ratings from participantExpectedValues.sourceElo * 2. Load Elo/FPI ratings from participantExpectedValues.sourceElo
* (entered via Admin Elo Ratings page; use FPI, S&P+, or any Elo-scale rating) * (entered via Admin Elo Ratings page; use FPI, S&P+, or any Elo-scale rating)
* 3. If sourceOdds (American format) are also stored, build a normalized selection * 3. Field-selection weight is a softmax over the resolved Elo. Futures odds are
* weight from implied championship probability (used for field selection in step 4) * not blended into per-game win probability the input policy already folded
* and blend into per-game win probability (ELO_WEIGHT=0.6 / ODDS_WEIGHT=0.4). * them into sourceElo, so blending again would double-count them.
* 4. Per simulation, select 12 teams for the CFP field: * 4. Per simulation, select 12 teams for the CFP field:
* - If the pool has exactly 12 teams: use all of them (post-bracket mode). * - If the pool has exactly 12 teams: use all of them (post-bracket mode).
* - If the pool has >12 teams: weighted sample without replacement using each * - If the pool has >12 teams: weighted sample without replacement using each
@ -31,8 +31,8 @@
* fields, so its champion probability accounts for that. * fields, so its champion probability accounts for that.
* Post-bracket (exactly 12 participants): deterministic field, bracket-only sim. * Post-bracket (exactly 12 participants): deterministic field, bracket-only sim.
* *
* Win probability (per game): eloWinProbability() from probability-engine (400-divisor). * Win probability (per game): eloWinProbabilityWithParity(eloA, eloB, parityFactor),
* Blended win probability: ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb (when odds present). * where parityFactor comes from the season config (default 400).
* *
* Selection weight (pre-bracket mode): * Selection weight (pre-bracket mode):
* With sourceOdds: normalized implied championship probability (vig removed, sums to 1). * With sourceOdds: normalized implied championship probability (vig removed, sums to 1).
@ -59,12 +59,15 @@
import { database } from "~/database/context"; import { database } from "~/database/context";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eloWinProbability } from "~/services/probability-engine"; import { eloWinProbabilityWithParity } from "~/services/probability-engine";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters (defaults; overridable via season config) ───────────
const NUM_SIMULATIONS = 50_000; const DEFAULT_NUM_SIMULATIONS = 50_000;
/** Elo parity factor. Defaults to 400 (standard formula). */
const DEFAULT_PARITY_FACTOR = 400;
const BRACKET_SIZE = 12; const BRACKET_SIZE = 12;
/** /**
@ -87,12 +90,12 @@ interface Team {
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
/** Win probability for team1 vs team2 from the single resolved Elo. */ /** Win probability for team1 vs team2 from the single resolved Elo. */
function gameWinProb(team1: Team, team2: Team): number { function gameWinProb(team1: Team, team2: Team, parityFactor = DEFAULT_PARITY_FACTOR): number {
return eloWinProbability(team1.elo, team2.elo); return eloWinProbabilityWithParity(team1.elo, team2.elo, parityFactor);
} }
function simGame(team1: Team, team2: Team): { winner: Team; loser: Team } { function simGame(team1: Team, team2: Team, parityFactor = DEFAULT_PARITY_FACTOR): { winner: Team; loser: Team } {
const p1Wins = Math.random() < gameWinProb(team1, team2); const p1Wins = Math.random() < gameWinProb(team1, team2, parityFactor);
return p1Wins return p1Wins
? { winner: team1, loser: team2 } ? { winner: team1, loser: team2 }
: { winner: team2, loser: team1 }; : { winner: team2, loser: team1 };
@ -141,18 +144,19 @@ interface PlacementCounts {
* Semifinals: qf1w vs qf2w, qf3w vs qf4w * Semifinals: qf1w vs qf2w, qf3w vs qf4w
* Championship: sf1w vs sf2w * Championship: sf1w vs sf2w
*/ */
function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>): void { function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>, parityFactor = DEFAULT_PARITY_FACTOR): void {
const game = (a: Team, b: Team) => simGame(a, b, parityFactor);
// ── First Round (seeds 512) ─────────────────────────────────────────────── // ── First Round (seeds 512) ───────────────────────────────────────────────
const fr1 = simGame(teams[4], teams[11]); // 5 vs 12 const fr1 = game(teams[4], teams[11]); // 5 vs 12
const fr2 = simGame(teams[5], teams[10]); // 6 vs 11 const fr2 = game(teams[5], teams[10]); // 6 vs 11
const fr3 = simGame(teams[6], teams[9]); // 7 vs 10 const fr3 = game(teams[6], teams[9]); // 7 vs 10
const fr4 = simGame(teams[7], teams[8]); // 8 vs 9 const fr4 = game(teams[7], teams[8]); // 8 vs 9
// ── Quarterfinals (seeds 14 get byes) ──────────────────────────────────── // ── Quarterfinals (seeds 14 get byes) ────────────────────────────────────
const qf1 = simGame(teams[0], fr4.winner); // 1 vs 8/9 winner const qf1 = game(teams[0], fr4.winner); // 1 vs 8/9 winner
const qf2 = simGame(teams[3], fr1.winner); // 4 vs 5/12 winner const qf2 = game(teams[3], fr1.winner); // 4 vs 5/12 winner
const qf3 = simGame(teams[2], fr2.winner); // 3 vs 6/11 winner const qf3 = game(teams[2], fr2.winner); // 3 vs 6/11 winner
const qf4 = simGame(teams[1], fr3.winner); // 2 vs 7/10 winner const qf4 = game(teams[1], fr3.winner); // 2 vs 7/10 winner
const bump = (id: string, key: keyof PlacementCounts) => { const bump = (id: string, key: keyof PlacementCounts) => {
const entry = counts.get(id); const entry = counts.get(id);
@ -165,14 +169,14 @@ function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>): v
bump(qf4.loser.participantId, "qfLoser"); bump(qf4.loser.participantId, "qfLoser");
// ── Semifinals ──────────────────────────────────────────────────────────── // ── Semifinals ────────────────────────────────────────────────────────────
const sf1 = simGame(qf1.winner, qf2.winner); const sf1 = game(qf1.winner, qf2.winner);
const sf2 = simGame(qf3.winner, qf4.winner); const sf2 = game(qf3.winner, qf4.winner);
bump(sf1.loser.participantId, "sfLoser"); bump(sf1.loser.participantId, "sfLoser");
bump(sf2.loser.participantId, "sfLoser"); bump(sf2.loser.participantId, "sfLoser");
// ── National Championship ───────────────────────────────────────────────── // ── National Championship ─────────────────────────────────────────────────
const final = simGame(sf1.winner, sf2.winner); const final = game(sf1.winner, sf2.winner);
bump(final.winner.participantId, "champion"); bump(final.winner.participantId, "champion");
bump(final.loser.participantId, "finalist"); bump(final.loser.participantId, "finalist");
@ -181,8 +185,10 @@ function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>): v
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class NCAAFootballSimulator implements Simulator { export class NCAAFootballSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const db = database(); const db = database();
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
// 1. Load all participants for this sports season. // 1. Load all participants for this sports season.
const participants = await db const participants = await db
.select({ id: schema.seasonParticipants.id }) .select({ id: schema.seasonParticipants.id })
@ -241,18 +247,18 @@ export class NCAAFootballSimulator implements Simulator {
); );
// 6. Run Monte Carlo simulations. // 6. Run Monte Carlo simulations.
for (let s = 0; s < NUM_SIMULATIONS; s++) { for (let s = 0; s < numSimulations; s++) {
const field = preBracketMode const field = preBracketMode
? sampleBracketField(allTeams, BRACKET_SIZE) ? sampleBracketField(allTeams, BRACKET_SIZE)
: (deterministicField ?? []); : (deterministicField ?? []);
simulateBracket(field, counts); simulateBracket(field, counts, parityFactor);
} }
// 7. Convert counts to probability distributions. // 7. Convert counts to probability distributions.
// SF losers: 2 per sim → each team's share = sfLoser / (2 * N). // SF losers: 2 per sim → each team's share = sfLoser / (2 * N).
// QF losers: 4 per sim → each team's share = qfLoser / (4 * N). // QF losers: 4 per sim → each team's share = qfLoser / (4 * N).
const sfDivisor = 2 * NUM_SIMULATIONS; const sfDivisor = 2 * numSimulations;
const qfDivisor = 4 * NUM_SIMULATIONS; const qfDivisor = 4 * numSimulations;
return allParticipantIds.map((id) => { return allParticipantIds.map((id) => {
const c = counts.get(id) ?? { champion: 0, finalist: 0, sfLoser: 0, qfLoser: 0 }; const c = counts.get(id) ?? { champion: 0, finalist: 0, sfLoser: 0, qfLoser: 0 };
@ -261,8 +267,8 @@ export class NCAAFootballSimulator implements Simulator {
return { return {
participantId: id, participantId: id,
probabilities: { probabilities: {
probFirst: c.champion / NUM_SIMULATIONS, probFirst: c.champion / numSimulations,
probSecond: c.finalist / NUM_SIMULATIONS, probSecond: c.finalist / numSimulations,
probThird: sfProb, probThird: sfProb,
probFourth: sfProb, probFourth: sfProb,
probFifth: qfProb, probFifth: qfProb,

View file

@ -1,5 +1,6 @@
import { NCAABasketballSimulator } from "./ncaa-basketball-simulator"; import { NCAABasketballSimulator } from "./ncaa-basketball-simulator";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { optionalPositiveNumber } from "./config-access";
const KENPOM_SCALE_FACTOR = 7.5; const KENPOM_SCALE_FACTOR = 7.5;
@ -11,10 +12,6 @@ export function kenpomWinProbability(netrtgA: number, netrtgB: number): number {
return 1 / (1 + Math.exp(-(netrtgA - netrtgB) / KENPOM_SCALE_FACTOR)); return 1 / (1 + Math.exp(-(netrtgA - netrtgB) / KENPOM_SCALE_FACTOR));
} }
function optionalPositiveNumber(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
}
function kenpomWinProbabilityWithScale(scaleFactor: number): (netrtgA: number, netrtgB: number) => number { function kenpomWinProbabilityWithScale(scaleFactor: number): (netrtgA: number, netrtgB: number) => number {
return (netrtgA, netrtgB) => 1 / (1 + Math.exp(-(netrtgA - netrtgB) / scaleFactor)); return (netrtgA, netrtgB) => 1 / (1 + Math.exp(-(netrtgA - netrtgB) / scaleFactor));
} }

View file

@ -46,15 +46,19 @@ import { eq } from "drizzle-orm";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { normalizeTeamName } from "~/lib/normalize-team-name"; import { normalizeTeamName } from "~/lib/normalize-team-name";
import { eloWinProbability, convertFuturesToElo } from "~/services/probability-engine"; import { eloWinProbabilityWithParity, convertFuturesToElo } from "~/services/probability-engine";
import { getRegularSeasonStandings } from "~/models/regular-season-standings"; import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters (defaults; overridable via season config) ───────────
const NUM_SIMULATIONS = 50_000; const DEFAULT_NUM_SIMULATIONS = 50_000;
/** Elo parity factor. NFL defaults to 400 (standard formula). */
const DEFAULT_PARITY_FACTOR = 400;
/** NFL regular season games per team. */ /** NFL regular season games per team. */
const NFL_REGULAR_SEASON_GAMES = 17; const DEFAULT_REGULAR_SEASON_GAMES = 17;
/** Average opponent Elo for regular season game projection. */ /** Average opponent Elo for regular season game projection. */
const AVG_OPPONENT_ELO = 1500; const AVG_OPPONENT_ELO = 1500;
@ -173,9 +177,10 @@ export function getTeamData(name: string): NflTeamData | undefined {
function simulateProjectedWins( function simulateProjectedWins(
currentWins: number, currentWins: number,
gamesPlayed: number, gamesPlayed: number,
winProb: number winProb: number,
seasonGames = DEFAULT_REGULAR_SEASON_GAMES
): number { ): number {
const remaining = Math.max(0, NFL_REGULAR_SEASON_GAMES - gamesPlayed); const remaining = Math.max(0, seasonGames - gamesPlayed);
let additional = 0; let additional = 0;
for (let g = 0; g < remaining; g++) { for (let g = 0; g < remaining; g++) {
if (Math.random() < winProb) additional++; if (Math.random() < winProb) additional++;
@ -231,11 +236,12 @@ export function seedConference(
* Simulate a playoff game with home-field advantage for the higher seed. * Simulate a playoff game with home-field advantage for the higher seed.
* The team with the lower seed number (better seed) is the home team. * The team with the lower seed number (better seed) is the home team.
*/ */
function playGame(a: SeededTeam, b: SeededTeam): { winner: SeededTeam; loser: SeededTeam } { function playGame(a: SeededTeam, b: SeededTeam, parityFactor = DEFAULT_PARITY_FACTOR): { winner: SeededTeam; loser: SeededTeam } {
const [home, away] = a.seed < b.seed ? [a, b] : [b, a]; const [home, away] = a.seed < b.seed ? [a, b] : [b, a];
const winProbHome = eloWinProbability( const winProbHome = eloWinProbabilityWithParity(
home.elo + NFL_HOME_FIELD_ELO_ADVANTAGE, home.elo + NFL_HOME_FIELD_ELO_ADVANTAGE,
away.elo away.elo,
parityFactor
); );
if (Math.random() < winProbHome) return { winner: home, loser: away }; if (Math.random() < winProbHome) return { winner: home, loser: away };
return { winner: away, loser: home }; return { winner: away, loser: home };
@ -245,8 +251,8 @@ function playGame(a: SeededTeam, b: SeededTeam): { winner: SeededTeam; loser: Se
* Simulate a game at a neutral site (no home-field adjustment). * Simulate a game at a neutral site (no home-field adjustment).
* Used for the Super Bowl. * Used for the Super Bowl.
*/ */
function playNeutralGame(a: SeededTeam, b: SeededTeam): { winner: SeededTeam; loser: SeededTeam } { function playNeutralGame(a: SeededTeam, b: SeededTeam, parityFactor = DEFAULT_PARITY_FACTOR): { winner: SeededTeam; loser: SeededTeam } {
const winProbA = eloWinProbability(a.elo, b.elo); const winProbA = eloWinProbabilityWithParity(a.elo, b.elo, parityFactor);
if (Math.random() < winProbA) return { winner: a, loser: b }; if (Math.random() < winProbA) return { winner: a, loser: b };
return { winner: b, loser: a }; return { winner: b, loser: a };
} }
@ -261,14 +267,15 @@ function playNeutralGame(a: SeededTeam, b: SeededTeam): { winner: SeededTeam; lo
* Returns the finalist, the conference championship loser, and the two * Returns the finalist, the conference championship loser, and the two
* divisional-round losers (for 5th-8th place EV). * divisional-round losers (for 5th-8th place EV).
*/ */
export function simulateConferenceBracket(seeds: SeededTeam[]): ConferenceBracketResult { export function simulateConferenceBracket(seeds: SeededTeam[], parityFactor = DEFAULT_PARITY_FACTOR): ConferenceBracketResult {
const byIndex = new Map(seeds.map((t) => [t.seed, t])); const byIndex = new Map(seeds.map((t) => [t.seed, t]));
const s = (seed: number) => byIndex.get(seed) as SeededTeam; const s = (seed: number) => byIndex.get(seed) as SeededTeam;
const pg = (a: SeededTeam, b: SeededTeam) => playGame(a, b, parityFactor);
// Wild Card (seed 1 bye; lower seed = home) // Wild Card (seed 1 bye; lower seed = home)
const wc27 = playGame(s(2), s(7)); const wc27 = pg(s(2), s(7));
const wc36 = playGame(s(3), s(6)); const wc36 = pg(s(3), s(6));
const wc45 = playGame(s(4), s(5)); const wc45 = pg(s(4), s(5));
// Divisional: sort WC winners by original seed (ascending = better seed = home) // Divisional: sort WC winners by original seed (ascending = better seed = home)
// Seed 1 hosts the lowest-ranked (highest seed number) WC winner // Seed 1 hosts the lowest-ranked (highest seed number) WC winner
@ -276,11 +283,11 @@ export function simulateConferenceBracket(seeds: SeededTeam[]): ConferenceBracke
(a, b) => a.seed - b.seed (a, b) => a.seed - b.seed
); );
const div1 = playGame(s(1), wcWinners[wcWinners.length - 1]); const div1 = pg(s(1), wcWinners[wcWinners.length - 1]);
const div2 = playGame(wcWinners[0], wcWinners[1]); const div2 = pg(wcWinners[0], wcWinners[1]);
// Conference Championship (higher remaining seed is home) // Conference Championship (higher remaining seed is home)
const conf = playGame(div1.winner, div2.winner); const conf = pg(div1.winner, div2.winner);
return { return {
finalist: conf.winner, finalist: conf.winner,
@ -292,8 +299,11 @@ export function simulateConferenceBracket(seeds: SeededTeam[]): ConferenceBracke
// ─── Simulator class ────────────────────────────────────────────────────────── // ─── Simulator class ──────────────────────────────────────────────────────────
export class NFLSimulator implements Simulator { export class NFLSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const db = database(); const db = database();
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const seasonGames = Math.round(positiveConfigNumber(config, "seasonGames", DEFAULT_REGULAR_SEASON_GAMES));
// Load participants // Load participants
const participants = await db.query.seasonParticipants.findMany({ const participants = await db.query.seasonParticipants.findMany({
@ -385,7 +395,7 @@ export class NFLSimulator implements Simulator {
// Pre-compute per-team win probability vs average opponent (constant across sims) // Pre-compute per-team win probability vs average opponent (constant across sims)
const winProbMap = new Map<string, number>(); const winProbMap = new Map<string, number>();
for (const t of teams) { for (const t of teams) {
winProbMap.set(t.participantId, eloWinProbability(t.elo, AVG_OPPONENT_ELO)); winProbMap.set(t.participantId, eloWinProbabilityWithParity(t.elo, AVG_OPPONENT_ELO, parityFactor));
} }
// Placement counters // Placement counters
@ -396,7 +406,7 @@ export class NFLSimulator implements Simulator {
// ─── Monte Carlo ────────────────────────────────────────────────────────── // ─── Monte Carlo ──────────────────────────────────────────────────────────
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) { for (let sim = 0; sim < numSimulations; sim++) {
// Project wins for each team this simulation // Project wins for each team this simulation
const projected = teams.map((t) => ({ const projected = teams.map((t) => ({
participantId: t.participantId, participantId: t.participantId,
@ -406,7 +416,8 @@ export class NFLSimulator implements Simulator {
projectedWins: simulateProjectedWins( projectedWins: simulateProjectedWins(
t.currentWins, t.currentWins,
t.gamesPlayed, t.gamesPlayed,
winProbMap.get(t.participantId) as number winProbMap.get(t.participantId) as number,
seasonGames
), ),
})); }));
@ -416,11 +427,11 @@ export class NFLSimulator implements Simulator {
const nfcSeeds = seedConference(nfcTeams); const nfcSeeds = seedConference(nfcTeams);
// Simulate conference brackets // Simulate conference brackets
const afcResult = simulateConferenceBracket(afcSeeds); const afcResult = simulateConferenceBracket(afcSeeds, parityFactor);
const nfcResult = simulateConferenceBracket(nfcSeeds); const nfcResult = simulateConferenceBracket(nfcSeeds, parityFactor);
// Super Bowl (neutral site) // Super Bowl (neutral site)
const sb = playNeutralGame(afcResult.finalist, nfcResult.finalist); const sb = playNeutralGame(afcResult.finalist, nfcResult.finalist, parityFactor);
// 1st / 2nd // 1st / 2nd
counts[sb.winner.participantId][1] += 1; counts[sb.winner.participantId][1] += 1;
@ -445,7 +456,7 @@ export class NFLSimulator implements Simulator {
// Convert counts to probabilities // Convert counts to probabilities
return teams.map((t) => { return teams.map((t) => {
const c = counts[t.participantId]; const c = counts[t.participantId];
const N = NUM_SIMULATIONS; const N = numSimulations;
return { return {
participantId: t.participantId, participantId: t.participantId,
probabilities: { probabilities: {

View file

@ -64,38 +64,34 @@ import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import {
convertAmericanOddsToProbability,
normalizeProbabilities,
} from "~/services/probability-engine";
import { getRegularSeasonStandings } from "~/models/regular-season-standings"; import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { positiveConfigNumber, configNumber } from "./config-access";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters (defaults; overridable via season config) ───────────
const NUM_SIMULATIONS = 50_000; const DEFAULT_NUM_SIMULATIONS = 50_000;
/** NHL regular season games per team. */ /** NHL regular season games per team. */
const NHL_REGULAR_SEASON_GAMES = 82; const DEFAULT_REGULAR_SEASON_GAMES = 82;
/** /**
* Fraction of NHL games that go to overtime / shootout. * Fraction of NHL games that go to overtime / shootout.
* The losing team earns 1 point (instead of 0) in these games. * The losing team earns 1 point (instead of 0) in these games.
* Historical average: ~23% of games. * Historical average: ~23% of games.
*/ */
const NHL_OT_RATE = 0.23; const DEFAULT_OT_RATE = 0.23;
/** /**
* Elo parity factor. NHL uses 1000 (higher than the standard 400) to reflect * Elo parity factor. NHL defaults to 1000 (higher than the standard 400) to
* the elevated game-to-game variance in hockey. * reflect the elevated game-to-game variance in hockey. Raising it flattens the
* finish-position distribution; the season config's `parityFactor` overrides it.
*
* Futures odds influence the simulation only through each team's resolved Elo
* (odds Elo happens once, in the central input-policy resolver). The simulator
* does not blend raw odds into per-game probabilities doing so double-counted
* the same signal and over-sharpened favorites.
*/ */
const PARITY_FACTOR = 1000; const DEFAULT_PARITY_FACTOR = 1000;
/**
* Blend weights for Elo vs. Vegas futures odds when sourceOdds are available.
* Same calibration as the UCL simulator (0.7 / 0.3).
*/
const ELO_WEIGHT = 0.7;
const ODDS_WEIGHT = 1 - ELO_WEIGHT;
// ─── Team data (2025-26 season) ─────────────────────────────────────────────── // ─── Team data (2025-26 season) ───────────────────────────────────────────────
// //
@ -179,11 +175,11 @@ export function getTeamData(name: string): NhlTeamData | undefined {
/** /**
* Elo win probability for team A over team B. * Elo win probability for team A over team B.
* P(A) = 1 / (1 + 10^((eloB - eloA) / PARITY_FACTOR)) * P(A) = 1 / (1 + 10^((eloB - eloA) / parityFactor))
* Exported for unit testing. * Exported for unit testing.
*/ */
export function eloWinProbability(eloA: number, eloB: number): number { export function eloWinProbability(eloA: number, eloB: number, parityFactor = DEFAULT_PARITY_FACTOR): number {
return 1 / (1 + Math.pow(10, (eloB - eloA) / PARITY_FACTOR)); return 1 / (1 + Math.pow(10, (eloB - eloA) / parityFactor));
} }
// ─── Internal types ─────────────────────────────────────────────────────────── // ─── Internal types ───────────────────────────────────────────────────────────
@ -217,10 +213,10 @@ function elo(entry: TeamEntry): number {
* - Regulation loss (0 pts) otherwise * - Regulation loss (0 pts) otherwise
* Returns projected total points for the season. * Returns projected total points for the season.
*/ */
export function simulateProjectedPoints(entry: TeamEntry): number { export function simulateProjectedPoints(entry: TeamEntry, otRate = DEFAULT_OT_RATE): number {
let extra = 0; let extra = 0;
const { winProb, remainingGames } = entry; const { winProb, remainingGames } = entry;
const otlProb = (1 - winProb) * NHL_OT_RATE; const otlProb = (1 - winProb) * otRate;
for (let g = 0; g < remainingGames; g++) { for (let g = 0; g < remainingGames; g++) {
const r = Math.random(); const r = Math.random();
if (r < winProb) { if (r < winProb) {
@ -241,8 +237,8 @@ interface ProjectedTeam {
} }
/** Project one team's end-of-season point total with a random tiebreaker. */ /** Project one team's end-of-season point total with a random tiebreaker. */
function projectTeam(t: TeamEntry): ProjectedTeam { function projectTeam(t: TeamEntry, otRate: number): ProjectedTeam {
return { team: t, pts: simulateProjectedPoints(t), tb: Math.random() }; return { team: t, pts: simulateProjectedPoints(t, otRate), tb: Math.random() };
} }
/** Sort projected teams descending by pts, then by random tiebreaker. Mutates arr. */ /** Sort projected teams descending by pts, then by random tiebreaker. Mutates arr. */
@ -253,7 +249,7 @@ function sortProjected(arr: ProjectedTeam[]): ProjectedTeam[] {
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class NHLSimulator implements Simulator { export class NHLSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const db = database(); const db = database();
// Check for a populated playoff bracket; if found, use bracket-aware simulation. // Check for a populated playoff bracket; if found, use bracket-aware simulation.
@ -271,7 +267,7 @@ export class NHLSimulator implements Simulator {
}); });
const bracketPopulated = allMatches.some((m) => m.participant1Id && m.participant2Id); const bracketPopulated = allMatches.some((m) => m.participant1Id && m.participant2Id);
if (bracketPopulated) { if (bracketPopulated) {
return this.simulateBracket(sportsSeasonId, allMatches); return this.simulateBracket(sportsSeasonId, allMatches, config);
} }
} }
@ -286,7 +282,6 @@ export class NHLSimulator implements Simulator {
.select({ .select({
participantId: schema.seasonParticipantExpectedValues.participantId, participantId: schema.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo, sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
}) })
.from(schema.seasonParticipantExpectedValues) .from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)), .where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)),
@ -299,6 +294,12 @@ export class NHLSimulator implements Simulator {
); );
} }
// Engine knobs (admin-overridable via the season config; default to NHL values).
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const seasonGames = Math.round(positiveConfigNumber(config, "seasonGames", DEFAULT_REGULAR_SEASON_GAMES));
const otRate = configNumber(config, "overtimeRate", DEFAULT_OT_RATE);
const participantIds = participantRows.map((r) => r.id); const participantIds = participantRows.map((r) => r.id);
// 2. Build standings lookup, sourceElo map, and construct team entries. // 2. Build standings lookup, sourceElo map, and construct team entries.
@ -336,7 +337,7 @@ export class NHLSimulator implements Simulator {
const wins = standing?.wins ?? 0; const wins = standing?.wins ?? 0;
const otLosses = standing?.otLosses ?? 0; const otLosses = standing?.otLosses ?? 0;
const currentPoints = wins * 2 + otLosses; const currentPoints = wins * 2 + otLosses;
const remainingGames = Math.max(0, NHL_REGULAR_SEASON_GAMES - gamesPlayed); const remainingGames = Math.max(0, seasonGames - gamesPlayed);
const resolvedElo = dbEloMap.get(r.id) ?? data?.elo ?? 1400; const resolvedElo = dbEloMap.get(r.id) ?? data?.elo ?? 1400;
return { return {
@ -347,7 +348,7 @@ export class NHLSimulator implements Simulator {
division, division,
currentPoints, currentPoints,
remainingGames, remainingGames,
winProb: eloWinProbability(resolvedElo, 1500), winProb: eloWinProbability(resolvedElo, 1500, parityFactor),
resolvedElo, resolvedElo,
}; };
}); });
@ -381,40 +382,15 @@ export class NHLSimulator implements Simulator {
); );
} }
// ─── Futures odds blending ─────────────────────────────────────────────────
const participantIdSet = new Set(participantIds);
const oddsRows = evRows.filter(
(r) => r.sourceOdds !== null && participantIdSet.has(r.participantId)
);
const hasOdds = oddsRows.length > 0;
const normalizedOddsMap = new Map<string, number>();
if (hasOdds) {
const rawProbs = oddsRows.map((r) =>
convertAmericanOddsToProbability(r.sourceOdds ?? 0)
);
const normalized = normalizeProbabilities(rawProbs);
oddsRows.forEach(({ participantId }, i) => {
normalizedOddsMap.set(participantId, normalized[i]);
});
}
// ─── Helpers (defined once, outside the hot loop) ───────────────────────── // ─── Helpers (defined once, outside the hot loop) ─────────────────────────
/** /**
* Blended per-game win probability for team A over team B. * Per-game win probability for team A over team B, from resolved Elo.
* When odds are available: 70% Elo + 30% vig-removed futures head-to-head. * Futures odds already influenced `elo()` via the central oddsElo resolver,
* so they are not blended in again here.
*/ */
const gameWinProb = (a: TeamEntry, b: TeamEntry): number => { const gameWinProb = (a: TeamEntry, b: TeamEntry): number =>
const eloProb = eloWinProbability(elo(a), elo(b)); eloWinProbability(elo(a), elo(b), parityFactor);
if (!hasOdds) return eloProb;
const o1 = normalizedOddsMap.get(a.id) ?? 0;
const o2 = normalizedOddsMap.get(b.id) ?? 0;
const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5;
return ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb;
};
/** Simulate a best-of-7 series. Returns winner and loser. */ /** Simulate a best-of-7 series. Returns winner and loser. */
const simSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => { const simSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => {
@ -448,7 +424,7 @@ export class NHLSimulator implements Simulator {
// Project all conference teams exactly once (single simulateProjectedPoints call per team). // Project all conference teams exactly once (single simulateProjectedPoints call per team).
const divASet = new Set(divATeams.map((t) => t.id)); const divASet = new Set(divATeams.map((t) => t.id));
const allProjected = sortProjected([...divATeams, ...divBTeams].map(projectTeam)); const allProjected = sortProjected([...divATeams, ...divBTeams].map((t) => projectTeam(t, otRate)));
const divAProjected = allProjected.filter((p) => divASet.has(p.team.id)); const divAProjected = allProjected.filter((p) => divASet.has(p.team.id));
const divBProjected = allProjected.filter((p) => !divASet.has(p.team.id)); const divBProjected = allProjected.filter((p) => !divASet.has(p.team.id));
@ -487,7 +463,7 @@ export class NHLSimulator implements Simulator {
let effectiveN = 0; let effectiveN = 0;
for (let s = 0; s < NUM_SIMULATIONS; s++) { for (let s = 0; s < numSimulations; s++) {
const eastBracket = buildConferenceBracket(easternAtlantic, easternMetro); const eastBracket = buildConferenceBracket(easternAtlantic, easternMetro);
const westBracket = buildConferenceBracket(westernCentral, westernPacific); const westBracket = buildConferenceBracket(westernCentral, westernPacific);
if (!eastBracket || !westBracket) continue; if (!eastBracket || !westBracket) continue;
@ -574,7 +550,8 @@ export class NHLSimulator implements Simulator {
private async simulateBracket( private async simulateBracket(
sportsSeasonId: string, sportsSeasonId: string,
allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>> allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>,
config: Record<string, unknown> = {}
): Promise<SimulationResult[]> { ): Promise<SimulationResult[]> {
const db = database(); const db = database();
@ -626,13 +603,15 @@ export class NHLSimulator implements Simulator {
db db
.select({ .select({
participantId: schema.seasonParticipantExpectedValues.participantId, participantId: schema.seasonParticipantExpectedValues.participantId,
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo, sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
}) })
.from(schema.seasonParticipantExpectedValues) .from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)), .where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)),
]); ]);
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const allParticipantIds = participantRows.map((r) => r.id); const allParticipantIds = participantRows.map((r) => r.id);
const dbEloMap = new Map<string, number>(); const dbEloMap = new Map<string, number>();
@ -648,21 +627,6 @@ export class NHLSimulator implements Simulator {
eloMap.set(r.id, dbEloMap.get(r.id) ?? getTeamData(r.name)?.elo ?? 1400); eloMap.set(r.id, dbEloMap.get(r.id) ?? getTeamData(r.name)?.elo ?? 1400);
} }
const participantIdSet = new Set(allParticipantIds);
const oddsRows = evRows.filter(
(r) => r.sourceOdds !== null && participantIdSet.has(r.participantId)
);
const hasOdds = oddsRows.length > 0;
const normalizedOddsMap = new Map<string, number>();
if (hasOdds) {
const rawProbs = oddsRows.map((r) => convertAmericanOddsToProbability(r.sourceOdds ?? 0));
const normalized = normalizeProbabilities(rawProbs);
oddsRows.forEach(({ participantId }, i) => {
normalizedOddsMap.set(participantId, normalized[i]);
});
}
// Per-round lookup maps for O(1) access. // Per-round lookup maps for O(1) access.
const r1ByNum = new Map(r1Matches.map((m) => [m.matchNumber, m])); const r1ByNum = new Map(r1Matches.map((m) => [m.matchNumber, m]));
const r2ByNum = new Map(r2Matches.map((m) => [m.matchNumber, m])); const r2ByNum = new Map(r2Matches.map((m) => [m.matchNumber, m]));
@ -671,14 +635,10 @@ export class NHLSimulator implements Simulator {
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
const gameWinProb = (aId: string, bId: string): number => { // Futures odds already shaped each team's resolved Elo via the central
const eloProb = eloWinProbability(eloMap.get(aId) ?? 1400, eloMap.get(bId) ?? 1400); // odds→Elo resolver, so per-game probability is pure Elo (no odds re-blend).
if (!hasOdds) return eloProb; const gameWinProb = (aId: string, bId: string): number =>
const o1 = normalizedOddsMap.get(aId) ?? 0; eloWinProbability(eloMap.get(aId) ?? 1400, eloMap.get(bId) ?? 1400, parityFactor);
const o2 = normalizedOddsMap.get(bId) ?? 0;
const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5;
return ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb;
};
const simSeries = (aId: string, bId: string): { winner: string; loser: string } => { const simSeries = (aId: string, bId: string): { winner: string; loser: string } => {
const winProb = gameWinProb(aId, bId); const winProb = gameWinProb(aId, bId);
@ -718,7 +678,7 @@ export class NHLSimulator implements Simulator {
// ── Monte Carlo simulation loop ─────────────────────────────────────────────── // ── Monte Carlo simulation loop ───────────────────────────────────────────────
for (let s = 0; s < NUM_SIMULATIONS; s++) { for (let s = 0; s < numSimulations; s++) {
// Round 1: R1 losers score 0 points — not tracked. // Round 1: R1 losers score 0 points — not tracked.
const r1Winners: string[] = []; const r1Winners: string[] = [];
for (let i = 1; i <= 8; i++) { for (let i = 1; i <= 8; i++) {
@ -758,7 +718,7 @@ export class NHLSimulator implements Simulator {
// ── Convert counts to probability distributions ─────────────────────────────── // ── Convert counts to probability distributions ───────────────────────────────
const N = NUM_SIMULATIONS; const N = numSimulations;
const results: SimulationResult[] = allParticipantIds.map((participantId) => { const results: SimulationResult[] = allParticipantIds.map((participantId) => {
const c = championCounts.get(participantId) ?? 0; const c = championCounts.get(participantId) ?? 0;
const f = finalistCounts.get(participantId) ?? 0; const f = finalistCounts.get(participantId) ?? 0;

View file

@ -43,10 +43,11 @@ import { eloWinProbabilityWithParity } from "~/services/probability-engine";
import { getRegularSeasonStandings } from "~/models/regular-season-standings"; import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { getParticipantSimulatorInputs } from "~/models/simulator"; import { getParticipantSimulatorInputs } from "~/models/simulator";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters (defaults; overridable via season config) ───────────
const NUM_SIMULATIONS = 50_000; const DEFAULT_NUM_SIMULATIONS = 50_000;
const NLL_REGULAR_SEASON_GAMES = 18; const NLL_REGULAR_SEASON_GAMES = 18;
const NLL_PLAYOFF_TEAMS = 8; const NLL_PLAYOFF_TEAMS = 8;
const PARITY_FACTOR = 400; const PARITY_FACTOR = 400;
@ -216,7 +217,8 @@ export function simulateNllPlayoffs(
*/ */
export function resolveQfMatch( export function resolveQfMatch(
match: MatchSnapshot | undefined, match: MatchSnapshot | undefined,
eloMap: Map<string, number> eloMap: Map<string, number>,
parityFactor = PARITY_FACTOR
): { winner: string; loser: string } { ): { winner: string; loser: string } {
if (match?.isComplete && match.winnerId && match.loserId) { if (match?.isComplete && match.winnerId && match.loserId) {
return { winner: match.winnerId, loser: match.loserId }; return { winner: match.winnerId, loser: match.loserId };
@ -231,7 +233,8 @@ export function resolveQfMatch(
} }
const result = simulateNllGame( const result = simulateNllGame(
{ participantId: p1, elo: eloMap.get(p1) ?? FALLBACK_ELO }, { participantId: p1, elo: eloMap.get(p1) ?? FALLBACK_ELO },
{ participantId: p2, elo: eloMap.get(p2) ?? FALLBACK_ELO } { participantId: p2, elo: eloMap.get(p2) ?? FALLBACK_ELO },
parityFactor
); );
return { winner: result.winner.participantId, loser: result.loser.participantId }; return { winner: result.winner.participantId, loser: result.loser.participantId };
} }
@ -249,7 +252,8 @@ export function resolveBo3Match(
games: GameSnapshot[], games: GameSnapshot[],
eloMap: Map<string, number>, eloMap: Map<string, number>,
p1Override?: string, p1Override?: string,
p2Override?: string p2Override?: string,
parityFactor = PARITY_FACTOR
): { winner: string; loser: string } { ): { winner: string; loser: string } {
if (match?.isComplete && match.winnerId && match.loserId) { if (match?.isComplete && match.winnerId && match.loserId) {
return { winner: match.winnerId, loser: match.loserId }; return { winner: match.winnerId, loser: match.loserId };
@ -278,7 +282,7 @@ export function resolveBo3Match(
const { winner, loser } = simulateBestOfThree( const { winner, loser } = simulateBestOfThree(
{ participantId: p1, elo: eloMap.get(p1) ?? FALLBACK_ELO }, { participantId: p1, elo: eloMap.get(p1) ?? FALLBACK_ELO },
{ participantId: p2, elo: eloMap.get(p2) ?? FALLBACK_ELO }, { participantId: p2, elo: eloMap.get(p2) ?? FALLBACK_ELO },
PARITY_FACTOR, parityFactor,
{ winsA: winsP1, winsB: winsP2 } { winsA: winsP1, winsB: winsP2 }
); );
return { winner: winner.participantId, loser: loser.participantId }; return { winner: winner.participantId, loser: loser.participantId };
@ -354,8 +358,10 @@ export function simulateRegularSeasonSeeds(
// ─── Simulator class ────────────────────────────────────────────────────────── // ─── Simulator class ──────────────────────────────────────────────────────────
export class NLLSimulator implements Simulator { export class NLLSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const db = database(); const db = database();
const parityFactor = positiveConfigNumber(config, "parityFactor", PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
// ── Load participants ───────────────────────────────────────────────────── // ── Load participants ─────────────────────────────────────────────────────
const participants = await db.query.seasonParticipants.findMany({ const participants = await db.query.seasonParticipants.findMany({
@ -421,7 +427,7 @@ export class NLLSimulator implements Simulator {
}); });
if (bracketMatches.length > 0) { if (bracketMatches.length > 0) {
return this.simulateBracketAware(allIds, eloMap, bracketMatches); return this.simulateBracketAware(allIds, eloMap, bracketMatches, parityFactor, numSimulations);
} }
} }
@ -439,7 +445,7 @@ export class NLLSimulator implements Simulator {
knownSeeds.length === NLL_PLAYOFF_TEAMS && knownSeeds.length === NLL_PLAYOFF_TEAMS &&
new Set(knownSeeds.map((s) => s.seed)).size === NLL_PLAYOFF_TEAMS new Set(knownSeeds.map((s) => s.seed)).size === NLL_PLAYOFF_TEAMS
) { ) {
return this.simulateKnownSeeds(allIds, knownSeeds); return this.simulateKnownSeeds(allIds, knownSeeds, parityFactor, numSimulations);
} }
// Mode 3: regular-season projection (default, including preseason). // Mode 3: regular-season projection (default, including preseason).
@ -466,7 +472,7 @@ export class NLLSimulator implements Simulator {
); );
} }
return this.simulateRegularSeason(allIds, teams); return this.simulateRegularSeason(allIds, teams, parityFactor, numSimulations);
} }
// ── Mode 1: Bracket-Aware ───────────────────────────────────────────────── // ── Mode 1: Bracket-Aware ─────────────────────────────────────────────────
@ -474,7 +480,9 @@ export class NLLSimulator implements Simulator {
private async simulateBracketAware( private async simulateBracketAware(
allIds: string[], allIds: string[],
eloMap: Map<string, number>, eloMap: Map<string, number>,
allMatches: typeof schema.playoffMatches.$inferSelect[] allMatches: typeof schema.playoffMatches.$inferSelect[],
parityFactor = PARITY_FACTOR,
numSimulations = DEFAULT_NUM_SIMULATIONS
): Promise<SimulationResult[]> { ): Promise<SimulationResult[]> {
const db = database(); const db = database();
@ -535,18 +543,18 @@ export class NLLSimulator implements Simulator {
const [sf1, sf2] = sfMatches; const [sf1, sf2] = sfMatches;
const finalMatch = finalMatches[0]; const finalMatch = finalMatches[0];
for (let s = 0; s < NUM_SIMULATIONS; s++) { for (let s = 0; s < numSimulations; s++) {
const r_qf1 = resolveQfMatch(qf1, eloMap); const r_qf1 = resolveQfMatch(qf1, eloMap, parityFactor);
const r_qf2 = resolveQfMatch(qf2, eloMap); const r_qf2 = resolveQfMatch(qf2, eloMap, parityFactor);
const r_qf3 = resolveQfMatch(qf3, eloMap); const r_qf3 = resolveQfMatch(qf3, eloMap, parityFactor);
const r_qf4 = resolveQfMatch(qf4, eloMap); const r_qf4 = resolveQfMatch(qf4, eloMap, parityFactor);
// SF arm A: QF1 winner vs QF2 winner (1/8 vs 4/5 bracket arm) // SF arm A: QF1 winner vs QF2 winner (1/8 vs 4/5 bracket arm)
const r_sf1 = resolveBo3Match(sf1, gamesByMatch.get(sf1.id) ?? [], eloMap, r_qf1.winner, r_qf2.winner); const r_sf1 = resolveBo3Match(sf1, gamesByMatch.get(sf1.id) ?? [], eloMap, r_qf1.winner, r_qf2.winner, parityFactor);
// SF arm B: QF3 winner vs QF4 winner (2/7 vs 3/6 bracket arm) // SF arm B: QF3 winner vs QF4 winner (2/7 vs 3/6 bracket arm)
const r_sf2 = resolveBo3Match(sf2, gamesByMatch.get(sf2.id) ?? [], eloMap, r_qf3.winner, r_qf4.winner); const r_sf2 = resolveBo3Match(sf2, gamesByMatch.get(sf2.id) ?? [], eloMap, r_qf3.winner, r_qf4.winner, parityFactor);
const r_final = resolveBo3Match(finalMatch, gamesByMatch.get(finalMatch.id) ?? [], eloMap, r_sf1.winner, r_sf2.winner); const r_final = resolveBo3Match(finalMatch, gamesByMatch.get(finalMatch.id) ?? [], eloMap, r_sf1.winner, r_sf2.winner, parityFactor);
championCounts.set(r_final.winner, (championCounts.get(r_final.winner) ?? 0) + 1); championCounts.set(r_final.winner, (championCounts.get(r_final.winner) ?? 0) + 1);
finalistCounts.set(r_final.loser, (finalistCounts.get(r_final.loser) ?? 0) + 1); finalistCounts.set(r_final.loser, (finalistCounts.get(r_final.loser) ?? 0) + 1);
@ -558,22 +566,24 @@ export class NLLSimulator implements Simulator {
qfLoserCounts.set(r_qf4.loser, (qfLoserCounts.get(r_qf4.loser) ?? 0) + 1); qfLoserCounts.set(r_qf4.loser, (qfLoserCounts.get(r_qf4.loser) ?? 0) + 1);
} }
return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts); return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts, numSimulations);
} }
// ── Mode 2: Known-Seed ──────────────────────────────────────────────────── // ── Mode 2: Known-Seed ────────────────────────────────────────────────────
private simulateKnownSeeds( private simulateKnownSeeds(
allIds: string[], allIds: string[],
seeds: SeedEntry[] seeds: SeedEntry[],
parityFactor = PARITY_FACTOR,
numSimulations = DEFAULT_NUM_SIMULATIONS
): SimulationResult[] { ): SimulationResult[] {
const championCounts = new Map(allIds.map((id) => [id, 0])); const championCounts = new Map(allIds.map((id) => [id, 0]));
const finalistCounts = new Map(allIds.map((id) => [id, 0])); const finalistCounts = new Map(allIds.map((id) => [id, 0]));
const sfLoserCounts = new Map(allIds.map((id) => [id, 0])); const sfLoserCounts = new Map(allIds.map((id) => [id, 0]));
const qfLoserCounts = new Map(allIds.map((id) => [id, 0])); const qfLoserCounts = new Map(allIds.map((id) => [id, 0]));
for (let s = 0; s < NUM_SIMULATIONS; s++) { for (let s = 0; s < numSimulations; s++) {
const result = simulateNllPlayoffs(seeds); const result = simulateNllPlayoffs(seeds, parityFactor);
championCounts.set(result.champion, (championCounts.get(result.champion) ?? 0) + 1); championCounts.set(result.champion, (championCounts.get(result.champion) ?? 0) + 1);
finalistCounts.set(result.finalist, (finalistCounts.get(result.finalist) ?? 0) + 1); finalistCounts.set(result.finalist, (finalistCounts.get(result.finalist) ?? 0) + 1);
for (const id of result.sfLosers) { for (const id of result.sfLosers) {
@ -584,23 +594,25 @@ export class NLLSimulator implements Simulator {
} }
} }
return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts); return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts, numSimulations);
} }
// ── Mode 3: Regular-Season Projection ──────────────────────────────────── // ── Mode 3: Regular-Season Projection ────────────────────────────────────
private simulateRegularSeason( private simulateRegularSeason(
allIds: string[], allIds: string[],
teams: TeamProjection[] teams: TeamProjection[],
parityFactor = PARITY_FACTOR,
numSimulations = DEFAULT_NUM_SIMULATIONS
): SimulationResult[] { ): SimulationResult[] {
const championCounts = new Map(allIds.map((id) => [id, 0])); const championCounts = new Map(allIds.map((id) => [id, 0]));
const finalistCounts = new Map(allIds.map((id) => [id, 0])); const finalistCounts = new Map(allIds.map((id) => [id, 0]));
const sfLoserCounts = new Map(allIds.map((id) => [id, 0])); const sfLoserCounts = new Map(allIds.map((id) => [id, 0]));
const qfLoserCounts = new Map(allIds.map((id) => [id, 0])); const qfLoserCounts = new Map(allIds.map((id) => [id, 0]));
for (let s = 0; s < NUM_SIMULATIONS; s++) { for (let s = 0; s < numSimulations; s++) {
const seeds = simulateRegularSeasonSeeds(teams); const seeds = simulateRegularSeasonSeeds(teams, parityFactor);
const result = simulateNllPlayoffs(seeds); const result = simulateNllPlayoffs(seeds, parityFactor);
championCounts.set(result.champion, (championCounts.get(result.champion) ?? 0) + 1); championCounts.set(result.champion, (championCounts.get(result.champion) ?? 0) + 1);
finalistCounts.set(result.finalist, (finalistCounts.get(result.finalist) ?? 0) + 1); finalistCounts.set(result.finalist, (finalistCounts.get(result.finalist) ?? 0) + 1);
for (const id of result.sfLosers) { for (const id of result.sfLosers) {
@ -611,7 +623,7 @@ export class NLLSimulator implements Simulator {
} }
} }
return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts); return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts, numSimulations);
} }
// ── Shared result builder ───────────────────────────────────────────────── // ── Shared result builder ─────────────────────────────────────────────────
@ -621,9 +633,10 @@ export class NLLSimulator implements Simulator {
championCounts: Map<string, number>, championCounts: Map<string, number>,
finalistCounts: Map<string, number>, finalistCounts: Map<string, number>,
sfLoserCounts: Map<string, number>, sfLoserCounts: Map<string, number>,
qfLoserCounts: Map<string, number> qfLoserCounts: Map<string, number>,
numSimulations = DEFAULT_NUM_SIMULATIONS
): SimulationResult[] { ): SimulationResult[] {
const N = NUM_SIMULATIONS; const N = numSimulations;
return allIds.map((id) => ({ return allIds.map((id) => ({
participantId: id, participantId: id,
probabilities: { probabilities: {

View file

@ -105,7 +105,7 @@ export async function runSportsSeasonSimulation(
try { try {
const simulator = getSimulator(simulatorConfig.simulatorType); const simulator = getSimulator(simulatorConfig.simulatorType);
const results = await simulator.simulate(sportsSeasonId); const results = await simulator.simulate(sportsSeasonId, simulatorConfig.config);
if (results.length === 0) { if (results.length === 0) {
throw new Error("Simulation returned no results. Check that participants have simulator input data."); throw new Error("Simulation returned no results. Check that participants have simulator input data.");

View file

@ -41,10 +41,11 @@ import { database } from "~/database/context";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters ────────────────────────────────────────────────────
const NUM_SIMULATIONS = 50000; const DEFAULT_NUM_SIMULATIONS = 50000;
/** /**
* Controls how much Elo gaps affect per-frame win probability. * Controls how much Elo gaps affect per-frame win probability.
@ -229,7 +230,8 @@ function shuffle<T>(arr: T[]): T[] {
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class SnookerSimulator implements Simulator { export class SnookerSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const db = database(); const db = database();
// 1. Find the bracket scoring event for this sports season. // 1. Find the bracket scoring event for this sports season.
@ -272,9 +274,9 @@ export class SnookerSimulator implements Simulator {
allMatches.some((m) => m.participant1Id && m.participant2Id); allMatches.some((m) => m.participant1Id && m.participant2Id);
if (bracketPopulated) { if (bracketPopulated) {
return this.simulateBracket(allMatches, eloMap); return this.simulateBracket(allMatches, eloMap, numSimulations);
} else { } else {
return this.simulatePreBracket(sportsSeasonId, eloMap, db); return this.simulatePreBracket(sportsSeasonId, eloMap, db, numSimulations);
} }
} }
@ -282,7 +284,8 @@ export class SnookerSimulator implements Simulator {
private async simulateBracket( private async simulateBracket(
allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>, allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>,
eloMap: Map<string, number> eloMap: Map<string, number>,
numSimulations: number
): Promise<SimulationResult[]> { ): Promise<SimulationResult[]> {
// Group matches by round, sorted by match count descending (R32 first). // Group matches by round, sorted by match count descending (R32 first).
const byRound = new Map<string, typeof allMatches>(); const byRound = new Map<string, typeof allMatches>();
@ -354,7 +357,7 @@ export class SnookerSimulator implements Simulator {
const sfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0])); const sfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const qfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0])); const qfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
for (let s = 0; s < NUM_SIMULATIONS; s++) { for (let s = 0; s < numSimulations; s++) {
// ── First Round (R32) ────────────────────────────────────────────────── // ── First Round (R32) ──────────────────────────────────────────────────
const r32Winners: string[] = []; const r32Winners: string[] = [];
for (let i = 1; i <= 16; i++) { for (let i = 1; i <= 16; i++) {
@ -433,7 +436,7 @@ export class SnookerSimulator implements Simulator {
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1); finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
} }
return buildResults(participantIds, NUM_SIMULATIONS, { return buildResults(participantIds, numSimulations, {
championCounts, championCounts,
finalistCounts, finalistCounts,
sfLoserCounts, sfLoserCounts,
@ -450,7 +453,8 @@ export class SnookerSimulator implements Simulator {
private async simulatePreBracket( private async simulatePreBracket(
sportsSeasonId: string, sportsSeasonId: string,
eloMap: Map<string, number>, eloMap: Map<string, number>,
db: ReturnType<typeof database> db: ReturnType<typeof database>,
numSimulations: number
): Promise<SimulationResult[]> { ): Promise<SimulationResult[]> {
const allParticipants = await db const allParticipants = await db
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name }) .select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
@ -500,7 +504,7 @@ export class SnookerSimulator implements Simulator {
// are in the DB), skip the qualifying simulation and use them directly as seeds 17-32. // are in the DB), skip the qualifying simulation and use them directly as seeds 17-32.
const needsQualifying = qualifiers.length > 16; const needsQualifying = qualifiers.length > 16;
for (let s = 0; s < NUM_SIMULATIONS; s++) { for (let s = 0; s < numSimulations; s++) {
// ── Qualifying: run elimination rounds until exactly 16 qualifiers remain ── // ── Qualifying: run elimination rounds until exactly 16 qualifiers remain ──
let drawnQualifiers: string[]; let drawnQualifiers: string[];
if (!needsQualifying) { if (!needsQualifying) {
@ -579,7 +583,7 @@ export class SnookerSimulator implements Simulator {
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1); finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
} }
return buildResults(allParticipantIds, NUM_SIMULATIONS, { return buildResults(allParticipantIds, numSimulations, {
championCounts, championCounts,
finalistCounts, finalistCounts,
sfLoserCounts, sfLoserCounts,

View file

@ -48,10 +48,11 @@ import { getQPConfig, calculateSplitQualifyingPoints } from "~/models/qualifying
import { resolveStructureSource, type IdTranslator } from "./shared-major"; import { resolveStructureSource, type IdTranslator } from "./shared-major";
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates"; import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters ────────────────────────────────────────────────────
const NUM_SIMULATIONS = 10_000; const DEFAULT_NUM_SIMULATIONS = 10_000;
/** /**
* Elo divisor for per-match win probability. * Elo divisor for per-match win probability.
@ -363,7 +364,8 @@ export function drawFromBracket(
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class TennisSimulator implements Simulator { export class TennisSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const db = database(); const db = database();
// 1. Load all participants for this sports season. // 1. Load all participants for this sports season.
@ -505,7 +507,7 @@ export class TennisSimulator implements Simulator {
const counts: number[][] = Array.from({ length: participantIds.length }, () => Array.from({ length: 8 }, () => 0)); const counts: number[][] = Array.from({ length: participantIds.length }, () => Array.from({ length: 8 }, () => 0));
const idToIndex = new Map<string, number>(participantIds.map((id, i) => [id, i])); const idToIndex = new Map<string, number>(participantIds.map((id, i) => [id, i]));
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) { for (let sim = 0; sim < numSimulations; sim++) {
// Start each simulation from the locked actual QP. // Start each simulation from the locked actual QP.
const simQP = new Map<string, number>(actualQPMap); const simQP = new Map<string, number>(actualQPMap);
@ -551,14 +553,14 @@ export class TennisSimulator implements Simulator {
return participantIds.map((participantId, i) => ({ return participantIds.map((participantId, i) => ({
participantId, participantId,
probabilities: { probabilities: {
probFirst: counts[i][0] / NUM_SIMULATIONS, probFirst: counts[i][0] / numSimulations,
probSecond: counts[i][1] / NUM_SIMULATIONS, probSecond: counts[i][1] / numSimulations,
probThird: counts[i][2] / NUM_SIMULATIONS, probThird: counts[i][2] / numSimulations,
probFourth: counts[i][3] / NUM_SIMULATIONS, probFourth: counts[i][3] / numSimulations,
probFifth: counts[i][4] / NUM_SIMULATIONS, probFifth: counts[i][4] / numSimulations,
probSixth: counts[i][5] / NUM_SIMULATIONS, probSixth: counts[i][5] / numSimulations,
probSeventh: counts[i][6] / NUM_SIMULATIONS, probSeventh: counts[i][6] / numSimulations,
probEighth: counts[i][7] / NUM_SIMULATIONS, probEighth: counts[i][7] / numSimulations,
}, },
source: "tennis_grand_slam_monte_carlo", source: "tennis_grand_slam_monte_carlo",
})); }));

View file

@ -33,7 +33,12 @@ export interface SimulationResult {
* Simulators consume a single resolved Elo per participant (already blended from * Simulators consume a single resolved Elo per participant (already blended from
* its sources raw Elo, projections, futures odds by the input policy and * its sources raw Elo, projections, futures odds by the input policy and
* persisted before the run), so they need only the season id. * persisted before the run), so they need only the season id.
*
* `config` is the merged simulator config (profile defaults overlaid with the
* season's overrides) that the runner has already fetched. It carries the engine
* knobs (`iterations`, `parityFactor`, `seasonGames`, ). It is optional so unit
* tests can call `simulate(id)` and get each simulator's built-in defaults.
*/ */
export interface Simulator { export interface Simulator {
simulate(sportsSeasonId: string): Promise<SimulationResult[]>; simulate(sportsSeasonId: string, config?: Record<string, unknown>): Promise<SimulationResult[]>;
} }

View file

@ -5,12 +5,12 @@
* *
* Algorithm: * Algorithm:
* 1. Load the bracket scoring event and all playoff matches from DB * 1. Load the bracket scoring event and all playoff matches from DB
* 2. Load futures odds (American format) from participantExpectedValues * 2. Load each team's resolved Elo (the input policy already blended any raw
* 3. Build two probability signals per team: * Elo and futures odds into a single sourceElo before the run)
* a. Elo derived from futures via convertFuturesToElo() (long-run team strength) * 3. Per-match win probability = eloWinProbabilityWithParity(eloA, eloB, parityFactor).
* b. Normalized odds vig-removed implied win probability from the same futures * Odds are NOT blended in per-match here that would double-count the futures
* 4. Per-match win probability = ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb * signal already baked into the Elo.
* 5. Simulate 50,000 tournaments, respecting already-completed matches * 5. Simulate `iterations` tournaments, respecting already-completed matches
* 6. Track integer placement counts per tier (champion / finalist / SF loser / QF loser). * 6. Track integer placement counts per tier (champion / finalist / SF loser / QF loser).
* At conversion, exact denominators guarantee column sums of 1.0 by construction: * At conversion, exact denominators guarantee column sums of 1.0 by construction:
* - probFirst = champion / N * - probFirst = champion / N
@ -30,19 +30,23 @@
* *
* Notes: * Notes:
* - Requires futures odds in sourceOdds (American format) to be imported first. * - Requires futures odds in sourceOdds (American format) to be imported first.
* - Falls back to uniform probability (coin flip) when no odds are stored. * - Falls back to a 1500 Elo (coin flip vs. equals) when no Elo is resolved.
* - ELO_WEIGHT and ODDS_WEIGHT can be tuned here; 0.7/0.3 matches the Python calibration. * - `parityFactor` (season config) tunes per-match variance; default 400.
*/ */
import { database } from "~/database/context"; import { database } from "~/database/context";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eloWinProbability } from "~/services/probability-engine"; import { eloWinProbabilityWithParity } from "~/services/probability-engine";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters (defaults; overridable via season config) ───────────
const NUM_SIMULATIONS = 50000; const DEFAULT_NUM_SIMULATIONS = 50000;
/** Elo parity factor. Defaults to 400 (standard formula). */
const DEFAULT_PARITY_FACTOR = 400;
// ─── Odds helper ────────────────────────────────────────────────────────────── // ─── Odds helper ──────────────────────────────────────────────────────────────
@ -55,8 +59,10 @@ export function americanToImpliedProb(odds: number): number {
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class UCLSimulator implements Simulator { export class UCLSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const db = database(); const db = database();
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
// 1. Find the bracket scoring event for this sports season. // 1. Find the bracket scoring event for this sports season.
// UCL has exactly one playoff_game event per season. // UCL has exactly one playoff_game event per season.
@ -166,7 +172,7 @@ export class UCLSimulator implements Simulator {
const blendedWinProb = (p1: string, p2: string): number => { const blendedWinProb = (p1: string, p2: string): number => {
const elo1 = eloMap.get(p1) ?? 1500; const elo1 = eloMap.get(p1) ?? 1500;
const elo2 = eloMap.get(p2) ?? 1500; const elo2 = eloMap.get(p2) ?? 1500;
return eloWinProbability(elo1, elo2); return eloWinProbabilityWithParity(elo1, elo2, parityFactor);
}; };
const simMatch = (p1: string, p2: string): { winner: string; loser: string } => { const simMatch = (p1: string, p2: string): { winner: string; loser: string } => {
@ -183,7 +189,7 @@ export class UCLSimulator implements Simulator {
const qfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0])); const qfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
// 10. Run Monte Carlo simulations. // 10. Run Monte Carlo simulations.
for (let s = 0; s < NUM_SIMULATIONS; s++) { for (let s = 0; s < numSimulations; s++) {
// ── Round of 16 ────────────────────────────────────────────────────── // ── Round of 16 ──────────────────────────────────────────────────────
// R16 losers: no count added (0 points per scoring rules) // R16 losers: no count added (0 points per scoring rules)
const r16Winners: string[] = []; const r16Winners: string[] = [];
@ -261,7 +267,7 @@ export class UCLSimulator implements Simulator {
// probFirst/Second → N total (1 per sim) // probFirst/Second → N total (1 per sim)
// probThird/Fourth → sfLoserCounts / (2*N) — 2 SF losers per sim // probThird/Fourth → sfLoserCounts / (2*N) — 2 SF losers per sim
// probFifthEighth → qfLoserCounts / (4*N) — 4 QF losers per sim // probFifthEighth → qfLoserCounts / (4*N) — 4 QF losers per sim
const N = NUM_SIMULATIONS; const N = numSimulations;
const results: SimulationResult[] = participantIds.map((participantId) => { const results: SimulationResult[] = participantIds.map((participantId) => {
const c = championCounts.get(participantId) ?? 0; const c = championCounts.get(participantId) ?? 0;
const f = finalistCounts.get(participantId) ?? 0; const f = finalistCounts.get(participantId) ?? 0;

View file

@ -53,14 +53,18 @@ import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { normalizeTeamName } from "~/lib/normalize-team-name"; import { normalizeTeamName } from "~/lib/normalize-team-name";
import { getRegularSeasonStandings } from "~/models/regular-season-standings"; import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { convertFuturesToElo, eloWinProbability } from "~/services/probability-engine"; import { convertFuturesToElo, eloWinProbabilityWithParity } from "~/services/probability-engine";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters (defaults; overridable via season config) ───────────
const NUM_SIMULATIONS = 50_000; const DEFAULT_NUM_SIMULATIONS = 50_000;
/** Elo parity factor. WNBA defaults to 400 (standard formula). */
const DEFAULT_PARITY_FACTOR = 400;
/** WNBA regular season games per team. */ /** WNBA regular season games per team. */
const WNBA_REGULAR_SEASON_GAMES = 40; const DEFAULT_REGULAR_SEASON_GAMES = 40;
/** /**
* Multiplier converting SRS to Elo offset from 1500. * Multiplier converting SRS to Elo offset from 1500.
@ -129,9 +133,10 @@ interface TeamEntry {
export function simSeriesN( export function simSeriesN(
a: TeamEntry, a: TeamEntry,
b: TeamEntry, b: TeamEntry,
winTarget: number winTarget: number,
parityFactor = DEFAULT_PARITY_FACTOR
): { winner: TeamEntry; loser: TeamEntry } { ): { winner: TeamEntry; loser: TeamEntry } {
const winProb = eloWinProbability(a.elo, b.elo); const winProb = eloWinProbabilityWithParity(a.elo, b.elo, parityFactor);
let winsA = 0; let winsA = 0;
let winsB = 0; let winsB = 0;
while (winsA < winTarget && winsB < winTarget) { while (winsA < winTarget && winsB < winTarget) {
@ -153,8 +158,11 @@ function simulateProjectedWins(team: TeamEntry): number {
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class WNBASimulator implements Simulator { export class WNBASimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const db = database(); const db = database();
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const seasonGames = Math.round(positiveConfigNumber(config, "seasonGames", DEFAULT_REGULAR_SEASON_GAMES));
// 1. Load participants, standings, and futures odds in parallel. // 1. Load participants, standings, and futures odds in parallel.
const [participantRows, standings, evRows] = await Promise.all([ const [participantRows, standings, evRows] = await Promise.all([
@ -228,8 +236,8 @@ export class WNBASimulator implements Simulator {
name: r.name, name: r.name,
elo, elo,
currentWins: standing?.wins ?? 0, currentWins: standing?.wins ?? 0,
remainingGames: Math.max(0, WNBA_REGULAR_SEASON_GAMES - gamesPlayed), remainingGames: Math.max(0, seasonGames - gamesPlayed),
winProb: eloWinProbability(elo, 1500), winProb: eloWinProbabilityWithParity(elo, 1500, parityFactor),
}; };
}); });
@ -255,14 +263,14 @@ export class WNBASimulator implements Simulator {
const r1LoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0])); const r1LoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
// 6. Monte Carlo simulation loop. // 6. Monte Carlo simulation loop.
for (let s = 0; s < NUM_SIMULATIONS; s++) { for (let s = 0; s < numSimulations; s++) {
const [s1, s2, s3, s4, s5, s6, s7, s8] = buildSeededBracket(); const [s1, s2, s3, s4, s5, s6, s7, s8] = buildSeededBracket();
// Round 1 (best-of-3): 1v8, 4v5, 2v7, 3v6 // Round 1 (best-of-3): 1v8, 4v5, 2v7, 3v6
const r1_a = simSeriesN(s1, s8, 2); const r1_a = simSeriesN(s1, s8, 2, parityFactor);
const r1_b = simSeriesN(s4, s5, 2); const r1_b = simSeriesN(s4, s5, 2, parityFactor);
const r1_c = simSeriesN(s2, s7, 2); const r1_c = simSeriesN(s2, s7, 2, parityFactor);
const r1_d = simSeriesN(s3, s6, 2); const r1_d = simSeriesN(s3, s6, 2, parityFactor);
r1LoserCounts.set(r1_a.loser.id, (r1LoserCounts.get(r1_a.loser.id) ?? 0) + 1); r1LoserCounts.set(r1_a.loser.id, (r1LoserCounts.get(r1_a.loser.id) ?? 0) + 1);
r1LoserCounts.set(r1_b.loser.id, (r1LoserCounts.get(r1_b.loser.id) ?? 0) + 1); r1LoserCounts.set(r1_b.loser.id, (r1LoserCounts.get(r1_b.loser.id) ?? 0) + 1);
@ -270,14 +278,14 @@ export class WNBASimulator implements Simulator {
r1LoserCounts.set(r1_d.loser.id, (r1LoserCounts.get(r1_d.loser.id) ?? 0) + 1); r1LoserCounts.set(r1_d.loser.id, (r1LoserCounts.get(r1_d.loser.id) ?? 0) + 1);
// Semifinals (best-of-5): winner(1/8) vs winner(4/5), winner(2/7) vs winner(3/6) // Semifinals (best-of-5): winner(1/8) vs winner(4/5), winner(2/7) vs winner(3/6)
const sf_a = simSeriesN(r1_a.winner, r1_b.winner, 3); const sf_a = simSeriesN(r1_a.winner, r1_b.winner, 3, parityFactor);
const sf_b = simSeriesN(r1_c.winner, r1_d.winner, 3); const sf_b = simSeriesN(r1_c.winner, r1_d.winner, 3, parityFactor);
semiLoserCounts.set(sf_a.loser.id, (semiLoserCounts.get(sf_a.loser.id) ?? 0) + 1); semiLoserCounts.set(sf_a.loser.id, (semiLoserCounts.get(sf_a.loser.id) ?? 0) + 1);
semiLoserCounts.set(sf_b.loser.id, (semiLoserCounts.get(sf_b.loser.id) ?? 0) + 1); semiLoserCounts.set(sf_b.loser.id, (semiLoserCounts.get(sf_b.loser.id) ?? 0) + 1);
// Finals (best-of-7) // Finals (best-of-7)
const final = simSeriesN(sf_a.winner, sf_b.winner, 4); const final = simSeriesN(sf_a.winner, sf_b.winner, 4, parityFactor);
championCounts.set(final.winner.id, (championCounts.get(final.winner.id) ?? 0) + 1); championCounts.set(final.winner.id, (championCounts.get(final.winner.id) ?? 0) + 1);
finalistCounts.set(final.loser.id, (finalistCounts.get(final.loser.id) ?? 0) + 1); finalistCounts.set(final.loser.id, (finalistCounts.get(final.loser.id) ?? 0) + 1);
@ -292,14 +300,14 @@ export class WNBASimulator implements Simulator {
return { return {
participantId, participantId,
probabilities: { probabilities: {
probFirst: c / NUM_SIMULATIONS, probFirst: c / numSimulations,
probSecond: f / NUM_SIMULATIONS, probSecond: f / numSimulations,
probThird: sl / (2 * NUM_SIMULATIONS), probThird: sl / (2 * numSimulations),
probFourth: sl / (2 * NUM_SIMULATIONS), probFourth: sl / (2 * numSimulations),
probFifth: r1 / (4 * NUM_SIMULATIONS), probFifth: r1 / (4 * numSimulations),
probSixth: r1 / (4 * NUM_SIMULATIONS), probSixth: r1 / (4 * numSimulations),
probSeventh: r1 / (4 * NUM_SIMULATIONS), probSeventh: r1 / (4 * numSimulations),
probEighth: r1 / (4 * NUM_SIMULATIONS), probEighth: r1 / (4 * numSimulations),
}, },
source, source,
}; };

View file

@ -39,6 +39,7 @@ import { database } from "~/database/context";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eloWinProbability } from "~/services/probability-engine"; import { eloWinProbability } from "~/services/probability-engine";
import { positiveConfigNumber } from "./config-access";
import { simulateEloSoccerMatch } from "./soccer-helpers"; import { simulateEloSoccerMatch } from "./soccer-helpers";
import { normalizeSimulationResultColumns } from "./simulation-probabilities"; import { normalizeSimulationResultColumns } from "./simulation-probabilities";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
@ -440,8 +441,9 @@ export class WorldCupSimulator implements Simulator {
this.numSimulations = numSimulations; this.numSimulations = numSimulations;
} }
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const db = database(); const db = database();
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", this.numSimulations));
// 1. Load all participants for this season // 1. Load all participants for this season
const participantRows = await db.query.seasonParticipants.findMany({ const participantRows = await db.query.seasonParticipants.findMany({
@ -669,7 +671,7 @@ export class WorldCupSimulator implements Simulator {
return seeded; return seeded;
}; };
for (let sim = 0; sim < this.numSimulations; sim++) { for (let sim = 0; sim < numSimulations; sim++) {
// ── Knockout rounds ────────────────────────────────────────── // ── Knockout rounds ──────────────────────────────────────────
// R32 → R16 → QF → SF → Third Place Game + Final, advancing via the // R32 → R16 → QF → SF → Third Place Game + Final, advancing via the
// canonical ceil(n/2) tree. Completed matches lock in real results. // canonical ceil(n/2) tree. Completed matches lock in real results.
@ -719,7 +721,7 @@ export class WorldCupSimulator implements Simulator {
} }
// 9. Convert counts to probabilities // 9. Convert counts to probabilities
const N = this.numSimulations; const N = numSimulations;
const numQfLosers = 4; // 4 QF losers per sim const numQfLosers = 4; // 4 QF losers per sim
const source = const source =