Unify simulator strength on a single blended Elo

Replace the two parallel odds/Elo mechanisms with one model: every strength
source (raw Elo, projected wins, projected table points, futures odds) converts
to an Elo, those blend by weight into a single Elo, and that one Elo feeds every
simulator. This supersedes the earlier source-priority + per-match probability
blend approach.

Resolution (app/services/simulations/input-policy.ts):
- SimulatorInputPolicy gains baseEloPriority (order among the substitutable base
  sources: raw Elo / projected wins / projected table points) and oddsWeight
  (0–1). resolveSourceElos/resolveRatings now compute baseElo, derive an
  odds Elo via convertFuturesToElo, and blend: 0 = base only, 1 = futures
  override, between = weighted blend (method "blend").
- Drops the odds-inclusive sourceEloPriority and the prefersFuturesOdds helper.

Simulators consume the single resolved Elo:
- UCL, World Cup, NCAA Football, MLB drop their separate normalized-odds signal,
  convertFuturesToElo calls, and per-match probability blend; they read the
  resolved sourceElo (preserving each sim's hardcoded fallback Elo table). The
  optional SimulationContext oddsWeight plumbing (types.ts/runner.ts) is removed.
- UCL is routed through the central blend (requiredInputs sourceElo, derivable
  from sourceOdds) so any entered Elo and futures blend uniformly.
- College hockey already blends odds into Elo internally (and uses NPI rank the
  central resolver can't), so its central oddsWeight is set to 0 to avoid
  double-counting; the simulator is unchanged.

Manifest: per-profile oddsWeight defaults (World Cup/UCL/MLB 0.3, NCAA FB 0.4,
college hockey 0; global default 0.3).

UI: the Input Policy card exposes one "Futures vs. Elo — Odds Blend Weight"
control; the /admin/simulators inventory badge shows the effective blend
("Elo only" / "NN% blend" / "overrides Elo") with the odds participant count.

Tests: input-policy blend math (0/0.5/1) for Elo and ratings, baseEloPriority
and oddsWeight parsing/clamping, manifest per-profile weights; obsolete
source-priority and oddsWeight-context tests removed/replaced.

Note: this intentionally shifts the calibrated EV outputs of the four sims that
previously blended at the probability level (accepted in design discussion).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QhNeB7gN6VKene7sdbBVQT
This commit is contained in:
Claude 2026-06-26 01:39:12 +00:00
parent dc4efe87cf
commit 24de966b3b
No known key found for this signature in database
14 changed files with 328 additions and 559 deletions

View file

@ -9,7 +9,6 @@ import {
} from "~/services/simulations/manifest"; } from "~/services/simulations/manifest";
import { import {
getSimulatorInputPolicy, getSimulatorInputPolicy,
prefersFuturesOdds,
ratingRequirementLabel, ratingRequirementLabel,
resolveRatings, resolveRatings,
resolveSourceElos, resolveSourceElos,
@ -88,8 +87,8 @@ export interface SportsSeasonSimulatorSummary {
supportsFuturesOdds: boolean; supportsFuturesOdds: boolean;
/** Number of participants that currently have stored futures odds. */ /** Number of participants that currently have stored futures odds. */
oddsParticipantCount: number; oddsParticipantCount: number;
/** Whether the season's input policy makes futures odds override Elo/projections. */ /** Weight (01) the season's policy gives futures odds when blending into Elo. */
prefersFuturesOdds: boolean; oddsWeight: number;
} }
function parseDecimal(value: string | null | undefined): number | null { function parseDecimal(value: string | null | undefined): number | null {
@ -133,8 +132,8 @@ function isFallbackMethod(method: string): boolean {
return method === "fallbackElo" || method === "fallbackRating" || method === "averageKnown" || method === "worstKnownMinus"; return method === "fallbackElo" || method === "fallbackRating" || method === "averageKnown" || method === "worstKnownMinus";
} }
const GENERATED_RATING_METHODS = ["sourceOdds", "fallbackRating", "averageKnown", "worstKnownMinus"] as const; const GENERATED_RATING_METHODS = ["sourceOdds", "blend", "fallbackRating", "averageKnown", "worstKnownMinus"] as const;
const GENERATED_SOURCE_ELO_METHODS = ["projectedWins", "projectedTablePoints", "sourceOdds", "fallbackElo", "averageKnown", "worstKnownMinus"] as const; const GENERATED_SOURCE_ELO_METHODS = ["projectedWins", "projectedTablePoints", "sourceOdds", "blend", "fallbackElo", "averageKnown", "worstKnownMinus"] as const;
function isGeneratedRatingMethod(method: unknown): boolean { function isGeneratedRatingMethod(method: unknown): boolean {
return typeof method === "string" && GENERATED_RATING_METHODS.includes(method as (typeof GENERATED_RATING_METHODS)[number]); return typeof method === "string" && GENERATED_RATING_METHODS.includes(method as (typeof GENERATED_RATING_METHODS)[number]);
@ -697,7 +696,7 @@ export async function listSportsSeasonSimulatorSummaries(): Promise<SportsSeason
readiness, readiness,
supportsFuturesOdds: profile.setupSections.includes("futuresOdds"), supportsFuturesOdds: profile.setupSections.includes("futuresOdds"),
oddsParticipantCount: oddsCountBySeason.get(season.id) ?? 0, oddsParticipantCount: oddsCountBySeason.get(season.id) ?? 0,
prefersFuturesOdds: prefersFuturesOdds(policy.sourceEloPriority), oddsWeight: policy.oddsWeight,
}; };
}) })
); );

View file

@ -77,6 +77,12 @@ export async function action({ request }: Route.ActionArgs): Promise<ActionData>
}; };
} }
function futuresBlendLabel(oddsWeight: number): string {
if (oddsWeight >= 1) return "overrides Elo";
if (oddsWeight <= 0) return "Elo only";
return `${Math.round(oddsWeight * 100)}% blend`;
}
function statusBadge(status: string) { function statusBadge(status: string) {
if (status === "ready") return <Badge className="gap-1"><CheckCircle2 className="h-3 w-3" /> Ready</Badge>; if (status === "ready") return <Badge className="gap-1"><CheckCircle2 className="h-3 w-3" /> Ready</Badge>;
return <Badge variant="secondary" className="gap-1"><XCircle className="h-3 w-3" /> Needs setup</Badge>; return <Badge variant="secondary" className="gap-1"><XCircle className="h-3 w-3" /> Needs setup</Badge>;
@ -247,9 +253,9 @@ export default function AdminSimulators({ loaderData }: Route.ComponentProps) {
<TableCell className="text-sm text-muted-foreground"> <TableCell className="text-sm text-muted-foreground">
<div>{sim.participantInputCount}/{sim.participantCount}</div> <div>{sim.participantInputCount}/{sim.participantCount}</div>
{sim.supportsFuturesOdds && ( {sim.supportsFuturesOdds && (
<Badge variant={sim.prefersFuturesOdds ? "default" : "outline"} className="mt-1 text-xs font-normal"> <Badge variant={sim.oddsWeight > 0 ? "default" : "outline"} className="mt-1 text-xs font-normal">
{sim.oddsParticipantCount > 0 {sim.oddsParticipantCount > 0
? `Futures: ${sim.oddsParticipantCount}${sim.prefersFuturesOdds ? " (overrides Elo)" : " (fallback)"}` ? `Futures: ${sim.oddsParticipantCount} · ${futuresBlendLabel(sim.oddsWeight)}`
: "No futures odds"} : "No futures odds"}
</Badge> </Badge>
)} )}

View file

@ -27,9 +27,6 @@ import {
import { normalizeName } from "~/lib/fuzzy-match"; import { normalizeName } from "~/lib/fuzzy-match";
import { import {
getSimulatorInputPolicy, getSimulatorInputPolicy,
prefersFuturesOdds,
ELO_FIRST_SOURCE_PRIORITY,
FUTURES_FIRST_SOURCE_PRIORITY,
type MissingEloStrategy, type MissingEloStrategy,
type MissingRatingStrategy, type MissingRatingStrategy,
} from "~/services/simulations/input-policy"; } from "~/services/simulations/input-policy";
@ -212,10 +209,7 @@ export async function action({ request, params }: Route.ActionArgs): Promise<Act
inputPolicy: { inputPolicy: {
missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")), missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")),
missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")), missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")),
sourceEloPriority: // Stored as-is; getSimulatorInputPolicy clamps to [0,1] on read.
formData.get("sourceStrategy") === "futuresFirst"
? FUTURES_FIRST_SOURCE_PRIORITY
: ELO_FIRST_SOURCE_PRIORITY,
oddsWeight: parsePolicyNumber(formData, "oddsWeight", currentPolicy.oddsWeight), oddsWeight: parsePolicyNumber(formData, "oddsWeight", currentPolicy.oddsWeight),
fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo), fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo),
fallbackEloDelta: parsePolicyNumber(formData, "fallbackEloDelta", currentPolicy.fallbackEloDelta), fallbackEloDelta: parsePolicyNumber(formData, "fallbackEloDelta", currentPolicy.fallbackEloDelta),
@ -373,27 +367,14 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
<CardContent> <CardContent>
<Form method="post" className="grid gap-4 md:grid-cols-5"> <Form method="post" className="grid gap-4 md:grid-cols-5">
<input type="hidden" name="intent" value="save-input-policy" /> <input type="hidden" name="intent" value="save-input-policy" />
<div className="space-y-2 md:col-span-3"> <div className="space-y-2 md:col-span-5">
<Label htmlFor="sourceStrategy">Futures vs. Elo</Label> <Label htmlFor="oddsWeight">Futures vs. Elo Odds Blend Weight (01)</Label>
<select
id="sourceStrategy"
name="sourceStrategy"
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
defaultValue={prefersFuturesOdds(inputPolicy.sourceEloPriority) ? "futuresFirst" : "eloFirst"}
>
<option value="eloFirst">Elo / projections first (futures odds are fallback)</option>
<option value="futuresFirst">Futures odds first (override stored Elo / projections)</option>
</select>
<p className="text-xs text-muted-foreground">
Controls which signal wins when a participant has both. Futures-first makes freshly
entered odds drive the run without discarding stored Elo.
</p>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="oddsWeight">Odds Blend Weight (01)</Label>
<Input id="oddsWeight" name="oddsWeight" type="number" step="0.05" min="0" max="1" defaultValue={inputPolicy.oddsWeight} /> <Input id="oddsWeight" name="oddsWeight" type="number" step="0.05" min="0" max="1" defaultValue={inputPolicy.oddsWeight} />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
For simulators that blend Elo and odds per match. 1.0 = pure odds; 0 = pure Elo. 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> </p>
</div> </div>
{config.profile.requiredInputs.includes("sourceElo") && ( {config.profile.requiredInputs.includes("sourceElo") && (

View file

@ -1,11 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
getSimulatorInputPolicy, getSimulatorInputPolicy,
prefersFuturesOdds,
resolveRatings, resolveRatings,
resolveSourceElos, resolveSourceElos,
ELO_FIRST_SOURCE_PRIORITY, DEFAULT_BASE_ELO_PRIORITY,
FUTURES_FIRST_SOURCE_PRIORITY,
} from "../input-policy"; } from "../input-policy";
import type { SimulatorManifestProfile } from "../manifest"; import type { SimulatorManifestProfile } from "../manifest";
@ -190,86 +188,95 @@ describe("simulator input policy", () => {
expect(resolved.get("missing-tail")).toMatchObject({ rating: -10, method: "worstKnownMinus" }); expect(resolved.get("missing-tail")).toMatchObject({ rating: -10, method: "worstKnownMinus" });
}); });
it("lets a futures-first priority override a stored direct Elo", () => { it("blends base Elo and futures odds into a single Elo by oddsWeight", () => {
const inputs = [ const inputs = [
{ participantId: "favorite", sourceElo: 1800, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null }, { participantId: "favorite", sourceElo: 1800, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
{ participantId: "longshot", sourceElo: 1200, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null }, { participantId: "longshot", sourceElo: 1200, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
]; ];
// Default (Elo-first): the stored direct Elo wins. // oddsWeight 0: the stored base Elo wins outright.
const eloFirst = resolveSourceElos(inputs, profile, {}); const eloOnly = resolveSourceElos(inputs, profile, { inputPolicy: { oddsWeight: 0 } });
expect(eloFirst.get("favorite")).toMatchObject({ sourceElo: 1800, method: "direct" }); expect(eloOnly.get("favorite")).toMatchObject({ sourceElo: 1800, method: "direct" });
// Futures-first: odds win even though a direct Elo is present. // oddsWeight 1: futures fully override the base Elo.
const futuresFirst = resolveSourceElos(inputs, profile, { const oddsOnly = resolveSourceElos(inputs, profile, { inputPolicy: { oddsWeight: 1 } });
inputPolicy: { sourceEloPriority: FUTURES_FIRST_SOURCE_PRIORITY }, expect(oddsOnly.get("favorite")?.method).toBe("sourceOdds");
}); expect(oddsOnly.get("longshot")?.method).toBe("sourceOdds");
expect(futuresFirst.get("favorite")?.method).toBe("sourceOdds");
expect(futuresFirst.get("longshot")?.method).toBe("sourceOdds"); // Between: a blend that sits strictly between the base and the odds-derived Elo.
const oddsElo = oddsOnly.get("favorite")!.sourceElo;
const blended = resolveSourceElos(inputs, profile, { inputPolicy: { oddsWeight: 0.5 } });
expect(blended.get("favorite")?.method).toBe("blend");
const blendedElo = blended.get("favorite")!.sourceElo;
expect(blendedElo).toBeGreaterThan(Math.min(1800, oddsElo));
expect(blendedElo).toBeLessThan(Math.max(1800, oddsElo));
expect(blendedElo).toBeCloseTo(Math.round(0.5 * 1800 + 0.5 * oddsElo), 0);
}); });
it("lets a futures-first priority override stored projected wins", () => { it("blends projected wins with futures odds when both are present", () => {
const inputs = [ const inputs = [
{ participantId: "team-1", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: 60, projectedTablePoints: null }, { participantId: "team-1", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: 60, projectedTablePoints: null },
{ participantId: "team-2", sourceElo: null, rating: null, sourceOdds: 20000, projectedWins: 20, projectedTablePoints: null }, { participantId: "team-2", sourceElo: null, rating: null, sourceOdds: 20000, projectedWins: 20, projectedTablePoints: null },
]; ];
// Default (Elo-first): projectedWins outranks odds. // oddsWeight 0: projectedWins is the base.
expect(resolveSourceElos(inputs, profile, { seasonGames: 82 }).get("team-1")?.method).toBe("projectedWins"); expect(resolveSourceElos(inputs, profile, { seasonGames: 82, inputPolicy: { oddsWeight: 0 } }).get("team-1")?.method)
.toBe("projectedWins");
// Futures-first: odds win over projections. // oddsWeight 1: futures override the projection.
const futuresFirst = resolveSourceElos(inputs, profile, { expect(resolveSourceElos(inputs, profile, { seasonGames: 82, inputPolicy: { oddsWeight: 1 } }).get("team-1")?.method)
seasonGames: 82, .toBe("sourceOdds");
inputPolicy: { sourceEloPriority: FUTURES_FIRST_SOURCE_PRIORITY },
}); // Between: blended.
expect(futuresFirst.get("team-1")?.method).toBe("sourceOdds"); expect(resolveSourceElos(inputs, profile, { seasonGames: 82, inputPolicy: { oddsWeight: 0.4 } }).get("team-1")?.method)
.toBe("blend");
}); });
it("lets a futures-first priority override a stored direct rating", () => { it("blends a direct rating with futures odds by oddsWeight", () => {
const ratingProfile = { derivableInputs: { rating: ["sourceOdds"] } } as Pick<SimulatorManifestProfile, "derivableInputs">; const ratingProfile = { derivableInputs: { rating: ["sourceOdds"] } } as Pick<SimulatorManifestProfile, "derivableInputs">;
const inputs = [ const inputs = [
{ participantId: "favorite", sourceElo: null, rating: 30, sourceOdds: 300, projectedWins: null, projectedTablePoints: null }, { participantId: "favorite", sourceElo: null, rating: 30, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
{ participantId: "longshot", sourceElo: null, rating: -5, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null }, { participantId: "longshot", sourceElo: null, rating: -5, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
]; ];
// Default: direct rating wins. // oddsWeight 0: direct rating wins.
expect(resolveRatings(inputs, ratingProfile, { inputPolicy: { ratingMin: -10, ratingMax: 35 } }).get("favorite")) expect(resolveRatings(inputs, ratingProfile, { inputPolicy: { ratingMin: -10, ratingMax: 35, oddsWeight: 0 } }).get("favorite"))
.toMatchObject({ rating: 30, method: "direct" }); .toMatchObject({ rating: 30, method: "direct" });
// Futures-first: odds win. // oddsWeight 1: odds override the rating.
expect( expect(
resolveRatings(inputs, ratingProfile, { resolveRatings(inputs, ratingProfile, {
inputPolicy: { ratingMin: -10, ratingMax: 35, sourceEloPriority: FUTURES_FIRST_SOURCE_PRIORITY }, inputPolicy: { ratingMin: -10, ratingMax: 35, oddsWeight: 1 },
}).get("favorite")?.method }).get("favorite")?.method
).toBe("sourceOdds"); ).toBe("sourceOdds");
// Between: blended.
expect(
resolveRatings(inputs, ratingProfile, {
inputPolicy: { ratingMin: -10, ratingMax: 35, oddsWeight: 0.5 },
}).get("favorite")?.method
).toBe("blend");
}); });
it("parses and clamps the input policy source priority and odds weight", () => { it("parses and clamps the base Elo priority and odds weight", () => {
const defaults = getSimulatorInputPolicy({}); const defaults = getSimulatorInputPolicy({});
expect(defaults.sourceEloPriority).toEqual(ELO_FIRST_SOURCE_PRIORITY); expect(defaults.baseEloPriority).toEqual(DEFAULT_BASE_ELO_PRIORITY);
expect(defaults.oddsWeight).toBe(0.3); expect(defaults.oddsWeight).toBe(0.3);
// oddsWeight can be sourced from the top-level config or the input policy, // oddsWeight can be sourced from the top-level config or the input policy,
// and is clamped to [0, 1]; junk priority entries are dropped. // and is clamped to [0, 1]; junk base-priority entries are dropped.
expect(getSimulatorInputPolicy({ oddsWeight: 0.6 }).oddsWeight).toBe(0.6); expect(getSimulatorInputPolicy({ oddsWeight: 0.6 }).oddsWeight).toBe(0.6);
expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: 5 } }).oddsWeight).toBe(1); expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: 5 } }).oddsWeight).toBe(1);
expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: -2 } }).oddsWeight).toBe(0); expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: -2 } }).oddsWeight).toBe(0);
expect( expect(
getSimulatorInputPolicy({ inputPolicy: { sourceEloPriority: ["sourceOdds", "bogus", "sourceElo"] } }).sourceEloPriority getSimulatorInputPolicy({ inputPolicy: { baseEloPriority: ["projectedWins", "sourceOdds", "sourceElo"] } }).baseEloPriority
).toEqual(["sourceOdds", "sourceElo"]); ).toEqual(["projectedWins", "sourceElo"]);
// An empty/all-invalid priority falls back to the default. // An empty/all-invalid priority falls back to the default.
expect(getSimulatorInputPolicy({ inputPolicy: { sourceEloPriority: ["nope"] } }).sourceEloPriority).toEqual( expect(getSimulatorInputPolicy({ inputPolicy: { baseEloPriority: ["nope"] } }).baseEloPriority).toEqual(
ELO_FIRST_SOURCE_PRIORITY DEFAULT_BASE_ELO_PRIORITY
); );
}); });
it("detects whether a priority prefers futures odds", () => {
expect(prefersFuturesOdds(ELO_FIRST_SOURCE_PRIORITY)).toBe(false);
expect(prefersFuturesOdds(FUTURES_FIRST_SOURCE_PRIORITY)).toBe(true);
expect(prefersFuturesOdds(["projectedWins", "sourceElo"])).toBe(false);
expect(prefersFuturesOdds(["sourceOdds"])).toBe(true);
});
it("uses NCAAW-style rating scale when deriving and falling back", () => { it("uses NCAAW-style rating scale when deriving and falling back", () => {
const resolved = resolveRatings( const resolved = resolveRatings(
[ [

View file

@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { SIMULATOR_MANIFEST } from "../manifest"; import { SIMULATOR_MANIFEST } from "../manifest";
import { SIMULATOR_TYPES } from "../registry"; import { SIMULATOR_TYPES } from "../registry";
import { getSimulatorInputPolicy, prefersFuturesOdds } from "../input-policy"; import { getSimulatorInputPolicy } from "../input-policy";
import { assertRegistrySchemaDriftFree } from "~/models/simulator"; import { assertRegistrySchemaDriftFree } from "~/models/simulator";
describe("simulator manifest", () => { describe("simulator manifest", () => {
@ -41,19 +41,15 @@ describe("simulator manifest", () => {
} }
}); });
it("defaults futures-centric simulators to a futures-override source priority", () => { it("exposes the per-profile futures blend weight through the input policy", () => {
const futuresCentric = ["ncaam_bracket", "ncaaw_bracket", "world_cup", "ncaa_football_bracket", "college_hockey_bracket"] as const;
for (const simulatorType of futuresCentric) {
const policy = getSimulatorInputPolicy(SIMULATOR_MANIFEST[simulatorType].defaultConfig);
expect(prefersFuturesOdds(policy.sourceEloPriority)).toBe(true);
}
});
it("exposes the blended Elo/odds weight through the input policy", () => {
expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.ncaa_football_bracket.defaultConfig).oddsWeight).toBe(0.4); expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.ncaa_football_bracket.defaultConfig).oddsWeight).toBe(0.4);
expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.world_cup.defaultConfig).oddsWeight).toBe(0.3); expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.world_cup.defaultConfig).oddsWeight).toBe(0.3);
// A non-futures team sport keeps the Elo-first default. expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.ucl_bracket.defaultConfig).oddsWeight).toBe(0.3);
expect(prefersFuturesOdds(getSimulatorInputPolicy(SIMULATOR_MANIFEST.nba_bracket.defaultConfig).sourceEloPriority)).toBe(false); // College hockey blends odds internally, so the central blend is disabled.
expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.college_hockey_bracket.defaultConfig).oddsWeight).toBe(0);
// Every profile resolves to the default base-Elo priority unless overridden.
expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.nba_bracket.defaultConfig).baseEloPriority)
.toEqual(["sourceElo", "projectedWins", "projectedTablePoints"]);
}); });
it("keeps EPL projection and match parity as separate config knobs", () => { it("keeps EPL projection and match parity as separate config knobs", () => {

View file

@ -143,42 +143,14 @@ describe("NCAAFootballSimulator", () => {
} }
}); });
it("uses futures odds when sourceOdds present", async () => { it("runs from the resolved Elo (futures already blended upstream)", async () => {
setupMockDb(TEAM_IDS, { includeOdds: true }); // sourceElo is the single resolved Elo; the sim no longer reads sourceOdds.
setupMockDb(TEAM_IDS);
const results = await new NCAAFootballSimulator().simulate("season-1"); const results = await new NCAAFootballSimulator().simulate("season-1");
expect(results).toHaveLength(12); expect(results).toHaveLength(12);
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0); const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(total).toBeCloseTo(1.0, 1); expect(total).toBeCloseTo(1.0, 1);
}); });
it("honors the oddsWeight context when odds and Elo disagree", async () => {
// team-12 has the worst Elo but by far the best futures odds.
function setupConflicting() {
const participants = makeParticipants(TEAM_IDS);
const evRows = TEAM_IDS.map((participantId, i) => ({
participantId,
sourceElo: 1700 - i * 50,
sourceOdds: i === 11 ? -100000 : 50000,
}));
mockDb.select.mockImplementation(() => {
const callIndex = selectCallCount++;
const data = callIndex === 0 ? participants : evRows;
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) };
});
}
setupConflicting();
const pureOdds = await new NCAAFootballSimulator().simulate("season-1", { config: {}, oddsWeight: 1 });
selectCallCount = 0;
setupConflicting();
const pureElo = await new NCAAFootballSimulator().simulate("season-1", { config: {}, oddsWeight: 0 });
const champByOdds = pureOdds.find((r) => r.participantId === "team-12")?.probabilities.probFirst ?? 0;
const champByElo = pureElo.find((r) => r.participantId === "team-12")?.probabilities.probFirst ?? 0;
// With the odds signal fully weighted, the odds-favored team wins far more
// often than when only its (worst) Elo drives the result.
expect(champByOdds).toBeGreaterThan(champByElo);
});
}); });
// ── Pre-bracket mode (>12 teams — probabilistic selection) ─────────────── // ── Pre-bracket mode (>12 teams — probabilistic selection) ───────────────

