claude/probability-config-review-1tdolw (#119)
Co-authored-by: Claude <noreply@anthropic.com> Reviewed-on: #119
This commit is contained in:
parent
8efa4aab9e
commit
3e50619629
28 changed files with 763 additions and 592 deletions
|
|
@ -69,6 +69,25 @@ interface ActionData {
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Engine knobs that a simulator (or the shared input-policy resolver) actually
|
||||||
|
* reads from config. The structured Engine fields are limited to these so the UI
|
||||||
|
* never shows a control that silently does nothing — bespoke per-sim constants
|
||||||
|
* (e.g. homeFieldElo, eloDivisor, srsEloScale, raceNoise) that live in a profile
|
||||||
|
* but are not read from config stay editable only via the raw-JSON escape hatch.
|
||||||
|
*/
|
||||||
|
const HONORED_ENGINE_KNOBS = new Set([
|
||||||
|
"iterations",
|
||||||
|
"parityFactor",
|
||||||
|
"seasonGames",
|
||||||
|
"overtimeRate",
|
||||||
|
"matchParityFactor",
|
||||||
|
"averageOpponentElo",
|
||||||
|
"baseDrawRate",
|
||||||
|
"drawDecay",
|
||||||
|
"ratingScaleFactor",
|
||||||
|
]);
|
||||||
|
|
||||||
function parseOptionalNumber(value: string | undefined): number | null {
|
function parseOptionalNumber(value: string | undefined): number | null {
|
||||||
if (value === undefined || value.trim() === "") return null;
|
if (value === undefined || value.trim() === "") return null;
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
|
|
@ -232,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);
|
const currentConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId);
|
||||||
if (!currentConfig) return { success: false, message: "Simulator config not found." };
|
if (!currentConfig) return { success: false, message: "Simulator config not found." };
|
||||||
|
|
||||||
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({
|
await upsertSportsSeasonSimulatorConfig({
|
||||||
sportsSeasonId,
|
sportsSeasonId,
|
||||||
simulatorType: currentConfig.simulatorType,
|
simulatorType: currentConfig.simulatorType,
|
||||||
config: {
|
config: next,
|
||||||
...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),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return { success: true, message: "Simulator input policy saved." };
|
return { success: true, message: "Simulator configuration saved." };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (intent === "save-inputs") {
|
if (intent === "save-inputs") {
|
||||||
|
|
@ -316,6 +350,13 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
||||||
const showsInputPolicy =
|
const showsInputPolicy =
|
||||||
config.profile.requiredInputs.includes("sourceElo") || config.profile.requiredInputs.includes("rating");
|
config.profile.requiredInputs.includes("sourceElo") || config.profile.requiredInputs.includes("rating");
|
||||||
|
|
||||||
|
// Structured engine knobs: every top-level numeric config key (inputPolicy is a
|
||||||
|
// nested object edited in its own section). Driving the fields from the merged
|
||||||
|
// config means each simulator shows exactly the knobs it actually reads.
|
||||||
|
const engineEntries = Object.entries(config.config)
|
||||||
|
.filter(([key, value]) => HONORED_ENGINE_KNOBS.has(key) && typeof value === "number")
|
||||||
|
.map(([key, value]) => [key, value as unknown as number] as [string, number]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<div className="container mx-auto p-6 space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|
@ -407,130 +448,164 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 (0–1)</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Season Config</CardTitle>
|
<CardTitle>Simulator Configuration</CardTitle>
|
||||||
<CardDescription>
|
<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>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-6">
|
||||||
<Form method="post" className="space-y-4">
|
<Form method="post" className="space-y-6">
|
||||||
<input type="hidden" name="intent" value="save-config" />
|
<input type="hidden" name="intent" value="save-configuration" />
|
||||||
<Textarea
|
{showsInputPolicy && <input type="hidden" name="hasInputPolicy" value="1" />}
|
||||||
name="config"
|
|
||||||
rows={10}
|
<section className="space-y-3">
|
||||||
className="font-mono text-sm"
|
<div>
|
||||||
defaultValue={JSON.stringify(config.config, null, 2)}
|
<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 (0–1)</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}>
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
Save Config
|
Save Configuration
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,13 @@ describe("eloWinProbability (PARITY_FACTOR = 1000)", () => {
|
||||||
const p = eloWinProbability(1700, 1500);
|
const p = eloWinProbability(1700, 1500);
|
||||||
expect(p).toBeCloseTo(0.613, 2);
|
expect(p).toBeCloseTo(0.613, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a higher parity factor flattens the per-game win probability toward 0.5", () => {
|
||||||
|
const standard = eloWinProbability(1700, 1500); // default parity 1000
|
||||||
|
const flatter = eloWinProbability(1700, 1500, 2500); // season config override
|
||||||
|
expect(flatter).toBeLessThan(standard);
|
||||||
|
expect(flatter).toBeGreaterThan(0.5);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── simulateProjectedPoints ──────────────────────────────────────────────────
|
// ─── simulateProjectedPoints ──────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -69,20 +69,22 @@ import { normalizeTeamName } from "~/lib/normalize-team-name";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
||||||
import { eloWinProbabilityWithParity } from "~/services/probability-engine";
|
import { eloWinProbabilityWithParity } from "~/services/probability-engine";
|
||||||
|
import { positiveConfigNumber } from "./config-access";
|
||||||
|
|
||||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
// ─── Simulation parameters (defaults; overridable via season config) ───────────
|
||||||
|
|
||||||
const NUM_SIMULATIONS = 10_000;
|
const DEFAULT_NUM_SIMULATIONS = 10_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Elo parity factor for AFL single-game win probability.
|
* Elo parity factor for AFL single-game win probability.
|
||||||
* 450 reflects moderate variance — lower than NHL (1000) to account for
|
* 450 reflects moderate variance — lower than NHL (1000) to account for
|
||||||
* AFL's relatively predictable results vs. basketball's coin-flip tendencies.
|
* AFL's relatively predictable results vs. basketball's coin-flip tendencies.
|
||||||
|
* Overridable via the season config's `parityFactor`.
|
||||||
*/
|
*/
|
||||||
const PARITY_FACTOR = 450;
|
const DEFAULT_PARITY_FACTOR = 450;
|
||||||
|
|
||||||
/** Approximate total regular season games per AFL team (2026 season). */
|
/** Approximate total regular season games per AFL team (2026 season). */
|
||||||
const AFL_REGULAR_SEASON_GAMES = 23;
|
const DEFAULT_REGULAR_SEASON_GAMES = 23;
|
||||||
|
|
||||||
/** Average opponent Elo used for regular season projections. */
|
/** Average opponent Elo used for regular season projections. */
|
||||||
const AVERAGE_OPPONENT_ELO = 1500;
|
const AVERAGE_OPPONENT_ELO = 1500;
|
||||||
|
|
@ -161,8 +163,8 @@ export function getTeamData(name: string): AflTeamData | undefined {
|
||||||
* P(A) = 1 / (1 + 10^((eloB - eloA) / PARITY_FACTOR))
|
* P(A) = 1 / (1 + 10^((eloB - eloA) / PARITY_FACTOR))
|
||||||
* Exported for unit testing.
|
* Exported for unit testing.
|
||||||
*/
|
*/
|
||||||
export function eloWinProbability(eloA: number, eloB: number): number {
|
export function eloWinProbability(eloA: number, eloB: number, parityFactor = DEFAULT_PARITY_FACTOR): number {
|
||||||
return eloWinProbabilityWithParity(eloA, eloB, PARITY_FACTOR);
|
return eloWinProbabilityWithParity(eloA, eloB, parityFactor);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Internal types ───────────────────────────────────────────────────────────
|
// ─── Internal types ───────────────────────────────────────────────────────────
|
||||||
|
|
@ -193,8 +195,11 @@ function simulateProjectedWins(entry: TeamEntry): number {
|
||||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class AFLSimulator implements Simulator {
|
export class AFLSimulator implements Simulator {
|
||||||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||||||
const db = database();
|
const db = database();
|
||||||
|
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
|
||||||
|
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
||||||
|
const seasonGames = Math.round(positiveConfigNumber(config, "seasonGames", DEFAULT_REGULAR_SEASON_GAMES));
|
||||||
|
|
||||||
// 1. Load participants, DB Elo, and standings in parallel.
|
// 1. Load participants, DB Elo, and standings in parallel.
|
||||||
const [participantRows, evRows, standings] = await Promise.all([
|
const [participantRows, evRows, standings] = await Promise.all([
|
||||||
|
|
@ -260,8 +265,8 @@ export class AFLSimulator implements Simulator {
|
||||||
name: r.name,
|
name: r.name,
|
||||||
elo: resolvedElo,
|
elo: resolvedElo,
|
||||||
currentWins: standing?.wins ?? 0,
|
currentWins: standing?.wins ?? 0,
|
||||||
remainingGames: Math.max(0, AFL_REGULAR_SEASON_GAMES - gamesPlayed),
|
remainingGames: Math.max(0, seasonGames - gamesPlayed),
|
||||||
winProb: eloWinProbability(resolvedElo, AVERAGE_OPPONENT_ELO),
|
winProb: eloWinProbability(resolvedElo, AVERAGE_OPPONENT_ELO, parityFactor),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -269,7 +274,7 @@ export class AFLSimulator implements Simulator {
|
||||||
|
|
||||||
/** Simulate a single AFL game. Returns the winner. */
|
/** Simulate a single AFL game. Returns the winner. */
|
||||||
const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry =>
|
const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry =>
|
||||||
Math.random() < eloWinProbability(a.elo, b.elo) ? a : b;
|
Math.random() < eloWinProbability(a.elo, b.elo, parityFactor) ? a : b;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Project end-of-season ladder and return the top 10 finalists seeded 1–10.
|
* Project end-of-season ladder and return the top 10 finalists seeded 1–10.
|
||||||
|
|
@ -364,7 +369,7 @@ export class AFLSimulator implements Simulator {
|
||||||
const efLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
const efLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||||||
|
|
||||||
// 4. Monte Carlo simulation loop.
|
// 4. Monte Carlo simulation loop.
|
||||||
for (let s = 0; s < NUM_SIMULATIONS; s++) {
|
for (let s = 0; s < numSimulations; s++) {
|
||||||
const finalists = buildFinalsList();
|
const finalists = buildFinalsList();
|
||||||
const { gfWinner, gfLoser, pfLosers, sfLosers, efLosers } = simAFLFinals(finalists);
|
const { gfWinner, gfLoser, pfLosers, sfLosers, efLosers } = simAFLFinals(finalists);
|
||||||
|
|
||||||
|
|
@ -393,7 +398,7 @@ export class AFLSimulator implements Simulator {
|
||||||
//
|
//
|
||||||
// Within each pair (3rd/4th, 5th/6th, 7th/8th), both positions receive the
|
// Within each pair (3rd/4th, 5th/6th, 7th/8th), both positions receive the
|
||||||
// same probability — matching the AFL_10 bracket's averaged point values.
|
// same probability — matching the AFL_10 bracket's averaged point values.
|
||||||
const N = NUM_SIMULATIONS;
|
const N = numSimulations;
|
||||||
const results: SimulationResult[] = participantIds.map((participantId) => {
|
const results: SimulationResult[] = participantIds.map((participantId) => {
|
||||||
const c = championCounts.get(participantId) ?? 0;
|
const c = championCounts.get(participantId) ?? 0;
|
||||||
const f = finalistCounts.get(participantId) ?? 0;
|
const f = finalistCounts.get(participantId) ?? 0;
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,11 @@ import * as schema from "~/database/schema";
|
||||||
import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value";
|
import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value";
|
||||||
import { getSeasonResults } from "~/models/participant-season-result";
|
import { getSeasonResults } from "~/models/participant-season-result";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
|
import { positiveConfigNumber } from "./config-access";
|
||||||
|
|
||||||
// ─── Simulation parameters (mirrors Python constants) ────────────────────────
|
// ─── Simulation parameters (mirrors Python constants) ────────────────────────
|
||||||
|
|
||||||
const NUM_SIMULATIONS = 10000;
|
const DEFAULT_NUM_SIMULATIONS = 10000;
|
||||||
|
|
||||||
/** Per-race performance variance. 0 = no noise, 1 = fully random each race. */
|
/** Per-race performance variance. 0 = no noise, 1 = fully random each race. */
|
||||||
const RACE_NOISE = 0.50;
|
const RACE_NOISE = 0.50;
|
||||||
|
|
@ -107,7 +108,8 @@ export class AutoRacingSimulator implements Simulator {
|
||||||
private readonly source: string,
|
private readonly source: string,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||||||
|
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
||||||
const db = database();
|
const db = database();
|
||||||
|
|
||||||
// 1. Load all participants for this sports season
|
// 1. Load all participants for this sports season
|
||||||
|
|
@ -186,7 +188,7 @@ export class AutoRacingSimulator implements Simulator {
|
||||||
// Pre-season: no races to simulate, derive placement probabilities
|
// Pre-season: no races to simulate, derive placement probabilities
|
||||||
// from sourceOdds via pure weighted draws.
|
// from sourceOdds via pure weighted draws.
|
||||||
const weights = ids.map((id) => baseProbs.get(id) ?? fallbackProb);
|
const weights = ids.map((id) => baseProbs.get(id) ?? fallbackProb);
|
||||||
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
|
for (let sim = 0; sim < numSimulations; sim++) {
|
||||||
const finishOrder = weightedDrawWithoutReplacement(ids, weights);
|
const finishOrder = weightedDrawWithoutReplacement(ids, weights);
|
||||||
for (let rank = 0; rank < Math.min(8, finishOrder.length); rank++) {
|
for (let rank = 0; rank < Math.min(8, finishOrder.length); rank++) {
|
||||||
const counts = rankCounts.get(finishOrder[rank]);
|
const counts = rankCounts.get(finishOrder[rank]);
|
||||||
|
|
@ -225,7 +227,7 @@ export class AutoRacingSimulator implements Simulator {
|
||||||
// Volatility shrinks as the season progresses — late-season standings are
|
// Volatility shrinks as the season progresses — late-season standings are
|
||||||
// much more predictive than early-season odds.
|
// much more predictive than early-season odds.
|
||||||
const effectiveVolatility = PARTICIPANT_VOLATILITY * (1 - seasonProgress * VOLATILITY_DECAY_FACTOR);
|
const effectiveVolatility = PARTICIPANT_VOLATILITY * (1 - seasonProgress * VOLATILITY_DECAY_FACTOR);
|
||||||
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
|
for (let sim = 0; sim < numSimulations; sim++) {
|
||||||
// 7a. Season-long performance multiplier per driver (uses blended strength)
|
// 7a. Season-long performance multiplier per driver (uses blended strength)
|
||||||
const seasonWeights = new Map<string, number>();
|
const seasonWeights = new Map<string, number>();
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
|
|
@ -277,14 +279,14 @@ export class AutoRacingSimulator implements Simulator {
|
||||||
return {
|
return {
|
||||||
participantId: p.id,
|
participantId: p.id,
|
||||||
probabilities: {
|
probabilities: {
|
||||||
probFirst: counts[0] / NUM_SIMULATIONS,
|
probFirst: counts[0] / numSimulations,
|
||||||
probSecond: counts[1] / NUM_SIMULATIONS,
|
probSecond: counts[1] / numSimulations,
|
||||||
probThird: counts[2] / NUM_SIMULATIONS,
|
probThird: counts[2] / numSimulations,
|
||||||
probFourth: counts[3] / NUM_SIMULATIONS,
|
probFourth: counts[3] / numSimulations,
|
||||||
probFifth: counts[4] / NUM_SIMULATIONS,
|
probFifth: counts[4] / numSimulations,
|
||||||
probSixth: counts[5] / NUM_SIMULATIONS,
|
probSixth: counts[5] / numSimulations,
|
||||||
probSeventh: counts[6] / NUM_SIMULATIONS,
|
probSeventh: counts[6] / numSimulations,
|
||||||
probEighth: counts[7] / NUM_SIMULATIONS,
|
probEighth: counts[7] / numSimulations,
|
||||||
},
|
},
|
||||||
source: this.source,
|
source: this.source,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,9 @@ import {
|
||||||
eloWinProbabilityWithParity,
|
eloWinProbabilityWithParity,
|
||||||
} from "~/services/probability-engine";
|
} from "~/services/probability-engine";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
|
import { positiveConfigNumber } from "./config-access";
|
||||||
|
|
||||||
const NUM_SIMULATIONS = 50_000;
|
const DEFAULT_NUM_SIMULATIONS = 50_000;
|
||||||
const BRACKET_SIZE = 16;
|
const BRACKET_SIZE = 16;
|
||||||
const HOCKEY_PARITY_FACTOR = 850;
|
const HOCKEY_PARITY_FACTOR = 850;
|
||||||
const DEFAULT_ELO = 1500;
|
const DEFAULT_ELO = 1500;
|
||||||
|
|
@ -156,8 +157,8 @@ export function buildCollegeHockeyTeams(
|
||||||
return teams;
|
return teams;
|
||||||
}
|
}
|
||||||
|
|
||||||
function simGame(team1: Team, team2: Team): { winner: Team; loser: Team } {
|
function simGame(team1: Team, team2: Team, parityFactor = HOCKEY_PARITY_FACTOR): { winner: Team; loser: Team } {
|
||||||
const p1Wins = Math.random() < eloWinProbabilityWithParity(team1.elo, team2.elo, HOCKEY_PARITY_FACTOR);
|
const p1Wins = Math.random() < eloWinProbabilityWithParity(team1.elo, team2.elo, parityFactor);
|
||||||
return p1Wins ? { winner: team1, loser: team2 } : { winner: team2, loser: team1 };
|
return p1Wins ? { winner: team1, loser: team2 } : { winner: team2, loser: team1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -189,26 +190,27 @@ function seedField(teams: Team[]): Team[] {
|
||||||
return [...teams].toSorted((a, b) => b.seedScore - a.seedScore || b.elo - a.elo);
|
return [...teams].toSorted((a, b) => b.seedScore - a.seedScore || b.elo - a.elo);
|
||||||
}
|
}
|
||||||
|
|
||||||
function simulateSeeded16TeamBracket(teams: Team[], counts: Map<string, PlacementCounts>): void {
|
function simulateSeeded16TeamBracket(teams: Team[], counts: Map<string, PlacementCounts>, parityFactor = HOCKEY_PARITY_FACTOR): void {
|
||||||
const r16Pairs: Array<[number, number]> = [
|
const r16Pairs: Array<[number, number]> = [
|
||||||
[0, 15], [7, 8], [4, 11], [3, 12], [5, 10], [2, 13], [6, 9], [1, 14],
|
[0, 15], [7, 8], [4, 11], [3, 12], [5, 10], [2, 13], [6, 9], [1, 14],
|
||||||
];
|
];
|
||||||
|
const game = (a: Team, b: Team) => simGame(a, b, parityFactor);
|
||||||
|
|
||||||
const r16Winners = r16Pairs.map(([a, b]) => simGame(teams[a], teams[b]).winner);
|
const r16Winners = r16Pairs.map(([a, b]) => game(teams[a], teams[b]).winner);
|
||||||
|
|
||||||
const qfWinners: Team[] = [];
|
const qfWinners: Team[] = [];
|
||||||
for (let i = 0; i < 4; i++) {
|
for (let i = 0; i < 4; i++) {
|
||||||
const result = simGame(r16Winners[i * 2], r16Winners[i * 2 + 1]);
|
const result = game(r16Winners[i * 2], r16Winners[i * 2 + 1]);
|
||||||
qfWinners.push(result.winner);
|
qfWinners.push(result.winner);
|
||||||
bump(counts, result.loser.participantId, "quarterfinalLoser");
|
bump(counts, result.loser.participantId, "quarterfinalLoser");
|
||||||
}
|
}
|
||||||
|
|
||||||
const sf1 = simGame(qfWinners[0], qfWinners[1]);
|
const sf1 = game(qfWinners[0], qfWinners[1]);
|
||||||
const sf2 = simGame(qfWinners[2], qfWinners[3]);
|
const sf2 = game(qfWinners[2], qfWinners[3]);
|
||||||
bump(counts, sf1.loser.participantId, "semifinalLoser");
|
bump(counts, sf1.loser.participantId, "semifinalLoser");
|
||||||
bump(counts, sf2.loser.participantId, "semifinalLoser");
|
bump(counts, sf2.loser.participantId, "semifinalLoser");
|
||||||
|
|
||||||
const final = simGame(sf1.winner, sf2.winner);
|
const final = game(sf1.winner, sf2.winner);
|
||||||
bump(counts, final.winner.participantId, "champion");
|
bump(counts, final.winner.participantId, "champion");
|
||||||
bump(counts, final.loser.participantId, "finalist");
|
bump(counts, final.loser.participantId, "finalist");
|
||||||
}
|
}
|
||||||
|
|
@ -236,19 +238,21 @@ function simulateMatchFromIds(
|
||||||
p1: string,
|
p1: string,
|
||||||
p2: string,
|
p2: string,
|
||||||
match: PlayoffMatch | undefined,
|
match: PlayoffMatch | undefined,
|
||||||
teamById: Map<string, Team>
|
teamById: Map<string, Team>,
|
||||||
|
parityFactor = HOCKEY_PARITY_FACTOR
|
||||||
): { winner: Team; loser: Team } {
|
): { winner: Team; loser: Team } {
|
||||||
if (match?.isComplete && match.winnerId) {
|
if (match?.isComplete && match.winnerId) {
|
||||||
const loserId = completedLoser(match);
|
const loserId = completedLoser(match);
|
||||||
return { winner: getTeam(teamById, match.winnerId), loser: getTeam(teamById, loserId) };
|
return { winner: getTeam(teamById, match.winnerId), loser: getTeam(teamById, loserId) };
|
||||||
}
|
}
|
||||||
return simGame(getTeam(teamById, p1), getTeam(teamById, p2));
|
return simGame(getTeam(teamById, p1), getTeam(teamById, p2), parityFactor);
|
||||||
}
|
}
|
||||||
|
|
||||||
function simulateExistingBracket(
|
function simulateExistingBracket(
|
||||||
rounds: { r16: PlayoffMatch[]; qf: PlayoffMatch[]; sf: PlayoffMatch[]; final: PlayoffMatch },
|
rounds: { r16: PlayoffMatch[]; qf: PlayoffMatch[]; sf: PlayoffMatch[]; final: PlayoffMatch },
|
||||||
teamById: Map<string, Team>,
|
teamById: Map<string, Team>,
|
||||||
counts: Map<string, PlacementCounts>
|
counts: Map<string, PlacementCounts>,
|
||||||
|
parityFactor = HOCKEY_PARITY_FACTOR
|
||||||
): void {
|
): void {
|
||||||
const r16Winners: Team[] = [];
|
const r16Winners: Team[] = [];
|
||||||
for (let i = 0; i < 8; i++) {
|
for (let i = 0; i < 8; i++) {
|
||||||
|
|
@ -256,7 +260,7 @@ function simulateExistingBracket(
|
||||||
if (!match.participant1Id || !match.participant2Id) {
|
if (!match.participant1Id || !match.participant2Id) {
|
||||||
throw new Error(`${match.round} match ${match.matchNumber} is missing participants.`);
|
throw new Error(`${match.round} match ${match.matchNumber} is missing participants.`);
|
||||||
}
|
}
|
||||||
r16Winners.push(simulateMatchFromIds(match.participant1Id, match.participant2Id, match, teamById).winner);
|
r16Winners.push(simulateMatchFromIds(match.participant1Id, match.participant2Id, match, teamById, parityFactor).winner);
|
||||||
}
|
}
|
||||||
|
|
||||||
const qfWinners: Team[] = [];
|
const qfWinners: Team[] = [];
|
||||||
|
|
@ -265,7 +269,7 @@ function simulateExistingBracket(
|
||||||
const p1 = r16Winners[i * 2]?.participantId;
|
const p1 = r16Winners[i * 2]?.participantId;
|
||||||
const p2 = r16Winners[i * 2 + 1]?.participantId;
|
const p2 = r16Winners[i * 2 + 1]?.participantId;
|
||||||
if (!p1 || !p2) throw new Error(`${match.round} match ${match.matchNumber} cannot resolve participants.`);
|
if (!p1 || !p2) throw new Error(`${match.round} match ${match.matchNumber} cannot resolve participants.`);
|
||||||
const result = simulateMatchFromIds(p1, p2, match, teamById);
|
const result = simulateMatchFromIds(p1, p2, match, teamById, parityFactor);
|
||||||
qfWinners.push(result.winner);
|
qfWinners.push(result.winner);
|
||||||
bump(counts, result.loser.participantId, "quarterfinalLoser");
|
bump(counts, result.loser.participantId, "quarterfinalLoser");
|
||||||
}
|
}
|
||||||
|
|
@ -276,7 +280,7 @@ function simulateExistingBracket(
|
||||||
const p1 = qfWinners[i * 2]?.participantId;
|
const p1 = qfWinners[i * 2]?.participantId;
|
||||||
const p2 = qfWinners[i * 2 + 1]?.participantId;
|
const p2 = qfWinners[i * 2 + 1]?.participantId;
|
||||||
if (!p1 || !p2) throw new Error(`${match.round} match ${match.matchNumber} cannot resolve participants.`);
|
if (!p1 || !p2) throw new Error(`${match.round} match ${match.matchNumber} cannot resolve participants.`);
|
||||||
const result = simulateMatchFromIds(p1, p2, match, teamById);
|
const result = simulateMatchFromIds(p1, p2, match, teamById, parityFactor);
|
||||||
sfWinners.push(result.winner);
|
sfWinners.push(result.winner);
|
||||||
bump(counts, result.loser.participantId, "semifinalLoser");
|
bump(counts, result.loser.participantId, "semifinalLoser");
|
||||||
}
|
}
|
||||||
|
|
@ -284,21 +288,21 @@ function simulateExistingBracket(
|
||||||
const p1 = sfWinners[0]?.participantId;
|
const p1 = sfWinners[0]?.participantId;
|
||||||
const p2 = sfWinners[1]?.participantId;
|
const p2 = sfWinners[1]?.participantId;
|
||||||
if (!p1 || !p2) throw new Error(`${rounds.final.round} match ${rounds.final.matchNumber} cannot resolve participants.`);
|
if (!p1 || !p2) throw new Error(`${rounds.final.round} match ${rounds.final.matchNumber} cannot resolve participants.`);
|
||||||
const final = simulateMatchFromIds(p1, p2, rounds.final, teamById);
|
const final = simulateMatchFromIds(p1, p2, rounds.final, teamById, parityFactor);
|
||||||
bump(counts, final.winner.participantId, "champion");
|
bump(counts, final.winner.participantId, "champion");
|
||||||
bump(counts, final.loser.participantId, "finalist");
|
bump(counts, final.loser.participantId, "finalist");
|
||||||
}
|
}
|
||||||
|
|
||||||
function toResults(participantIds: string[], counts: Map<string, PlacementCounts>): SimulationResult[] {
|
function toResults(participantIds: string[], counts: Map<string, PlacementCounts>, numSimulations: number): SimulationResult[] {
|
||||||
const results = participantIds.map((participantId) => {
|
const results = participantIds.map((participantId) => {
|
||||||
const c = counts.get(participantId) ?? { champion: 0, finalist: 0, semifinalLoser: 0, quarterfinalLoser: 0 };
|
const c = counts.get(participantId) ?? { champion: 0, finalist: 0, semifinalLoser: 0, quarterfinalLoser: 0 };
|
||||||
const sf = c.semifinalLoser / (2 * NUM_SIMULATIONS);
|
const sf = c.semifinalLoser / (2 * numSimulations);
|
||||||
const qf = c.quarterfinalLoser / (4 * NUM_SIMULATIONS);
|
const qf = c.quarterfinalLoser / (4 * numSimulations);
|
||||||
return {
|
return {
|
||||||
participantId,
|
participantId,
|
||||||
probabilities: {
|
probabilities: {
|
||||||
probFirst: c.champion / NUM_SIMULATIONS,
|
probFirst: c.champion / numSimulations,
|
||||||
probSecond: c.finalist / NUM_SIMULATIONS,
|
probSecond: c.finalist / numSimulations,
|
||||||
probThird: sf,
|
probThird: sf,
|
||||||
probFourth: sf,
|
probFourth: sf,
|
||||||
probFifth: qf,
|
probFifth: qf,
|
||||||
|
|
@ -325,8 +329,10 @@ function toResults(participantIds: string[], counts: Map<string, PlacementCounts
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CollegeHockeySimulator implements Simulator {
|
export class CollegeHockeySimulator implements Simulator {
|
||||||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||||||
const db = database();
|
const db = database();
|
||||||
|
const parityFactor = positiveConfigNumber(config, "parityFactor", HOCKEY_PARITY_FACTOR);
|
||||||
|
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
||||||
|
|
||||||
const bracketEvent = await db.query.scoringEvents.findFirst({
|
const bracketEvent = await db.query.scoringEvents.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
|
|
@ -373,19 +379,19 @@ export class CollegeHockeySimulator implements Simulator {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingBracket) {
|
if (existingBracket) {
|
||||||
for (let i = 0; i < NUM_SIMULATIONS; i++) {
|
for (let i = 0; i < numSimulations; i++) {
|
||||||
simulateExistingBracket(existingBracket, teamById, counts);
|
simulateExistingBracket(existingBracket, teamById, counts, parityFactor);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const preBracketMode = participantIds.length > BRACKET_SIZE;
|
const preBracketMode = participantIds.length > BRACKET_SIZE;
|
||||||
const deterministicField = preBracketMode ? null : seedField(teams);
|
const deterministicField = preBracketMode ? null : seedField(teams);
|
||||||
for (let i = 0; i < NUM_SIMULATIONS; i++) {
|
for (let i = 0; i < numSimulations; i++) {
|
||||||
const field = preBracketMode ? sampleField(teams, BRACKET_SIZE) : deterministicField ?? [];
|
const field = preBracketMode ? sampleField(teams, BRACKET_SIZE) : deterministicField ?? [];
|
||||||
simulateSeeded16TeamBracket(field, counts);
|
simulateSeeded16TeamBracket(field, counts, parityFactor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return toResults(participantIds, counts);
|
return toResults(participantIds, counts, numSimulations);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getExistingBracketRounds(matches: PlayoffMatch[]): { r16: PlayoffMatch[]; qf: PlayoffMatch[]; sf: PlayoffMatch[]; final: PlayoffMatch } | null {
|
private getExistingBracketRounds(matches: PlayoffMatch[]): { r16: PlayoffMatch[]; qf: PlayoffMatch[]; sf: PlayoffMatch[]; final: PlayoffMatch } | null {
|
||||||
|
|
|
||||||
43
app/services/simulations/config-access.ts
Normal file
43
app/services/simulations/config-access.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -59,10 +59,11 @@ import {
|
||||||
type StructureSource,
|
type StructureSource,
|
||||||
} from "./shared-major";
|
} from "./shared-major";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
|
import { positiveConfigNumber } from "./config-access";
|
||||||
|
|
||||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const NUM_SIMULATIONS = 10_000;
|
const DEFAULT_NUM_SIMULATIONS = 10_000;
|
||||||
|
|
||||||
/** Total field size per CS2 Major. */
|
/** Total field size per CS2 Major. */
|
||||||
const FIELD_SIZE = 32;
|
const FIELD_SIZE = 32;
|
||||||
|
|
@ -545,7 +546,8 @@ export function simulateChampionsStage(
|
||||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class CSMajorSimulator implements Simulator {
|
export class CSMajorSimulator implements Simulator {
|
||||||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||||||
|
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
||||||
const db = database();
|
const db = database();
|
||||||
|
|
||||||
// 1. Load all participants for this sports season.
|
// 1. Load all participants for this sports season.
|
||||||
|
|
@ -800,7 +802,7 @@ export class CSMajorSimulator implements Simulator {
|
||||||
const counts: number[][] = Array.from({ length: participantIds.length }, () => Array(8).fill(0));
|
const counts: number[][] = Array.from({ length: participantIds.length }, () => Array(8).fill(0));
|
||||||
const idToIndex = new Map<string, number>(participantIds.map((id, i) => [id, i]));
|
const idToIndex = new Map<string, number>(participantIds.map((id, i) => [id, i]));
|
||||||
|
|
||||||
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
|
for (let sim = 0; sim < numSimulations; sim++) {
|
||||||
const simQP = new Map<string, number>(actualQPMap);
|
const simQP = new Map<string, number>(actualQPMap);
|
||||||
|
|
||||||
for (const event of incompleteEvents) {
|
for (const event of incompleteEvents) {
|
||||||
|
|
@ -831,14 +833,14 @@ export class CSMajorSimulator implements Simulator {
|
||||||
return participantIds.map((participantId, i) => ({
|
return participantIds.map((participantId, i) => ({
|
||||||
participantId,
|
participantId,
|
||||||
probabilities: {
|
probabilities: {
|
||||||
probFirst: counts[i][0] / NUM_SIMULATIONS,
|
probFirst: counts[i][0] / numSimulations,
|
||||||
probSecond: counts[i][1] / NUM_SIMULATIONS,
|
probSecond: counts[i][1] / numSimulations,
|
||||||
probThird: counts[i][2] / NUM_SIMULATIONS,
|
probThird: counts[i][2] / numSimulations,
|
||||||
probFourth: counts[i][3] / NUM_SIMULATIONS,
|
probFourth: counts[i][3] / numSimulations,
|
||||||
probFifth: counts[i][4] / NUM_SIMULATIONS,
|
probFifth: counts[i][4] / numSimulations,
|
||||||
probSixth: counts[i][5] / NUM_SIMULATIONS,
|
probSixth: counts[i][5] / numSimulations,
|
||||||
probSeventh: counts[i][6] / NUM_SIMULATIONS,
|
probSeventh: counts[i][6] / numSimulations,
|
||||||
probEighth: counts[i][7] / NUM_SIMULATIONS,
|
probEighth: counts[i][7] / numSimulations,
|
||||||
},
|
},
|
||||||
source: "cs2_major_qualifying_points_monte_carlo",
|
source: "cs2_major_qualifying_points_monte_carlo",
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ import { database } from "~/database/context";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
|
import { positiveConfigNumber } from "./config-access";
|
||||||
|
|
||||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -204,8 +205,9 @@ export class DartsSimulator implements Simulator {
|
||||||
this.numSimulations = numSimulations;
|
this.numSimulations = numSimulations;
|
||||||
}
|
}
|
||||||
|
|
||||||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||||||
const db = database();
|
const db = database();
|
||||||
|
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", this.numSimulations));
|
||||||
|
|
||||||
// 1. Find the bracket scoring event (if it exists).
|
// 1. Find the bracket scoring event (if it exists).
|
||||||
const bracketEvent = await db.query.scoringEvents.findFirst({
|
const bracketEvent = await db.query.scoringEvents.findFirst({
|
||||||
|
|
@ -248,9 +250,9 @@ export class DartsSimulator implements Simulator {
|
||||||
const bracketPopulated = allMatches.some((m) => m.participant1Id && m.participant2Id);
|
const bracketPopulated = allMatches.some((m) => m.participant1Id && m.participant2Id);
|
||||||
|
|
||||||
if (bracketPopulated) {
|
if (bracketPopulated) {
|
||||||
return this.simulateBracket(allMatches, eloMap);
|
return this.simulateBracket(allMatches, eloMap, numSimulations);
|
||||||
} else {
|
} else {
|
||||||
return this.simulatePreBracket(sportsSeasonId, eloMap, rankingMap, db);
|
return this.simulatePreBracket(sportsSeasonId, eloMap, rankingMap, db, numSimulations);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -258,7 +260,8 @@ export class DartsSimulator implements Simulator {
|
||||||
|
|
||||||
private async simulateBracket(
|
private async simulateBracket(
|
||||||
allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>,
|
allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>,
|
||||||
eloMap: Map<string, number>
|
eloMap: Map<string, number>,
|
||||||
|
numSimulations: number
|
||||||
): Promise<SimulationResult[]> {
|
): Promise<SimulationResult[]> {
|
||||||
// Group matches by round, sorted by match count descending (R1 first = most matches).
|
// Group matches by round, sorted by match count descending (R1 first = most matches).
|
||||||
const byRound = new Map<string, typeof allMatches>();
|
const byRound = new Map<string, typeof allMatches>();
|
||||||
|
|
@ -331,7 +334,7 @@ export class DartsSimulator implements Simulator {
|
||||||
const sfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
const sfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||||||
const qfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
const qfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||||||
|
|
||||||
for (let s = 0; s < this.numSimulations; s++) {
|
for (let s = 0; s < numSimulations; s++) {
|
||||||
// R1 (64 matches)
|
// R1 (64 matches)
|
||||||
const r1Winners: string[] = [];
|
const r1Winners: string[] = [];
|
||||||
for (let i = 1; i <= 64; i++) {
|
for (let i = 1; i <= 64; i++) {
|
||||||
|
|
@ -439,7 +442,7 @@ export class DartsSimulator implements Simulator {
|
||||||
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
|
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildResults(participantIds, this.numSimulations, {
|
return buildResults(participantIds, numSimulations, {
|
||||||
championCounts,
|
championCounts,
|
||||||
finalistCounts,
|
finalistCounts,
|
||||||
sfLoserCounts,
|
sfLoserCounts,
|
||||||
|
|
@ -455,7 +458,8 @@ export class DartsSimulator implements Simulator {
|
||||||
sportsSeasonId: string,
|
sportsSeasonId: string,
|
||||||
eloMap: Map<string, number>,
|
eloMap: Map<string, number>,
|
||||||
rankingMap: Map<string, number>,
|
rankingMap: Map<string, number>,
|
||||||
db: ReturnType<typeof database>
|
db: ReturnType<typeof database>,
|
||||||
|
numSimulations: number
|
||||||
): Promise<SimulationResult[]> {
|
): Promise<SimulationResult[]> {
|
||||||
const allParticipants = await db
|
const allParticipants = await db
|
||||||
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
|
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
|
||||||
|
|
@ -520,7 +524,7 @@ export class DartsSimulator implements Simulator {
|
||||||
unseededPool.push(`__bye_${unseededPool.length}`);
|
unseededPool.push(`__bye_${unseededPool.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let s = 0; s < this.numSimulations; s++) {
|
for (let s = 0; s < numSimulations; s++) {
|
||||||
// Draw: shuffle the unseeded pool — seeded positions are pre-computed.
|
// Draw: shuffle the unseeded pool — seeded positions are pre-computed.
|
||||||
const drawnUnseeded = shuffle([...unseededPool]);
|
const drawnUnseeded = shuffle([...unseededPool]);
|
||||||
|
|
||||||
|
|
@ -588,7 +592,7 @@ export class DartsSimulator implements Simulator {
|
||||||
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
|
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildResults(allParticipantIds, this.numSimulations, {
|
return buildResults(allParticipantIds, numSimulations, {
|
||||||
championCounts,
|
championCounts,
|
||||||
finalistCounts,
|
finalistCounts,
|
||||||
sfLoserCounts,
|
sfLoserCounts,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { convertFuturesToElo, eloWinProbabilityWithParity } from "~/services/pro
|
||||||
import { normalizeSimulationResultColumns } from "./simulation-probabilities";
|
import { normalizeSimulationResultColumns } from "./simulation-probabilities";
|
||||||
import { simulateEloSoccerMatch, simulateSimpleSoccerGoals } from "./soccer-helpers";
|
import { simulateEloSoccerMatch, simulateSimpleSoccerGoals } from "./soccer-helpers";
|
||||||
import { getSportsSeasonSimulatorConfig } from "~/models/simulator";
|
import { getSportsSeasonSimulatorConfig } from "~/models/simulator";
|
||||||
|
import { positiveConfigNumber, requiredConfigNumber } from "./config-access";
|
||||||
|
|
||||||
const DEFAULT_NUM_SIMULATIONS = 10_000;
|
const DEFAULT_NUM_SIMULATIONS = 10_000;
|
||||||
const DEFAULT_EPL_REGULAR_SEASON_GAMES = 38;
|
const DEFAULT_EPL_REGULAR_SEASON_GAMES = 38;
|
||||||
|
|
@ -44,17 +45,6 @@ interface SimulatedTableRow {
|
||||||
tiebreaker: number;
|
tiebreaker: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function configNumber(config: Record<string, unknown> | undefined, key: string, fallback: number): number {
|
|
||||||
const value = config?.[key];
|
|
||||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requiredConfigNumber(config: Record<string, unknown> | undefined, key: string): number {
|
|
||||||
const value = config?.[key];
|
|
||||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
|
|
||||||
throw new Error(`EPL simulator config is missing positive numeric ${key}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** EPL single-match win probability using the sport-specific parity factor. */
|
/** EPL single-match win probability using the sport-specific parity factor. */
|
||||||
export function eplWinProbability(
|
export function eplWinProbability(
|
||||||
eloA: number,
|
eloA: number,
|
||||||
|
|
@ -99,13 +89,13 @@ export class EPLSimulator implements Simulator {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const config = simulatorConfig?.config;
|
const config = simulatorConfig?.config;
|
||||||
const numSimulations = Math.round(configNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
||||||
const seasonGames = Math.round(configNumber(config, "seasonGames", DEFAULT_EPL_REGULAR_SEASON_GAMES));
|
const seasonGames = Math.round(positiveConfigNumber(config, "seasonGames", DEFAULT_EPL_REGULAR_SEASON_GAMES));
|
||||||
const averageOpponentElo = configNumber(config, "averageOpponentElo", DEFAULT_AVERAGE_OPPONENT_ELO);
|
const averageOpponentElo = positiveConfigNumber(config, "averageOpponentElo", DEFAULT_AVERAGE_OPPONENT_ELO);
|
||||||
const matchOptions = {
|
const matchOptions = {
|
||||||
baseDrawRate: configNumber(config, "baseDrawRate", DEFAULT_BASE_DRAW_RATE),
|
baseDrawRate: positiveConfigNumber(config, "baseDrawRate", DEFAULT_BASE_DRAW_RATE),
|
||||||
drawDecay: configNumber(config, "drawDecay", DEFAULT_DRAW_DECAY),
|
drawDecay: positiveConfigNumber(config, "drawDecay", DEFAULT_DRAW_DECAY),
|
||||||
matchParityFactor: requiredConfigNumber(config, "matchParityFactor"),
|
matchParityFactor: requiredConfigNumber(config, "matchParityFactor", "EPL simulator"),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (participantRows.length === 0) {
|
if (participantRows.length === 0) {
|
||||||
|
|
|
||||||
|
|
@ -38,10 +38,11 @@ import { getGolfSkillsMap, type GolfSkillsRecord } from "~/models/golf-skills";
|
||||||
import { getQPConfig } from "~/models/qualifying-points";
|
import { getQPConfig } from "~/models/qualifying-points";
|
||||||
import { getExcludedByEventMap } from "~/models/event-result";
|
import { getExcludedByEventMap } from "~/models/event-result";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
|
import { positiveConfigNumber } from "./config-access";
|
||||||
|
|
||||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const NUM_SIMULATIONS = 10_000;
|
const DEFAULT_NUM_SIMULATIONS = 10_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simulated field size for each major.
|
* Simulated field size for each major.
|
||||||
|
|
@ -180,7 +181,8 @@ export function simulateMajor(
|
||||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class GolfSimulator implements Simulator {
|
export class GolfSimulator implements Simulator {
|
||||||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||||||
|
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
||||||
const db = database();
|
const db = database();
|
||||||
|
|
||||||
// Load participants, skills, QP config, and scoring events in parallel.
|
// Load participants, skills, QP config, and scoring events in parallel.
|
||||||
|
|
@ -269,7 +271,7 @@ export class GolfSimulator implements Simulator {
|
||||||
);
|
);
|
||||||
const idToIndex = new Map<string, number>(participantIds.map((id, i) => [id, i]));
|
const idToIndex = new Map<string, number>(participantIds.map((id, i) => [id, i]));
|
||||||
|
|
||||||
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
|
for (let sim = 0; sim < numSimulations; sim++) {
|
||||||
const simQP = new Map<string, number>(actualQPMap);
|
const simQP = new Map<string, number>(actualQPMap);
|
||||||
|
|
||||||
for (const { players, restCount, restStrength } of majorConfigs) {
|
for (const { players, restCount, restStrength } of majorConfigs) {
|
||||||
|
|
@ -292,14 +294,14 @@ export class GolfSimulator implements Simulator {
|
||||||
return participantIds.map((participantId, i) => ({
|
return participantIds.map((participantId, i) => ({
|
||||||
participantId,
|
participantId,
|
||||||
probabilities: {
|
probabilities: {
|
||||||
probFirst: counts[i][0] / NUM_SIMULATIONS,
|
probFirst: counts[i][0] / numSimulations,
|
||||||
probSecond: counts[i][1] / NUM_SIMULATIONS,
|
probSecond: counts[i][1] / numSimulations,
|
||||||
probThird: counts[i][2] / NUM_SIMULATIONS,
|
probThird: counts[i][2] / numSimulations,
|
||||||
probFourth: counts[i][3] / NUM_SIMULATIONS,
|
probFourth: counts[i][3] / numSimulations,
|
||||||
probFifth: counts[i][4] / NUM_SIMULATIONS,
|
probFifth: counts[i][4] / numSimulations,
|
||||||
probSixth: counts[i][5] / NUM_SIMULATIONS,
|
probSixth: counts[i][5] / numSimulations,
|
||||||
probSeventh: counts[i][6] / NUM_SIMULATIONS,
|
probSeventh: counts[i][6] / numSimulations,
|
||||||
probEighth: counts[i][7] / NUM_SIMULATIONS,
|
probEighth: counts[i][7] / numSimulations,
|
||||||
},
|
},
|
||||||
source: "golf_qualifying_points_monte_carlo",
|
source: "golf_qualifying_points_monte_carlo",
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,10 @@ export function resolveSourceElos(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (oddsInputs.length >= 2) {
|
if (oddsInputs.length >= 2) {
|
||||||
|
// Odds map onto the calibrated band (probability-engine DEFAULT_CALIBRATION);
|
||||||
|
// the policy's eloMin/eloMax then clamp the result via clampDerived below, so
|
||||||
|
// a season's Elo floor/ceiling still narrows the odds-derived spread without
|
||||||
|
// widening it (and without clamping a high directly-entered base Elo's blend).
|
||||||
for (const [participantId, elo] of convertFuturesToElo(oddsInputs)) {
|
for (const [participantId, elo] of convertFuturesToElo(oddsInputs)) {
|
||||||
oddsElo.set(participantId, elo);
|
oddsElo.set(participantId, elo);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ import { eq } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { convertAmericanOddsToProbability } from "~/services/probability-engine";
|
import { convertAmericanOddsToProbability } from "~/services/probability-engine";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
|
import { positiveConfigNumber } from "./config-access";
|
||||||
|
|
||||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -261,7 +262,8 @@ function determineRandomized(sideTeams: Team[], sideName: string): boolean {
|
||||||
export class LLWSSimulator implements Simulator {
|
export class LLWSSimulator implements Simulator {
|
||||||
constructor(private numSimulations = NUM_SIMULATIONS) {}
|
constructor(private numSimulations = NUM_SIMULATIONS) {}
|
||||||
|
|
||||||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||||||
|
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", this.numSimulations));
|
||||||
const db = database();
|
const db = database();
|
||||||
|
|
||||||
// 1. Load all participants.
|
// 1. Load all participants.
|
||||||
|
|
@ -347,7 +349,7 @@ export class LLWSSimulator implements Simulator {
|
||||||
};
|
};
|
||||||
|
|
||||||
// 6. Run Monte Carlo simulations.
|
// 6. Run Monte Carlo simulations.
|
||||||
for (let s = 0; s < this.numSimulations; s++) {
|
for (let s = 0; s < numSimulations; s++) {
|
||||||
// Assign pools for this simulation.
|
// Assign pools for this simulation.
|
||||||
const [usPoolA, usPoolB] = assignPools(usTeams, usRandomized);
|
const [usPoolA, usPoolB] = assignPools(usTeams, usRandomized);
|
||||||
const [intlPoolA, intlPoolB] = assignPools(intlTeams, intlRandomized);
|
const [intlPoolA, intlPoolB] = assignPools(intlTeams, intlRandomized);
|
||||||
|
|
@ -378,7 +380,7 @@ export class LLWSSimulator implements Simulator {
|
||||||
// 7. Convert counts to probability distributions.
|
// 7. Convert counts to probability distributions.
|
||||||
// bracketLosers: 4 per sim (2 US + 2 Intl) → split evenly.
|
// bracketLosers: 4 per sim (2 US + 2 Intl) → split evenly.
|
||||||
const bracketLosersPerSim = 4;
|
const bracketLosersPerSim = 4;
|
||||||
const bracketDivisor = bracketLosersPerSim * this.numSimulations;
|
const bracketDivisor = bracketLosersPerSim * numSimulations;
|
||||||
|
|
||||||
const zeroCounts: PlacementCounts = { champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0, bracketLoser: 0 };
|
const zeroCounts: PlacementCounts = { champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0, bracketLoser: 0 };
|
||||||
return allIds.map((id) => {
|
return allIds.map((id) => {
|
||||||
|
|
@ -387,10 +389,10 @@ export class LLWSSimulator implements Simulator {
|
||||||
return {
|
return {
|
||||||
participantId: id,
|
participantId: id,
|
||||||
probabilities: {
|
probabilities: {
|
||||||
probFirst: c.champion / this.numSimulations,
|
probFirst: c.champion / numSimulations,
|
||||||
probSecond: c.finalist / this.numSimulations,
|
probSecond: c.finalist / numSimulations,
|
||||||
probThird: c.thirdPlace / this.numSimulations,
|
probThird: c.thirdPlace / numSimulations,
|
||||||
probFourth: c.fourthPlace / this.numSimulations,
|
probFourth: c.fourthPlace / numSimulations,
|
||||||
probFifth: bracketProb,
|
probFifth: bracketProb,
|
||||||
probSixth: bracketProb,
|
probSixth: bracketProb,
|
||||||
probSeventh: bracketProb,
|
probSeventh: bracketProb,
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
setupSections: ["participants", "futuresOdds", "bracket"],
|
setupSections: ["participants", "futuresOdds", "bracket"],
|
||||||
},
|
},
|
||||||
ucl_bracket: {
|
ucl_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, inputPolicy: { oddsWeight: 0.3 } },
|
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, inputPolicy: { oddsWeight: 0.3 } },
|
||||||
requiredInputs: ["sourceElo"],
|
requiredInputs: ["sourceElo"],
|
||||||
optionalInputs: ["sourceOdds"],
|
optionalInputs: ["sourceOdds"],
|
||||||
derivableInputs: { sourceElo: ["sourceOdds"] },
|
derivableInputs: { sourceElo: ["sourceOdds"] },
|
||||||
|
|
@ -150,7 +150,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
setupSections: ["participants", "eloRatings", "regularStandings"],
|
setupSections: ["participants", "eloRatings", "regularStandings"],
|
||||||
},
|
},
|
||||||
wnba_bracket: {
|
wnba_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, seasonGames: 44, srsEloScale: 30 },
|
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 44, srsEloScale: 30 },
|
||||||
requiredInputs: ["sourceElo"],
|
requiredInputs: ["sourceElo"],
|
||||||
optionalInputs: ["sourceOdds", "projectedWins"],
|
optionalInputs: ["sourceOdds", "projectedWins"],
|
||||||
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
||||||
|
|
@ -176,7 +176,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"],
|
setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"],
|
||||||
},
|
},
|
||||||
ncaa_football_bracket: {
|
ncaa_football_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, bracketSize: 12, inputPolicy: { oddsWeight: 0.4 } },
|
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, bracketSize: 12, inputPolicy: { oddsWeight: 0.4 } },
|
||||||
requiredInputs: ["sourceElo"],
|
requiredInputs: ["sourceElo"],
|
||||||
optionalInputs: ["sourceOdds", "worldRanking"],
|
optionalInputs: ["sourceOdds", "worldRanking"],
|
||||||
derivableInputs: { sourceElo: ["sourceOdds"] },
|
derivableInputs: { sourceElo: ["sourceOdds"] },
|
||||||
|
|
|
||||||
|
|
@ -74,12 +74,13 @@ import { database } from "~/database/context";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
|
import { positiveConfigNumber } from "./config-access";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
||||||
|
|
||||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const NUM_SIMULATIONS = 50_000;
|
const DEFAULT_NUM_SIMULATIONS = 50_000;
|
||||||
const TOTAL_SEASON_GAMES = 162;
|
const TOTAL_SEASON_GAMES = 162;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -440,7 +441,8 @@ function simLeagueBracket(
|
||||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class MLBSimulator implements Simulator {
|
export class MLBSimulator implements Simulator {
|
||||||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||||||
|
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
||||||
const db = database();
|
const db = database();
|
||||||
|
|
||||||
// 1. Load all participants for this sports season.
|
// 1. Load all participants for this sports season.
|
||||||
|
|
@ -569,7 +571,7 @@ export class MLBSimulator implements Simulator {
|
||||||
|
|
||||||
let effectiveN = 0;
|
let effectiveN = 0;
|
||||||
|
|
||||||
for (let s = 0; s < NUM_SIMULATIONS; s++) {
|
for (let s = 0; s < numSimulations; s++) {
|
||||||
const alField = drawLeaguePlayoffField(alTeams, seedingWinRate);
|
const alField = drawLeaguePlayoffField(alTeams, seedingWinRate);
|
||||||
const nlField = drawLeaguePlayoffField(nlTeams, seedingWinRate);
|
const nlField = drawLeaguePlayoffField(nlTeams, seedingWinRate);
|
||||||
if (!alField || !nlField) continue; // degenerate draw — skip
|
if (!alField || !nlField) continue; // degenerate draw — skip
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ import { simulateEloSoccerMatch, simulateSimpleSoccerGoals } from "./soccer-help
|
||||||
import type { EloSoccerMatchOptions } from "./soccer-helpers";
|
import type { EloSoccerMatchOptions } from "./soccer-helpers";
|
||||||
import { normalizeSimulationResultColumns } from "./simulation-probabilities";
|
import { normalizeSimulationResultColumns } from "./simulation-probabilities";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
|
import { configNumber } from "./config-access";
|
||||||
|
|
||||||
// ─── Default constants (overridable via season simulator config) ───────────────
|
// ─── Default constants (overridable via season simulator config) ───────────────
|
||||||
|
|
||||||
|
|
@ -62,12 +63,6 @@ const DEFAULT_DRAW_DECAY = 0.002;
|
||||||
|
|
||||||
const MLS_PLAYOFF_TEAMS_PER_CONF = 9;
|
const MLS_PLAYOFF_TEAMS_PER_CONF = 9;
|
||||||
|
|
||||||
// ─── Config helpers ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function configNumber(config: Record<string, unknown> | undefined, key: string, fallback: number): number {
|
|
||||||
const value = config?.[key];
|
|
||||||
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Infer MLS conference from seasonParticipant.externalId.
|
* Infer MLS conference from seasonParticipant.externalId.
|
||||||
|
|
|
||||||
|
|
@ -53,19 +53,21 @@ import * as schema from "~/database/schema";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
||||||
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
||||||
|
import { positiveConfigNumber } from "./config-access";
|
||||||
|
|
||||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
// ─── Simulation parameters (defaults; overridable via season config) ───────────
|
||||||
|
|
||||||
const NUM_SIMULATIONS = 50_000;
|
const DEFAULT_NUM_SIMULATIONS = 50_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Elo parity factor. NBA uses 400 (standard formula).
|
* Elo parity factor. NBA defaults to 400 (standard formula).
|
||||||
* A 400-point Elo difference → ~90.9% win probability per game.
|
* A 400-point Elo difference → ~90.9% win probability per game.
|
||||||
|
* Raising it (via the season config's `parityFactor`) flattens the distribution.
|
||||||
*/
|
*/
|
||||||
const PARITY_FACTOR = 400;
|
const DEFAULT_PARITY_FACTOR = 400;
|
||||||
|
|
||||||
/** NBA regular season games per team. */
|
/** NBA regular season games per team. */
|
||||||
const NBA_REGULAR_SEASON_GAMES = 82;
|
const DEFAULT_REGULAR_SEASON_GAMES = 82;
|
||||||
|
|
||||||
// ─── Team data (2025-26 season, as of March 2026) ─────────────────────────────
|
// ─── Team data (2025-26 season, as of March 2026) ─────────────────────────────
|
||||||
|
|
||||||
|
|
@ -129,8 +131,8 @@ export function getTeamData(name: string): NbaTeamData | undefined {
|
||||||
* Elo win probability for team A over team B.
|
* Elo win probability for team A over team B.
|
||||||
* P(A) = 1 / (1 + 10^((eloB - eloA) / PARITY_FACTOR))
|
* P(A) = 1 / (1 + 10^((eloB - eloA) / PARITY_FACTOR))
|
||||||
*/
|
*/
|
||||||
export function eloWinProbability(eloA: number, eloB: number): number {
|
export function eloWinProbability(eloA: number, eloB: number, parityFactor = DEFAULT_PARITY_FACTOR): number {
|
||||||
return 1 / (1 + Math.pow(10, (eloB - eloA) / PARITY_FACTOR));
|
return 1 / (1 + Math.pow(10, (eloB - eloA) / parityFactor));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Per-position normalization ───────────────────────────────────────────────
|
// ─── Per-position normalization ───────────────────────────────────────────────
|
||||||
|
|
@ -157,90 +159,99 @@ function normalizeColumns(results: SimulationResult[]): void {
|
||||||
|
|
||||||
type DbMatch = typeof schema.playoffMatches.$inferSelect;
|
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.
|
* Build the bracket match helpers bound to a given `parityFactor`. Returning them
|
||||||
* Uses the real result if the match is complete; otherwise simulates.
|
* from a factory keeps every call site unchanged while letting the season config
|
||||||
* p1Override / p2Override are used when the DB participant slot is still null
|
* tune per-game variance.
|
||||||
* (i.e. filled in by the previous round's simulated result).
|
|
||||||
*/
|
*/
|
||||||
function resolveGame(
|
function makeBracketHelpers(parityFactor: number) {
|
||||||
eloMap: Map<string, number>,
|
/** Simulate a single-game matchup. Returns winner and loser. */
|
||||||
match: DbMatch | undefined,
|
function simGame(
|
||||||
p1Override?: string,
|
eloMap: Map<string, number>,
|
||||||
p2Override?: string
|
a: string,
|
||||||
): { winner: string; loser: string } {
|
b: string
|
||||||
if (match?.isComplete && match.winnerId && match.loserId) {
|
): { winner: string; loser: string } {
|
||||||
return { winner: match.winnerId, loser: match.loserId };
|
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/** Simulate a best-of-7 series. Returns winner and loser. */
|
||||||
* Resolve a playoff series.
|
function simSeries(
|
||||||
* Uses the real result if the match is complete; otherwise simulates best-of-7.
|
eloMap: Map<string, number>,
|
||||||
* p1Override / p2Override fill null DB participant slots with the simulated seed.
|
a: string,
|
||||||
*/
|
b: string
|
||||||
function resolveSeries(
|
): { winner: string; loser: string } {
|
||||||
eloMap: Map<string, number>,
|
const winProb = eloWinProbability(eloMap.get(a) ?? 1400, eloMap.get(b) ?? 1400, parityFactor);
|
||||||
match: DbMatch | undefined,
|
let wA = 0;
|
||||||
p1Override?: string,
|
let wB = 0;
|
||||||
p2Override?: string
|
while (wA < 4 && wB < 4) {
|
||||||
): { winner: string; loser: string } {
|
if (Math.random() < winProb) wA++; else wB++;
|
||||||
if (match?.isComplete && match.winnerId && match.loserId) {
|
}
|
||||||
return { winner: match.winnerId, loser: match.loserId };
|
return wA === 4 ? { winner: a, loser: b } : { winner: b, loser: a };
|
||||||
}
|
}
|
||||||
const p1 = match?.participant1Id ?? p1Override;
|
|
||||||
const p2 = match?.participant2Id ?? p2Override;
|
/**
|
||||||
if (!p1 || !p2) {
|
* Resolve a play-in single game.
|
||||||
throw new Error(
|
* Uses the real result if the match is complete; otherwise simulates.
|
||||||
`Cannot resolve series: missing participant(s) on match ${match?.id ?? "(undefined)"} ` +
|
* p1Override / p2Override are used when the DB participant slot is still null
|
||||||
`(p1=${p1 ?? "null"}, p2=${p2 ?? "null"}). ` +
|
* (i.e. filled in by the previous round's simulated result).
|
||||||
`Ensure the bracket is fully generated before simulating.`
|
*/
|
||||||
);
|
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 ────────────────────────────────────────────────────────────────
|
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class NBASimulator implements Simulator {
|
export class NBASimulator implements Simulator {
|
||||||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||||||
const db = database();
|
const db = database();
|
||||||
|
|
||||||
// ── Mode detection: bracket-aware if a playoff event with matches exists ──
|
// ── Mode detection: bracket-aware if a playoff event with matches exists ──
|
||||||
|
|
@ -258,21 +269,25 @@ export class NBASimulator implements Simulator {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (bracketMatches.length > 0) {
|
if (bracketMatches.length > 0) {
|
||||||
return this.simulateBracketAware(sportsSeasonId, bracketMatches);
|
return this.simulateBracketAware(sportsSeasonId, bracketMatches, config);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Fall back to season-projection mode ───────────────────────────────────
|
// ── Fall back to season-projection mode ───────────────────────────────────
|
||||||
return this.simulateSeasonProjection(sportsSeasonId);
|
return this.simulateSeasonProjection(sportsSeasonId, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mode 1: Bracket-Aware ──────────────────────────────────────────────────
|
// ── Mode 1: Bracket-Aware ──────────────────────────────────────────────────
|
||||||
|
|
||||||
private async simulateBracketAware(
|
private async simulateBracketAware(
|
||||||
sportsSeasonId: string,
|
sportsSeasonId: string,
|
||||||
allMatches: DbMatch[]
|
allMatches: DbMatch[],
|
||||||
|
config: Record<string, unknown> = {}
|
||||||
): Promise<SimulationResult[]> {
|
): Promise<SimulationResult[]> {
|
||||||
const db = database();
|
const db = database();
|
||||||
|
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
|
||||||
|
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
||||||
|
const { resolveGame, resolveSeries } = makeBracketHelpers(parityFactor);
|
||||||
|
|
||||||
// Group matches by round.
|
// Group matches by round.
|
||||||
const pir1 = allMatches.filter((m) => m.round === "Play-In Round 1")
|
const pir1 = allMatches.filter((m) => m.round === "Play-In Round 1")
|
||||||
|
|
@ -355,7 +370,7 @@ export class NBASimulator implements Simulator {
|
||||||
const csLoserCounts = new Map(participantIds.map((id) => [id, 0]));
|
const csLoserCounts = new Map(participantIds.map((id) => [id, 0]));
|
||||||
|
|
||||||
// ── Monte Carlo loop ──────────────────────────────────────────────────────
|
// ── Monte Carlo loop ──────────────────────────────────────────────────────
|
||||||
for (let s = 0; s < NUM_SIMULATIONS; s++) {
|
for (let s = 0; s < numSimulations; s++) {
|
||||||
|
|
||||||
// ── Play-In Round 1 (4 single games) ─────────────────────────────────
|
// ── Play-In Round 1 (4 single games) ─────────────────────────────────
|
||||||
// M1: East 7 vs 8 → winner = E7 seed; loser enters PIR2 as p1
|
// M1: East 7 vs 8 → winner = E7 seed; loser enters PIR2 as p1
|
||||||
|
|
@ -439,7 +454,7 @@ export class NBASimulator implements Simulator {
|
||||||
// probFirst/Second → N total (1 per sim)
|
// probFirst/Second → N total (1 per sim)
|
||||||
// probThird/Fourth → cfLoserCounts / (2×N) — 2 CF losers per sim
|
// probThird/Fourth → cfLoserCounts / (2×N) — 2 CF losers per sim
|
||||||
// probFifth–Eighth → csLoserCounts / (4×N) — 4 CS losers per sim
|
// probFifth–Eighth → csLoserCounts / (4×N) — 4 CS losers per sim
|
||||||
const N = NUM_SIMULATIONS;
|
const N = numSimulations;
|
||||||
const results: SimulationResult[] = participantIds.map((participantId) => {
|
const results: SimulationResult[] = participantIds.map((participantId) => {
|
||||||
const c = championCounts.get(participantId) ?? 0;
|
const c = championCounts.get(participantId) ?? 0;
|
||||||
const f = finalistCounts.get(participantId) ?? 0;
|
const f = finalistCounts.get(participantId) ?? 0;
|
||||||
|
|
@ -467,8 +482,11 @@ export class NBASimulator implements Simulator {
|
||||||
|
|
||||||
// ── Mode 2: Season Projection ──────────────────────────────────────────────
|
// ── Mode 2: Season Projection ──────────────────────────────────────────────
|
||||||
|
|
||||||
private async simulateSeasonProjection(sportsSeasonId: string): Promise<SimulationResult[]> {
|
private async simulateSeasonProjection(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||||||
const db = database();
|
const db = database();
|
||||||
|
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
|
||||||
|
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
||||||
|
const seasonGames = Math.round(positiveConfigNumber(config, "seasonGames", DEFAULT_REGULAR_SEASON_GAMES));
|
||||||
|
|
||||||
const [participantRows, standings, evRows] = await Promise.all([
|
const [participantRows, standings, evRows] = await Promise.all([
|
||||||
db
|
db
|
||||||
|
|
@ -529,8 +547,8 @@ export class NBASimulator implements Simulator {
|
||||||
data,
|
data,
|
||||||
conference,
|
conference,
|
||||||
currentWins: standing?.wins ?? 0,
|
currentWins: standing?.wins ?? 0,
|
||||||
remainingGames: Math.max(0, NBA_REGULAR_SEASON_GAMES - gamesPlayed),
|
remainingGames: Math.max(0, seasonGames - gamesPlayed),
|
||||||
winProb: eloWinProbability(resolvedElo, 1500),
|
winProb: eloWinProbability(resolvedElo, 1500, parityFactor),
|
||||||
resolvedElo,
|
resolvedElo,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
@ -546,10 +564,10 @@ export class NBASimulator implements Simulator {
|
||||||
}
|
}
|
||||||
|
|
||||||
const simTeamGame = (a: TeamEntry, b: TeamEntry): TeamEntry =>
|
const simTeamGame = (a: TeamEntry, b: TeamEntry): TeamEntry =>
|
||||||
Math.random() < eloWinProbability(eloOfEntry(a), eloOfEntry(b)) ? a : b;
|
Math.random() < eloWinProbability(eloOfEntry(a), eloOfEntry(b), parityFactor) ? a : b;
|
||||||
|
|
||||||
const simTeamSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => {
|
const simTeamSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => {
|
||||||
const winProb = eloWinProbability(eloOfEntry(a), eloOfEntry(b));
|
const winProb = eloWinProbability(eloOfEntry(a), eloOfEntry(b), parityFactor);
|
||||||
let wA = 0;
|
let wA = 0;
|
||||||
let wB = 0;
|
let wB = 0;
|
||||||
while (wA < 4 && wB < 4) {
|
while (wA < 4 && wB < 4) {
|
||||||
|
|
@ -597,7 +615,7 @@ export class NBASimulator implements Simulator {
|
||||||
const confFinalLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
const confFinalLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||||||
const confSemiLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
const confSemiLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||||||
|
|
||||||
for (let s = 0; s < NUM_SIMULATIONS; s++) {
|
for (let s = 0; s < numSimulations; s++) {
|
||||||
const eastBracket = buildConferenceBracket(easternTeams);
|
const eastBracket = buildConferenceBracket(easternTeams);
|
||||||
const westBracket = buildConferenceBracket(westernTeams);
|
const westBracket = buildConferenceBracket(westernTeams);
|
||||||
|
|
||||||
|
|
@ -618,7 +636,7 @@ export class NBASimulator implements Simulator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const N = NUM_SIMULATIONS;
|
const N = numSimulations;
|
||||||
const results: SimulationResult[] = participantIds.map((participantId) => {
|
const results: SimulationResult[] = participantIds.map((participantId) => {
|
||||||
const c = championCounts.get(participantId) ?? 0;
|
const c = championCounts.get(participantId) ?? 0;
|
||||||
const f = finalistCounts.get(participantId) ?? 0;
|
const f = finalistCounts.get(participantId) ?? 0;
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@
|
||||||
* 1. Load all participants for the sports season from DB
|
* 1. Load all participants for the sports season from DB
|
||||||
* 2. Load Elo/FPI ratings from participantExpectedValues.sourceElo
|
* 2. Load Elo/FPI ratings from participantExpectedValues.sourceElo
|
||||||
* (entered via Admin → Elo Ratings page; use FPI, S&P+, or any Elo-scale rating)
|
* (entered via Admin → Elo Ratings page; use FPI, S&P+, or any Elo-scale rating)
|
||||||
* 3. If sourceOdds (American format) are also stored, build a normalized selection
|
* 3. Field-selection weight is a softmax over the resolved Elo. Futures odds are
|
||||||
* weight from implied championship probability (used for field selection in step 4)
|
* not blended into per-game win probability — the input policy already folded
|
||||||
* and blend into per-game win probability (ELO_WEIGHT=0.6 / ODDS_WEIGHT=0.4).
|
* them into sourceElo, so blending again would double-count them.
|
||||||
* 4. Per simulation, select 12 teams for the CFP field:
|
* 4. Per simulation, select 12 teams for the CFP field:
|
||||||
* - If the pool has exactly 12 teams: use all of them (post-bracket mode).
|
* - If the pool has exactly 12 teams: use all of them (post-bracket mode).
|
||||||
* - If the pool has >12 teams: weighted sample without replacement using each
|
* - If the pool has >12 teams: weighted sample without replacement using each
|
||||||
|
|
@ -31,8 +31,8 @@
|
||||||
* fields, so its champion probability accounts for that.
|
* fields, so its champion probability accounts for that.
|
||||||
* Post-bracket (exactly 12 participants): deterministic field, bracket-only sim.
|
* Post-bracket (exactly 12 participants): deterministic field, bracket-only sim.
|
||||||
*
|
*
|
||||||
* Win probability (per game): eloWinProbability() from probability-engine (400-divisor).
|
* Win probability (per game): eloWinProbabilityWithParity(eloA, eloB, parityFactor),
|
||||||
* Blended win probability: ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb (when odds present).
|
* where parityFactor comes from the season config (default 400).
|
||||||
*
|
*
|
||||||
* Selection weight (pre-bracket mode):
|
* Selection weight (pre-bracket mode):
|
||||||
* With sourceOdds: normalized implied championship probability (vig removed, sums to 1).
|
* With sourceOdds: normalized implied championship probability (vig removed, sums to 1).
|
||||||
|
|
@ -59,12 +59,15 @@
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eloWinProbability } from "~/services/probability-engine";
|
import { eloWinProbabilityWithParity } from "~/services/probability-engine";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
|
import { positiveConfigNumber } from "./config-access";
|
||||||
|
|
||||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
// ─── Simulation parameters (defaults; overridable via season config) ───────────
|
||||||
|
|
||||||
const NUM_SIMULATIONS = 50_000;
|
const DEFAULT_NUM_SIMULATIONS = 50_000;
|
||||||
|
/** Elo parity factor. Defaults to 400 (standard formula). */
|
||||||
|
const DEFAULT_PARITY_FACTOR = 400;
|
||||||
const BRACKET_SIZE = 12;
|
const BRACKET_SIZE = 12;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -87,12 +90,12 @@ interface Team {
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Win probability for team1 vs team2 from the single resolved Elo. */
|
/** Win probability for team1 vs team2 from the single resolved Elo. */
|
||||||
function gameWinProb(team1: Team, team2: Team): number {
|
function gameWinProb(team1: Team, team2: Team, parityFactor = DEFAULT_PARITY_FACTOR): number {
|
||||||
return eloWinProbability(team1.elo, team2.elo);
|
return eloWinProbabilityWithParity(team1.elo, team2.elo, parityFactor);
|
||||||
}
|
}
|
||||||
|
|
||||||
function simGame(team1: Team, team2: Team): { winner: Team; loser: Team } {
|
function simGame(team1: Team, team2: Team, parityFactor = DEFAULT_PARITY_FACTOR): { winner: Team; loser: Team } {
|
||||||
const p1Wins = Math.random() < gameWinProb(team1, team2);
|
const p1Wins = Math.random() < gameWinProb(team1, team2, parityFactor);
|
||||||
return p1Wins
|
return p1Wins
|
||||||
? { winner: team1, loser: team2 }
|
? { winner: team1, loser: team2 }
|
||||||
: { winner: team2, loser: team1 };
|
: { winner: team2, loser: team1 };
|
||||||
|
|
@ -141,18 +144,19 @@ interface PlacementCounts {
|
||||||
* Semifinals: qf1w vs qf2w, qf3w vs qf4w
|
* Semifinals: qf1w vs qf2w, qf3w vs qf4w
|
||||||
* Championship: sf1w vs sf2w
|
* Championship: sf1w vs sf2w
|
||||||
*/
|
*/
|
||||||
function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>): void {
|
function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>, parityFactor = DEFAULT_PARITY_FACTOR): void {
|
||||||
|
const game = (a: Team, b: Team) => simGame(a, b, parityFactor);
|
||||||
// ── First Round (seeds 5–12) ───────────────────────────────────────────────
|
// ── First Round (seeds 5–12) ───────────────────────────────────────────────
|
||||||
const fr1 = simGame(teams[4], teams[11]); // 5 vs 12
|
const fr1 = game(teams[4], teams[11]); // 5 vs 12
|
||||||
const fr2 = simGame(teams[5], teams[10]); // 6 vs 11
|
const fr2 = game(teams[5], teams[10]); // 6 vs 11
|
||||||
const fr3 = simGame(teams[6], teams[9]); // 7 vs 10
|
const fr3 = game(teams[6], teams[9]); // 7 vs 10
|
||||||
const fr4 = simGame(teams[7], teams[8]); // 8 vs 9
|
const fr4 = game(teams[7], teams[8]); // 8 vs 9
|
||||||
|
|
||||||
// ── Quarterfinals (seeds 1–4 get byes) ────────────────────────────────────
|
// ── Quarterfinals (seeds 1–4 get byes) ────────────────────────────────────
|
||||||
const qf1 = simGame(teams[0], fr4.winner); // 1 vs 8/9 winner
|
const qf1 = game(teams[0], fr4.winner); // 1 vs 8/9 winner
|
||||||
const qf2 = simGame(teams[3], fr1.winner); // 4 vs 5/12 winner
|
const qf2 = game(teams[3], fr1.winner); // 4 vs 5/12 winner
|
||||||
const qf3 = simGame(teams[2], fr2.winner); // 3 vs 6/11 winner
|
const qf3 = game(teams[2], fr2.winner); // 3 vs 6/11 winner
|
||||||
const qf4 = simGame(teams[1], fr3.winner); // 2 vs 7/10 winner
|
const qf4 = game(teams[1], fr3.winner); // 2 vs 7/10 winner
|
||||||
|
|
||||||
const bump = (id: string, key: keyof PlacementCounts) => {
|
const bump = (id: string, key: keyof PlacementCounts) => {
|
||||||
const entry = counts.get(id);
|
const entry = counts.get(id);
|
||||||
|
|
@ -165,14 +169,14 @@ function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>): v
|
||||||
bump(qf4.loser.participantId, "qfLoser");
|
bump(qf4.loser.participantId, "qfLoser");
|
||||||
|
|
||||||
// ── Semifinals ────────────────────────────────────────────────────────────
|
// ── Semifinals ────────────────────────────────────────────────────────────
|
||||||
const sf1 = simGame(qf1.winner, qf2.winner);
|
const sf1 = game(qf1.winner, qf2.winner);
|
||||||
const sf2 = simGame(qf3.winner, qf4.winner);
|
const sf2 = game(qf3.winner, qf4.winner);
|
||||||
|
|
||||||
bump(sf1.loser.participantId, "sfLoser");
|
bump(sf1.loser.participantId, "sfLoser");
|
||||||
bump(sf2.loser.participantId, "sfLoser");
|
bump(sf2.loser.participantId, "sfLoser");
|
||||||
|
|
||||||
// ── National Championship ─────────────────────────────────────────────────
|
// ── National Championship ─────────────────────────────────────────────────
|
||||||
const final = simGame(sf1.winner, sf2.winner);
|
const final = game(sf1.winner, sf2.winner);
|
||||||
|
|
||||||
bump(final.winner.participantId, "champion");
|
bump(final.winner.participantId, "champion");
|
||||||
bump(final.loser.participantId, "finalist");
|
bump(final.loser.participantId, "finalist");
|
||||||
|
|
@ -181,8 +185,10 @@ function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>): v
|
||||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class NCAAFootballSimulator implements Simulator {
|
export class NCAAFootballSimulator implements Simulator {
|
||||||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||||||
const db = database();
|
const db = database();
|
||||||
|
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
|
||||||
|
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
||||||
// 1. Load all participants for this sports season.
|
// 1. Load all participants for this sports season.
|
||||||
const participants = await db
|
const participants = await db
|
||||||
.select({ id: schema.seasonParticipants.id })
|
.select({ id: schema.seasonParticipants.id })
|
||||||
|
|
@ -241,18 +247,18 @@ export class NCAAFootballSimulator implements Simulator {
|
||||||
);
|
);
|
||||||
|
|
||||||
// 6. Run Monte Carlo simulations.
|
// 6. Run Monte Carlo simulations.
|
||||||
for (let s = 0; s < NUM_SIMULATIONS; s++) {
|
for (let s = 0; s < numSimulations; s++) {
|
||||||
const field = preBracketMode
|
const field = preBracketMode
|
||||||
? sampleBracketField(allTeams, BRACKET_SIZE)
|
? sampleBracketField(allTeams, BRACKET_SIZE)
|
||||||
: (deterministicField ?? []);
|
: (deterministicField ?? []);
|
||||||
simulateBracket(field, counts);
|
simulateBracket(field, counts, parityFactor);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Convert counts to probability distributions.
|
// 7. Convert counts to probability distributions.
|
||||||
// SF losers: 2 per sim → each team's share = sfLoser / (2 * N).
|
// SF losers: 2 per sim → each team's share = sfLoser / (2 * N).
|
||||||
// QF losers: 4 per sim → each team's share = qfLoser / (4 * N).
|
// QF losers: 4 per sim → each team's share = qfLoser / (4 * N).
|
||||||
const sfDivisor = 2 * NUM_SIMULATIONS;
|
const sfDivisor = 2 * numSimulations;
|
||||||
const qfDivisor = 4 * NUM_SIMULATIONS;
|
const qfDivisor = 4 * numSimulations;
|
||||||
|
|
||||||
return allParticipantIds.map((id) => {
|
return allParticipantIds.map((id) => {
|
||||||
const c = counts.get(id) ?? { champion: 0, finalist: 0, sfLoser: 0, qfLoser: 0 };
|
const c = counts.get(id) ?? { champion: 0, finalist: 0, sfLoser: 0, qfLoser: 0 };
|
||||||
|
|
@ -261,8 +267,8 @@ export class NCAAFootballSimulator implements Simulator {
|
||||||
return {
|
return {
|
||||||
participantId: id,
|
participantId: id,
|
||||||
probabilities: {
|
probabilities: {
|
||||||
probFirst: c.champion / NUM_SIMULATIONS,
|
probFirst: c.champion / numSimulations,
|
||||||
probSecond: c.finalist / NUM_SIMULATIONS,
|
probSecond: c.finalist / numSimulations,
|
||||||
probThird: sfProb,
|
probThird: sfProb,
|
||||||
probFourth: sfProb,
|
probFourth: sfProb,
|
||||||
probFifth: qfProb,
|
probFifth: qfProb,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { NCAABasketballSimulator } from "./ncaa-basketball-simulator";
|
import { NCAABasketballSimulator } from "./ncaa-basketball-simulator";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
|
import { optionalPositiveNumber } from "./config-access";
|
||||||
|
|
||||||
const KENPOM_SCALE_FACTOR = 7.5;
|
const KENPOM_SCALE_FACTOR = 7.5;
|
||||||
|
|
||||||
|
|
@ -11,10 +12,6 @@ export function kenpomWinProbability(netrtgA: number, netrtgB: number): number {
|
||||||
return 1 / (1 + Math.exp(-(netrtgA - netrtgB) / KENPOM_SCALE_FACTOR));
|
return 1 / (1 + Math.exp(-(netrtgA - netrtgB) / KENPOM_SCALE_FACTOR));
|
||||||
}
|
}
|
||||||
|
|
||||||
function optionalPositiveNumber(value: unknown, fallback: number): number {
|
|
||||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function kenpomWinProbabilityWithScale(scaleFactor: number): (netrtgA: number, netrtgB: number) => number {
|
function kenpomWinProbabilityWithScale(scaleFactor: number): (netrtgA: number, netrtgB: number) => number {
|
||||||
return (netrtgA, netrtgB) => 1 / (1 + Math.exp(-(netrtgA - netrtgB) / scaleFactor));
|
return (netrtgA, netrtgB) => 1 / (1 + Math.exp(-(netrtgA - netrtgB) / scaleFactor));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,15 +46,19 @@ import { eq } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
||||||
import { eloWinProbability, convertFuturesToElo } from "~/services/probability-engine";
|
import { eloWinProbabilityWithParity, convertFuturesToElo } from "~/services/probability-engine";
|
||||||
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
||||||
|
import { positiveConfigNumber } from "./config-access";
|
||||||
|
|
||||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
// ─── Simulation parameters (defaults; overridable via season config) ───────────
|
||||||
|
|
||||||
const NUM_SIMULATIONS = 50_000;
|
const DEFAULT_NUM_SIMULATIONS = 50_000;
|
||||||
|
|
||||||
|
/** Elo parity factor. NFL defaults to 400 (standard formula). */
|
||||||
|
const DEFAULT_PARITY_FACTOR = 400;
|
||||||
|
|
||||||
/** NFL regular season games per team. */
|
/** NFL regular season games per team. */
|
||||||
const NFL_REGULAR_SEASON_GAMES = 17;
|
const DEFAULT_REGULAR_SEASON_GAMES = 17;
|
||||||
|
|
||||||
/** Average opponent Elo for regular season game projection. */
|
/** Average opponent Elo for regular season game projection. */
|
||||||
const AVG_OPPONENT_ELO = 1500;
|
const AVG_OPPONENT_ELO = 1500;
|
||||||
|
|
@ -173,9 +177,10 @@ export function getTeamData(name: string): NflTeamData | undefined {
|
||||||
function simulateProjectedWins(
|
function simulateProjectedWins(
|
||||||
currentWins: number,
|
currentWins: number,
|
||||||
gamesPlayed: number,
|
gamesPlayed: number,
|
||||||
winProb: number
|
winProb: number,
|
||||||
|
seasonGames = DEFAULT_REGULAR_SEASON_GAMES
|
||||||
): number {
|
): number {
|
||||||
const remaining = Math.max(0, NFL_REGULAR_SEASON_GAMES - gamesPlayed);
|
const remaining = Math.max(0, seasonGames - gamesPlayed);
|
||||||
let additional = 0;
|
let additional = 0;
|
||||||
for (let g = 0; g < remaining; g++) {
|
for (let g = 0; g < remaining; g++) {
|
||||||
if (Math.random() < winProb) additional++;
|
if (Math.random() < winProb) additional++;
|
||||||
|
|
@ -231,11 +236,12 @@ export function seedConference(
|
||||||
* Simulate a playoff game with home-field advantage for the higher seed.
|
* Simulate a playoff game with home-field advantage for the higher seed.
|
||||||
* The team with the lower seed number (better seed) is the home team.
|
* The team with the lower seed number (better seed) is the home team.
|
||||||
*/
|
*/
|
||||||
function playGame(a: SeededTeam, b: SeededTeam): { winner: SeededTeam; loser: SeededTeam } {
|
function playGame(a: SeededTeam, b: SeededTeam, parityFactor = DEFAULT_PARITY_FACTOR): { winner: SeededTeam; loser: SeededTeam } {
|
||||||
const [home, away] = a.seed < b.seed ? [a, b] : [b, a];
|
const [home, away] = a.seed < b.seed ? [a, b] : [b, a];
|
||||||
const winProbHome = eloWinProbability(
|
const winProbHome = eloWinProbabilityWithParity(
|
||||||
home.elo + NFL_HOME_FIELD_ELO_ADVANTAGE,
|
home.elo + NFL_HOME_FIELD_ELO_ADVANTAGE,
|
||||||
away.elo
|
away.elo,
|
||||||
|
parityFactor
|
||||||
);
|
);
|
||||||
if (Math.random() < winProbHome) return { winner: home, loser: away };
|
if (Math.random() < winProbHome) return { winner: home, loser: away };
|
||||||
return { winner: away, loser: home };
|
return { winner: away, loser: home };
|
||||||
|
|
@ -245,8 +251,8 @@ function playGame(a: SeededTeam, b: SeededTeam): { winner: SeededTeam; loser: Se
|
||||||
* Simulate a game at a neutral site (no home-field adjustment).
|
* Simulate a game at a neutral site (no home-field adjustment).
|
||||||
* Used for the Super Bowl.
|
* Used for the Super Bowl.
|
||||||
*/
|
*/
|
||||||
function playNeutralGame(a: SeededTeam, b: SeededTeam): { winner: SeededTeam; loser: SeededTeam } {
|
function playNeutralGame(a: SeededTeam, b: SeededTeam, parityFactor = DEFAULT_PARITY_FACTOR): { winner: SeededTeam; loser: SeededTeam } {
|
||||||
const winProbA = eloWinProbability(a.elo, b.elo);
|
const winProbA = eloWinProbabilityWithParity(a.elo, b.elo, parityFactor);
|
||||||
if (Math.random() < winProbA) return { winner: a, loser: b };
|
if (Math.random() < winProbA) return { winner: a, loser: b };
|
||||||
return { winner: b, loser: a };
|
return { winner: b, loser: a };
|
||||||
}
|
}
|
||||||
|
|
@ -261,14 +267,15 @@ function playNeutralGame(a: SeededTeam, b: SeededTeam): { winner: SeededTeam; lo
|
||||||
* Returns the finalist, the conference championship loser, and the two
|
* Returns the finalist, the conference championship loser, and the two
|
||||||
* divisional-round losers (for 5th-8th place EV).
|
* divisional-round losers (for 5th-8th place EV).
|
||||||
*/
|
*/
|
||||||
export function simulateConferenceBracket(seeds: SeededTeam[]): ConferenceBracketResult {
|
export function simulateConferenceBracket(seeds: SeededTeam[], parityFactor = DEFAULT_PARITY_FACTOR): ConferenceBracketResult {
|
||||||
const byIndex = new Map(seeds.map((t) => [t.seed, t]));
|
const byIndex = new Map(seeds.map((t) => [t.seed, t]));
|
||||||
const s = (seed: number) => byIndex.get(seed) as SeededTeam;
|
const s = (seed: number) => byIndex.get(seed) as SeededTeam;
|
||||||
|
const pg = (a: SeededTeam, b: SeededTeam) => playGame(a, b, parityFactor);
|
||||||
|
|
||||||
// Wild Card (seed 1 bye; lower seed = home)
|
// Wild Card (seed 1 bye; lower seed = home)
|
||||||
const wc27 = playGame(s(2), s(7));
|
const wc27 = pg(s(2), s(7));
|
||||||
const wc36 = playGame(s(3), s(6));
|
const wc36 = pg(s(3), s(6));
|
||||||
const wc45 = playGame(s(4), s(5));
|
const wc45 = pg(s(4), s(5));
|
||||||
|
|
||||||
// Divisional: sort WC winners by original seed (ascending = better seed = home)
|
// Divisional: sort WC winners by original seed (ascending = better seed = home)
|
||||||
// Seed 1 hosts the lowest-ranked (highest seed number) WC winner
|
// Seed 1 hosts the lowest-ranked (highest seed number) WC winner
|
||||||
|
|
@ -276,11 +283,11 @@ export function simulateConferenceBracket(seeds: SeededTeam[]): ConferenceBracke
|
||||||
(a, b) => a.seed - b.seed
|
(a, b) => a.seed - b.seed
|
||||||
);
|
);
|
||||||
|
|
||||||
const div1 = playGame(s(1), wcWinners[wcWinners.length - 1]);
|
const div1 = pg(s(1), wcWinners[wcWinners.length - 1]);
|
||||||
const div2 = playGame(wcWinners[0], wcWinners[1]);
|
const div2 = pg(wcWinners[0], wcWinners[1]);
|
||||||
|
|
||||||
// Conference Championship (higher remaining seed is home)
|
// Conference Championship (higher remaining seed is home)
|
||||||
const conf = playGame(div1.winner, div2.winner);
|
const conf = pg(div1.winner, div2.winner);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
finalist: conf.winner,
|
finalist: conf.winner,
|
||||||
|
|
@ -292,8 +299,11 @@ export function simulateConferenceBracket(seeds: SeededTeam[]): ConferenceBracke
|
||||||
// ─── Simulator class ──────────────────────────────────────────────────────────
|
// ─── Simulator class ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class NFLSimulator implements Simulator {
|
export class NFLSimulator implements Simulator {
|
||||||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||||||
const db = database();
|
const db = database();
|
||||||
|
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
|
||||||
|
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
||||||
|
const seasonGames = Math.round(positiveConfigNumber(config, "seasonGames", DEFAULT_REGULAR_SEASON_GAMES));
|
||||||
|
|
||||||
// Load participants
|
// Load participants
|
||||||
const participants = await db.query.seasonParticipants.findMany({
|
const participants = await db.query.seasonParticipants.findMany({
|
||||||
|
|
@ -385,7 +395,7 @@ export class NFLSimulator implements Simulator {
|
||||||
// Pre-compute per-team win probability vs average opponent (constant across sims)
|
// Pre-compute per-team win probability vs average opponent (constant across sims)
|
||||||
const winProbMap = new Map<string, number>();
|
const winProbMap = new Map<string, number>();
|
||||||
for (const t of teams) {
|
for (const t of teams) {
|
||||||
winProbMap.set(t.participantId, eloWinProbability(t.elo, AVG_OPPONENT_ELO));
|
winProbMap.set(t.participantId, eloWinProbabilityWithParity(t.elo, AVG_OPPONENT_ELO, parityFactor));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Placement counters
|
// Placement counters
|
||||||
|
|
@ -396,7 +406,7 @@ export class NFLSimulator implements Simulator {
|
||||||
|
|
||||||
// ─── Monte Carlo ──────────────────────────────────────────────────────────
|
// ─── Monte Carlo ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
|
for (let sim = 0; sim < numSimulations; sim++) {
|
||||||
// Project wins for each team this simulation
|
// Project wins for each team this simulation
|
||||||
const projected = teams.map((t) => ({
|
const projected = teams.map((t) => ({
|
||||||
participantId: t.participantId,
|
participantId: t.participantId,
|
||||||
|
|
@ -406,7 +416,8 @@ export class NFLSimulator implements Simulator {
|
||||||
projectedWins: simulateProjectedWins(
|
projectedWins: simulateProjectedWins(
|
||||||
t.currentWins,
|
t.currentWins,
|
||||||
t.gamesPlayed,
|
t.gamesPlayed,
|
||||||
winProbMap.get(t.participantId) as number
|
winProbMap.get(t.participantId) as number,
|
||||||
|
seasonGames
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -416,11 +427,11 @@ export class NFLSimulator implements Simulator {
|
||||||
const nfcSeeds = seedConference(nfcTeams);
|
const nfcSeeds = seedConference(nfcTeams);
|
||||||
|
|
||||||
// Simulate conference brackets
|
// Simulate conference brackets
|
||||||
const afcResult = simulateConferenceBracket(afcSeeds);
|
const afcResult = simulateConferenceBracket(afcSeeds, parityFactor);
|
||||||
const nfcResult = simulateConferenceBracket(nfcSeeds);
|
const nfcResult = simulateConferenceBracket(nfcSeeds, parityFactor);
|
||||||
|
|
||||||
// Super Bowl (neutral site)
|
// Super Bowl (neutral site)
|
||||||
const sb = playNeutralGame(afcResult.finalist, nfcResult.finalist);
|
const sb = playNeutralGame(afcResult.finalist, nfcResult.finalist, parityFactor);
|
||||||
|
|
||||||
// 1st / 2nd
|
// 1st / 2nd
|
||||||
counts[sb.winner.participantId][1] += 1;
|
counts[sb.winner.participantId][1] += 1;
|
||||||
|
|
@ -445,7 +456,7 @@ export class NFLSimulator implements Simulator {
|
||||||
// Convert counts to probabilities
|
// Convert counts to probabilities
|
||||||
return teams.map((t) => {
|
return teams.map((t) => {
|
||||||
const c = counts[t.participantId];
|
const c = counts[t.participantId];
|
||||||
const N = NUM_SIMULATIONS;
|
const N = numSimulations;
|
||||||
return {
|
return {
|
||||||
participantId: t.participantId,
|
participantId: t.participantId,
|
||||||
probabilities: {
|
probabilities: {
|
||||||
|
|
|
||||||
|
|
@ -64,38 +64,34 @@ import { eq, and } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import {
|
|
||||||
convertAmericanOddsToProbability,
|
|
||||||
normalizeProbabilities,
|
|
||||||
} from "~/services/probability-engine";
|
|
||||||
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
||||||
|
import { positiveConfigNumber, configNumber } from "./config-access";
|
||||||
|
|
||||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
// ─── Simulation parameters (defaults; overridable via season config) ───────────
|
||||||
|
|
||||||
const NUM_SIMULATIONS = 50_000;
|
const DEFAULT_NUM_SIMULATIONS = 50_000;
|
||||||
|
|
||||||
/** NHL regular season games per team. */
|
/** NHL regular season games per team. */
|
||||||
const NHL_REGULAR_SEASON_GAMES = 82;
|
const DEFAULT_REGULAR_SEASON_GAMES = 82;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fraction of NHL games that go to overtime / shootout.
|
* Fraction of NHL games that go to overtime / shootout.
|
||||||
* The losing team earns 1 point (instead of 0) in these games.
|
* The losing team earns 1 point (instead of 0) in these games.
|
||||||
* Historical average: ~23% of games.
|
* Historical average: ~23% of games.
|
||||||
*/
|
*/
|
||||||
const NHL_OT_RATE = 0.23;
|
const DEFAULT_OT_RATE = 0.23;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Elo parity factor. NHL uses 1000 (higher than the standard 400) to reflect
|
* Elo parity factor. NHL defaults to 1000 (higher than the standard 400) to
|
||||||
* the elevated game-to-game variance in hockey.
|
* reflect the elevated game-to-game variance in hockey. Raising it flattens the
|
||||||
|
* finish-position distribution; the season config's `parityFactor` overrides it.
|
||||||
|
*
|
||||||
|
* Futures odds influence the simulation only through each team's resolved Elo
|
||||||
|
* (odds → Elo happens once, in the central input-policy resolver). The simulator
|
||||||
|
* does not blend raw odds into per-game probabilities — doing so double-counted
|
||||||
|
* the same signal and over-sharpened favorites.
|
||||||
*/
|
*/
|
||||||
const PARITY_FACTOR = 1000;
|
const DEFAULT_PARITY_FACTOR = 1000;
|
||||||
|
|
||||||
/**
|
|
||||||
* Blend weights for Elo vs. Vegas futures odds when sourceOdds are available.
|
|
||||||
* Same calibration as the UCL simulator (0.7 / 0.3).
|
|
||||||
*/
|
|
||||||
const ELO_WEIGHT = 0.7;
|
|
||||||
const ODDS_WEIGHT = 1 - ELO_WEIGHT;
|
|
||||||
|
|
||||||
// ─── Team data (2025-26 season) ───────────────────────────────────────────────
|
// ─── Team data (2025-26 season) ───────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
|
|
@ -179,11 +175,11 @@ export function getTeamData(name: string): NhlTeamData | undefined {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Elo win probability for team A over team B.
|
* Elo win probability for team A over team B.
|
||||||
* P(A) = 1 / (1 + 10^((eloB - eloA) / PARITY_FACTOR))
|
* P(A) = 1 / (1 + 10^((eloB - eloA) / parityFactor))
|
||||||
* Exported for unit testing.
|
* Exported for unit testing.
|
||||||
*/
|
*/
|
||||||
export function eloWinProbability(eloA: number, eloB: number): number {
|
export function eloWinProbability(eloA: number, eloB: number, parityFactor = DEFAULT_PARITY_FACTOR): number {
|
||||||
return 1 / (1 + Math.pow(10, (eloB - eloA) / PARITY_FACTOR));
|
return 1 / (1 + Math.pow(10, (eloB - eloA) / parityFactor));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Internal types ───────────────────────────────────────────────────────────
|
// ─── Internal types ───────────────────────────────────────────────────────────
|
||||||
|
|
@ -217,10 +213,10 @@ function elo(entry: TeamEntry): number {
|
||||||
* - Regulation loss (0 pts) otherwise
|
* - Regulation loss (0 pts) otherwise
|
||||||
* Returns projected total points for the season.
|
* Returns projected total points for the season.
|
||||||
*/
|
*/
|
||||||
export function simulateProjectedPoints(entry: TeamEntry): number {
|
export function simulateProjectedPoints(entry: TeamEntry, otRate = DEFAULT_OT_RATE): number {
|
||||||
let extra = 0;
|
let extra = 0;
|
||||||
const { winProb, remainingGames } = entry;
|
const { winProb, remainingGames } = entry;
|
||||||
const otlProb = (1 - winProb) * NHL_OT_RATE;
|
const otlProb = (1 - winProb) * otRate;
|
||||||
for (let g = 0; g < remainingGames; g++) {
|
for (let g = 0; g < remainingGames; g++) {
|
||||||
const r = Math.random();
|
const r = Math.random();
|
||||||
if (r < winProb) {
|
if (r < winProb) {
|
||||||
|
|
@ -241,8 +237,8 @@ interface ProjectedTeam {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Project one team's end-of-season point total with a random tiebreaker. */
|
/** Project one team's end-of-season point total with a random tiebreaker. */
|
||||||
function projectTeam(t: TeamEntry): ProjectedTeam {
|
function projectTeam(t: TeamEntry, otRate: number): ProjectedTeam {
|
||||||
return { team: t, pts: simulateProjectedPoints(t), tb: Math.random() };
|
return { team: t, pts: simulateProjectedPoints(t, otRate), tb: Math.random() };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sort projected teams descending by pts, then by random tiebreaker. Mutates arr. */
|
/** Sort projected teams descending by pts, then by random tiebreaker. Mutates arr. */
|
||||||
|
|
@ -253,7 +249,7 @@ function sortProjected(arr: ProjectedTeam[]): ProjectedTeam[] {
|
||||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class NHLSimulator implements Simulator {
|
export class NHLSimulator implements Simulator {
|
||||||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||||||
const db = database();
|
const db = database();
|
||||||
|
|
||||||
// Check for a populated playoff bracket; if found, use bracket-aware simulation.
|
// Check for a populated playoff bracket; if found, use bracket-aware simulation.
|
||||||
|
|
@ -271,7 +267,7 @@ export class NHLSimulator implements Simulator {
|
||||||
});
|
});
|
||||||
const bracketPopulated = allMatches.some((m) => m.participant1Id && m.participant2Id);
|
const bracketPopulated = allMatches.some((m) => m.participant1Id && m.participant2Id);
|
||||||
if (bracketPopulated) {
|
if (bracketPopulated) {
|
||||||
return this.simulateBracket(sportsSeasonId, allMatches);
|
return this.simulateBracket(sportsSeasonId, allMatches, config);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -286,7 +282,6 @@ export class NHLSimulator implements Simulator {
|
||||||
.select({
|
.select({
|
||||||
participantId: schema.seasonParticipantExpectedValues.participantId,
|
participantId: schema.seasonParticipantExpectedValues.participantId,
|
||||||
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
|
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
|
||||||
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
|
|
||||||
})
|
})
|
||||||
.from(schema.seasonParticipantExpectedValues)
|
.from(schema.seasonParticipantExpectedValues)
|
||||||
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)),
|
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)),
|
||||||
|
|
@ -299,6 +294,12 @@ export class NHLSimulator implements Simulator {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Engine knobs (admin-overridable via the season config; default to NHL values).
|
||||||
|
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
|
||||||
|
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
||||||
|
const seasonGames = Math.round(positiveConfigNumber(config, "seasonGames", DEFAULT_REGULAR_SEASON_GAMES));
|
||||||
|
const otRate = configNumber(config, "overtimeRate", DEFAULT_OT_RATE);
|
||||||
|
|
||||||
const participantIds = participantRows.map((r) => r.id);
|
const participantIds = participantRows.map((r) => r.id);
|
||||||
|
|
||||||
// 2. Build standings lookup, sourceElo map, and construct team entries.
|
// 2. Build standings lookup, sourceElo map, and construct team entries.
|
||||||
|
|
@ -336,7 +337,7 @@ export class NHLSimulator implements Simulator {
|
||||||
const wins = standing?.wins ?? 0;
|
const wins = standing?.wins ?? 0;
|
||||||
const otLosses = standing?.otLosses ?? 0;
|
const otLosses = standing?.otLosses ?? 0;
|
||||||
const currentPoints = wins * 2 + otLosses;
|
const currentPoints = wins * 2 + otLosses;
|
||||||
const remainingGames = Math.max(0, NHL_REGULAR_SEASON_GAMES - gamesPlayed);
|
const remainingGames = Math.max(0, seasonGames - gamesPlayed);
|
||||||
const resolvedElo = dbEloMap.get(r.id) ?? data?.elo ?? 1400;
|
const resolvedElo = dbEloMap.get(r.id) ?? data?.elo ?? 1400;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -347,7 +348,7 @@ export class NHLSimulator implements Simulator {
|
||||||
division,
|
division,
|
||||||
currentPoints,
|
currentPoints,
|
||||||
remainingGames,
|
remainingGames,
|
||||||
winProb: eloWinProbability(resolvedElo, 1500),
|
winProb: eloWinProbability(resolvedElo, 1500, parityFactor),
|
||||||
resolvedElo,
|
resolvedElo,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
@ -381,40 +382,15 @@ export class NHLSimulator implements Simulator {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Futures odds blending ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const participantIdSet = new Set(participantIds);
|
|
||||||
const oddsRows = evRows.filter(
|
|
||||||
(r) => r.sourceOdds !== null && participantIdSet.has(r.participantId)
|
|
||||||
);
|
|
||||||
const hasOdds = oddsRows.length > 0;
|
|
||||||
|
|
||||||
const normalizedOddsMap = new Map<string, number>();
|
|
||||||
if (hasOdds) {
|
|
||||||
const rawProbs = oddsRows.map((r) =>
|
|
||||||
convertAmericanOddsToProbability(r.sourceOdds ?? 0)
|
|
||||||
);
|
|
||||||
const normalized = normalizeProbabilities(rawProbs);
|
|
||||||
oddsRows.forEach(({ participantId }, i) => {
|
|
||||||
normalizedOddsMap.set(participantId, normalized[i]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers (defined once, outside the hot loop) ─────────────────────────
|
// ─── Helpers (defined once, outside the hot loop) ─────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Blended per-game win probability for team A over team B.
|
* Per-game win probability for team A over team B, from resolved Elo.
|
||||||
* When odds are available: 70% Elo + 30% vig-removed futures head-to-head.
|
* Futures odds already influenced `elo()` via the central odds→Elo resolver,
|
||||||
|
* so they are not blended in again here.
|
||||||
*/
|
*/
|
||||||
const gameWinProb = (a: TeamEntry, b: TeamEntry): number => {
|
const gameWinProb = (a: TeamEntry, b: TeamEntry): number =>
|
||||||
const eloProb = eloWinProbability(elo(a), elo(b));
|
eloWinProbability(elo(a), elo(b), parityFactor);
|
||||||
if (!hasOdds) return eloProb;
|
|
||||||
|
|
||||||
const o1 = normalizedOddsMap.get(a.id) ?? 0;
|
|
||||||
const o2 = normalizedOddsMap.get(b.id) ?? 0;
|
|
||||||
const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5;
|
|
||||||
return ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Simulate a best-of-7 series. Returns winner and loser. */
|
/** Simulate a best-of-7 series. Returns winner and loser. */
|
||||||
const simSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => {
|
const simSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => {
|
||||||
|
|
@ -448,7 +424,7 @@ export class NHLSimulator implements Simulator {
|
||||||
|
|
||||||
// Project all conference teams exactly once (single simulateProjectedPoints call per team).
|
// Project all conference teams exactly once (single simulateProjectedPoints call per team).
|
||||||
const divASet = new Set(divATeams.map((t) => t.id));
|
const divASet = new Set(divATeams.map((t) => t.id));
|
||||||
const allProjected = sortProjected([...divATeams, ...divBTeams].map(projectTeam));
|
const allProjected = sortProjected([...divATeams, ...divBTeams].map((t) => projectTeam(t, otRate)));
|
||||||
const divAProjected = allProjected.filter((p) => divASet.has(p.team.id));
|
const divAProjected = allProjected.filter((p) => divASet.has(p.team.id));
|
||||||
const divBProjected = allProjected.filter((p) => !divASet.has(p.team.id));
|
const divBProjected = allProjected.filter((p) => !divASet.has(p.team.id));
|
||||||
|
|
||||||
|
|
@ -487,7 +463,7 @@ export class NHLSimulator implements Simulator {
|
||||||
|
|
||||||
let effectiveN = 0;
|
let effectiveN = 0;
|
||||||
|
|
||||||
for (let s = 0; s < NUM_SIMULATIONS; s++) {
|
for (let s = 0; s < numSimulations; s++) {
|
||||||
const eastBracket = buildConferenceBracket(easternAtlantic, easternMetro);
|
const eastBracket = buildConferenceBracket(easternAtlantic, easternMetro);
|
||||||
const westBracket = buildConferenceBracket(westernCentral, westernPacific);
|
const westBracket = buildConferenceBracket(westernCentral, westernPacific);
|
||||||
if (!eastBracket || !westBracket) continue;
|
if (!eastBracket || !westBracket) continue;
|
||||||
|
|
@ -574,7 +550,8 @@ export class NHLSimulator implements Simulator {
|
||||||
|
|
||||||
private async simulateBracket(
|
private async simulateBracket(
|
||||||
sportsSeasonId: string,
|
sportsSeasonId: string,
|
||||||
allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>
|
allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>,
|
||||||
|
config: Record<string, unknown> = {}
|
||||||
): Promise<SimulationResult[]> {
|
): Promise<SimulationResult[]> {
|
||||||
const db = database();
|
const db = database();
|
||||||
|
|
||||||
|
|
@ -626,13 +603,15 @@ export class NHLSimulator implements Simulator {
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
participantId: schema.seasonParticipantExpectedValues.participantId,
|
participantId: schema.seasonParticipantExpectedValues.participantId,
|
||||||
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
|
|
||||||
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
|
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
|
||||||
})
|
})
|
||||||
.from(schema.seasonParticipantExpectedValues)
|
.from(schema.seasonParticipantExpectedValues)
|
||||||
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)),
|
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
|
||||||
|
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
||||||
|
|
||||||
const allParticipantIds = participantRows.map((r) => r.id);
|
const allParticipantIds = participantRows.map((r) => r.id);
|
||||||
|
|
||||||
const dbEloMap = new Map<string, number>();
|
const dbEloMap = new Map<string, number>();
|
||||||
|
|
@ -648,21 +627,6 @@ export class NHLSimulator implements Simulator {
|
||||||
eloMap.set(r.id, dbEloMap.get(r.id) ?? getTeamData(r.name)?.elo ?? 1400);
|
eloMap.set(r.id, dbEloMap.get(r.id) ?? getTeamData(r.name)?.elo ?? 1400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const participantIdSet = new Set(allParticipantIds);
|
|
||||||
const oddsRows = evRows.filter(
|
|
||||||
(r) => r.sourceOdds !== null && participantIdSet.has(r.participantId)
|
|
||||||
);
|
|
||||||
const hasOdds = oddsRows.length > 0;
|
|
||||||
|
|
||||||
const normalizedOddsMap = new Map<string, number>();
|
|
||||||
if (hasOdds) {
|
|
||||||
const rawProbs = oddsRows.map((r) => convertAmericanOddsToProbability(r.sourceOdds ?? 0));
|
|
||||||
const normalized = normalizeProbabilities(rawProbs);
|
|
||||||
oddsRows.forEach(({ participantId }, i) => {
|
|
||||||
normalizedOddsMap.set(participantId, normalized[i]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-round lookup maps for O(1) access.
|
// Per-round lookup maps for O(1) access.
|
||||||
const r1ByNum = new Map(r1Matches.map((m) => [m.matchNumber, m]));
|
const r1ByNum = new Map(r1Matches.map((m) => [m.matchNumber, m]));
|
||||||
const r2ByNum = new Map(r2Matches.map((m) => [m.matchNumber, m]));
|
const r2ByNum = new Map(r2Matches.map((m) => [m.matchNumber, m]));
|
||||||
|
|
@ -671,14 +635,10 @@ export class NHLSimulator implements Simulator {
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const gameWinProb = (aId: string, bId: string): number => {
|
// Futures odds already shaped each team's resolved Elo via the central
|
||||||
const eloProb = eloWinProbability(eloMap.get(aId) ?? 1400, eloMap.get(bId) ?? 1400);
|
// odds→Elo resolver, so per-game probability is pure Elo (no odds re-blend).
|
||||||
if (!hasOdds) return eloProb;
|
const gameWinProb = (aId: string, bId: string): number =>
|
||||||
const o1 = normalizedOddsMap.get(aId) ?? 0;
|
eloWinProbability(eloMap.get(aId) ?? 1400, eloMap.get(bId) ?? 1400, parityFactor);
|
||||||
const o2 = normalizedOddsMap.get(bId) ?? 0;
|
|
||||||
const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5;
|
|
||||||
return ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb;
|
|
||||||
};
|
|
||||||
|
|
||||||
const simSeries = (aId: string, bId: string): { winner: string; loser: string } => {
|
const simSeries = (aId: string, bId: string): { winner: string; loser: string } => {
|
||||||
const winProb = gameWinProb(aId, bId);
|
const winProb = gameWinProb(aId, bId);
|
||||||
|
|
@ -718,7 +678,7 @@ export class NHLSimulator implements Simulator {
|
||||||
|
|
||||||
// ── Monte Carlo simulation loop ───────────────────────────────────────────────
|
// ── Monte Carlo simulation loop ───────────────────────────────────────────────
|
||||||
|
|
||||||
for (let s = 0; s < NUM_SIMULATIONS; s++) {
|
for (let s = 0; s < numSimulations; s++) {
|
||||||
// Round 1: R1 losers score 0 points — not tracked.
|
// Round 1: R1 losers score 0 points — not tracked.
|
||||||
const r1Winners: string[] = [];
|
const r1Winners: string[] = [];
|
||||||
for (let i = 1; i <= 8; i++) {
|
for (let i = 1; i <= 8; i++) {
|
||||||
|
|
@ -758,7 +718,7 @@ export class NHLSimulator implements Simulator {
|
||||||
|
|
||||||
// ── Convert counts to probability distributions ───────────────────────────────
|
// ── Convert counts to probability distributions ───────────────────────────────
|
||||||
|
|
||||||
const N = NUM_SIMULATIONS;
|
const N = numSimulations;
|
||||||
const results: SimulationResult[] = allParticipantIds.map((participantId) => {
|
const results: SimulationResult[] = allParticipantIds.map((participantId) => {
|
||||||
const c = championCounts.get(participantId) ?? 0;
|
const c = championCounts.get(participantId) ?? 0;
|
||||||
const f = finalistCounts.get(participantId) ?? 0;
|
const f = finalistCounts.get(participantId) ?? 0;
|
||||||
|
|
|
||||||
|
|
@ -43,10 +43,11 @@ import { eloWinProbabilityWithParity } from "~/services/probability-engine";
|
||||||
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
||||||
import { getParticipantSimulatorInputs } from "~/models/simulator";
|
import { getParticipantSimulatorInputs } from "~/models/simulator";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
|
import { positiveConfigNumber } from "./config-access";
|
||||||
|
|
||||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
// ─── Simulation parameters (defaults; overridable via season config) ───────────
|
||||||
|
|
||||||
const NUM_SIMULATIONS = 50_000;
|
const DEFAULT_NUM_SIMULATIONS = 50_000;
|
||||||
const NLL_REGULAR_SEASON_GAMES = 18;
|
const NLL_REGULAR_SEASON_GAMES = 18;
|
||||||
const NLL_PLAYOFF_TEAMS = 8;
|
const NLL_PLAYOFF_TEAMS = 8;
|
||||||
const PARITY_FACTOR = 400;
|
const PARITY_FACTOR = 400;
|
||||||
|
|
@ -216,7 +217,8 @@ export function simulateNllPlayoffs(
|
||||||
*/
|
*/
|
||||||
export function resolveQfMatch(
|
export function resolveQfMatch(
|
||||||
match: MatchSnapshot | undefined,
|
match: MatchSnapshot | undefined,
|
||||||
eloMap: Map<string, number>
|
eloMap: Map<string, number>,
|
||||||
|
parityFactor = PARITY_FACTOR
|
||||||
): { winner: string; loser: string } {
|
): { winner: string; loser: string } {
|
||||||
if (match?.isComplete && match.winnerId && match.loserId) {
|
if (match?.isComplete && match.winnerId && match.loserId) {
|
||||||
return { winner: match.winnerId, loser: match.loserId };
|
return { winner: match.winnerId, loser: match.loserId };
|
||||||
|
|
@ -231,7 +233,8 @@ export function resolveQfMatch(
|
||||||
}
|
}
|
||||||
const result = simulateNllGame(
|
const result = simulateNllGame(
|
||||||
{ participantId: p1, elo: eloMap.get(p1) ?? FALLBACK_ELO },
|
{ participantId: p1, elo: eloMap.get(p1) ?? FALLBACK_ELO },
|
||||||
{ participantId: p2, elo: eloMap.get(p2) ?? FALLBACK_ELO }
|
{ participantId: p2, elo: eloMap.get(p2) ?? FALLBACK_ELO },
|
||||||
|
parityFactor
|
||||||
);
|
);
|
||||||
return { winner: result.winner.participantId, loser: result.loser.participantId };
|
return { winner: result.winner.participantId, loser: result.loser.participantId };
|
||||||
}
|
}
|
||||||
|
|
@ -249,7 +252,8 @@ export function resolveBo3Match(
|
||||||
games: GameSnapshot[],
|
games: GameSnapshot[],
|
||||||
eloMap: Map<string, number>,
|
eloMap: Map<string, number>,
|
||||||
p1Override?: string,
|
p1Override?: string,
|
||||||
p2Override?: string
|
p2Override?: string,
|
||||||
|
parityFactor = PARITY_FACTOR
|
||||||
): { winner: string; loser: string } {
|
): { winner: string; loser: string } {
|
||||||
if (match?.isComplete && match.winnerId && match.loserId) {
|
if (match?.isComplete && match.winnerId && match.loserId) {
|
||||||
return { winner: match.winnerId, loser: match.loserId };
|
return { winner: match.winnerId, loser: match.loserId };
|
||||||
|
|
@ -278,7 +282,7 @@ export function resolveBo3Match(
|
||||||
const { winner, loser } = simulateBestOfThree(
|
const { winner, loser } = simulateBestOfThree(
|
||||||
{ participantId: p1, elo: eloMap.get(p1) ?? FALLBACK_ELO },
|
{ participantId: p1, elo: eloMap.get(p1) ?? FALLBACK_ELO },
|
||||||
{ participantId: p2, elo: eloMap.get(p2) ?? FALLBACK_ELO },
|
{ participantId: p2, elo: eloMap.get(p2) ?? FALLBACK_ELO },
|
||||||
PARITY_FACTOR,
|
parityFactor,
|
||||||
{ winsA: winsP1, winsB: winsP2 }
|
{ winsA: winsP1, winsB: winsP2 }
|
||||||
);
|
);
|
||||||
return { winner: winner.participantId, loser: loser.participantId };
|
return { winner: winner.participantId, loser: loser.participantId };
|
||||||
|
|
@ -354,8 +358,10 @@ export function simulateRegularSeasonSeeds(
|
||||||
// ─── Simulator class ──────────────────────────────────────────────────────────
|
// ─── Simulator class ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class NLLSimulator implements Simulator {
|
export class NLLSimulator implements Simulator {
|
||||||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||||||
const db = database();
|
const db = database();
|
||||||
|
const parityFactor = positiveConfigNumber(config, "parityFactor", PARITY_FACTOR);
|
||||||
|
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
||||||
|
|
||||||
// ── Load participants ─────────────────────────────────────────────────────
|
// ── Load participants ─────────────────────────────────────────────────────
|
||||||
const participants = await db.query.seasonParticipants.findMany({
|
const participants = await db.query.seasonParticipants.findMany({
|
||||||
|
|
@ -421,7 +427,7 @@ export class NLLSimulator implements Simulator {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (bracketMatches.length > 0) {
|
if (bracketMatches.length > 0) {
|
||||||
return this.simulateBracketAware(allIds, eloMap, bracketMatches);
|
return this.simulateBracketAware(allIds, eloMap, bracketMatches, parityFactor, numSimulations);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -439,7 +445,7 @@ export class NLLSimulator implements Simulator {
|
||||||
knownSeeds.length === NLL_PLAYOFF_TEAMS &&
|
knownSeeds.length === NLL_PLAYOFF_TEAMS &&
|
||||||
new Set(knownSeeds.map((s) => s.seed)).size === NLL_PLAYOFF_TEAMS
|
new Set(knownSeeds.map((s) => s.seed)).size === NLL_PLAYOFF_TEAMS
|
||||||
) {
|
) {
|
||||||
return this.simulateKnownSeeds(allIds, knownSeeds);
|
return this.simulateKnownSeeds(allIds, knownSeeds, parityFactor, numSimulations);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mode 3: regular-season projection (default, including preseason).
|
// Mode 3: regular-season projection (default, including preseason).
|
||||||
|
|
@ -466,7 +472,7 @@ export class NLLSimulator implements Simulator {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.simulateRegularSeason(allIds, teams);
|
return this.simulateRegularSeason(allIds, teams, parityFactor, numSimulations);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mode 1: Bracket-Aware ─────────────────────────────────────────────────
|
// ── Mode 1: Bracket-Aware ─────────────────────────────────────────────────
|
||||||
|
|
@ -474,7 +480,9 @@ export class NLLSimulator implements Simulator {
|
||||||
private async simulateBracketAware(
|
private async simulateBracketAware(
|
||||||
allIds: string[],
|
allIds: string[],
|
||||||
eloMap: Map<string, number>,
|
eloMap: Map<string, number>,
|
||||||
allMatches: typeof schema.playoffMatches.$inferSelect[]
|
allMatches: typeof schema.playoffMatches.$inferSelect[],
|
||||||
|
parityFactor = PARITY_FACTOR,
|
||||||
|
numSimulations = DEFAULT_NUM_SIMULATIONS
|
||||||
): Promise<SimulationResult[]> {
|
): Promise<SimulationResult[]> {
|
||||||
const db = database();
|
const db = database();
|
||||||
|
|
||||||
|
|
@ -535,18 +543,18 @@ export class NLLSimulator implements Simulator {
|
||||||
const [sf1, sf2] = sfMatches;
|
const [sf1, sf2] = sfMatches;
|
||||||
const finalMatch = finalMatches[0];
|
const finalMatch = finalMatches[0];
|
||||||
|
|
||||||
for (let s = 0; s < NUM_SIMULATIONS; s++) {
|
for (let s = 0; s < numSimulations; s++) {
|
||||||
const r_qf1 = resolveQfMatch(qf1, eloMap);
|
const r_qf1 = resolveQfMatch(qf1, eloMap, parityFactor);
|
||||||
const r_qf2 = resolveQfMatch(qf2, eloMap);
|
const r_qf2 = resolveQfMatch(qf2, eloMap, parityFactor);
|
||||||
const r_qf3 = resolveQfMatch(qf3, eloMap);
|
const r_qf3 = resolveQfMatch(qf3, eloMap, parityFactor);
|
||||||
const r_qf4 = resolveQfMatch(qf4, eloMap);
|
const r_qf4 = resolveQfMatch(qf4, eloMap, parityFactor);
|
||||||
|
|
||||||
// SF arm A: QF1 winner vs QF2 winner (1/8 vs 4/5 bracket arm)
|
// SF arm A: QF1 winner vs QF2 winner (1/8 vs 4/5 bracket arm)
|
||||||
const r_sf1 = resolveBo3Match(sf1, gamesByMatch.get(sf1.id) ?? [], eloMap, r_qf1.winner, r_qf2.winner);
|
const r_sf1 = resolveBo3Match(sf1, gamesByMatch.get(sf1.id) ?? [], eloMap, r_qf1.winner, r_qf2.winner, parityFactor);
|
||||||
// SF arm B: QF3 winner vs QF4 winner (2/7 vs 3/6 bracket arm)
|
// SF arm B: QF3 winner vs QF4 winner (2/7 vs 3/6 bracket arm)
|
||||||
const r_sf2 = resolveBo3Match(sf2, gamesByMatch.get(sf2.id) ?? [], eloMap, r_qf3.winner, r_qf4.winner);
|
const r_sf2 = resolveBo3Match(sf2, gamesByMatch.get(sf2.id) ?? [], eloMap, r_qf3.winner, r_qf4.winner, parityFactor);
|
||||||
|
|
||||||
const r_final = resolveBo3Match(finalMatch, gamesByMatch.get(finalMatch.id) ?? [], eloMap, r_sf1.winner, r_sf2.winner);
|
const r_final = resolveBo3Match(finalMatch, gamesByMatch.get(finalMatch.id) ?? [], eloMap, r_sf1.winner, r_sf2.winner, parityFactor);
|
||||||
|
|
||||||
championCounts.set(r_final.winner, (championCounts.get(r_final.winner) ?? 0) + 1);
|
championCounts.set(r_final.winner, (championCounts.get(r_final.winner) ?? 0) + 1);
|
||||||
finalistCounts.set(r_final.loser, (finalistCounts.get(r_final.loser) ?? 0) + 1);
|
finalistCounts.set(r_final.loser, (finalistCounts.get(r_final.loser) ?? 0) + 1);
|
||||||
|
|
@ -558,22 +566,24 @@ export class NLLSimulator implements Simulator {
|
||||||
qfLoserCounts.set(r_qf4.loser, (qfLoserCounts.get(r_qf4.loser) ?? 0) + 1);
|
qfLoserCounts.set(r_qf4.loser, (qfLoserCounts.get(r_qf4.loser) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts);
|
return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts, numSimulations);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mode 2: Known-Seed ────────────────────────────────────────────────────
|
// ── Mode 2: Known-Seed ────────────────────────────────────────────────────
|
||||||
|
|
||||||
private simulateKnownSeeds(
|
private simulateKnownSeeds(
|
||||||
allIds: string[],
|
allIds: string[],
|
||||||
seeds: SeedEntry[]
|
seeds: SeedEntry[],
|
||||||
|
parityFactor = PARITY_FACTOR,
|
||||||
|
numSimulations = DEFAULT_NUM_SIMULATIONS
|
||||||
): SimulationResult[] {
|
): SimulationResult[] {
|
||||||
const championCounts = new Map(allIds.map((id) => [id, 0]));
|
const championCounts = new Map(allIds.map((id) => [id, 0]));
|
||||||
const finalistCounts = new Map(allIds.map((id) => [id, 0]));
|
const finalistCounts = new Map(allIds.map((id) => [id, 0]));
|
||||||
const sfLoserCounts = new Map(allIds.map((id) => [id, 0]));
|
const sfLoserCounts = new Map(allIds.map((id) => [id, 0]));
|
||||||
const qfLoserCounts = new Map(allIds.map((id) => [id, 0]));
|
const qfLoserCounts = new Map(allIds.map((id) => [id, 0]));
|
||||||
|
|
||||||
for (let s = 0; s < NUM_SIMULATIONS; s++) {
|
for (let s = 0; s < numSimulations; s++) {
|
||||||
const result = simulateNllPlayoffs(seeds);
|
const result = simulateNllPlayoffs(seeds, parityFactor);
|
||||||
championCounts.set(result.champion, (championCounts.get(result.champion) ?? 0) + 1);
|
championCounts.set(result.champion, (championCounts.get(result.champion) ?? 0) + 1);
|
||||||
finalistCounts.set(result.finalist, (finalistCounts.get(result.finalist) ?? 0) + 1);
|
finalistCounts.set(result.finalist, (finalistCounts.get(result.finalist) ?? 0) + 1);
|
||||||
for (const id of result.sfLosers) {
|
for (const id of result.sfLosers) {
|
||||||
|
|
@ -584,23 +594,25 @@ export class NLLSimulator implements Simulator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts);
|
return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts, numSimulations);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mode 3: Regular-Season Projection ────────────────────────────────────
|
// ── Mode 3: Regular-Season Projection ────────────────────────────────────
|
||||||
|
|
||||||
private simulateRegularSeason(
|
private simulateRegularSeason(
|
||||||
allIds: string[],
|
allIds: string[],
|
||||||
teams: TeamProjection[]
|
teams: TeamProjection[],
|
||||||
|
parityFactor = PARITY_FACTOR,
|
||||||
|
numSimulations = DEFAULT_NUM_SIMULATIONS
|
||||||
): SimulationResult[] {
|
): SimulationResult[] {
|
||||||
const championCounts = new Map(allIds.map((id) => [id, 0]));
|
const championCounts = new Map(allIds.map((id) => [id, 0]));
|
||||||
const finalistCounts = new Map(allIds.map((id) => [id, 0]));
|
const finalistCounts = new Map(allIds.map((id) => [id, 0]));
|
||||||
const sfLoserCounts = new Map(allIds.map((id) => [id, 0]));
|
const sfLoserCounts = new Map(allIds.map((id) => [id, 0]));
|
||||||
const qfLoserCounts = new Map(allIds.map((id) => [id, 0]));
|
const qfLoserCounts = new Map(allIds.map((id) => [id, 0]));
|
||||||
|
|
||||||
for (let s = 0; s < NUM_SIMULATIONS; s++) {
|
for (let s = 0; s < numSimulations; s++) {
|
||||||
const seeds = simulateRegularSeasonSeeds(teams);
|
const seeds = simulateRegularSeasonSeeds(teams, parityFactor);
|
||||||
const result = simulateNllPlayoffs(seeds);
|
const result = simulateNllPlayoffs(seeds, parityFactor);
|
||||||
championCounts.set(result.champion, (championCounts.get(result.champion) ?? 0) + 1);
|
championCounts.set(result.champion, (championCounts.get(result.champion) ?? 0) + 1);
|
||||||
finalistCounts.set(result.finalist, (finalistCounts.get(result.finalist) ?? 0) + 1);
|
finalistCounts.set(result.finalist, (finalistCounts.get(result.finalist) ?? 0) + 1);
|
||||||
for (const id of result.sfLosers) {
|
for (const id of result.sfLosers) {
|
||||||
|
|
@ -611,7 +623,7 @@ export class NLLSimulator implements Simulator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts);
|
return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts, numSimulations);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Shared result builder ─────────────────────────────────────────────────
|
// ── Shared result builder ─────────────────────────────────────────────────
|
||||||
|
|
@ -621,9 +633,10 @@ export class NLLSimulator implements Simulator {
|
||||||
championCounts: Map<string, number>,
|
championCounts: Map<string, number>,
|
||||||
finalistCounts: Map<string, number>,
|
finalistCounts: Map<string, number>,
|
||||||
sfLoserCounts: Map<string, number>,
|
sfLoserCounts: Map<string, number>,
|
||||||
qfLoserCounts: Map<string, number>
|
qfLoserCounts: Map<string, number>,
|
||||||
|
numSimulations = DEFAULT_NUM_SIMULATIONS
|
||||||
): SimulationResult[] {
|
): SimulationResult[] {
|
||||||
const N = NUM_SIMULATIONS;
|
const N = numSimulations;
|
||||||
return allIds.map((id) => ({
|
return allIds.map((id) => ({
|
||||||
participantId: id,
|
participantId: id,
|
||||||
probabilities: {
|
probabilities: {
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ export async function runSportsSeasonSimulation(
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const simulator = getSimulator(simulatorConfig.simulatorType);
|
const simulator = getSimulator(simulatorConfig.simulatorType);
|
||||||
const results = await simulator.simulate(sportsSeasonId);
|
const results = await simulator.simulate(sportsSeasonId, simulatorConfig.config);
|
||||||
|
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
throw new Error("Simulation returned no results. Check that participants have simulator input data.");
|
throw new Error("Simulation returned no results. Check that participants have simulator input data.");
|
||||||
|
|
|
||||||
|
|
@ -41,10 +41,11 @@ import { database } from "~/database/context";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
|
import { positiveConfigNumber } from "./config-access";
|
||||||
|
|
||||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const NUM_SIMULATIONS = 50000;
|
const DEFAULT_NUM_SIMULATIONS = 50000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Controls how much Elo gaps affect per-frame win probability.
|
* Controls how much Elo gaps affect per-frame win probability.
|
||||||
|
|
@ -229,7 +230,8 @@ function shuffle<T>(arr: T[]): T[] {
|
||||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class SnookerSimulator implements Simulator {
|
export class SnookerSimulator implements Simulator {
|
||||||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||||||
|
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
||||||
const db = database();
|
const db = database();
|
||||||
|
|
||||||
// 1. Find the bracket scoring event for this sports season.
|
// 1. Find the bracket scoring event for this sports season.
|
||||||
|
|
@ -272,9 +274,9 @@ export class SnookerSimulator implements Simulator {
|
||||||
allMatches.some((m) => m.participant1Id && m.participant2Id);
|
allMatches.some((m) => m.participant1Id && m.participant2Id);
|
||||||
|
|
||||||
if (bracketPopulated) {
|
if (bracketPopulated) {
|
||||||
return this.simulateBracket(allMatches, eloMap);
|
return this.simulateBracket(allMatches, eloMap, numSimulations);
|
||||||
} else {
|
} else {
|
||||||
return this.simulatePreBracket(sportsSeasonId, eloMap, db);
|
return this.simulatePreBracket(sportsSeasonId, eloMap, db, numSimulations);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -282,7 +284,8 @@ export class SnookerSimulator implements Simulator {
|
||||||
|
|
||||||
private async simulateBracket(
|
private async simulateBracket(
|
||||||
allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>,
|
allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>,
|
||||||
eloMap: Map<string, number>
|
eloMap: Map<string, number>,
|
||||||
|
numSimulations: number
|
||||||
): Promise<SimulationResult[]> {
|
): Promise<SimulationResult[]> {
|
||||||
// Group matches by round, sorted by match count descending (R32 first).
|
// Group matches by round, sorted by match count descending (R32 first).
|
||||||
const byRound = new Map<string, typeof allMatches>();
|
const byRound = new Map<string, typeof allMatches>();
|
||||||
|
|
@ -354,7 +357,7 @@ export class SnookerSimulator implements Simulator {
|
||||||
const sfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
const sfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||||||
const qfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
const qfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||||||
|
|
||||||
for (let s = 0; s < NUM_SIMULATIONS; s++) {
|
for (let s = 0; s < numSimulations; s++) {
|
||||||
// ── First Round (R32) ──────────────────────────────────────────────────
|
// ── First Round (R32) ──────────────────────────────────────────────────
|
||||||
const r32Winners: string[] = [];
|
const r32Winners: string[] = [];
|
||||||
for (let i = 1; i <= 16; i++) {
|
for (let i = 1; i <= 16; i++) {
|
||||||
|
|
@ -433,7 +436,7 @@ export class SnookerSimulator implements Simulator {
|
||||||
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
|
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildResults(participantIds, NUM_SIMULATIONS, {
|
return buildResults(participantIds, numSimulations, {
|
||||||
championCounts,
|
championCounts,
|
||||||
finalistCounts,
|
finalistCounts,
|
||||||
sfLoserCounts,
|
sfLoserCounts,
|
||||||
|
|
@ -450,7 +453,8 @@ export class SnookerSimulator implements Simulator {
|
||||||
private async simulatePreBracket(
|
private async simulatePreBracket(
|
||||||
sportsSeasonId: string,
|
sportsSeasonId: string,
|
||||||
eloMap: Map<string, number>,
|
eloMap: Map<string, number>,
|
||||||
db: ReturnType<typeof database>
|
db: ReturnType<typeof database>,
|
||||||
|
numSimulations: number
|
||||||
): Promise<SimulationResult[]> {
|
): Promise<SimulationResult[]> {
|
||||||
const allParticipants = await db
|
const allParticipants = await db
|
||||||
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
|
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
|
||||||
|
|
@ -500,7 +504,7 @@ export class SnookerSimulator implements Simulator {
|
||||||
// are in the DB), skip the qualifying simulation and use them directly as seeds 17-32.
|
// are in the DB), skip the qualifying simulation and use them directly as seeds 17-32.
|
||||||
const needsQualifying = qualifiers.length > 16;
|
const needsQualifying = qualifiers.length > 16;
|
||||||
|
|
||||||
for (let s = 0; s < NUM_SIMULATIONS; s++) {
|
for (let s = 0; s < numSimulations; s++) {
|
||||||
// ── Qualifying: run elimination rounds until exactly 16 qualifiers remain ──
|
// ── Qualifying: run elimination rounds until exactly 16 qualifiers remain ──
|
||||||
let drawnQualifiers: string[];
|
let drawnQualifiers: string[];
|
||||||
if (!needsQualifying) {
|
if (!needsQualifying) {
|
||||||
|
|
@ -579,7 +583,7 @@ export class SnookerSimulator implements Simulator {
|
||||||
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
|
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildResults(allParticipantIds, NUM_SIMULATIONS, {
|
return buildResults(allParticipantIds, numSimulations, {
|
||||||
championCounts,
|
championCounts,
|
||||||
finalistCounts,
|
finalistCounts,
|
||||||
sfLoserCounts,
|
sfLoserCounts,
|
||||||
|
|
|
||||||
|
|
@ -48,10 +48,11 @@ import { getQPConfig, calculateSplitQualifyingPoints } from "~/models/qualifying
|
||||||
import { resolveStructureSource, type IdTranslator } from "./shared-major";
|
import { resolveStructureSource, type IdTranslator } from "./shared-major";
|
||||||
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
|
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
|
import { positiveConfigNumber } from "./config-access";
|
||||||
|
|
||||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const NUM_SIMULATIONS = 10_000;
|
const DEFAULT_NUM_SIMULATIONS = 10_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Elo divisor for per-match win probability.
|
* Elo divisor for per-match win probability.
|
||||||
|
|
@ -363,7 +364,8 @@ export function drawFromBracket(
|
||||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class TennisSimulator implements Simulator {
|
export class TennisSimulator implements Simulator {
|
||||||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||||||
|
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
||||||
const db = database();
|
const db = database();
|
||||||
|
|
||||||
// 1. Load all participants for this sports season.
|
// 1. Load all participants for this sports season.
|
||||||
|
|
@ -505,7 +507,7 @@ export class TennisSimulator implements Simulator {
|
||||||
const counts: number[][] = Array.from({ length: participantIds.length }, () => Array.from({ length: 8 }, () => 0));
|
const counts: number[][] = Array.from({ length: participantIds.length }, () => Array.from({ length: 8 }, () => 0));
|
||||||
const idToIndex = new Map<string, number>(participantIds.map((id, i) => [id, i]));
|
const idToIndex = new Map<string, number>(participantIds.map((id, i) => [id, i]));
|
||||||
|
|
||||||
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
|
for (let sim = 0; sim < numSimulations; sim++) {
|
||||||
// Start each simulation from the locked actual QP.
|
// Start each simulation from the locked actual QP.
|
||||||
const simQP = new Map<string, number>(actualQPMap);
|
const simQP = new Map<string, number>(actualQPMap);
|
||||||
|
|
||||||
|
|
@ -551,14 +553,14 @@ export class TennisSimulator implements Simulator {
|
||||||
return participantIds.map((participantId, i) => ({
|
return participantIds.map((participantId, i) => ({
|
||||||
participantId,
|
participantId,
|
||||||
probabilities: {
|
probabilities: {
|
||||||
probFirst: counts[i][0] / NUM_SIMULATIONS,
|
probFirst: counts[i][0] / numSimulations,
|
||||||
probSecond: counts[i][1] / NUM_SIMULATIONS,
|
probSecond: counts[i][1] / numSimulations,
|
||||||
probThird: counts[i][2] / NUM_SIMULATIONS,
|
probThird: counts[i][2] / numSimulations,
|
||||||
probFourth: counts[i][3] / NUM_SIMULATIONS,
|
probFourth: counts[i][3] / numSimulations,
|
||||||
probFifth: counts[i][4] / NUM_SIMULATIONS,
|
probFifth: counts[i][4] / numSimulations,
|
||||||
probSixth: counts[i][5] / NUM_SIMULATIONS,
|
probSixth: counts[i][5] / numSimulations,
|
||||||
probSeventh: counts[i][6] / NUM_SIMULATIONS,
|
probSeventh: counts[i][6] / numSimulations,
|
||||||
probEighth: counts[i][7] / NUM_SIMULATIONS,
|
probEighth: counts[i][7] / numSimulations,
|
||||||
},
|
},
|
||||||
source: "tennis_grand_slam_monte_carlo",
|
source: "tennis_grand_slam_monte_carlo",
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,12 @@ export interface SimulationResult {
|
||||||
* Simulators consume a single resolved Elo per participant (already blended from
|
* Simulators consume a single resolved Elo per participant (already blended from
|
||||||
* its sources — raw Elo, projections, futures odds — by the input policy and
|
* its sources — raw Elo, projections, futures odds — by the input policy and
|
||||||
* persisted before the run), so they need only the season id.
|
* persisted before the run), so they need only the season id.
|
||||||
|
*
|
||||||
|
* `config` is the merged simulator config (profile defaults overlaid with the
|
||||||
|
* season's overrides) that the runner has already fetched. It carries the engine
|
||||||
|
* knobs (`iterations`, `parityFactor`, `seasonGames`, …). It is optional so unit
|
||||||
|
* tests can call `simulate(id)` and get each simulator's built-in defaults.
|
||||||
*/
|
*/
|
||||||
export interface Simulator {
|
export interface Simulator {
|
||||||
simulate(sportsSeasonId: string): Promise<SimulationResult[]>;
|
simulate(sportsSeasonId: string, config?: Record<string, unknown>): Promise<SimulationResult[]>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,12 @@
|
||||||
*
|
*
|
||||||
* Algorithm:
|
* Algorithm:
|
||||||
* 1. Load the bracket scoring event and all playoff matches from DB
|
* 1. Load the bracket scoring event and all playoff matches from DB
|
||||||
* 2. Load futures odds (American format) from participantExpectedValues
|
* 2. Load each team's resolved Elo (the input policy already blended any raw
|
||||||
* 3. Build two probability signals per team:
|
* Elo and futures odds into a single sourceElo before the run)
|
||||||
* a. Elo — derived from futures via convertFuturesToElo() (long-run team strength)
|
* 3. Per-match win probability = eloWinProbabilityWithParity(eloA, eloB, parityFactor).
|
||||||
* b. Normalized odds — vig-removed implied win probability from the same futures
|
* Odds are NOT blended in per-match here — that would double-count the futures
|
||||||
* 4. Per-match win probability = ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb
|
* signal already baked into the Elo.
|
||||||
* 5. Simulate 50,000 tournaments, respecting already-completed matches
|
* 5. Simulate `iterations` tournaments, respecting already-completed matches
|
||||||
* 6. Track integer placement counts per tier (champion / finalist / SF loser / QF loser).
|
* 6. Track integer placement counts per tier (champion / finalist / SF loser / QF loser).
|
||||||
* At conversion, exact denominators guarantee column sums of 1.0 by construction:
|
* At conversion, exact denominators guarantee column sums of 1.0 by construction:
|
||||||
* - probFirst = champion / N
|
* - probFirst = champion / N
|
||||||
|
|
@ -30,19 +30,23 @@
|
||||||
*
|
*
|
||||||
* Notes:
|
* Notes:
|
||||||
* - Requires futures odds in sourceOdds (American format) to be imported first.
|
* - Requires futures odds in sourceOdds (American format) to be imported first.
|
||||||
* - Falls back to uniform probability (coin flip) when no odds are stored.
|
* - Falls back to a 1500 Elo (coin flip vs. equals) when no Elo is resolved.
|
||||||
* - ELO_WEIGHT and ODDS_WEIGHT can be tuned here; 0.7/0.3 matches the Python calibration.
|
* - `parityFactor` (season config) tunes per-match variance; default 400.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eloWinProbability } from "~/services/probability-engine";
|
import { eloWinProbabilityWithParity } from "~/services/probability-engine";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
|
import { positiveConfigNumber } from "./config-access";
|
||||||
|
|
||||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
// ─── Simulation parameters (defaults; overridable via season config) ───────────
|
||||||
|
|
||||||
const NUM_SIMULATIONS = 50000;
|
const DEFAULT_NUM_SIMULATIONS = 50000;
|
||||||
|
|
||||||
|
/** Elo parity factor. Defaults to 400 (standard formula). */
|
||||||
|
const DEFAULT_PARITY_FACTOR = 400;
|
||||||
|
|
||||||
// ─── Odds helper ──────────────────────────────────────────────────────────────
|
// ─── Odds helper ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -55,8 +59,10 @@ export function americanToImpliedProb(odds: number): number {
|
||||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class UCLSimulator implements Simulator {
|
export class UCLSimulator implements Simulator {
|
||||||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||||||
const db = database();
|
const db = database();
|
||||||
|
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
|
||||||
|
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
||||||
|
|
||||||
// 1. Find the bracket scoring event for this sports season.
|
// 1. Find the bracket scoring event for this sports season.
|
||||||
// UCL has exactly one playoff_game event per season.
|
// UCL has exactly one playoff_game event per season.
|
||||||
|
|
@ -166,7 +172,7 @@ export class UCLSimulator implements Simulator {
|
||||||
const blendedWinProb = (p1: string, p2: string): number => {
|
const blendedWinProb = (p1: string, p2: string): number => {
|
||||||
const elo1 = eloMap.get(p1) ?? 1500;
|
const elo1 = eloMap.get(p1) ?? 1500;
|
||||||
const elo2 = eloMap.get(p2) ?? 1500;
|
const elo2 = eloMap.get(p2) ?? 1500;
|
||||||
return eloWinProbability(elo1, elo2);
|
return eloWinProbabilityWithParity(elo1, elo2, parityFactor);
|
||||||
};
|
};
|
||||||
|
|
||||||
const simMatch = (p1: string, p2: string): { winner: string; loser: string } => {
|
const simMatch = (p1: string, p2: string): { winner: string; loser: string } => {
|
||||||
|
|
@ -183,7 +189,7 @@ export class UCLSimulator implements Simulator {
|
||||||
const qfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
const qfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||||||
|
|
||||||
// 10. Run Monte Carlo simulations.
|
// 10. Run Monte Carlo simulations.
|
||||||
for (let s = 0; s < NUM_SIMULATIONS; s++) {
|
for (let s = 0; s < numSimulations; s++) {
|
||||||
// ── Round of 16 ──────────────────────────────────────────────────────
|
// ── Round of 16 ──────────────────────────────────────────────────────
|
||||||
// R16 losers: no count added (0 points per scoring rules)
|
// R16 losers: no count added (0 points per scoring rules)
|
||||||
const r16Winners: string[] = [];
|
const r16Winners: string[] = [];
|
||||||
|
|
@ -261,7 +267,7 @@ export class UCLSimulator implements Simulator {
|
||||||
// probFirst/Second → N total (1 per sim)
|
// probFirst/Second → N total (1 per sim)
|
||||||
// probThird/Fourth → sfLoserCounts / (2*N) — 2 SF losers per sim
|
// probThird/Fourth → sfLoserCounts / (2*N) — 2 SF losers per sim
|
||||||
// probFifth–Eighth → qfLoserCounts / (4*N) — 4 QF losers per sim
|
// probFifth–Eighth → qfLoserCounts / (4*N) — 4 QF losers per sim
|
||||||
const N = NUM_SIMULATIONS;
|
const N = numSimulations;
|
||||||
const results: SimulationResult[] = participantIds.map((participantId) => {
|
const results: SimulationResult[] = participantIds.map((participantId) => {
|
||||||
const c = championCounts.get(participantId) ?? 0;
|
const c = championCounts.get(participantId) ?? 0;
|
||||||
const f = finalistCounts.get(participantId) ?? 0;
|
const f = finalistCounts.get(participantId) ?? 0;
|
||||||
|
|
|
||||||
|
|
@ -53,14 +53,18 @@ import * as schema from "~/database/schema";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
||||||
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
||||||
import { convertFuturesToElo, eloWinProbability } from "~/services/probability-engine";
|
import { convertFuturesToElo, eloWinProbabilityWithParity } from "~/services/probability-engine";
|
||||||
|
import { positiveConfigNumber } from "./config-access";
|
||||||
|
|
||||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
// ─── Simulation parameters (defaults; overridable via season config) ───────────
|
||||||
|
|
||||||
const NUM_SIMULATIONS = 50_000;
|
const DEFAULT_NUM_SIMULATIONS = 50_000;
|
||||||
|
|
||||||
|
/** Elo parity factor. WNBA defaults to 400 (standard formula). */
|
||||||
|
const DEFAULT_PARITY_FACTOR = 400;
|
||||||
|
|
||||||
/** WNBA regular season games per team. */
|
/** WNBA regular season games per team. */
|
||||||
const WNBA_REGULAR_SEASON_GAMES = 40;
|
const DEFAULT_REGULAR_SEASON_GAMES = 40;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Multiplier converting SRS to Elo offset from 1500.
|
* Multiplier converting SRS to Elo offset from 1500.
|
||||||
|
|
@ -129,9 +133,10 @@ interface TeamEntry {
|
||||||
export function simSeriesN(
|
export function simSeriesN(
|
||||||
a: TeamEntry,
|
a: TeamEntry,
|
||||||
b: TeamEntry,
|
b: TeamEntry,
|
||||||
winTarget: number
|
winTarget: number,
|
||||||
|
parityFactor = DEFAULT_PARITY_FACTOR
|
||||||
): { winner: TeamEntry; loser: TeamEntry } {
|
): { winner: TeamEntry; loser: TeamEntry } {
|
||||||
const winProb = eloWinProbability(a.elo, b.elo);
|
const winProb = eloWinProbabilityWithParity(a.elo, b.elo, parityFactor);
|
||||||
let winsA = 0;
|
let winsA = 0;
|
||||||
let winsB = 0;
|
let winsB = 0;
|
||||||
while (winsA < winTarget && winsB < winTarget) {
|
while (winsA < winTarget && winsB < winTarget) {
|
||||||
|
|
@ -153,8 +158,11 @@ function simulateProjectedWins(team: TeamEntry): number {
|
||||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class WNBASimulator implements Simulator {
|
export class WNBASimulator implements Simulator {
|
||||||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||||||
const db = database();
|
const db = database();
|
||||||
|
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
|
||||||
|
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
||||||
|
const seasonGames = Math.round(positiveConfigNumber(config, "seasonGames", DEFAULT_REGULAR_SEASON_GAMES));
|
||||||
|
|
||||||
// 1. Load participants, standings, and futures odds in parallel.
|
// 1. Load participants, standings, and futures odds in parallel.
|
||||||
const [participantRows, standings, evRows] = await Promise.all([
|
const [participantRows, standings, evRows] = await Promise.all([
|
||||||
|
|
@ -228,8 +236,8 @@ export class WNBASimulator implements Simulator {
|
||||||
name: r.name,
|
name: r.name,
|
||||||
elo,
|
elo,
|
||||||
currentWins: standing?.wins ?? 0,
|
currentWins: standing?.wins ?? 0,
|
||||||
remainingGames: Math.max(0, WNBA_REGULAR_SEASON_GAMES - gamesPlayed),
|
remainingGames: Math.max(0, seasonGames - gamesPlayed),
|
||||||
winProb: eloWinProbability(elo, 1500),
|
winProb: eloWinProbabilityWithParity(elo, 1500, parityFactor),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -255,14 +263,14 @@ export class WNBASimulator implements Simulator {
|
||||||
const r1LoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
const r1LoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||||||
|
|
||||||
// 6. Monte Carlo simulation loop.
|
// 6. Monte Carlo simulation loop.
|
||||||
for (let s = 0; s < NUM_SIMULATIONS; s++) {
|
for (let s = 0; s < numSimulations; s++) {
|
||||||
const [s1, s2, s3, s4, s5, s6, s7, s8] = buildSeededBracket();
|
const [s1, s2, s3, s4, s5, s6, s7, s8] = buildSeededBracket();
|
||||||
|
|
||||||
// Round 1 (best-of-3): 1v8, 4v5, 2v7, 3v6
|
// Round 1 (best-of-3): 1v8, 4v5, 2v7, 3v6
|
||||||
const r1_a = simSeriesN(s1, s8, 2);
|
const r1_a = simSeriesN(s1, s8, 2, parityFactor);
|
||||||
const r1_b = simSeriesN(s4, s5, 2);
|
const r1_b = simSeriesN(s4, s5, 2, parityFactor);
|
||||||
const r1_c = simSeriesN(s2, s7, 2);
|
const r1_c = simSeriesN(s2, s7, 2, parityFactor);
|
||||||
const r1_d = simSeriesN(s3, s6, 2);
|
const r1_d = simSeriesN(s3, s6, 2, parityFactor);
|
||||||
|
|
||||||
r1LoserCounts.set(r1_a.loser.id, (r1LoserCounts.get(r1_a.loser.id) ?? 0) + 1);
|
r1LoserCounts.set(r1_a.loser.id, (r1LoserCounts.get(r1_a.loser.id) ?? 0) + 1);
|
||||||
r1LoserCounts.set(r1_b.loser.id, (r1LoserCounts.get(r1_b.loser.id) ?? 0) + 1);
|
r1LoserCounts.set(r1_b.loser.id, (r1LoserCounts.get(r1_b.loser.id) ?? 0) + 1);
|
||||||
|
|
@ -270,14 +278,14 @@ export class WNBASimulator implements Simulator {
|
||||||
r1LoserCounts.set(r1_d.loser.id, (r1LoserCounts.get(r1_d.loser.id) ?? 0) + 1);
|
r1LoserCounts.set(r1_d.loser.id, (r1LoserCounts.get(r1_d.loser.id) ?? 0) + 1);
|
||||||
|
|
||||||
// Semifinals (best-of-5): winner(1/8) vs winner(4/5), winner(2/7) vs winner(3/6)
|
// Semifinals (best-of-5): winner(1/8) vs winner(4/5), winner(2/7) vs winner(3/6)
|
||||||
const sf_a = simSeriesN(r1_a.winner, r1_b.winner, 3);
|
const sf_a = simSeriesN(r1_a.winner, r1_b.winner, 3, parityFactor);
|
||||||
const sf_b = simSeriesN(r1_c.winner, r1_d.winner, 3);
|
const sf_b = simSeriesN(r1_c.winner, r1_d.winner, 3, parityFactor);
|
||||||
|
|
||||||
semiLoserCounts.set(sf_a.loser.id, (semiLoserCounts.get(sf_a.loser.id) ?? 0) + 1);
|
semiLoserCounts.set(sf_a.loser.id, (semiLoserCounts.get(sf_a.loser.id) ?? 0) + 1);
|
||||||
semiLoserCounts.set(sf_b.loser.id, (semiLoserCounts.get(sf_b.loser.id) ?? 0) + 1);
|
semiLoserCounts.set(sf_b.loser.id, (semiLoserCounts.get(sf_b.loser.id) ?? 0) + 1);
|
||||||
|
|
||||||
// Finals (best-of-7)
|
// Finals (best-of-7)
|
||||||
const final = simSeriesN(sf_a.winner, sf_b.winner, 4);
|
const final = simSeriesN(sf_a.winner, sf_b.winner, 4, parityFactor);
|
||||||
|
|
||||||
championCounts.set(final.winner.id, (championCounts.get(final.winner.id) ?? 0) + 1);
|
championCounts.set(final.winner.id, (championCounts.get(final.winner.id) ?? 0) + 1);
|
||||||
finalistCounts.set(final.loser.id, (finalistCounts.get(final.loser.id) ?? 0) + 1);
|
finalistCounts.set(final.loser.id, (finalistCounts.get(final.loser.id) ?? 0) + 1);
|
||||||
|
|
@ -292,14 +300,14 @@ export class WNBASimulator implements Simulator {
|
||||||
return {
|
return {
|
||||||
participantId,
|
participantId,
|
||||||
probabilities: {
|
probabilities: {
|
||||||
probFirst: c / NUM_SIMULATIONS,
|
probFirst: c / numSimulations,
|
||||||
probSecond: f / NUM_SIMULATIONS,
|
probSecond: f / numSimulations,
|
||||||
probThird: sl / (2 * NUM_SIMULATIONS),
|
probThird: sl / (2 * numSimulations),
|
||||||
probFourth: sl / (2 * NUM_SIMULATIONS),
|
probFourth: sl / (2 * numSimulations),
|
||||||
probFifth: r1 / (4 * NUM_SIMULATIONS),
|
probFifth: r1 / (4 * numSimulations),
|
||||||
probSixth: r1 / (4 * NUM_SIMULATIONS),
|
probSixth: r1 / (4 * numSimulations),
|
||||||
probSeventh: r1 / (4 * NUM_SIMULATIONS),
|
probSeventh: r1 / (4 * numSimulations),
|
||||||
probEighth: r1 / (4 * NUM_SIMULATIONS),
|
probEighth: r1 / (4 * numSimulations),
|
||||||
},
|
},
|
||||||
source,
|
source,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import { database } from "~/database/context";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eloWinProbability } from "~/services/probability-engine";
|
import { eloWinProbability } from "~/services/probability-engine";
|
||||||
|
import { positiveConfigNumber } from "./config-access";
|
||||||
import { simulateEloSoccerMatch } from "./soccer-helpers";
|
import { simulateEloSoccerMatch } from "./soccer-helpers";
|
||||||
import { normalizeSimulationResultColumns } from "./simulation-probabilities";
|
import { normalizeSimulationResultColumns } from "./simulation-probabilities";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
|
|
@ -440,8 +441,9 @@ export class WorldCupSimulator implements Simulator {
|
||||||
this.numSimulations = numSimulations;
|
this.numSimulations = numSimulations;
|
||||||
}
|
}
|
||||||
|
|
||||||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||||||
const db = database();
|
const db = database();
|
||||||
|
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", this.numSimulations));
|
||||||
|
|
||||||
// 1. Load all participants for this season
|
// 1. Load all participants for this season
|
||||||
const participantRows = await db.query.seasonParticipants.findMany({
|
const participantRows = await db.query.seasonParticipants.findMany({
|
||||||
|
|
@ -669,7 +671,7 @@ export class WorldCupSimulator implements Simulator {
|
||||||
return seeded;
|
return seeded;
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let sim = 0; sim < this.numSimulations; sim++) {
|
for (let sim = 0; sim < numSimulations; sim++) {
|
||||||
// ── Knockout rounds ──────────────────────────────────────────
|
// ── Knockout rounds ──────────────────────────────────────────
|
||||||
// R32 → R16 → QF → SF → Third Place Game + Final, advancing via the
|
// R32 → R16 → QF → SF → Third Place Game + Final, advancing via the
|
||||||
// canonical ceil(n/2) tree. Completed matches lock in real results.
|
// canonical ceil(n/2) tree. Completed matches lock in real results.
|
||||||
|
|
@ -719,7 +721,7 @@ export class WorldCupSimulator implements Simulator {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. Convert counts to probabilities
|
// 9. Convert counts to probabilities
|
||||||
const N = this.numSimulations;
|
const N = numSimulations;
|
||||||
const numQfLosers = 4; // 4 QF losers per sim
|
const numQfLosers = 4; // 4 QF losers per sim
|
||||||
|
|
||||||
const source =
|
const source =
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue