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;
}
/**
* 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 {
if (value === undefined || value.trim() === "") return null;
const parsed = Number(value);
@ -232,33 +251,48 @@ export async function action({ request, params }: Route.ActionArgs): Promise<Act
}
}
if (intent === "save-input-policy") {
if (intent === "save-configuration") {
const currentConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId);
if (!currentConfig) return { success: false, message: "Simulator config not found." };
const currentPolicy = getSimulatorInputPolicy(currentConfig.config);
// 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);
next.inputPolicy = {
...currentPolicy,
missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")),
missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")),
// Stored as-is; getSimulatorInputPolicy clamps to [0,1] on read.
oddsWeight: parsePolicyNumber(formData, "oddsWeight", currentPolicy.oddsWeight),
fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo),
fallbackEloDelta: parsePolicyNumber(formData, "fallbackEloDelta", currentPolicy.fallbackEloDelta),
eloMin: parsePolicyNumber(formData, "eloMin", currentPolicy.eloMin),
eloMax: parsePolicyNumber(formData, "eloMax", currentPolicy.eloMax),
fallbackRating: parsePolicyNumber(formData, "fallbackRating", currentPolicy.fallbackRating),
fallbackRatingDelta: parsePolicyNumber(formData, "fallbackRatingDelta", currentPolicy.fallbackRatingDelta),
ratingMin: parsePolicyNumber(formData, "ratingMin", currentPolicy.ratingMin),
ratingMax: parsePolicyNumber(formData, "ratingMax", currentPolicy.ratingMax),
};
}
await upsertSportsSeasonSimulatorConfig({
sportsSeasonId,
simulatorType: currentConfig.simulatorType,
config: {
...currentConfig.config,
inputPolicy: {
missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")),
missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")),
// Stored as-is; getSimulatorInputPolicy clamps to [0,1] on read.
oddsWeight: parsePolicyNumber(formData, "oddsWeight", currentPolicy.oddsWeight),
fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo),
fallbackEloDelta: parsePolicyNumber(formData, "fallbackEloDelta", currentPolicy.fallbackEloDelta),
eloMin: parsePolicyNumber(formData, "eloMin", currentPolicy.eloMin),
eloMax: parsePolicyNumber(formData, "eloMax", currentPolicy.eloMax),
fallbackRating: parsePolicyNumber(formData, "fallbackRating", currentPolicy.fallbackRating),
fallbackRatingDelta: parsePolicyNumber(formData, "fallbackRatingDelta", currentPolicy.fallbackRatingDelta),
ratingMin: parsePolicyNumber(formData, "ratingMin", currentPolicy.ratingMin),
ratingMax: parsePolicyNumber(formData, "ratingMax", currentPolicy.ratingMax),
},
},
config: next,
});
return { success: true, message: "Simulator input policy saved." };
return { success: true, message: "Simulator configuration saved." };
}
if (intent === "save-inputs") {
@ -316,6 +350,13 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
const showsInputPolicy =
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 (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center gap-4">
@ -407,130 +448,164 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
</CardContent>
</Card>
{showsInputPolicy && (
<Card>
<CardHeader>
<CardTitle>Input Policy</CardTitle>
<CardDescription>
Direct inputs win. This simulator can derive Elo from{" "}
{sourceEloAlternatives.length > 0 ? sourceEloAlternatives.join(", ") : "no alternate Elo inputs"}
{ratingAlternatives.length > 0 ? ` and ratings from ${ratingAlternatives.join(", ")}` : ""}.
Tail fallbacks are explicit so low-impact missing participants do not silently get invented ratings.
</CardDescription>
</CardHeader>
<CardContent>
<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">
<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} />
<p className="text-xs text-muted-foreground">
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:
<strong> 0</strong> = Elo / projections only, <strong>1</strong> = futures fully override,
in between = blend (e.g. 0.3 = 70% Elo / 30% futures).
</p>
</div>
{config.profile.requiredInputs.includes("sourceElo") && (
<>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="missingEloStrategy">Missing Elo Strategy</Label>
<select
id="missingEloStrategy"
name="missingEloStrategy"
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
defaultValue={inputPolicy.missingEloStrategy}
>
<option value="block">Block until complete</option>
<option value="fallbackElo">Use fixed fallback Elo</option>
<option value="averageKnown">Use average known Elo</option>
<option value="worstKnownMinus">Use worst known Elo minus delta</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="fallbackElo">Fallback Elo</Label>
<Input id="fallbackElo" name="fallbackElo" type="number" defaultValue={inputPolicy.fallbackElo} />
</div>
<div className="space-y-2">
<Label htmlFor="fallbackEloDelta">Worst Elo Delta</Label>
<Input id="fallbackEloDelta" name="fallbackEloDelta" type="number" defaultValue={inputPolicy.fallbackEloDelta} />
</div>
<div className="space-y-2">
<Label htmlFor="eloMin">Elo Floor</Label>
<Input id="eloMin" name="eloMin" type="number" defaultValue={inputPolicy.eloMin} />
</div>
<div className="space-y-2">
<Label htmlFor="eloMax">Elo Ceiling</Label>
<Input id="eloMax" name="eloMax" type="number" defaultValue={inputPolicy.eloMax} />
</div>
</>
)}
{config.profile.requiredInputs.includes("rating") && (
<>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="missingRatingStrategy">Missing Rating Strategy</Label>
<select
id="missingRatingStrategy"
name="missingRatingStrategy"
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
defaultValue={inputPolicy.missingRatingStrategy}
>
<option value="block">Block until complete</option>
<option value="fallbackRating">Use fixed fallback rating</option>
<option value="averageKnown">Use average known rating</option>
<option value="worstKnownMinus">Use worst known rating minus delta</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="fallbackRating">Fallback Rating</Label>
<Input id="fallbackRating" name="fallbackRating" type="number" step="0.01" defaultValue={inputPolicy.fallbackRating} />
</div>
<div className="space-y-2">
<Label htmlFor="fallbackRatingDelta">Worst Rating Delta</Label>
<Input id="fallbackRatingDelta" name="fallbackRatingDelta" type="number" step="0.01" defaultValue={inputPolicy.fallbackRatingDelta} />
</div>
</>
)}
<div className="space-y-2">
<Label htmlFor="ratingMin">Rating Floor</Label>
<Input id="ratingMin" name="ratingMin" type="number" step="0.01" defaultValue={inputPolicy.ratingMin} />
</div>
<div className="space-y-2">
<Label htmlFor="ratingMax">Rating Ceiling</Label>
<Input id="ratingMax" name="ratingMax" type="number" step="0.01" defaultValue={inputPolicy.ratingMax} />
</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>
</Form>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Season Config</CardTitle>
<CardTitle>Simulator Configuration</CardTitle>
<CardDescription>
JSON overrides for this specific sports season. Defaults come from the simulator profile.
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>
<Form method="post" className="space-y-4">
<input type="hidden" name="intent" value="save-config" />
<Textarea
name="config"
rows={10}
className="font-mono text-sm"
defaultValue={JSON.stringify(config.config, null, 2)}
/>
<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{" "}
{sourceEloAlternatives.length > 0 ? sourceEloAlternatives.join(", ") : "no alternate Elo inputs"}
{ratingAlternatives.length > 0 ? ` and ratings from ${ratingAlternatives.join(", ")}` : ""}.
Tail fallbacks are explicit so low-impact missing participants do not silently get invented ratings.
</p>
</div>
<div className="grid gap-4 md:grid-cols-5">
<div className="space-y-2 md:col-span-5">
<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} />
<p className="text-xs text-muted-foreground">
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:
<strong> 0</strong> = Elo / projections only, <strong>1</strong> = futures fully override,
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>
</div>
{config.profile.requiredInputs.includes("sourceElo") && (
<>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="missingEloStrategy">Missing Elo Strategy</Label>
<select
id="missingEloStrategy"
name="missingEloStrategy"
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
defaultValue={inputPolicy.missingEloStrategy}
>
<option value="block">Block until complete</option>
<option value="fallbackElo">Use fixed fallback Elo</option>
<option value="averageKnown">Use average known Elo</option>
<option value="worstKnownMinus">Use worst known Elo minus delta</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="fallbackElo">Fallback Elo</Label>
<Input id="fallbackElo" name="fallbackElo" type="number" defaultValue={inputPolicy.fallbackElo} />
</div>
<div className="space-y-2">
<Label htmlFor="fallbackEloDelta">Worst Elo Delta</Label>
<Input id="fallbackEloDelta" name="fallbackEloDelta" type="number" defaultValue={inputPolicy.fallbackEloDelta} />
</div>
<div className="space-y-2">
<Label htmlFor="eloMin">Elo Floor</Label>
<Input id="eloMin" name="eloMin" type="number" defaultValue={inputPolicy.eloMin} />
</div>
<div className="space-y-2">
<Label htmlFor="eloMax">Elo Ceiling</Label>
<Input id="eloMax" name="eloMax" type="number" defaultValue={inputPolicy.eloMax} />
</div>
</>
)}
{config.profile.requiredInputs.includes("rating") && (
<>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="missingRatingStrategy">Missing Rating Strategy</Label>
<select
id="missingRatingStrategy"
name="missingRatingStrategy"
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
defaultValue={inputPolicy.missingRatingStrategy}
>
<option value="block">Block until complete</option>
<option value="fallbackRating">Use fixed fallback rating</option>
<option value="averageKnown">Use average known rating</option>
<option value="worstKnownMinus">Use worst known rating minus delta</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="fallbackRating">Fallback Rating</Label>
<Input id="fallbackRating" name="fallbackRating" type="number" step="0.01" defaultValue={inputPolicy.fallbackRating} />
</div>
<div className="space-y-2">
<Label htmlFor="fallbackRatingDelta">Worst Rating Delta</Label>
<Input id="fallbackRatingDelta" name="fallbackRatingDelta" type="number" step="0.01" defaultValue={inputPolicy.fallbackRatingDelta} />
</div>
</>
)}
<div className="space-y-2">
<Label htmlFor="ratingMin">Rating Floor</Label>
<Input id="ratingMin" name="ratingMin" type="number" step="0.01" defaultValue={inputPolicy.ratingMin} />
</div>
<div className="space-y-2">
<Label htmlFor="ratingMax">Rating Ceiling</Label>
<Input id="ratingMax" name="ratingMax" type="number" step="0.01" defaultValue={inputPolicy.ratingMax} />
</div>
</div>
</section>
)}
<Button type="submit" disabled={isSubmitting}>
<Save className="mr-2 h-4 w-4" />
Save Config
Save Configuration
</Button>
</Form>
<details>
<summary className="cursor-pointer text-sm font-medium">Advanced: edit raw config JSON</summary>
<Form method="post" className="space-y-3 mt-3">
<input type="hidden" name="intent" value="save-config" />
<Textarea
name="config"
rows={10}
className="font-mono text-sm"
defaultValue={JSON.stringify(config.config, null, 2)}
/>
<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 Raw JSON
</Button>
</Form>
</details>
</CardContent>
</Card>

View file

@ -119,6 +119,13 @@ describe("eloWinProbability (PARITY_FACTOR = 1000)", () => {
const p = eloWinProbability(1700, 1500);
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 ──────────────────────────────────────────────────

View file

@ -69,20 +69,22 @@ import { normalizeTeamName } from "~/lib/normalize-team-name";
import { logger } from "~/lib/logger";
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
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.
* 450 reflects moderate variance lower than NHL (1000) to account for
* 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). */
const AFL_REGULAR_SEASON_GAMES = 23;
const DEFAULT_REGULAR_SEASON_GAMES = 23;
/** Average opponent Elo used for regular season projections. */
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))
* Exported for unit testing.
*/
export function eloWinProbability(eloA: number, eloB: number): number {
return eloWinProbabilityWithParity(eloA, eloB, PARITY_FACTOR);
export function eloWinProbability(eloA: number, eloB: number, parityFactor = DEFAULT_PARITY_FACTOR): number {
return eloWinProbabilityWithParity(eloA, eloB, parityFactor);
}
// ─── Internal types ───────────────────────────────────────────────────────────
@ -193,8 +195,11 @@ function simulateProjectedWins(entry: TeamEntry): number {
// ─── 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 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.
const [participantRows, evRows, standings] = await Promise.all([
@ -260,8 +265,8 @@ export class AFLSimulator implements Simulator {
name: r.name,
elo: resolvedElo,
currentWins: standing?.wins ?? 0,
remainingGames: Math.max(0, AFL_REGULAR_SEASON_GAMES - gamesPlayed),
winProb: eloWinProbability(resolvedElo, AVERAGE_OPPONENT_ELO),
remainingGames: Math.max(0, seasonGames - gamesPlayed),
winProb: eloWinProbability(resolvedElo, AVERAGE_OPPONENT_ELO, parityFactor),
};
});
@ -269,7 +274,7 @@ export class AFLSimulator implements Simulator {
/** Simulate a single AFL game. Returns the winner. */
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.
@ -364,7 +369,7 @@ export class AFLSimulator implements Simulator {
const efLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
// 4. Monte Carlo simulation loop.
for (let s = 0; s < NUM_SIMULATIONS; s++) {
for (let s = 0; s < numSimulations; s++) {
const finalists = buildFinalsList();
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
// 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 c = championCounts.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 { getSeasonResults } from "~/models/participant-season-result";
import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── 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. */
const RACE_NOISE = 0.50;
@ -107,7 +108,8 @@ export class AutoRacingSimulator implements Simulator {
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();
// 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
// from sourceOdds via pure weighted draws.
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);
for (let rank = 0; rank < Math.min(8, finishOrder.length); 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
// much more predictive than early-season odds.
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)
const seasonWeights = new Map<string, number>();
for (const id of ids) {
@ -277,14 +279,14 @@ export class AutoRacingSimulator implements Simulator {
return {
participantId: p.id,
probabilities: {
probFirst: counts[0] / NUM_SIMULATIONS,
probSecond: counts[1] / NUM_SIMULATIONS,
probThird: counts[2] / NUM_SIMULATIONS,
probFourth: counts[3] / NUM_SIMULATIONS,
probFifth: counts[4] / NUM_SIMULATIONS,
probSixth: counts[5] / NUM_SIMULATIONS,
probSeventh: counts[6] / NUM_SIMULATIONS,
probEighth: counts[7] / NUM_SIMULATIONS,
probFirst: counts[0] / numSimulations,
probSecond: counts[1] / numSimulations,
probThird: counts[2] / numSimulations,
probFourth: counts[3] / numSimulations,
probFifth: counts[4] / numSimulations,
probSixth: counts[5] / numSimulations,
probSeventh: counts[6] / numSimulations,
probEighth: counts[7] / numSimulations,
},
source: this.source,
};

View file

@ -22,8 +22,9 @@ import {
eloWinProbabilityWithParity,
} from "~/services/probability-engine";
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 HOCKEY_PARITY_FACTOR = 850;
const DEFAULT_ELO = 1500;
@ -156,8 +157,8 @@ export function buildCollegeHockeyTeams(
return teams;
}
function simGame(team1: Team, team2: Team): { winner: Team; loser: Team } {
const p1Wins = Math.random() < eloWinProbabilityWithParity(team1.elo, team2.elo, HOCKEY_PARITY_FACTOR);
function simGame(team1: Team, team2: Team, parityFactor = HOCKEY_PARITY_FACTOR): { winner: Team; loser: Team } {
const p1Wins = Math.random() < eloWinProbabilityWithParity(team1.elo, team2.elo, parityFactor);
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);
}
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]> = [
[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[] = [];
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);
bump(counts, result.loser.participantId, "quarterfinalLoser");
}
const sf1 = simGame(qfWinners[0], qfWinners[1]);
const sf2 = simGame(qfWinners[2], qfWinners[3]);
const sf1 = game(qfWinners[0], qfWinners[1]);
const sf2 = game(qfWinners[2], qfWinners[3]);
bump(counts, sf1.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.loser.participantId, "finalist");
}
@ -236,19 +238,21 @@ function simulateMatchFromIds(
p1: string,
p2: string,
match: PlayoffMatch | undefined,
teamById: Map<string, Team>
teamById: Map<string, Team>,
parityFactor = HOCKEY_PARITY_FACTOR
): { winner: Team; loser: Team } {
if (match?.isComplete && match.winnerId) {
const loserId = completedLoser(match);
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(
rounds: { r16: PlayoffMatch[]; qf: PlayoffMatch[]; sf: PlayoffMatch[]; final: PlayoffMatch },
teamById: Map<string, Team>,
counts: Map<string, PlacementCounts>
counts: Map<string, PlacementCounts>,
parityFactor = HOCKEY_PARITY_FACTOR
): void {
const r16Winners: Team[] = [];
for (let i = 0; i < 8; i++) {
@ -256,7 +260,7 @@ function simulateExistingBracket(
if (!match.participant1Id || !match.participant2Id) {
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[] = [];
@ -265,7 +269,7 @@ function simulateExistingBracket(
const p1 = r16Winners[i * 2]?.participantId;
const p2 = r16Winners[i * 2 + 1]?.participantId;
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);
bump(counts, result.loser.participantId, "quarterfinalLoser");
}
@ -276,7 +280,7 @@ function simulateExistingBracket(
const p1 = qfWinners[i * 2]?.participantId;
const p2 = qfWinners[i * 2 + 1]?.participantId;
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);
bump(counts, result.loser.participantId, "semifinalLoser");
}
@ -284,21 +288,21 @@ function simulateExistingBracket(
const p1 = sfWinners[0]?.participantId;
const p2 = sfWinners[1]?.participantId;
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.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 c = counts.get(participantId) ?? { champion: 0, finalist: 0, semifinalLoser: 0, quarterfinalLoser: 0 };
const sf = c.semifinalLoser / (2 * NUM_SIMULATIONS);
const qf = c.quarterfinalLoser / (4 * NUM_SIMULATIONS);
const sf = c.semifinalLoser / (2 * numSimulations);
const qf = c.quarterfinalLoser / (4 * numSimulations);
return {
participantId,
probabilities: {
probFirst: c.champion / NUM_SIMULATIONS,
probSecond: c.finalist / NUM_SIMULATIONS,
probFirst: c.champion / numSimulations,
probSecond: c.finalist / numSimulations,
probThird: sf,
probFourth: sf,
probFifth: qf,
@ -325,8 +329,10 @@ function toResults(participantIds: string[], counts: Map<string, PlacementCounts
}
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 parityFactor = positiveConfigNumber(config, "parityFactor", HOCKEY_PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const bracketEvent = await db.query.scoringEvents.findFirst({
where: and(
@ -373,19 +379,19 @@ export class CollegeHockeySimulator implements Simulator {
);
if (existingBracket) {
for (let i = 0; i < NUM_SIMULATIONS; i++) {
simulateExistingBracket(existingBracket, teamById, counts);
for (let i = 0; i < numSimulations; i++) {
simulateExistingBracket(existingBracket, teamById, counts, parityFactor);
}
} else {
const preBracketMode = participantIds.length > BRACKET_SIZE;
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 ?? [];
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 {

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,
} from "./shared-major";
import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ────────────────────────────────────────────────────
const NUM_SIMULATIONS = 10_000;
const DEFAULT_NUM_SIMULATIONS = 10_000;
/** Total field size per CS2 Major. */
const FIELD_SIZE = 32;
@ -545,7 +546,8 @@ export function simulateChampionsStage(
// ─── 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();
// 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 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);
for (const event of incompleteEvents) {
@ -831,14 +833,14 @@ export class CSMajorSimulator implements Simulator {
return participantIds.map((participantId, i) => ({
participantId,
probabilities: {
probFirst: counts[i][0] / NUM_SIMULATIONS,
probSecond: counts[i][1] / NUM_SIMULATIONS,
probThird: counts[i][2] / NUM_SIMULATIONS,
probFourth: counts[i][3] / NUM_SIMULATIONS,
probFifth: counts[i][4] / NUM_SIMULATIONS,
probSixth: counts[i][5] / NUM_SIMULATIONS,
probSeventh: counts[i][6] / NUM_SIMULATIONS,
probEighth: counts[i][7] / NUM_SIMULATIONS,
probFirst: counts[i][0] / numSimulations,
probSecond: counts[i][1] / numSimulations,
probThird: counts[i][2] / numSimulations,
probFourth: counts[i][3] / numSimulations,
probFifth: counts[i][4] / numSimulations,
probSixth: counts[i][5] / numSimulations,
probSeventh: counts[i][6] / numSimulations,
probEighth: counts[i][7] / numSimulations,
},
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 * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ────────────────────────────────────────────────────
@ -204,8 +205,9 @@ export class DartsSimulator implements Simulator {
this.numSimulations = numSimulations;
}
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const db = database();
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", this.numSimulations));
// 1. Find the bracket scoring event (if it exists).
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);
if (bracketPopulated) {
return this.simulateBracket(allMatches, eloMap);
return this.simulateBracket(allMatches, eloMap, numSimulations);
} 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(
allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>,
eloMap: Map<string, number>
eloMap: Map<string, number>,
numSimulations: number
): Promise<SimulationResult[]> {
// Group matches by round, sorted by match count descending (R1 first = most matches).
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 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)
const r1Winners: string[] = [];
for (let i = 1; i <= 64; i++) {
@ -439,7 +442,7 @@ export class DartsSimulator implements Simulator {
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
}
return buildResults(participantIds, this.numSimulations, {
return buildResults(participantIds, numSimulations, {
championCounts,
finalistCounts,
sfLoserCounts,
@ -455,7 +458,8 @@ export class DartsSimulator implements Simulator {
sportsSeasonId: string,
eloMap: Map<string, number>,
rankingMap: Map<string, number>,
db: ReturnType<typeof database>
db: ReturnType<typeof database>,
numSimulations: number
): Promise<SimulationResult[]> {
const allParticipants = await db
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
@ -520,7 +524,7 @@ export class DartsSimulator implements Simulator {
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.
const drawnUnseeded = shuffle([...unseededPool]);
@ -588,7 +592,7 @@ export class DartsSimulator implements Simulator {
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
}
return buildResults(allParticipantIds, this.numSimulations, {
return buildResults(allParticipantIds, numSimulations, {
championCounts,
finalistCounts,
sfLoserCounts,

View file

@ -18,6 +18,7 @@ import { convertFuturesToElo, eloWinProbabilityWithParity } from "~/services/pro
import { normalizeSimulationResultColumns } from "./simulation-probabilities";
import { simulateEloSoccerMatch, simulateSimpleSoccerGoals } from "./soccer-helpers";
import { getSportsSeasonSimulatorConfig } from "~/models/simulator";
import { positiveConfigNumber, requiredConfigNumber } from "./config-access";
const DEFAULT_NUM_SIMULATIONS = 10_000;
const DEFAULT_EPL_REGULAR_SEASON_GAMES = 38;
@ -44,17 +45,6 @@ interface SimulatedTableRow {
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. */
export function eplWinProbability(
eloA: number,
@ -99,13 +89,13 @@ export class EPLSimulator implements Simulator {
]);
const config = simulatorConfig?.config;
const numSimulations = Math.round(configNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const seasonGames = Math.round(configNumber(config, "seasonGames", DEFAULT_EPL_REGULAR_SEASON_GAMES));
const averageOpponentElo = configNumber(config, "averageOpponentElo", DEFAULT_AVERAGE_OPPONENT_ELO);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const seasonGames = Math.round(positiveConfigNumber(config, "seasonGames", DEFAULT_EPL_REGULAR_SEASON_GAMES));
const averageOpponentElo = positiveConfigNumber(config, "averageOpponentElo", DEFAULT_AVERAGE_OPPONENT_ELO);
const matchOptions = {
baseDrawRate: configNumber(config, "baseDrawRate", DEFAULT_BASE_DRAW_RATE),
drawDecay: configNumber(config, "drawDecay", DEFAULT_DRAW_DECAY),
matchParityFactor: requiredConfigNumber(config, "matchParityFactor"),
baseDrawRate: positiveConfigNumber(config, "baseDrawRate", DEFAULT_BASE_DRAW_RATE),
drawDecay: positiveConfigNumber(config, "drawDecay", DEFAULT_DRAW_DECAY),
matchParityFactor: requiredConfigNumber(config, "matchParityFactor", "EPL simulator"),
};
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 { getExcludedByEventMap } from "~/models/event-result";
import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ────────────────────────────────────────────────────
const NUM_SIMULATIONS = 10_000;
const DEFAULT_NUM_SIMULATIONS = 10_000;
/**
* Simulated field size for each major.
@ -180,7 +181,8 @@ export function simulateMajor(
// ─── 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();
// 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]));
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
for (let sim = 0; sim < numSimulations; sim++) {
const simQP = new Map<string, number>(actualQPMap);
for (const { players, restCount, restStrength } of majorConfigs) {
@ -292,14 +294,14 @@ export class GolfSimulator implements Simulator {
return participantIds.map((participantId, i) => ({
participantId,
probabilities: {
probFirst: counts[i][0] / NUM_SIMULATIONS,
probSecond: counts[i][1] / NUM_SIMULATIONS,
probThird: counts[i][2] / NUM_SIMULATIONS,
probFourth: counts[i][3] / NUM_SIMULATIONS,
probFifth: counts[i][4] / NUM_SIMULATIONS,
probSixth: counts[i][5] / NUM_SIMULATIONS,
probSeventh: counts[i][6] / NUM_SIMULATIONS,
probEighth: counts[i][7] / NUM_SIMULATIONS,
probFirst: counts[i][0] / numSimulations,
probSecond: counts[i][1] / numSimulations,
probThird: counts[i][2] / numSimulations,
probFourth: counts[i][3] / numSimulations,
probFifth: counts[i][4] / numSimulations,
probSixth: counts[i][5] / numSimulations,
probSeventh: counts[i][6] / numSimulations,
probEighth: counts[i][7] / numSimulations,
},
source: "golf_qualifying_points_monte_carlo",
}));

View file

@ -254,6 +254,10 @@ export function resolveSourceElos(
);
}
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)) {
oddsElo.set(participantId, elo);
}

View file

@ -61,6 +61,7 @@ import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import { convertAmericanOddsToProbability } from "~/services/probability-engine";
import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ────────────────────────────────────────────────────
@ -261,7 +262,8 @@ function determineRandomized(sideTeams: Team[], sideName: string): boolean {
export class LLWSSimulator implements Simulator {
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();
// 1. Load all participants.
@ -347,7 +349,7 @@ export class LLWSSimulator implements Simulator {
};
// 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.
const [usPoolA, usPoolB] = assignPools(usTeams, usRandomized);
const [intlPoolA, intlPoolB] = assignPools(intlTeams, intlRandomized);
@ -378,7 +380,7 @@ export class LLWSSimulator implements Simulator {
// 7. Convert counts to probability distributions.
// bracketLosers: 4 per sim (2 US + 2 Intl) → split evenly.
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 };
return allIds.map((id) => {
@ -387,10 +389,10 @@ export class LLWSSimulator implements Simulator {
return {
participantId: id,
probabilities: {
probFirst: c.champion / this.numSimulations,
probSecond: c.finalist / this.numSimulations,
probThird: c.thirdPlace / this.numSimulations,
probFourth: c.fourthPlace / this.numSimulations,
probFirst: c.champion / numSimulations,
probSecond: c.finalist / numSimulations,
probThird: c.thirdPlace / numSimulations,
probFourth: c.fourthPlace / numSimulations,
probFifth: bracketProb,
probSixth: bracketProb,
probSeventh: bracketProb,

View file

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

View file

@ -74,12 +74,13 @@ import { database } from "~/database/context";
import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
import { logger } from "~/lib/logger";
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
// ─── Simulation parameters ────────────────────────────────────────────────────
const NUM_SIMULATIONS = 50_000;
const DEFAULT_NUM_SIMULATIONS = 50_000;
const TOTAL_SEASON_GAMES = 162;
/**
@ -440,7 +441,8 @@ function simLeagueBracket(
// ─── 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();
// 1. Load all participants for this sports season.
@ -569,7 +571,7 @@ export class MLBSimulator implements Simulator {
let effectiveN = 0;
for (let s = 0; s < NUM_SIMULATIONS; s++) {
for (let s = 0; s < numSimulations; s++) {
const alField = drawLeaguePlayoffField(alTeams, seedingWinRate);
const nlField = drawLeaguePlayoffField(nlTeams, seedingWinRate);
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 { normalizeSimulationResultColumns } from "./simulation-probabilities";
import { logger } from "~/lib/logger";
import { configNumber } from "./config-access";
// ─── Default constants (overridable via season simulator config) ───────────────
@ -62,12 +63,6 @@ const DEFAULT_DRAW_DECAY = 0.002;
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.

View file

@ -53,19 +53,21 @@ import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
import { normalizeTeamName } from "~/lib/normalize-team-name";
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.
* 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. */
const NBA_REGULAR_SEASON_GAMES = 82;
const DEFAULT_REGULAR_SEASON_GAMES = 82;
// ─── 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.
* P(A) = 1 / (1 + 10^((eloB - eloA) / PARITY_FACTOR))
*/
export function eloWinProbability(eloA: number, eloB: number): number {
return 1 / (1 + Math.pow(10, (eloB - eloA) / PARITY_FACTOR));
export function eloWinProbability(eloA: number, eloB: number, parityFactor = DEFAULT_PARITY_FACTOR): number {
return 1 / (1 + Math.pow(10, (eloB - eloA) / parityFactor));
}
// ─── Per-position normalization ───────────────────────────────────────────────
@ -157,90 +159,99 @@ function normalizeColumns(results: SimulationResult[]): void {
type DbMatch = typeof schema.playoffMatches.$inferSelect;
/** Simulate a single-game matchup. Returns winner and loser. */
function simGame(
eloMap: Map<string, number>,
a: string,
b: string
): { winner: string; loser: string } {
const eloA = eloMap.get(a) ?? 1400;
const eloB = eloMap.get(b) ?? 1400;
const winner = Math.random() < eloWinProbability(eloA, eloB) ? a : b;
return { winner, loser: winner === a ? b : a };
}
/** Simulate a best-of-7 series. Returns winner and loser. */
function simSeries(
eloMap: Map<string, number>,
a: string,
b: string
): { winner: string; loser: string } {
const winProb = eloWinProbability(eloMap.get(a) ?? 1400, eloMap.get(b) ?? 1400);
let wA = 0;
let wB = 0;
while (wA < 4 && wB < 4) {
if (Math.random() < winProb) wA++; else wB++;
}
return wA === 4 ? { winner: a, loser: b } : { winner: b, loser: a };
}
/**
* Resolve a play-in single game.
* Uses the real result if the match is complete; otherwise simulates.
* p1Override / p2Override are used when the DB participant slot is still null
* (i.e. filled in by the previous round's simulated result).
* 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 resolveGame(
eloMap: Map<string, number>,
match: DbMatch | undefined,
p1Override?: string,
p2Override?: string
): { winner: string; loser: string } {
if (match?.isComplete && match.winnerId && match.loserId) {
return { winner: match.winnerId, loser: match.loserId };
function makeBracketHelpers(parityFactor: number) {
/** Simulate a single-game matchup. Returns winner and loser. */
function simGame(
eloMap: Map<string, number>,
a: string,
b: string
): { winner: string; loser: string } {
const eloA = eloMap.get(a) ?? 1400;
const eloB = eloMap.get(b) ?? 1400;
const winner = Math.random() < eloWinProbability(eloA, eloB, parityFactor) ? a : b;
return { winner, loser: winner === a ? b : a };
}
const p1 = match?.participant1Id ?? p1Override;
const p2 = match?.participant2Id ?? p2Override;
if (!p1 || !p2) {
throw new Error(
`Cannot resolve game: missing participant(s) on match ${match?.id ?? "(undefined)"} ` +
`(p1=${p1 ?? "null"}, p2=${p2 ?? "null"}). ` +
`Ensure the bracket is fully generated before simulating.`
);
}
return simGame(eloMap, p1, p2);
}
/**
* Resolve a playoff series.
* Uses the real result if the match is complete; otherwise simulates best-of-7.
* p1Override / p2Override fill null DB participant slots with the simulated seed.
*/
function resolveSeries(
eloMap: Map<string, number>,
match: DbMatch | undefined,
p1Override?: string,
p2Override?: string
): { winner: string; loser: string } {
if (match?.isComplete && match.winnerId && match.loserId) {
return { winner: match.winnerId, loser: match.loserId };
/** Simulate a best-of-7 series. Returns winner and loser. */
function simSeries(
eloMap: Map<string, number>,
a: string,
b: string
): { winner: string; loser: string } {
const winProb = eloWinProbability(eloMap.get(a) ?? 1400, eloMap.get(b) ?? 1400, parityFactor);
let wA = 0;
let wB = 0;
while (wA < 4 && wB < 4) {
if (Math.random() < winProb) wA++; else wB++;
}
return wA === 4 ? { winner: a, loser: b } : { winner: b, loser: a };
}
const p1 = match?.participant1Id ?? p1Override;
const p2 = match?.participant2Id ?? p2Override;
if (!p1 || !p2) {
throw new Error(
`Cannot resolve series: missing participant(s) on match ${match?.id ?? "(undefined)"} ` +
`(p1=${p1 ?? "null"}, p2=${p2 ?? "null"}). ` +
`Ensure the bracket is fully generated before simulating.`
);
/**
* Resolve a play-in single game.
* Uses the real result if the match is complete; otherwise simulates.
* p1Override / p2Override are used when the DB participant slot is still null
* (i.e. filled in by the previous round's simulated result).
*/
function resolveGame(
eloMap: Map<string, number>,
match: DbMatch | undefined,
p1Override?: string,
p2Override?: string
): { winner: string; loser: string } {
if (match?.isComplete && match.winnerId && match.loserId) {
return { winner: match.winnerId, loser: match.loserId };
}
const p1 = match?.participant1Id ?? p1Override;
const p2 = match?.participant2Id ?? p2Override;
if (!p1 || !p2) {
throw new Error(
`Cannot resolve game: missing participant(s) on match ${match?.id ?? "(undefined)"} ` +
`(p1=${p1 ?? "null"}, p2=${p2 ?? "null"}). ` +
`Ensure the bracket is fully generated before simulating.`
);
}
return simGame(eloMap, p1, p2);
}
return simSeries(eloMap, p1, p2);
/**
* Resolve a playoff series.
* Uses the real result if the match is complete; otherwise simulates best-of-7.
* p1Override / p2Override fill null DB participant slots with the simulated seed.
*/
function resolveSeries(
eloMap: Map<string, number>,
match: DbMatch | undefined,
p1Override?: string,
p2Override?: string
): { winner: string; loser: string } {
if (match?.isComplete && match.winnerId && match.loserId) {
return { winner: match.winnerId, loser: match.loserId };
}
const p1 = match?.participant1Id ?? p1Override;
const p2 = match?.participant2Id ?? p2Override;
if (!p1 || !p2) {
throw new Error(
`Cannot resolve series: missing participant(s) on match ${match?.id ?? "(undefined)"} ` +
`(p1=${p1 ?? "null"}, p2=${p2 ?? "null"}). ` +
`Ensure the bracket is fully generated before simulating.`
);
}
return simSeries(eloMap, p1, p2);
}
return { simGame, simSeries, resolveGame, resolveSeries };
}
// ─── 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();
// ── Mode detection: bracket-aware if a playoff event with matches exists ──
@ -258,21 +269,25 @@ export class NBASimulator implements Simulator {
});
if (bracketMatches.length > 0) {
return this.simulateBracketAware(sportsSeasonId, bracketMatches);
return this.simulateBracketAware(sportsSeasonId, bracketMatches, config);
}
}
// ── Fall back to season-projection mode ───────────────────────────────────
return this.simulateSeasonProjection(sportsSeasonId);
return this.simulateSeasonProjection(sportsSeasonId, config);
}
// ── Mode 1: Bracket-Aware ──────────────────────────────────────────────────
private async simulateBracketAware(
sportsSeasonId: string,
allMatches: DbMatch[]
allMatches: DbMatch[],
config: Record<string, unknown> = {}
): Promise<SimulationResult[]> {
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.
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]));
// ── 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) ─────────────────────────────────
// 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)
// probThird/Fourth → cfLoserCounts / (2×N) — 2 CF 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 c = championCounts.get(participantId) ?? 0;
const f = finalistCounts.get(participantId) ?? 0;
@ -467,8 +482,11 @@ export class NBASimulator implements Simulator {
// ── 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 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([
db
@ -529,8 +547,8 @@ export class NBASimulator implements Simulator {
data,
conference,
currentWins: standing?.wins ?? 0,
remainingGames: Math.max(0, NBA_REGULAR_SEASON_GAMES - gamesPlayed),
winProb: eloWinProbability(resolvedElo, 1500),
remainingGames: Math.max(0, seasonGames - gamesPlayed),
winProb: eloWinProbability(resolvedElo, 1500, parityFactor),
resolvedElo,
};
});
@ -546,10 +564,10 @@ export class NBASimulator implements Simulator {
}
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 winProb = eloWinProbability(eloOfEntry(a), eloOfEntry(b));
const winProb = eloWinProbability(eloOfEntry(a), eloOfEntry(b), parityFactor);
let wA = 0;
let wB = 0;
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 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 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 c = championCounts.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
* 2. Load Elo/FPI ratings from participantExpectedValues.sourceElo
* (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
* weight from implied championship probability (used for field selection in step 4)
* and blend into per-game win probability (ELO_WEIGHT=0.6 / ODDS_WEIGHT=0.4).
* 3. Field-selection weight is a softmax over the resolved Elo. Futures odds are
* not blended into per-game win probability the input policy already folded
* them into sourceElo, so blending again would double-count them.
* 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 >12 teams: weighted sample without replacement using each
@ -31,8 +31,8 @@
* fields, so its champion probability accounts for that.
* Post-bracket (exactly 12 participants): deterministic field, bracket-only sim.
*
* Win probability (per game): eloWinProbability() from probability-engine (400-divisor).
* Blended win probability: ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb (when odds present).
* Win probability (per game): eloWinProbabilityWithParity(eloA, eloB, parityFactor),
* where parityFactor comes from the season config (default 400).
*
* Selection weight (pre-bracket mode):
* With sourceOdds: normalized implied championship probability (vig removed, sums to 1).
@ -59,12 +59,15 @@
import { database } from "~/database/context";
import { eq } from "drizzle-orm";
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 { 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;
/**
@ -87,12 +90,12 @@ interface Team {
// ─── Helpers ─────────────────────────────────────────────────────────────────
/** Win probability for team1 vs team2 from the single resolved Elo. */
function gameWinProb(team1: Team, team2: Team): number {
return eloWinProbability(team1.elo, team2.elo);
function gameWinProb(team1: Team, team2: Team, parityFactor = DEFAULT_PARITY_FACTOR): number {
return eloWinProbabilityWithParity(team1.elo, team2.elo, parityFactor);
}
function simGame(team1: Team, team2: Team): { winner: Team; loser: Team } {
const p1Wins = Math.random() < gameWinProb(team1, team2);
function simGame(team1: Team, team2: Team, parityFactor = DEFAULT_PARITY_FACTOR): { winner: Team; loser: Team } {
const p1Wins = Math.random() < gameWinProb(team1, team2, parityFactor);
return p1Wins
? { winner: team1, loser: team2 }
: { winner: team2, loser: team1 };
@ -141,18 +144,19 @@ interface PlacementCounts {
* Semifinals: qf1w vs qf2w, qf3w vs qf4w
* 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) ───────────────────────────────────────────────
const fr1 = simGame(teams[4], teams[11]); // 5 vs 12
const fr2 = simGame(teams[5], teams[10]); // 6 vs 11
const fr3 = simGame(teams[6], teams[9]); // 7 vs 10
const fr4 = simGame(teams[7], teams[8]); // 8 vs 9
const fr1 = game(teams[4], teams[11]); // 5 vs 12
const fr2 = game(teams[5], teams[10]); // 6 vs 11
const fr3 = game(teams[6], teams[9]); // 7 vs 10
const fr4 = game(teams[7], teams[8]); // 8 vs 9
// ── Quarterfinals (seeds 14 get byes) ────────────────────────────────────
const qf1 = simGame(teams[0], fr4.winner); // 1 vs 8/9 winner
const qf2 = simGame(teams[3], fr1.winner); // 4 vs 5/12 winner
const qf3 = simGame(teams[2], fr2.winner); // 3 vs 6/11 winner
const qf4 = simGame(teams[1], fr3.winner); // 2 vs 7/10 winner
const qf1 = game(teams[0], fr4.winner); // 1 vs 8/9 winner
const qf2 = game(teams[3], fr1.winner); // 4 vs 5/12 winner
const qf3 = game(teams[2], fr2.winner); // 3 vs 6/11 winner
const qf4 = game(teams[1], fr3.winner); // 2 vs 7/10 winner
const bump = (id: string, key: keyof PlacementCounts) => {
const entry = counts.get(id);
@ -165,14 +169,14 @@ function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>): v
bump(qf4.loser.participantId, "qfLoser");
// ── Semifinals ────────────────────────────────────────────────────────────
const sf1 = simGame(qf1.winner, qf2.winner);
const sf2 = simGame(qf3.winner, qf4.winner);
const sf1 = game(qf1.winner, qf2.winner);
const sf2 = game(qf3.winner, qf4.winner);
bump(sf1.loser.participantId, "sfLoser");
bump(sf2.loser.participantId, "sfLoser");
// ── National Championship ─────────────────────────────────────────────────
const final = simGame(sf1.winner, sf2.winner);
const final = game(sf1.winner, sf2.winner);
bump(final.winner.participantId, "champion");
bump(final.loser.participantId, "finalist");
@ -181,8 +185,10 @@ function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>): v
// ─── 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 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.
const participants = await db
.select({ id: schema.seasonParticipants.id })
@ -241,18 +247,18 @@ export class NCAAFootballSimulator implements Simulator {
);
// 6. Run Monte Carlo simulations.
for (let s = 0; s < NUM_SIMULATIONS; s++) {
for (let s = 0; s < numSimulations; s++) {
const field = preBracketMode
? sampleBracketField(allTeams, BRACKET_SIZE)
: (deterministicField ?? []);
simulateBracket(field, counts);
simulateBracket(field, counts, parityFactor);
}
// 7. Convert counts to probability distributions.
// SF losers: 2 per sim → each team's share = sfLoser / (2 * N).
// QF losers: 4 per sim → each team's share = qfLoser / (4 * N).
const sfDivisor = 2 * NUM_SIMULATIONS;
const qfDivisor = 4 * NUM_SIMULATIONS;
const sfDivisor = 2 * numSimulations;
const qfDivisor = 4 * numSimulations;
return allParticipantIds.map((id) => {
const c = counts.get(id) ?? { champion: 0, finalist: 0, sfLoser: 0, qfLoser: 0 };
@ -261,8 +267,8 @@ export class NCAAFootballSimulator implements Simulator {
return {
participantId: id,
probabilities: {
probFirst: c.champion / NUM_SIMULATIONS,
probSecond: c.finalist / NUM_SIMULATIONS,
probFirst: c.champion / numSimulations,
probSecond: c.finalist / numSimulations,
probThird: sfProb,
probFourth: sfProb,
probFifth: qfProb,

View file

@ -1,5 +1,6 @@
import { NCAABasketballSimulator } from "./ncaa-basketball-simulator";
import type { Simulator, SimulationResult } from "./types";
import { optionalPositiveNumber } from "./config-access";
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));
}
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 {
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 type { Simulator, SimulationResult } from "./types";
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 { 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. */
const NFL_REGULAR_SEASON_GAMES = 17;
const DEFAULT_REGULAR_SEASON_GAMES = 17;
/** Average opponent Elo for regular season game projection. */
const AVG_OPPONENT_ELO = 1500;
@ -173,9 +177,10 @@ export function getTeamData(name: string): NflTeamData | undefined {
function simulateProjectedWins(
currentWins: number,
gamesPlayed: number,
winProb: number
winProb: number,
seasonGames = DEFAULT_REGULAR_SEASON_GAMES
): number {
const remaining = Math.max(0, NFL_REGULAR_SEASON_GAMES - gamesPlayed);
const remaining = Math.max(0, seasonGames - gamesPlayed);
let additional = 0;
for (let g = 0; g < remaining; g++) {
if (Math.random() < winProb) additional++;
@ -231,11 +236,12 @@ export function seedConference(
* 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.
*/
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 winProbHome = eloWinProbability(
const winProbHome = eloWinProbabilityWithParity(
home.elo + NFL_HOME_FIELD_ELO_ADVANTAGE,
away.elo
away.elo,
parityFactor
);
if (Math.random() < winProbHome) return { winner: home, loser: away };
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).
* Used for the Super Bowl.
*/
function playNeutralGame(a: SeededTeam, b: SeededTeam): { winner: SeededTeam; loser: SeededTeam } {
const winProbA = eloWinProbability(a.elo, b.elo);
function playNeutralGame(a: SeededTeam, b: SeededTeam, parityFactor = DEFAULT_PARITY_FACTOR): { winner: SeededTeam; loser: SeededTeam } {
const winProbA = eloWinProbabilityWithParity(a.elo, b.elo, parityFactor);
if (Math.random() < winProbA) return { winner: a, loser: b };
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
* 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 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)
const wc27 = playGame(s(2), s(7));
const wc36 = playGame(s(3), s(6));
const wc45 = playGame(s(4), s(5));
const wc27 = pg(s(2), s(7));
const wc36 = pg(s(3), s(6));
const wc45 = pg(s(4), s(5));
// Divisional: sort WC winners by original seed (ascending = better seed = home)
// 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
);
const div1 = playGame(s(1), wcWinners[wcWinners.length - 1]);
const div2 = playGame(wcWinners[0], wcWinners[1]);
const div1 = pg(s(1), wcWinners[wcWinners.length - 1]);
const div2 = pg(wcWinners[0], wcWinners[1]);
// Conference Championship (higher remaining seed is home)
const conf = playGame(div1.winner, div2.winner);
const conf = pg(div1.winner, div2.winner);
return {
finalist: conf.winner,
@ -292,8 +299,11 @@ export function simulateConferenceBracket(seeds: SeededTeam[]): ConferenceBracke
// ─── Simulator class ──────────────────────────────────────────────────────────
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 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
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)
const winProbMap = new Map<string, number>();
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
@ -396,7 +406,7 @@ export class NFLSimulator implements Simulator {
// ─── Monte Carlo ──────────────────────────────────────────────────────────
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
for (let sim = 0; sim < numSimulations; sim++) {
// Project wins for each team this simulation
const projected = teams.map((t) => ({
participantId: t.participantId,
@ -406,7 +416,8 @@ export class NFLSimulator implements Simulator {
projectedWins: simulateProjectedWins(
t.currentWins,
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);
// Simulate conference brackets
const afcResult = simulateConferenceBracket(afcSeeds);
const nfcResult = simulateConferenceBracket(nfcSeeds);
const afcResult = simulateConferenceBracket(afcSeeds, parityFactor);
const nfcResult = simulateConferenceBracket(nfcSeeds, parityFactor);
// Super Bowl (neutral site)
const sb = playNeutralGame(afcResult.finalist, nfcResult.finalist);
const sb = playNeutralGame(afcResult.finalist, nfcResult.finalist, parityFactor);
// 1st / 2nd
counts[sb.winner.participantId][1] += 1;
@ -445,7 +456,7 @@ export class NFLSimulator implements Simulator {
// Convert counts to probabilities
return teams.map((t) => {
const c = counts[t.participantId];
const N = NUM_SIMULATIONS;
const N = numSimulations;
return {
participantId: t.participantId,
probabilities: {

View file

@ -64,38 +64,34 @@ import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
import { logger } from "~/lib/logger";
import {
convertAmericanOddsToProbability,
normalizeProbabilities,
} from "~/services/probability-engine";
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. */
const NHL_REGULAR_SEASON_GAMES = 82;
const DEFAULT_REGULAR_SEASON_GAMES = 82;
/**
* Fraction of NHL games that go to overtime / shootout.
* The losing team earns 1 point (instead of 0) in these 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
* the elevated game-to-game variance in hockey.
* Elo parity factor. NHL defaults to 1000 (higher than the standard 400) to
* 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;
/**
* 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;
const DEFAULT_PARITY_FACTOR = 1000;
// ─── 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.
* P(A) = 1 / (1 + 10^((eloB - eloA) / PARITY_FACTOR))
* P(A) = 1 / (1 + 10^((eloB - eloA) / parityFactor))
* Exported for unit testing.
*/
export function eloWinProbability(eloA: number, eloB: number): number {
return 1 / (1 + Math.pow(10, (eloB - eloA) / PARITY_FACTOR));
export function eloWinProbability(eloA: number, eloB: number, parityFactor = DEFAULT_PARITY_FACTOR): number {
return 1 / (1 + Math.pow(10, (eloB - eloA) / parityFactor));
}
// ─── Internal types ───────────────────────────────────────────────────────────
@ -217,10 +213,10 @@ function elo(entry: TeamEntry): number {
* - Regulation loss (0 pts) otherwise
* 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;
const { winProb, remainingGames } = entry;
const otlProb = (1 - winProb) * NHL_OT_RATE;
const otlProb = (1 - winProb) * otRate;
for (let g = 0; g < remainingGames; g++) {
const r = Math.random();
if (r < winProb) {
@ -241,8 +237,8 @@ interface ProjectedTeam {
}
/** Project one team's end-of-season point total with a random tiebreaker. */
function projectTeam(t: TeamEntry): ProjectedTeam {
return { team: t, pts: simulateProjectedPoints(t), tb: Math.random() };
function projectTeam(t: TeamEntry, otRate: number): ProjectedTeam {
return { team: t, pts: simulateProjectedPoints(t, otRate), tb: Math.random() };
}
/** Sort projected teams descending by pts, then by random tiebreaker. Mutates arr. */
@ -253,7 +249,7 @@ function sortProjected(arr: ProjectedTeam[]): ProjectedTeam[] {
// ─── 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();
// 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);
if (bracketPopulated) {
return this.simulateBracket(sportsSeasonId, allMatches);
return this.simulateBracket(sportsSeasonId, allMatches, config);
}
}
@ -286,7 +282,6 @@ export class NHLSimulator implements Simulator {
.select({
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
})
.from(schema.seasonParticipantExpectedValues)
.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);
// 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 otLosses = standing?.otLosses ?? 0;
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;
return {
@ -347,7 +348,7 @@ export class NHLSimulator implements Simulator {
division,
currentPoints,
remainingGames,
winProb: eloWinProbability(resolvedElo, 1500),
winProb: eloWinProbability(resolvedElo, 1500, parityFactor),
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) ─────────────────────────
/**
* Blended per-game win probability for team A over team B.
* When odds are available: 70% Elo + 30% vig-removed futures head-to-head.
* Per-game win probability for team A over team B, from resolved Elo.
* 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 eloProb = eloWinProbability(elo(a), elo(b));
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;
};
const gameWinProb = (a: TeamEntry, b: TeamEntry): number =>
eloWinProbability(elo(a), elo(b), parityFactor);
/** Simulate a best-of-7 series. Returns winner and loser. */
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).
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 divBProjected = allProjected.filter((p) => !divASet.has(p.team.id));
@ -487,7 +463,7 @@ export class NHLSimulator implements Simulator {
let effectiveN = 0;
for (let s = 0; s < NUM_SIMULATIONS; s++) {
for (let s = 0; s < numSimulations; s++) {
const eastBracket = buildConferenceBracket(easternAtlantic, easternMetro);
const westBracket = buildConferenceBracket(westernCentral, westernPacific);
if (!eastBracket || !westBracket) continue;
@ -574,7 +550,8 @@ export class NHLSimulator implements Simulator {
private async simulateBracket(
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[]> {
const db = database();
@ -626,13 +603,15 @@ export class NHLSimulator implements Simulator {
db
.select({
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
})
.from(schema.seasonParticipantExpectedValues)
.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 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);
}
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.
const r1ByNum = new Map(r1Matches.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 ──────────────────────────────────────────────────────────────────
const gameWinProb = (aId: string, bId: string): number => {
const eloProb = eloWinProbability(eloMap.get(aId) ?? 1400, eloMap.get(bId) ?? 1400);
if (!hasOdds) return eloProb;
const o1 = normalizedOddsMap.get(aId) ?? 0;
const o2 = normalizedOddsMap.get(bId) ?? 0;
const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5;
return ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb;
};
// Futures odds already shaped each team's resolved Elo via the central
// odds→Elo resolver, so per-game probability is pure Elo (no odds re-blend).
const gameWinProb = (aId: string, bId: string): number =>
eloWinProbability(eloMap.get(aId) ?? 1400, eloMap.get(bId) ?? 1400, parityFactor);
const simSeries = (aId: string, bId: string): { winner: string; loser: string } => {
const winProb = gameWinProb(aId, bId);
@ -718,7 +678,7 @@ export class NHLSimulator implements Simulator {
// ── 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.
const r1Winners: string[] = [];
for (let i = 1; i <= 8; i++) {
@ -758,7 +718,7 @@ export class NHLSimulator implements Simulator {
// ── Convert counts to probability distributions ───────────────────────────────
const N = NUM_SIMULATIONS;
const N = numSimulations;
const results: SimulationResult[] = allParticipantIds.map((participantId) => {
const c = championCounts.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 { getParticipantSimulatorInputs } from "~/models/simulator";
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_PLAYOFF_TEAMS = 8;
const PARITY_FACTOR = 400;
@ -216,7 +217,8 @@ export function simulateNllPlayoffs(
*/
export function resolveQfMatch(
match: MatchSnapshot | undefined,
eloMap: Map<string, number>
eloMap: Map<string, number>,
parityFactor = PARITY_FACTOR
): { winner: string; loser: string } {
if (match?.isComplete && match.winnerId && match.loserId) {
return { winner: match.winnerId, loser: match.loserId };
@ -231,7 +233,8 @@ export function resolveQfMatch(
}
const result = simulateNllGame(
{ 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 };
}
@ -249,7 +252,8 @@ export function resolveBo3Match(
games: GameSnapshot[],
eloMap: Map<string, number>,
p1Override?: string,
p2Override?: string
p2Override?: string,
parityFactor = PARITY_FACTOR
): { winner: string; loser: string } {
if (match?.isComplete && match.winnerId && match.loserId) {
return { winner: match.winnerId, loser: match.loserId };
@ -278,7 +282,7 @@ export function resolveBo3Match(
const { winner, loser } = simulateBestOfThree(
{ participantId: p1, elo: eloMap.get(p1) ?? FALLBACK_ELO },
{ participantId: p2, elo: eloMap.get(p2) ?? FALLBACK_ELO },
PARITY_FACTOR,
parityFactor,
{ winsA: winsP1, winsB: winsP2 }
);
return { winner: winner.participantId, loser: loser.participantId };
@ -354,8 +358,10 @@ export function simulateRegularSeasonSeeds(
// ─── Simulator class ──────────────────────────────────────────────────────────
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 parityFactor = positiveConfigNumber(config, "parityFactor", PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
// ── Load participants ─────────────────────────────────────────────────────
const participants = await db.query.seasonParticipants.findMany({
@ -421,7 +427,7 @@ export class NLLSimulator implements Simulator {
});
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 &&
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).
@ -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 ─────────────────────────────────────────────────
@ -474,7 +480,9 @@ export class NLLSimulator implements Simulator {
private async simulateBracketAware(
allIds: string[],
eloMap: Map<string, number>,
allMatches: typeof schema.playoffMatches.$inferSelect[]
allMatches: typeof schema.playoffMatches.$inferSelect[],
parityFactor = PARITY_FACTOR,
numSimulations = DEFAULT_NUM_SIMULATIONS
): Promise<SimulationResult[]> {
const db = database();
@ -535,18 +543,18 @@ export class NLLSimulator implements Simulator {
const [sf1, sf2] = sfMatches;
const finalMatch = finalMatches[0];
for (let s = 0; s < NUM_SIMULATIONS; s++) {
const r_qf1 = resolveQfMatch(qf1, eloMap);
const r_qf2 = resolveQfMatch(qf2, eloMap);
const r_qf3 = resolveQfMatch(qf3, eloMap);
const r_qf4 = resolveQfMatch(qf4, eloMap);
for (let s = 0; s < numSimulations; s++) {
const r_qf1 = resolveQfMatch(qf1, eloMap, parityFactor);
const r_qf2 = resolveQfMatch(qf2, eloMap, parityFactor);
const r_qf3 = resolveQfMatch(qf3, eloMap, parityFactor);
const r_qf4 = resolveQfMatch(qf4, eloMap, parityFactor);
// 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)
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);
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);
}
return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts);
return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts, numSimulations);
}
// ── Mode 2: Known-Seed ────────────────────────────────────────────────────
private simulateKnownSeeds(
allIds: string[],
seeds: SeedEntry[]
seeds: SeedEntry[],
parityFactor = PARITY_FACTOR,
numSimulations = DEFAULT_NUM_SIMULATIONS
): SimulationResult[] {
const championCounts = 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 qfLoserCounts = new Map(allIds.map((id) => [id, 0]));
for (let s = 0; s < NUM_SIMULATIONS; s++) {
const result = simulateNllPlayoffs(seeds);
for (let s = 0; s < numSimulations; s++) {
const result = simulateNllPlayoffs(seeds, parityFactor);
championCounts.set(result.champion, (championCounts.get(result.champion) ?? 0) + 1);
finalistCounts.set(result.finalist, (finalistCounts.get(result.finalist) ?? 0) + 1);
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 ────────────────────────────────────
private simulateRegularSeason(
allIds: string[],
teams: TeamProjection[]
teams: TeamProjection[],
parityFactor = PARITY_FACTOR,
numSimulations = DEFAULT_NUM_SIMULATIONS
): SimulationResult[] {
const championCounts = 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 qfLoserCounts = new Map(allIds.map((id) => [id, 0]));
for (let s = 0; s < NUM_SIMULATIONS; s++) {
const seeds = simulateRegularSeasonSeeds(teams);
const result = simulateNllPlayoffs(seeds);
for (let s = 0; s < numSimulations; s++) {
const seeds = simulateRegularSeasonSeeds(teams, parityFactor);
const result = simulateNllPlayoffs(seeds, parityFactor);
championCounts.set(result.champion, (championCounts.get(result.champion) ?? 0) + 1);
finalistCounts.set(result.finalist, (finalistCounts.get(result.finalist) ?? 0) + 1);
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 ─────────────────────────────────────────────────
@ -621,9 +633,10 @@ export class NLLSimulator implements Simulator {
championCounts: Map<string, number>,
finalistCounts: Map<string, number>,
sfLoserCounts: Map<string, number>,
qfLoserCounts: Map<string, number>
qfLoserCounts: Map<string, number>,
numSimulations = DEFAULT_NUM_SIMULATIONS
): SimulationResult[] {
const N = NUM_SIMULATIONS;
const N = numSimulations;
return allIds.map((id) => ({
participantId: id,
probabilities: {

View file

@ -105,7 +105,7 @@ export async function runSportsSeasonSimulation(
try {
const simulator = getSimulator(simulatorConfig.simulatorType);
const results = await simulator.simulate(sportsSeasonId);
const results = await simulator.simulate(sportsSeasonId, simulatorConfig.config);
if (results.length === 0) {
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 * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ────────────────────────────────────────────────────
const NUM_SIMULATIONS = 50000;
const DEFAULT_NUM_SIMULATIONS = 50000;
/**
* Controls how much Elo gaps affect per-frame win probability.
@ -229,7 +230,8 @@ function shuffle<T>(arr: T[]): T[] {
// ─── 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();
// 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);
if (bracketPopulated) {
return this.simulateBracket(allMatches, eloMap);
return this.simulateBracket(allMatches, eloMap, numSimulations);
} 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(
allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>,
eloMap: Map<string, number>
eloMap: Map<string, number>,
numSimulations: number
): Promise<SimulationResult[]> {
// Group matches by round, sorted by match count descending (R32 first).
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 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) ──────────────────────────────────────────────────
const r32Winners: string[] = [];
for (let i = 1; i <= 16; i++) {
@ -433,7 +436,7 @@ export class SnookerSimulator implements Simulator {
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
}
return buildResults(participantIds, NUM_SIMULATIONS, {
return buildResults(participantIds, numSimulations, {
championCounts,
finalistCounts,
sfLoserCounts,
@ -450,7 +453,8 @@ export class SnookerSimulator implements Simulator {
private async simulatePreBracket(
sportsSeasonId: string,
eloMap: Map<string, number>,
db: ReturnType<typeof database>
db: ReturnType<typeof database>,
numSimulations: number
): Promise<SimulationResult[]> {
const allParticipants = await db
.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.
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 ──
let drawnQualifiers: string[];
if (!needsQualifying) {
@ -579,7 +583,7 @@ export class SnookerSimulator implements Simulator {
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
}
return buildResults(allParticipantIds, NUM_SIMULATIONS, {
return buildResults(allParticipantIds, numSimulations, {
championCounts,
finalistCounts,
sfLoserCounts,

View file

@ -48,10 +48,11 @@ import { getQPConfig, calculateSplitQualifyingPoints } from "~/models/qualifying
import { resolveStructureSource, type IdTranslator } from "./shared-major";
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
import type { Simulator, SimulationResult } from "./types";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ────────────────────────────────────────────────────
const NUM_SIMULATIONS = 10_000;
const DEFAULT_NUM_SIMULATIONS = 10_000;
/**
* Elo divisor for per-match win probability.
@ -363,7 +364,8 @@ export function drawFromBracket(
// ─── 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();
// 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 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.
const simQP = new Map<string, number>(actualQPMap);
@ -551,14 +553,14 @@ export class TennisSimulator implements Simulator {
return participantIds.map((participantId, i) => ({
participantId,
probabilities: {
probFirst: counts[i][0] / NUM_SIMULATIONS,
probSecond: counts[i][1] / NUM_SIMULATIONS,
probThird: counts[i][2] / NUM_SIMULATIONS,
probFourth: counts[i][3] / NUM_SIMULATIONS,
probFifth: counts[i][4] / NUM_SIMULATIONS,
probSixth: counts[i][5] / NUM_SIMULATIONS,
probSeventh: counts[i][6] / NUM_SIMULATIONS,
probEighth: counts[i][7] / NUM_SIMULATIONS,
probFirst: counts[i][0] / numSimulations,
probSecond: counts[i][1] / numSimulations,
probThird: counts[i][2] / numSimulations,
probFourth: counts[i][3] / numSimulations,
probFifth: counts[i][4] / numSimulations,
probSixth: counts[i][5] / numSimulations,
probSeventh: counts[i][6] / numSimulations,
probEighth: counts[i][7] / numSimulations,
},
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
* its sources raw Elo, projections, futures odds by the input policy and
* 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 {
simulate(sportsSeasonId: string): Promise<SimulationResult[]>;
simulate(sportsSeasonId: string, config?: Record<string, unknown>): Promise<SimulationResult[]>;
}

View file

@ -5,12 +5,12 @@
*
* Algorithm:
* 1. Load the bracket scoring event and all playoff matches from DB
* 2. Load futures odds (American format) from participantExpectedValues
* 3. Build two probability signals per team:
* a. Elo derived from futures via convertFuturesToElo() (long-run team strength)
* b. Normalized odds vig-removed implied win probability from the same futures
* 4. Per-match win probability = ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb
* 5. Simulate 50,000 tournaments, respecting already-completed matches
* 2. Load each team's resolved Elo (the input policy already blended any raw
* Elo and futures odds into a single sourceElo before the run)
* 3. Per-match win probability = eloWinProbabilityWithParity(eloA, eloB, parityFactor).
* Odds are NOT blended in per-match here that would double-count the futures
* signal already baked into the Elo.
* 5. Simulate `iterations` tournaments, respecting already-completed matches
* 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:
* - probFirst = champion / N
@ -30,19 +30,23 @@
*
* Notes:
* - Requires futures odds in sourceOdds (American format) to be imported first.
* - Falls back to uniform probability (coin flip) when no odds are stored.
* - ELO_WEIGHT and ODDS_WEIGHT can be tuned here; 0.7/0.3 matches the Python calibration.
* - Falls back to a 1500 Elo (coin flip vs. equals) when no Elo is resolved.
* - `parityFactor` (season config) tunes per-match variance; default 400.
*/
import { database } from "~/database/context";
import { eq, and } from "drizzle-orm";
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 { 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 ──────────────────────────────────────────────────────────────
@ -55,8 +59,10 @@ export function americanToImpliedProb(odds: number): number {
// ─── 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 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.
// 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 elo1 = eloMap.get(p1) ?? 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 } => {
@ -183,7 +189,7 @@ export class UCLSimulator implements Simulator {
const qfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
// 10. Run Monte Carlo simulations.
for (let s = 0; s < NUM_SIMULATIONS; s++) {
for (let s = 0; s < numSimulations; s++) {
// ── Round of 16 ──────────────────────────────────────────────────────
// R16 losers: no count added (0 points per scoring rules)
const r16Winners: string[] = [];
@ -261,7 +267,7 @@ export class UCLSimulator implements Simulator {
// probFirst/Second → N total (1 per sim)
// probThird/Fourth → sfLoserCounts / (2*N) — 2 SF 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 c = championCounts.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 { normalizeTeamName } from "~/lib/normalize-team-name";
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. */
const WNBA_REGULAR_SEASON_GAMES = 40;
const DEFAULT_REGULAR_SEASON_GAMES = 40;
/**
* Multiplier converting SRS to Elo offset from 1500.
@ -129,9 +133,10 @@ interface TeamEntry {
export function simSeriesN(
a: TeamEntry,
b: TeamEntry,
winTarget: number
winTarget: number,
parityFactor = DEFAULT_PARITY_FACTOR
): { winner: TeamEntry; loser: TeamEntry } {
const winProb = eloWinProbability(a.elo, b.elo);
const winProb = eloWinProbabilityWithParity(a.elo, b.elo, parityFactor);
let winsA = 0;
let winsB = 0;
while (winsA < winTarget && winsB < winTarget) {
@ -153,8 +158,11 @@ function simulateProjectedWins(team: TeamEntry): number {
// ─── 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 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.
const [participantRows, standings, evRows] = await Promise.all([
@ -228,8 +236,8 @@ export class WNBASimulator implements Simulator {
name: r.name,
elo,
currentWins: standing?.wins ?? 0,
remainingGames: Math.max(0, WNBA_REGULAR_SEASON_GAMES - gamesPlayed),
winProb: eloWinProbability(elo, 1500),
remainingGames: Math.max(0, seasonGames - gamesPlayed),
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]));
// 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();
// Round 1 (best-of-3): 1v8, 4v5, 2v7, 3v6
const r1_a = simSeriesN(s1, s8, 2);
const r1_b = simSeriesN(s4, s5, 2);
const r1_c = simSeriesN(s2, s7, 2);
const r1_d = simSeriesN(s3, s6, 2);
const r1_a = simSeriesN(s1, s8, 2, parityFactor);
const r1_b = simSeriesN(s4, s5, 2, parityFactor);
const r1_c = simSeriesN(s2, s7, 2, parityFactor);
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_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);
// 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_b = simSeriesN(r1_c.winner, r1_d.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, parityFactor);
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);
// 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);
finalistCounts.set(final.loser.id, (finalistCounts.get(final.loser.id) ?? 0) + 1);
@ -292,14 +300,14 @@ export class WNBASimulator implements Simulator {
return {
participantId,
probabilities: {
probFirst: c / NUM_SIMULATIONS,
probSecond: f / NUM_SIMULATIONS,
probThird: sl / (2 * NUM_SIMULATIONS),
probFourth: sl / (2 * NUM_SIMULATIONS),
probFifth: r1 / (4 * NUM_SIMULATIONS),
probSixth: r1 / (4 * NUM_SIMULATIONS),
probSeventh: r1 / (4 * NUM_SIMULATIONS),
probEighth: r1 / (4 * NUM_SIMULATIONS),
probFirst: c / numSimulations,
probSecond: f / numSimulations,
probThird: sl / (2 * numSimulations),
probFourth: sl / (2 * numSimulations),
probFifth: r1 / (4 * numSimulations),
probSixth: r1 / (4 * numSimulations),
probSeventh: r1 / (4 * numSimulations),
probEighth: r1 / (4 * numSimulations),
},
source,
};

View file

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