View file

@ -6,54 +6,36 @@ export type MissingEloStrategy = "block" | "fallbackElo" | "averageKnown" | "wor
export type MissingRatingStrategy = "block" | "fallbackRating" | "averageKnown" | "worstKnownMinus"; export type MissingRatingStrategy = "block" | "fallbackRating" | "averageKnown" | "worstKnownMinus";
/** /**
* Sources a participant's strength input can be derived from, in the order the * "Base" strength sources the ways to produce a participant's underlying Elo
* resolver should try them. `"sourceElo"` represents the simulator's primary * before any futures-odds blend. These are *substitutes* (a season rarely has
* direct input a manually entered Elo for Elo-based sims, or the direct * more than one), so they are tried in order and the first available one wins.
* `rating` for rating-based sims. Placing `"sourceOdds"` ahead of `"sourceElo"` * Futures odds are not in this list: they blend on top of the base Elo via
* is how an admin makes futures odds override stored Elo/ratings. * `oddsWeight`, rather than being one more interchangeable base.
*/ */
export type SourceEloKey = "sourceElo" | "projectedWins" | "projectedTablePoints" | "sourceOdds"; export type BaseEloKey = "sourceElo" | "projectedWins" | "projectedTablePoints";
export const SOURCE_ELO_KEYS: readonly SourceEloKey[] = [ export const BASE_ELO_KEYS: readonly BaseEloKey[] = [
"sourceElo",
"projectedWins",
"projectedTablePoints",
"sourceOdds",
];
/** Default priority: stored Elo/projections win, futures odds are the fallback. */
export const ELO_FIRST_SOURCE_PRIORITY: SourceEloKey[] = [
"sourceElo",
"projectedWins",
"projectedTablePoints",
"sourceOdds",
];
/** Futures-override priority: freshly entered odds win over stored Elo/projections. */
export const FUTURES_FIRST_SOURCE_PRIORITY: SourceEloKey[] = [
"sourceOdds",
"sourceElo", "sourceElo",
"projectedWins", "projectedWins",
"projectedTablePoints", "projectedTablePoints",
]; ];
/** Whether a priority list prefers futures odds over the direct Elo/rating input. */ /** Default order for choosing the base Elo: raw Elo, then projections. */
export function prefersFuturesOdds(priority: SourceEloKey[]): boolean { export const DEFAULT_BASE_ELO_PRIORITY: BaseEloKey[] = [
const oddsIndex = priority.indexOf("sourceOdds"); "sourceElo",
const eloIndex = priority.indexOf("sourceElo"); "projectedWins",
if (oddsIndex === -1) return false; "projectedTablePoints",
return eloIndex === -1 || oddsIndex < eloIndex; ];
}
export interface SimulatorInputPolicy { export interface SimulatorInputPolicy {
missingEloStrategy: MissingEloStrategy; missingEloStrategy: MissingEloStrategy;
missingRatingStrategy: MissingRatingStrategy; missingRatingStrategy: MissingRatingStrategy;
/** Ordered preference for deriving a participant's strength input. */ /** Order for choosing the base strength Elo from its substitutable sources. */
sourceEloPriority: SourceEloKey[]; baseEloPriority: BaseEloKey[];
/** /**
* Weight (01) given to the futures-odds signal in simulators that blend Elo * Weight (01) of the futures-odds-derived Elo when blending with the base Elo
* and odds (UCL, World Cup, NCAA Football, MLB, college hockey). The Elo * into the single Elo that feeds every simulator. `0` = base only (Elo /
* signal gets `1 - oddsWeight`. Set to 1.0 to make futures fully override Elo. * projections), `1` = futures fully override the base, in between = blend.
*/ */
oddsWeight: number; oddsWeight: number;
fallbackElo: number; fallbackElo: number;
@ -78,13 +60,13 @@ export interface ParticipantInputForPolicy {
export interface ResolvedSourceElo { export interface ResolvedSourceElo {
participantId: string; participantId: string;
sourceElo: number; sourceElo: number;
method: "direct" | "projectedWins" | "projectedTablePoints" | "sourceOdds" | MissingEloStrategy; method: "direct" | "projectedWins" | "projectedTablePoints" | "sourceOdds" | "blend" | MissingEloStrategy;
} }
export interface ResolvedRating { export interface ResolvedRating {
participantId: string; participantId: string;
rating: number; rating: number;
method: "direct" | "sourceOdds" | MissingRatingStrategy; method: "direct" | "sourceOdds" | "blend" | MissingRatingStrategy;
} }
const DEFAULT_ODDS_WEIGHT = 0.3; const DEFAULT_ODDS_WEIGHT = 0.3;
@ -92,7 +74,7 @@ const DEFAULT_ODDS_WEIGHT = 0.3;
const DEFAULT_POLICY: SimulatorInputPolicy = { const DEFAULT_POLICY: SimulatorInputPolicy = {
missingEloStrategy: "block", missingEloStrategy: "block",
missingRatingStrategy: "block", missingRatingStrategy: "block",
sourceEloPriority: ELO_FIRST_SOURCE_PRIORITY, baseEloPriority: DEFAULT_BASE_ELO_PRIORITY,
oddsWeight: DEFAULT_ODDS_WEIGHT, oddsWeight: DEFAULT_ODDS_WEIGHT,
fallbackElo: 1400, fallbackElo: 1400,
fallbackEloDelta: 25, fallbackEloDelta: 25,
@ -132,14 +114,14 @@ function projectionToElo(
return clamp(Math.round(elo), policy.eloMin, policy.eloMax); return clamp(Math.round(elo), policy.eloMin, policy.eloMax);
} }
function parseSourceEloPriority(value: unknown, fallback: SourceEloKey[]): SourceEloKey[] { function parseBaseEloPriority(value: unknown, fallback: BaseEloKey[]): BaseEloKey[] {
if (!Array.isArray(value)) return fallback; if (!Array.isArray(value)) return fallback;
const seen = new Set<SourceEloKey>(); const seen = new Set<BaseEloKey>();
const parsed: SourceEloKey[] = []; const parsed: BaseEloKey[] = [];
for (const entry of value) { for (const entry of value) {
if (typeof entry === "string" && (SOURCE_ELO_KEYS as readonly string[]).includes(entry) && !seen.has(entry as SourceEloKey)) { if (typeof entry === "string" && (BASE_ELO_KEYS as readonly string[]).includes(entry) && !seen.has(entry as BaseEloKey)) {
seen.add(entry as SourceEloKey); seen.add(entry as BaseEloKey);
parsed.push(entry as SourceEloKey); parsed.push(entry as BaseEloKey);
} }
} }
return parsed.length > 0 ? parsed : fallback; return parsed.length > 0 ? parsed : fallback;
@ -159,7 +141,7 @@ export function getSimulatorInputPolicy(config: Record<string, unknown>): Simula
ratingStrategy === "fallbackRating" || ratingStrategy === "averageKnown" || ratingStrategy === "worstKnownMinus" ratingStrategy === "fallbackRating" || ratingStrategy === "averageKnown" || ratingStrategy === "worstKnownMinus"
? ratingStrategy ? ratingStrategy
: "block", : "block",
sourceEloPriority: parseSourceEloPriority(rawPolicy.sourceEloPriority, DEFAULT_POLICY.sourceEloPriority), baseEloPriority: parseBaseEloPriority(rawPolicy.baseEloPriority, DEFAULT_POLICY.baseEloPriority),
// Allow oddsWeight to live either in inputPolicy (the unified knob) or at the // Allow oddsWeight to live either in inputPolicy (the unified knob) or at the
// top level of config (legacy per-profile defaultConfig), preferring the policy. // top level of config (legacy per-profile defaultConfig), preferring the policy.
oddsWeight: clamp( oddsWeight: clamp(
@ -201,33 +183,26 @@ export function resolveSourceElos(
const alternatives = new Set(profile.derivableInputs?.sourceElo ?? []); const alternatives = new Set(profile.derivableInputs?.sourceElo ?? []);
const parityFactor = optionalNumber(config.parityFactor, 400); const parityFactor = optionalNumber(config.parityFactor, 400);
// Resolve each participant from the first source in the configured priority // 1. Base Elo: the first available *substitutable* source per baseEloPriority.
// that (a) is permitted for this simulator and (b) has a value. The direct // `sourceElo` (raw) is always permitted; projections must be declared in
// `sourceElo` input is always permitted; the rest must be declared in the // the profile's derivableInputs.sourceElo.
// profile's `derivableInputs.sourceElo`. Reordering the priority so that const baseElo = new Map<string, { elo: number; method: ResolvedSourceElo["method"] }>();
// `sourceOdds` precedes `sourceElo` makes freshly entered futures odds for (const source of policy.baseEloPriority) {
// override a stale Elo (and projections) without destroying the stored value.
for (const source of policy.sourceEloPriority) {
if (source !== "sourceElo" && !alternatives.has(source)) continue; if (source !== "sourceElo" && !alternatives.has(source)) continue;
if (source === "sourceElo") { if (source === "sourceElo") {
for (const input of inputs) { for (const input of inputs) {
if (resolved.has(input.participantId)) continue; if (baseElo.has(input.participantId)) continue;
if (input.sourceElo !== null && input.sourceElo !== undefined) { if (input.sourceElo !== null && input.sourceElo !== undefined) {
resolved.set(input.participantId, { baseElo.set(input.participantId, { elo: input.sourceElo, method: "direct" });
participantId: input.participantId,
sourceElo: input.sourceElo,
method: "direct",
});
} }
} }
} else if (source === "projectedWins") { } else if (source === "projectedWins") {
const seasonGames = optionalNumber(config.seasonGames, Math.max(...inputs.map((input) => input.projectedWins ?? 0), 1)); const seasonGames = optionalNumber(config.seasonGames, Math.max(...inputs.map((input) => input.projectedWins ?? 0), 1));
for (const input of inputs) { for (const input of inputs) {
if (resolved.has(input.participantId) || input.projectedWins === null || input.projectedWins === undefined) continue; if (baseElo.has(input.participantId) || input.projectedWins === null || input.projectedWins === undefined) continue;
resolved.set(input.participantId, { baseElo.set(input.participantId, {
participantId: input.participantId, elo: projectionToElo(input.projectedWins, seasonGames, parityFactor, policy),
sourceElo: projectionToElo(input.projectedWins, seasonGames, parityFactor, policy),
method: "projectedWins", method: "projectedWins",
}); });
} }
@ -235,38 +210,68 @@ export function resolveSourceElos(
const seasonGames = optionalNumber(config.seasonGames, 38); const seasonGames = optionalNumber(config.seasonGames, 38);
const maxPoints = optionalNumber(config.maxTablePoints, seasonGames * 3); const maxPoints = optionalNumber(config.maxTablePoints, seasonGames * 3);
for (const input of inputs) { for (const input of inputs) {
if (resolved.has(input.participantId) || input.projectedTablePoints === null || input.projectedTablePoints === undefined) continue; if (baseElo.has(input.participantId) || input.projectedTablePoints === null || input.projectedTablePoints === undefined) continue;
resolved.set(input.participantId, { baseElo.set(input.participantId, {
participantId: input.participantId, elo: projectionToElo(input.projectedTablePoints, maxPoints, parityFactor, policy),
sourceElo: projectionToElo(input.projectedTablePoints, maxPoints, parityFactor, policy),
method: "projectedTablePoints", method: "projectedTablePoints",
}); });
} }
} else if (source === "sourceOdds") { }
// Odds-derived Elo maps onto eloMin/eloMax via convertFuturesToElo; when }
// mixed with direct Elo for other participants the two scales are not
// jointly calibrated. That mixed case is intentionally left as-is. // 2. Odds-derived Elo (field-relative): only when futures odds are a permitted
// source and at least two participants have odds (a spread is required).
const oddsElo = new Map<string, number>();
if (alternatives.has("sourceOdds")) {
const oddsInputs = inputs const oddsInputs = inputs
.filter((input) => !resolved.has(input.participantId) && input.sourceOdds !== null && input.sourceOdds !== undefined) .filter((input) => input.sourceOdds !== null && input.sourceOdds !== undefined)
.map((input) => ({ participantId: input.participantId, odds: input.sourceOdds as number })); .map((input) => ({ participantId: input.participantId, odds: input.sourceOdds as number }));
if (oddsInputs.length === 1) { if (oddsInputs.length === 1) {
logger.warn( logger.warn(
`resolveSourceElos: only 1 participant has sourceOdds (${oddsInputs[0].participantId}). ` + `resolveSourceElos: only 1 participant has sourceOdds (${oddsInputs[0].participantId}). ` +
`convertFuturesToElo requires at least 2 to derive a meaningful Elo spread — falling through to the next source.` `convertFuturesToElo requires at least 2 to derive a meaningful Elo spread — ignoring the lone odds value.`
); );
} }
if (oddsInputs.length >= 2) { if (oddsInputs.length >= 2) {
const converted = convertFuturesToElo(oddsInputs); for (const [participantId, elo] of convertFuturesToElo(oddsInputs)) {
for (const [participantId, elo] of converted) { oddsElo.set(participantId, elo);
resolved.set(participantId, { }
participantId, }
}
// 3. Blend base + odds into the single Elo that feeds the simulator.
const w = policy.oddsWeight;
for (const input of inputs) {
const base = baseElo.get(input.participantId);
const odds = oddsElo.get(input.participantId);
let elo: number;
let method: ResolvedSourceElo["method"];
if (base !== undefined && odds !== undefined) {
if (w <= 0) {
elo = base.elo;
method = base.method;
} else if (w >= 1) {
elo = odds;
method = "sourceOdds";
} else {
elo = (1 - w) * base.elo + w * odds;
method = "blend";
}
} else if (base !== undefined) {
elo = base.elo;
method = base.method;
} else if (odds !== undefined) {
elo = odds;
method = "sourceOdds";
} else {
continue;
}
resolved.set(input.participantId, {
participantId: input.participantId,
sourceElo: clamp(Math.round(elo), policy.eloMin, policy.eloMax), sourceElo: clamp(Math.round(elo), policy.eloMin, policy.eloMax),
method: "sourceOdds", method,
}); });
} }
}
}
}
const knownElos = [...resolved.values()].map((value) => value.sourceElo); const knownElos = [...resolved.values()].map((value) => value.sourceElo);
const averageKnown = knownElos.length > 0 const averageKnown = knownElos.length > 0
@ -305,30 +310,25 @@ export function resolveRatings(
const resolved = new Map<string, ResolvedRating>(); const resolved = new Map<string, ResolvedRating>();
const alternatives = new Set(profile.derivableInputs?.rating ?? []); const alternatives = new Set(profile.derivableInputs?.rating ?? []);
// Ratings share the configured priority list: the `"sourceElo"` slot stands // Ratings follow the same single-value blend as Elo: the base is the direct
// for this simulator's direct strength input (here, `rating`), so ordering // `rating`; futures odds (when permitted, scaled onto the rating range) blend
// `"sourceOdds"` ahead of `"sourceElo"` makes futures override the rating too. // on top by oddsWeight. `0` = rating only, `1` = futures override.
// Projection sources do not apply to rating-based simulators and are skipped. const baseRating = new Map<string, number>();
for (const source of policy.sourceEloPriority) {
if (source === "sourceElo") {
for (const input of inputs) { for (const input of inputs) {
if (resolved.has(input.participantId)) continue;
if (input.rating !== null && input.rating !== undefined) { if (input.rating !== null && input.rating !== undefined) {
resolved.set(input.participantId, { baseRating.set(input.participantId, input.rating);
participantId: input.participantId,
rating: input.rating,
method: "direct",
});
} }
} }
} else if (source === "sourceOdds" && alternatives.has("sourceOdds")) {
const oddsRating = new Map<string, number>();
if (alternatives.has("sourceOdds")) {
const oddsInputs = inputs const oddsInputs = inputs
.filter((input) => !resolved.has(input.participantId) && input.sourceOdds !== null && input.sourceOdds !== undefined) .filter((input) => input.sourceOdds !== null && input.sourceOdds !== undefined)
.map((input) => ({ participantId: input.participantId, odds: input.sourceOdds as number })); .map((input) => ({ participantId: input.participantId, odds: input.sourceOdds as number }));
if (oddsInputs.length === 1) { if (oddsInputs.length === 1) {
logger.warn( logger.warn(
`resolveRatings: only 1 participant has sourceOdds (${oddsInputs[0].participantId}). ` + `resolveRatings: only 1 participant has sourceOdds (${oddsInputs[0].participantId}). ` +
`convertFuturesToElo requires at least 2 to derive a meaningful rating — falling through to the next source.` `convertFuturesToElo requires at least 2 to derive a meaningful rating — ignoring the lone odds value.`
); );
} }
if (oddsInputs.length >= 2) { if (oddsInputs.length >= 2) {
@ -338,15 +338,43 @@ export function resolveRatings(
eloMax: policy.ratingMax, eloMax: policy.ratingMax,
}); });
for (const [participantId, rating] of converted) { for (const [participantId, rating] of converted) {
resolved.set(participantId, { oddsRating.set(participantId, rating);
participantId, }
}
}
const w = policy.oddsWeight;
for (const input of inputs) {
const base = baseRating.get(input.participantId);
const odds = oddsRating.get(input.participantId);
let rating: number;
let method: ResolvedRating["method"];
if (base !== undefined && odds !== undefined) {
if (w <= 0) {
rating = base;
method = "direct";
} else if (w >= 1) {
rating = odds;
method = "sourceOdds";
} else {
rating = (1 - w) * base + w * odds;
method = "blend";
}
} else if (base !== undefined) {
rating = base;
method = "direct";
} else if (odds !== undefined) {
rating = odds;
method = "sourceOdds";
} else {
continue;
}
resolved.set(input.participantId, {
participantId: input.participantId,
rating: clamp(rating, policy.ratingMin, policy.ratingMax), rating: clamp(rating, policy.ratingMin, policy.ratingMax),
method: "sourceOdds", method,
}); });
} }
}
}
}
const knownRatings = [...resolved.values()].map((value) => value.rating); const knownRatings = [...resolved.values()].map((value) => value.rating);
const averageKnown = knownRatings.length > 0 const averageKnown = knownRatings.length > 0

View file

@ -40,15 +40,6 @@ const BASE_CONFIG = {
iterations: 50_000, iterations: 50_000,
}; };
// Source-resolution priority that makes freshly entered futures odds win over a
// stored Elo/rating (and projections). Used as the default for simulators where
// futures odds are a deliberate, primary input (those exposing a Futures Odds
// setup section). Admins can still override this per season in the Input Policy.
// Kept as a local literal (not imported from input-policy) to avoid a circular
// module dependency that would be evaluated before input-policy's consts exist.
// Mirrors FUTURES_FIRST_SOURCE_PRIORITY in input-policy.ts.
const FUTURES_FIRST_PRIORITY = ["sourceOdds", "sourceElo", "projectedWins", "projectedTablePoints"] as const;
const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorType" | "displayName" | "description">> = { const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorType" | "displayName" | "description">> = {
f1_standings: { f1_standings: {
defaultConfig: { iterations: 10_000, raceNoise: 0.5, participantVolatility: 1.5 }, defaultConfig: { iterations: 10_000, raceNoise: 0.5, participantVolatility: 1.5 },
@ -75,20 +66,21 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "futuresOdds", "bracket"], setupSections: ["participants", "futuresOdds", "bracket"],
}, },
ucl_bracket: { ucl_bracket: {
defaultConfig: { ...BASE_CONFIG, eloWeight: 0.7, oddsWeight: 0.3 }, defaultConfig: { ...BASE_CONFIG, oddsWeight: 0.3 },
requiredInputs: ["sourceOdds"], requiredInputs: ["sourceElo"],
optionalInputs: ["sourceElo"], optionalInputs: ["sourceOdds"],
derivableInputs: { sourceElo: ["sourceOdds"] },
setupSections: ["participants", "futuresOdds", "bracket"], setupSections: ["participants", "futuresOdds", "bracket"],
}, },
ncaam_bracket: { ncaam_bracket: {
defaultConfig: { ...BASE_CONFIG, ratingScaleFactor: 7.5, inputPolicy: { ratingMin: -10, ratingMax: 35, fallbackRatingDelta: 5, sourceEloPriority: FUTURES_FIRST_PRIORITY } }, defaultConfig: { ...BASE_CONFIG, ratingScaleFactor: 7.5, inputPolicy: { ratingMin: -10, ratingMax: 35, fallbackRatingDelta: 5 } },
requiredInputs: ["rating"], requiredInputs: ["rating"],
optionalInputs: ["sourceOdds", "sourceElo", "seed", "region"], optionalInputs: ["sourceOdds", "sourceElo", "seed", "region"],
derivableInputs: { rating: ["sourceOdds"] }, derivableInputs: { rating: ["sourceOdds"] },
setupSections: ["participants", "ratings", "futuresOdds", "bracket"], setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
}, },
ncaaw_bracket: { ncaaw_bracket: {
defaultConfig: { ...BASE_CONFIG, inputPolicy: { ratingMin: 0.70, ratingMax: 0.97, missingRatingStrategy: "worstKnownMinus", fallbackRatingDelta: 0.01, sourceEloPriority: FUTURES_FIRST_PRIORITY } }, defaultConfig: { ...BASE_CONFIG, inputPolicy: { ratingMin: 0.70, ratingMax: 0.97, missingRatingStrategy: "worstKnownMinus", fallbackRatingDelta: 0.01 } },
requiredInputs: ["rating"], requiredInputs: ["rating"],
optionalInputs: ["sourceOdds", "seed", "region"], optionalInputs: ["sourceOdds", "seed", "region"],
derivableInputs: { rating: ["sourceOdds"] }, derivableInputs: { rating: ["sourceOdds"] },
@ -165,7 +157,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "eloRatings", "regularStandings"], setupSections: ["participants", "eloRatings", "regularStandings"],
}, },
world_cup: { world_cup: {
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, eloWeight: 0.7, oddsWeight: 0.3, inputPolicy: { sourceEloPriority: FUTURES_FIRST_PRIORITY, oddsWeight: 0.3 } }, defaultConfig: { ...BASE_CONFIG, iterations: 10_000, oddsWeight: 0.3 },
requiredInputs: ["sourceElo"], requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "worldRanking"], optionalInputs: ["sourceOdds", "worldRanking"],
derivableInputs: { sourceElo: ["sourceOdds"] }, derivableInputs: { sourceElo: ["sourceOdds"] },
@ -184,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, eloWeight: 0.6, oddsWeight: 0.4, inputPolicy: { sourceEloPriority: FUTURES_FIRST_PRIORITY, oddsWeight: 0.4 } }, defaultConfig: { ...BASE_CONFIG, bracketSize: 12, oddsWeight: 0.4 },
requiredInputs: ["sourceElo"], requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "worldRanking"], optionalInputs: ["sourceOdds", "worldRanking"],
derivableInputs: { sourceElo: ["sourceOdds"] }, derivableInputs: { sourceElo: ["sourceOdds"] },
@ -197,7 +189,10 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "futuresOdds"], setupSections: ["participants", "futuresOdds"],
}, },
college_hockey_bracket: { college_hockey_bracket: {
defaultConfig: { ...BASE_CONFIG, bracketSize: 16, parityFactor: 850, inputPolicy: { sourceEloPriority: FUTURES_FIRST_PRIORITY } }, // College hockey already blends odds into Elo internally (and also uses NPI
// rank, which the central resolver can't), so the central blend is disabled
// (oddsWeight 0) to avoid double-counting the odds.
defaultConfig: { ...BASE_CONFIG, bracketSize: 16, parityFactor: 850, oddsWeight: 0 },
requiredInputs: ["sourceElo"], requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "worldRanking"], optionalInputs: ["sourceOdds", "worldRanking"],
derivableInputs: { sourceElo: ["sourceOdds"] }, derivableInputs: { sourceElo: ["sourceOdds"] },

View file

@ -73,12 +73,8 @@
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 type { Simulator, SimulationResult, SimulationContext } 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";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters ────────────────────────────────────────────────────
@ -103,12 +99,6 @@ const RDIF_DIVISOR = 8000;
*/ */
const SEEDING_RDIF_SCALE = 1620; const SEEDING_RDIF_SCALE = 1620;
/**
* Blend weights for RDif vs. Vegas futures odds when sourceOdds are available.
*/
const RDIF_WEIGHT = 0.7;
const ODDS_WEIGHT = 1 - RDIF_WEIGHT;
// ─── Team data (2026 pre-season — FanGraphs Depth Charts) ──────────────────── // ─── Team data (2026 pre-season — FanGraphs Depth Charts) ────────────────────
// //
// rdif: Projected run differential from FanGraphs Depth Charts. // rdif: Projected run differential from FanGraphs Depth Charts.
@ -450,11 +440,7 @@ function simLeagueBracket(
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class MLBSimulator implements Simulator { export class MLBSimulator implements Simulator {
async simulate(sportsSeasonId: string, context?: SimulationContext): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
// RDif-vs-odds blend weight: configurable via the season input policy, with
// the historical 70/30 split as the default when invoked without context.
const oddsWeight = context?.oddsWeight ?? ODDS_WEIGHT;
const rdifWeight = 1 - oddsWeight;
const db = database(); const db = database();
// 1. Load all participants for this sports season. // 1. Load all participants for this sports season.
@ -525,33 +511,19 @@ export class MLBSimulator implements Simulator {
); );
} }
// ─── Futures odds blending ───────────────────────────────────────────────── // ─── Strength from the resolved Elo ────────────────────────────────────────
// sourceElo is the single Elo produced by the input policy — already a blend
// of any raw Elo / projections / futures odds — so the simulator just reads it
// and no longer blends odds itself.
const evRows = await db const evRows = await 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 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]);
});
}
// Build a map of sourceElo-derived RDif values (overrides hardcoded TEAMS_DATA.rdif) // Build a map of sourceElo-derived RDif values (overrides hardcoded TEAMS_DATA.rdif)
// for playoff series win probability. // for playoff series win probability.
const sourceEloRDifMap = new Map<string, number>(); const sourceEloRDifMap = new Map<string, number>();
@ -579,20 +551,11 @@ export class MLBSimulator implements Simulator {
rawWinRateMap.get(entry.id) ?? rawWinRateFromRDif(getEntryRDif(entry)); rawWinRateMap.get(entry.id) ?? rawWinRateFromRDif(getEntryRDif(entry));
/** /**
* Blended per-game win probability for team A over team B in a playoff series. * Per-game win probability for team A over team B in a playoff series, from
* When odds are available: 70% RDif log5 + 30% vig-removed futures head-to-head. * the (resolved-Elo-derived) run differential.
*/ */
const gameWinProb = (a: TeamEntry, b: TeamEntry): number => { const gameWinProb = (a: TeamEntry, b: TeamEntry): number =>
const rdifProb = rdifWinProbability(effectiveRDif(a), effectiveRDif(b)); rdifWinProbability(effectiveRDif(a), effectiveRDif(b));
if (!hasOdds) return rdifProb;
const o1 = normalizedOddsMap.get(a.id);
const o2 = normalizedOddsMap.get(b.id);
// Fall back to RDif if either team lacks odds — avoids inflating one side to 100%.
if (o1 === null || o1 === undefined || o2 === null || o2 === undefined) return rdifProb;
const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5;
return rdifWeight * rdifProb + oddsWeight * oddsProb;
};
// ─── Placement count maps ────────────────────────────────────────────────── // ─── Placement count maps ──────────────────────────────────────────────────

View file

@ -59,12 +59,8 @@
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 { import { eloWinProbability } from "~/services/probability-engine";
convertAmericanOddsToProbability, import type { Simulator, SimulationResult } from "./types";
convertFuturesToElo,
eloWinProbability,
} from "~/services/probability-engine";
import type { Simulator, SimulationResult, SimulationContext } from "./types";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters ────────────────────────────────────────────────────
@ -72,15 +68,7 @@ const NUM_SIMULATIONS = 50_000;
const BRACKET_SIZE = 12; const BRACKET_SIZE = 12;
/** /**
* Blend weights for per-game win probability when sourceOdds are present. * Softmax temperature for Elo-based selection weights (pre-bracket mode).
* Lower Elo weight than other sports (0.7) gives more influence to Vegas
* championship odds, which are highly informative in college football.
*/
const ELO_WEIGHT = 0.6;
const ODDS_WEIGHT = 1 - ELO_WEIGHT;
/**
* Softmax temperature for Elo-based selection weights (pre-bracket mode, no odds).
* At T=100, a 200-point Elo gap produces ~7× weight difference enough to strongly * At T=100, a 200-point Elo gap produces ~7× weight difference enough to strongly
* favour the top teams while still giving bubble teams meaningful selection probability. * favour the top teams while still giving bubble teams meaningful selection probability.
*/ */
@ -90,37 +78,21 @@ const SELECTION_TEMP = 100;
interface Team { interface Team {
participantId: string; participantId: string;
/** Single resolved Elo (already a blend of any raw Elo/FPI and futures odds). */
elo: number; elo: number;
/** Normalized futures win probability (01). Used for blending per-game win prob. */ /** Weight used for probabilistic CFP field selection (pre-bracket mode only). */
oddsProb: number;
/**
* Weight used for probabilistic CFP field selection (pre-bracket mode only).
* Derived from oddsProb when available; otherwise softmax on Elo.
*/
selectionWeight: number; selectionWeight: number;
} }
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
/** /** Win probability for team1 vs team2 from the single resolved Elo. */
* Blended win probability for team1 vs team2. function gameWinProb(team1: Team, team2: Team): number {
* Falls back to pure Elo when no futures data is present. return eloWinProbability(team1.elo, team2.elo);
*/
function blendedWinProb(team1: Team, team2: Team, oddsWeight: number = ODDS_WEIGHT): number {
const eloProbValue = eloWinProbability(team1.elo, team2.elo);
if (team1.oddsProb === 0 && team2.oddsProb === 0) {
return eloProbValue;
}
const oddsSum = team1.oddsProb + team2.oddsProb;
const oddsProbValue = oddsSum > 0 ? team1.oddsProb / oddsSum : 0.5;
return (1 - oddsWeight) * eloProbValue + oddsWeight * oddsProbValue;
} }
function simGame(team1: Team, team2: Team, oddsWeight: number = ODDS_WEIGHT): { winner: Team; loser: Team } { function simGame(team1: Team, team2: Team): { winner: Team; loser: Team } {
const p1Wins = Math.random() < blendedWinProb(team1, team2, oddsWeight); const p1Wins = Math.random() < gameWinProb(team1, team2);
return p1Wins return p1Wins
? { winner: team1, loser: team2 } ? { winner: team1, loser: team2 }
: { winner: team2, loser: team1 }; : { winner: team2, loser: team1 };
@ -169,18 +141,18 @@ 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>, oddsWeight: number = ODDS_WEIGHT): void { function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>): void {
// ── First Round (seeds 512) ─────────────────────────────────────────────── // ── First Round (seeds 512) ───────────────────────────────────────────────
const fr1 = simGame(teams[4], teams[11], oddsWeight); // 5 vs 12 const fr1 = simGame(teams[4], teams[11]); // 5 vs 12
const fr2 = simGame(teams[5], teams[10], oddsWeight); // 6 vs 11 const fr2 = simGame(teams[5], teams[10]); // 6 vs 11
const fr3 = simGame(teams[6], teams[9], oddsWeight); // 7 vs 10 const fr3 = simGame(teams[6], teams[9]); // 7 vs 10
const fr4 = simGame(teams[7], teams[8], oddsWeight); // 8 vs 9 const fr4 = simGame(teams[7], teams[8]); // 8 vs 9
// ── Quarterfinals (seeds 14 get byes) ──────────────────────────────────── // ── Quarterfinals (seeds 14 get byes) ────────────────────────────────────
const qf1 = simGame(teams[0], fr4.winner, oddsWeight); // 1 vs 8/9 winner const qf1 = simGame(teams[0], fr4.winner); // 1 vs 8/9 winner
const qf2 = simGame(teams[3], fr1.winner, oddsWeight); // 4 vs 5/12 winner const qf2 = simGame(teams[3], fr1.winner); // 4 vs 5/12 winner
const qf3 = simGame(teams[2], fr2.winner, oddsWeight); // 3 vs 6/11 winner const qf3 = simGame(teams[2], fr2.winner); // 3 vs 6/11 winner
const qf4 = simGame(teams[1], fr3.winner, oddsWeight); // 2 vs 7/10 winner const qf4 = simGame(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);
@ -193,14 +165,14 @@ function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>, od
bump(qf4.loser.participantId, "qfLoser"); bump(qf4.loser.participantId, "qfLoser");
// ── Semifinals ──────────────────────────────────────────────────────────── // ── Semifinals ────────────────────────────────────────────────────────────
const sf1 = simGame(qf1.winner, qf2.winner, oddsWeight); const sf1 = simGame(qf1.winner, qf2.winner);
const sf2 = simGame(qf3.winner, qf4.winner, oddsWeight); const sf2 = simGame(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, oddsWeight); const final = simGame(sf1.winner, sf2.winner);
bump(final.winner.participantId, "champion"); bump(final.winner.participantId, "champion");
bump(final.loser.participantId, "finalist"); bump(final.loser.participantId, "finalist");
@ -209,12 +181,8 @@ function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>, od
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class NCAAFootballSimulator implements Simulator { export class NCAAFootballSimulator implements Simulator {
async simulate(sportsSeasonId: string, context?: SimulationContext): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database(); const db = database();
// Elo-vs-odds blend weight: configurable via the season input policy, with
// the historical 60/40 split as the default when invoked without context.
const oddsWeight = context?.oddsWeight ?? ODDS_WEIGHT;
// 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 })
@ -228,79 +196,35 @@ export class NCAAFootballSimulator implements Simulator {
); );
} }
// 2. Load Elo/FPI ratings and optional futures odds in a single query. // 2. Load the resolved single Elo (the input policy already blended any
// raw Elo/FPI and futures odds into sourceElo before the run).
const evRows = await db const evRows = await db
.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));
// Build Elo and raw odds maps in a single pass.
const eloFromDb = new Map<string, number>(); const eloFromDb = new Map<string, number>();
const rawOddsProbs = new Map<string, number>();
for (const row of evRows) { for (const row of evRows) {
if (row.sourceElo !== null) { if (row.sourceElo !== null) {
eloFromDb.set(row.participantId, row.sourceElo); eloFromDb.set(row.participantId, row.sourceElo);
} }
if (row.sourceOdds !== null) {
rawOddsProbs.set(row.participantId, convertAmericanOddsToProbability(row.sourceOdds));
}
} }
// 3. Build normalized odds probability map (vig removed). // 3. Build team list; field-selection weight is a softmax on the resolved Elo.
const normalizedOddsMap = new Map<string, number>();
if (rawOddsProbs.size > 0) {
const rawSum = [...rawOddsProbs.values()].reduce((a, b) => a + b, 0);
for (const [id, prob] of rawOddsProbs) {
normalizedOddsMap.set(id, rawSum > 0 ? prob / rawSum : 0);
}
// Backfill Elo from futures for any team missing sourceElo.
if (eloFromDb.size < participants.length) {
const oddsInput = evRows
.filter((r) => r.sourceOdds !== null && !eloFromDb.has(r.participantId))
.map((r) => ({ participantId: r.participantId, odds: r.sourceOdds ?? 0 }));
if (oddsInput.length > 0) {
const oddsEloMap = convertFuturesToElo(oddsInput, "american");
for (const [id, elo] of oddsEloMap) {
eloFromDb.set(id, elo);
}
}
}
}
// 4. Build team list with Elo, oddsProb, and selectionWeight.
const hasOdds = normalizedOddsMap.size > 0;
const allTeams: Team[] = participants.map((p) => ({ const allTeams: Team[] = participants.map((p) => ({
participantId: p.id, participantId: p.id,
elo: eloFromDb.get(p.id) ?? 1500, elo: eloFromDb.get(p.id) ?? 1500,
oddsProb: normalizedOddsMap.get(p.id) ?? 0,
selectionWeight: 0, // computed below selectionWeight: 0, // computed below
})); }));
if (hasOdds) { const maxElo = Math.max(...allTeams.map((t) => t.elo));
// Selection weight = normalized championship implied probability.
// This encodes both "probability of making the field" and "strength once there."
for (const team of allTeams) {
team.selectionWeight = normalizedOddsMap.get(team.participantId) ?? 0;
}
} else {
// No odds: softmax on Elo so top-rated teams are strongly favoured.
const eloValues = allTeams.map((t) => t.elo);
const maxElo = Math.max(...eloValues);
// Subtract max for numerical stability before exp().
const expWeights = allTeams.map((t) => Math.exp((t.elo - maxElo) / SELECTION_TEMP)); const expWeights = allTeams.map((t) => Math.exp((t.elo - maxElo) / SELECTION_TEMP));
const expSum = expWeights.reduce((a, b) => a + b, 0); const expSum = expWeights.reduce((a, b) => a + b, 0);
for (let i = 0; i < allTeams.length; i++) { for (let i = 0; i < allTeams.length; i++) {
allTeams[i].selectionWeight = expWeights[i] / expSum; allTeams[i].selectionWeight = expSum > 0 ? expWeights[i] / expSum : 1 / allTeams.length;
}
} }
const preBracketMode = participants.length > BRACKET_SIZE; const preBracketMode = participants.length > BRACKET_SIZE;
@ -321,7 +245,7 @@ export class NCAAFootballSimulator implements Simulator {
const field = preBracketMode const field = preBracketMode
? sampleBracketField(allTeams, BRACKET_SIZE) ? sampleBracketField(allTeams, BRACKET_SIZE)
: (deterministicField ?? []); : (deterministicField ?? []);
simulateBracket(field, counts, oddsWeight); simulateBracket(field, counts);
} }
// 7. Convert counts to probability distributions. // 7. Convert counts to probability distributions.

View file

@ -16,7 +16,6 @@ import {
prepareSimulatorInputsForRun, prepareSimulatorInputsForRun,
validateSimulatorReadiness, validateSimulatorReadiness,
} from "~/models/simulator"; } from "~/models/simulator";
import { getSimulatorInputPolicy } from "~/services/simulations/input-policy";
const ZERO_PROBS = { const ZERO_PROBS = {
probFirst: 0, probFirst: 0,
@ -106,10 +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);
config: simulatorConfig.config,
oddsWeight: getSimulatorInputPolicy(simulatorConfig.config).oddsWeight,
});
if (results.length === 0) { if (results.length === 0) {
throw new Error("Simulation returned no results. Check that participants have simulator input data."); throw new Error("Simulation returned no results. Check that participants have simulator input data.");

View file

@ -27,22 +27,13 @@ export interface SimulationResult {
source: string; source: string;
} }
/**
* Optional run-time context handed to a simulator by the runner. Carries the
* resolved season config and input policy so simulators can honor configurable
* knobs (e.g. the Elo-vs-odds blend weight) without re-querying. It is optional
* so simulators can still be invoked directly (e.g. in unit tests) with just a
* season id, in which case they fall back to their built-in defaults.
*/
export interface SimulationContext {
config: Record<string, unknown>;
/** Weight (01) for the futures-odds signal in blended simulators. */
oddsWeight: number;
}
/** /**
* Common interface all sport simulators must implement. * Common interface all sport simulators must implement.
*
* Simulators consume a single resolved Elo per participant (already blended from
* its sources raw Elo, projections, futures odds by the input policy and
* persisted before the run), so they need only the season id.
*/ */
export interface Simulator { export interface Simulator {
simulate(sportsSeasonId: string, context?: SimulationContext): Promise<SimulationResult[]>; simulate(sportsSeasonId: string): Promise<SimulationResult[]>;
} }

View file

@ -37,20 +37,13 @@
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 { convertFuturesToElo, eloWinProbability } from "~/services/probability-engine"; import { eloWinProbability } from "~/services/probability-engine";
import type { Simulator, SimulationResult, SimulationContext } from "./types"; import type { Simulator, SimulationResult } from "./types";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters ────────────────────────────────────────────────────
const NUM_SIMULATIONS = 50000; const NUM_SIMULATIONS = 50000;
/**
* Weight given to the Elo-based win probability (derived from futures).
* Remaining weight (1 - ELO_WEIGHT) goes to the normalized futures odds component.
*/
const ELO_WEIGHT = 0.7;
const ODDS_WEIGHT = 1 - ELO_WEIGHT;
// ─── Odds helper ────────────────────────────────────────────────────────────── // ─── Odds helper ──────────────────────────────────────────────────────────────
/** Convert American odds to implied probability (with vig). Exported for testing. */ /** Convert American odds to implied probability (with vig). Exported for testing. */
@ -62,12 +55,8 @@ export function americanToImpliedProb(odds: number): number {
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class UCLSimulator implements Simulator { export class UCLSimulator implements Simulator {
async simulate(sportsSeasonId: string, context?: SimulationContext): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database(); const db = database();
// Elo-vs-odds blend weight: configurable via the season input policy, with
// the historical 70/30 split as the default when invoked without context.
const oddsWeight = context?.oddsWeight ?? ODDS_WEIGHT;
const eloWeight = 1 - oddsWeight;
// 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.
@ -146,52 +135,26 @@ export class UCLSimulator implements Simulator {
participantIds.push(m.participant1Id ?? "", m.participant2Id ?? ""); participantIds.push(m.participant1Id ?? "", m.participant2Id ?? "");
} }
const participantSet = new Set(participantIds); const participantSet = new Set(participantIds);
const fallbackProb = 1 / participantIds.length;
// 5. Load futures odds from participantExpectedValues. // 5. Load the resolved single Elo (input policy already blended any raw Elo
// and futures odds into sourceElo before the run).
const evRows = await db const evRows = await db
.select({ .select({
participantId: schema.seasonParticipantExpectedValues.participantId, participantId: schema.seasonParticipantExpectedValues.participantId,
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds, sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
}) })
.from(schema.seasonParticipantExpectedValues) .from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)); .where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
const evMap = new Map(evRows.map((r) => [r.participantId, r])); // 6. Build the Elo map; teams without a resolved Elo fall back to 1500.
const eloMap = new Map<string, number>(participantIds.map((id) => [id, 1500]));
// 6. Build Elo map via the futures → Elo pipeline. for (const r of evRows) {
const hasOdds = evRows.some( if (r.sourceElo !== null && r.sourceElo !== undefined && participantSet.has(r.participantId)) {
(r) => r.sourceOdds !== null && participantSet.has(r.participantId) eloMap.set(r.participantId, r.sourceElo);
); }
let eloMap: Map<string, number>;
if (hasOdds) {
const oddsInput = evRows
.filter((r) => r.sourceOdds !== null && participantSet.has(r.participantId))
.map((r) => ({ participantId: r.participantId, odds: r.sourceOdds ?? 0 }));
eloMap = convertFuturesToElo(oddsInput, "american");
} else {
// No odds stored — all teams get equal Elo (coin-flip bracket)
eloMap = new Map(participantIds.map((id) => [id, 1500]));
} }
// 7. Build normalized futures win-probability map (vig removed). // 7. Build per-round lookup maps keyed by matchNumber for O(1) access in the hot loop.
// Used as the second signal in the blended per-match probability.
const rawProbs = new Map<string, number>();
for (const id of participantIds) {
const ev = evMap.get(id);
rawProbs.set(
id,
ev !== undefined && ev.sourceOdds !== null && ev.sourceOdds !== undefined ? americanToImpliedProb(ev.sourceOdds) : fallbackProb
);
}
const rawSum = [...rawProbs.values()].reduce((a, b) => a + b, 0);
const normalizedOddsMap = new Map<string, number>();
for (const [id, prob] of rawProbs) {
normalizedOddsMap.set(id, prob / rawSum);
}
// 8. Build per-round lookup maps keyed by matchNumber for O(1) access in the hot loop.
const r16ByNum = new Map(r16Matches.map((m) => [m.matchNumber, m])); const r16ByNum = new Map(r16Matches.map((m) => [m.matchNumber, m]));
const qfByNum = new Map(qfMatches.map((m) => [m.matchNumber, m])); const qfByNum = new Map(qfMatches.map((m) => [m.matchNumber, m]));
const sfByNum = new Map(sfMatches.map((m) => [m.matchNumber, m])); const sfByNum = new Map(sfMatches.map((m) => [m.matchNumber, m]));
@ -199,17 +162,11 @@ export class UCLSimulator implements Simulator {
// ─── Helpers ────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────
/** Blended Elo + normalized-odds win probability for p1 vs p2. */ /** Pure-Elo win probability for p1 vs p2 (single resolved Elo per team). */
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;
const eloProb = eloWinProbability(elo1, elo2); return eloWinProbability(elo1, elo2);
const o1 = normalizedOddsMap.get(p1) ?? fallbackProb;
const o2 = normalizedOddsMap.get(p2) ?? fallbackProb;
const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5;
return eloWeight * eloProb + oddsWeight * oddsProb;
}; };
const simMatch = (p1: string, p2: string): { winner: string; loser: string } => { const simMatch = (p1: string, p2: string): { winner: string; loser: string } => {

View file

@ -38,13 +38,13 @@
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 { convertFuturesToElo, eloWinProbability } from "~/services/probability-engine"; import { eloWinProbability } from "~/services/probability-engine";
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";
import { FIFA_48 } from "~/lib/bracket-templates"; import { FIFA_48 } from "~/lib/bracket-templates";
import { FIFA_2026_R32_TEMPLATE, assignThirdPlaceSlots } from "~/lib/fifa-2026-bracket"; import { FIFA_2026_R32_TEMPLATE, assignThirdPlaceSlots } from "~/lib/fifa-2026-bracket";
import type { Simulator, SimulationResult, SimulationContext } from "./types"; import type { Simulator, SimulationResult } from "./types";
// ─── Name normalisation (same logic as elo-ratings bulk-import fuzzy match) ── // ─── Name normalisation (same logic as elo-ratings bulk-import fuzzy match) ──
@ -55,8 +55,6 @@ function normalizeTeamName(name: string): string {
// ─── Parameters ────────────────────────────────────────────────────────────── // ─── Parameters ──────────────────────────────────────────────────────────────
const NUM_SIMULATIONS = 10_000; const NUM_SIMULATIONS = 10_000;
const ELO_WEIGHT = 0.7;
const ODDS_WEIGHT = 1 - ELO_WEIGHT;
/** Base draw rate when teams are evenly matched. Decays with Elo difference. */ /** Base draw rate when teams are evenly matched. Decays with Elo difference. */
const BASE_DRAW_RATE = 0.28; const BASE_DRAW_RATE = 0.28;
@ -274,14 +272,10 @@ function sortTeams(a: TeamStats, b: TeamStats): number {
function simKnockout( function simKnockout(
teamA: string, teamA: string,
teamB: string, teamB: string,
eloFn: (id: string) => number, eloFn: (id: string) => number
normalizedProb: (id: string) => number,
oddsWeight: number = ODDS_WEIGHT
): { winner: string; loser: string } { ): { winner: string; loser: string } {
const eloProb = eloWinProbability(eloFn(teamA), eloFn(teamB)); const winProb = eloWinProbability(eloFn(teamA), eloFn(teamB));
const oddsProb = normalizedProb(teamA); const winner = Math.random() < winProb ? teamA : teamB;
const blended = (1 - oddsWeight) * eloProb + oddsWeight * (0.5 + (oddsProb - 0.5));
const winner = Math.random() < blended ? teamA : teamB;
return { winner, loser: winner === teamA ? teamB : teamA }; return { winner, loser: winner === teamA ? teamB : teamA };
} }
@ -319,9 +313,7 @@ function runKnockoutRoundByNumber(
roundName: string, roundName: string,
matches: Map<number, KnockoutMatch>, matches: Map<number, KnockoutMatch>,
completed: Map<string, { winnerId: string; loserId: string }>, completed: Map<string, { winnerId: string; loserId: string }>,
eloFn: (id: string) => number, eloFn: (id: string) => number
normalizedProb: (id: string) => number,
oddsWeight: number = ODDS_WEIGHT
): { ): {
winnersByNumber: Map<number, string>; winnersByNumber: Map<number, string>;
losersByNumber: Map<number, string>; losersByNumber: Map<number, string>;
@ -350,7 +342,7 @@ function runKnockoutRoundByNumber(
winnerId = fixed.winnerId; winnerId = fixed.winnerId;
loserId = fixed.loserId; loserId = fixed.loserId;
} else if (m.p1 && m.p2) { } else if (m.p1 && m.p2) {
const result = simKnockout(m.p1, m.p2, eloFn, normalizedProb, oddsWeight); const result = simKnockout(m.p1, m.p2, eloFn);
winnerId = result.winner; winnerId = result.winner;
loserId = result.loser; loserId = result.loser;
} else if (m.p1 || m.p2) { } else if (m.p1 || m.p2) {
@ -448,10 +440,7 @@ export class WorldCupSimulator implements Simulator {
this.numSimulations = numSimulations; this.numSimulations = numSimulations;
} }
async simulate(sportsSeasonId: string, context?: SimulationContext): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
// Elo-vs-odds blend weight for knockout matches: configurable via the season
// input policy, defaulting to the historical 70/30 split without context.
const oddsWeight = context?.oddsWeight ?? ODDS_WEIGHT;
const db = database(); const db = database();
// 1. Load all participants for this season // 1. Load all participants for this season
@ -466,20 +455,19 @@ export class WorldCupSimulator implements Simulator {
const participantIds = participantRows.map((p) => p.id); const participantIds = participantRows.map((p) => p.id);
const participantNames = new Map(participantRows.map((p) => [p.id, p.name])); const participantNames = new Map(participantRows.map((p) => [p.id, p.name]));
// 2. Load stored ratings (sourceElo from Elo page, sourceOdds from futures page) // 2. Load the resolved single Elo (the input policy already blended any raw
// Elo and futures odds into sourceElo before the run).
const evRows = await db const evRows = await 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 evMap = new Map(evRows.map((r) => [r.participantId, r]));
const participantSet = new Set(participantIds); const participantSet = new Set(participantIds);
// 3. Build Elo map — priority: sourceElo (direct) > sourceOdds (converted) > hardcoded // 3. Build the Elo map — resolved sourceElo, else hardcoded national-team Elo.
const sourceEloMap = new Map<string, number>(); const sourceEloMap = new Map<string, number>();
for (const r of evRows) { for (const r of evRows) {
if (r.sourceElo !== null && r.sourceElo !== undefined && participantSet.has(r.participantId)) { if (r.sourceElo !== null && r.sourceElo !== undefined && participantSet.has(r.participantId)) {
@ -487,47 +475,13 @@ export class WorldCupSimulator implements Simulator {
} }
} }
const hasOdds = evRows.some(
(r) => r.sourceOdds !== null && participantSet.has(r.participantId)
);
let eloFromOdds: Map<string, number>;
if (hasOdds) {
const oddsInput = evRows
.filter((r) => r.sourceOdds !== null && participantSet.has(r.participantId))
.map((r) => ({ participantId: r.participantId, odds: r.sourceOdds ?? 0 }));
eloFromOdds = convertFuturesToElo(oddsInput, "american");
} else {
eloFromOdds = new Map();
}
const eloFn = (id: string): number => { const eloFn = (id: string): number => {
if (sourceEloMap.has(id)) return sourceEloMap.get(id) ?? 1500; if (sourceEloMap.has(id)) return sourceEloMap.get(id) ?? 1500;
if (eloFromOdds.has(id)) return eloFromOdds.get(id) ?? 1500;
const name = participantNames.get(id) ?? ""; const name = participantNames.get(id) ?? "";
return getTeamElo(name, 1500); return getTeamElo(name, 1500);
}; };
// 4. Build normalized futures win-probability map (vig removed) // 4. Resolve the bracket scoring event. Prefer a fifa_48 playoff event; if
const rawProbs = new Map<string, number>();
for (const id of participantIds) {
const ev = evMap.get(id);
if (ev?.sourceOdds !== null && ev?.sourceOdds !== undefined) {
rawProbs.set(id, Math.abs(ev.sourceOdds) > 0
? ev.sourceOdds > 0
? 100 / (ev.sourceOdds + 100)
: Math.abs(ev.sourceOdds) / (Math.abs(ev.sourceOdds) + 100)
: 1 / participantIds.length);
} else {
rawProbs.set(id, 1 / participantIds.length);
}
}
const totalRawProb = [...rawProbs.values()].reduce((s, p) => s + p, 0);
const normalizedProb = (id: string): number =>
totalRawProb > 0 ? (rawProbs.get(id) ?? 0) / totalRawProb : 1 / participantIds.length;
// 5. Resolve the bracket scoring event. Prefer a fifa_48 playoff event; if
// several playoff_game events exist, take the most recent so a re-created // several playoff_game events exist, take the most recent so a re-created
// event wins over a stale one. // event wins over a stale one.
const playoffEvents = await db.query.scoringEvents.findMany({ const playoffEvents = await db.query.scoringEvents.findMany({
@ -719,10 +673,10 @@ export class WorldCupSimulator implements Simulator {
// ── 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.
const r32 = runKnockoutRoundByNumber(R32, buildR32(), completedByRoundAndNumber, eloFn, normalizedProb, oddsWeight); const r32 = runKnockoutRoundByNumber(R32, buildR32(), completedByRoundAndNumber, eloFn);
const r16 = runKnockoutRoundByNumber(R16, r32.nextRound, completedByRoundAndNumber, eloFn, normalizedProb, oddsWeight); const r16 = runKnockoutRoundByNumber(R16, r32.nextRound, completedByRoundAndNumber, eloFn);
const qf = runKnockoutRoundByNumber(QF, r16.nextRound, completedByRoundAndNumber, eloFn, normalizedProb, oddsWeight); const qf = runKnockoutRoundByNumber(QF, r16.nextRound, completedByRoundAndNumber, eloFn);
const sf = runKnockoutRoundByNumber(SF, qf.nextRound, completedByRoundAndNumber, eloFn, normalizedProb, oddsWeight); const sf = runKnockoutRoundByNumber(SF, qf.nextRound, completedByRoundAndNumber, eloFn);
// QF losers (placements 58) // QF losers (placements 58)
for (const id of qf.losersByNumber.values()) { for (const id of qf.losersByNumber.values()) {
@ -739,7 +693,7 @@ export class WorldCupSimulator implements Simulator {
counts.fourthPlace.set(fixed3pg.loserId, (counts.fourthPlace.get(fixed3pg.loserId) ?? 0) + 1); counts.fourthPlace.set(fixed3pg.loserId, (counts.fourthPlace.get(fixed3pg.loserId) ?? 0) + 1);
} else { } else {
const { winner: thirdWinner, loser: thirdLoser } = simKnockout( const { winner: thirdWinner, loser: thirdLoser } = simKnockout(
sf1Loser, sf2Loser, eloFn, normalizedProb, oddsWeight sf1Loser, sf2Loser, eloFn
); );
counts.thirdPlace.set(thirdWinner, (counts.thirdPlace.get(thirdWinner) ?? 0) + 1); counts.thirdPlace.set(thirdWinner, (counts.thirdPlace.get(thirdWinner) ?? 0) + 1);
counts.fourthPlace.set(thirdLoser, (counts.fourthPlace.get(thirdLoser) ?? 0) + 1); counts.fourthPlace.set(thirdLoser, (counts.fourthPlace.get(thirdLoser) ?? 0) + 1);
@ -756,7 +710,7 @@ export class WorldCupSimulator implements Simulator {
counts.runnerUp.set(fixedFinal.loserId, (counts.runnerUp.get(fixedFinal.loserId) ?? 0) + 1); counts.runnerUp.set(fixedFinal.loserId, (counts.runnerUp.get(fixedFinal.loserId) ?? 0) + 1);
} else { } else {
const { winner: champion, loser: runnerUp } = simKnockout( const { winner: champion, loser: runnerUp } = simKnockout(
sfWinner1, sfWinner2, eloFn, normalizedProb, oddsWeight sfWinner1, sfWinner2, eloFn
); );
counts.champion.set(champion, (counts.champion.get(champion) ?? 0) + 1); counts.champion.set(champion, (counts.champion.get(champion) ?? 0) + 1);
counts.runnerUp.set(runnerUp, (counts.runnerUp.get(runnerUp) ?? 0) + 1); counts.runnerUp.set(runnerUp, (counts.runnerUp.get(runnerUp) ?? 0) + 1);