Unify futures odds with the simulation system

Make the futures-vs-Elo relationship an explicit, configurable rule and fix
futures odds failing to override stored Elo.

Root causes addressed:
- resolveSourceElos/resolveRatings used a hardcoded precedence (direct Elo ->
  projectedWins -> projectedTablePoints -> odds). The prior fix nulled sourceElo
  on futures entry but not projections, which still outranked odds.
- The override relied on a destructive null-on-save hack that also wiped
  manually entered Elo.
- Blended simulators buried their Elo/odds weight in module constants.
- Futures odds were not surfaced on the /admin/simulators inventory.

Changes:
- Add a configurable source policy: sourceEloPriority (ordered) and oddsWeight,
  parsed/clamped in getSimulatorInputPolicy. resolveSourceElos/resolveRatings now
  resolve each participant by the configured priority instead of a fixed order.
  Futures-centric simulators (ncaam, ncaaw, world_cup, ncaa_football,
  college_hockey) default to a futures-override priority.
- Drop the destructive nulling in batchSaveFuturesOddsForSimulator and
  batchSaveSourceOdds; override is now governed by policy, preserving stored Elo.
- Thread an optional SimulationContext (oddsWeight) through the Simulator
  interface so blended sims (UCL, World Cup, NCAA FB, MLB) read the blend weight
  from the season policy; defaults preserve prior calibration when no context is
  passed.
- Add a "Futures vs. Elo" strategy control and Odds Blend Weight input to the
  Simulator Setup input-policy card, persisted via save-input-policy.
- Surface futures on /admin/simulators: a source badge and a Futures Odds quick
  link; extend listSportsSeasonSimulatorSummaries with odds source info.
- Tests: configurable priority override (Elo/projections/rating), oddsWeight
  parsing/clamping, prefersFuturesOdds, manifest defaults, an NCAA Football
  context-blend behavioral test, and an updated non-destructive save test.

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-25 22:11:32 +00:00
parent ab74b19420
commit dc4efe87cf
No known key found for this signature in database
17 changed files with 523 additions and 221 deletions

View file

@ -10,57 +10,43 @@ import { batchSaveFuturesOddsForSimulator } from "../simulator";
type SetPayload = Record<string, unknown>;
const setCalls: SetPayload[] = [];
const conflictSetCalls: SetPayload[] = [];
const tx = {
update: vi.fn(() => ({
set: vi.fn((arg: SetPayload) => {
setCalls.push(arg);
return { where: vi.fn().mockResolvedValue(undefined) };
}),
})),
insert: vi.fn(() => ({
values: vi.fn(() => ({
onConflictDoUpdate: vi.fn((arg: { set: SetPayload }) => {
const onConflictDoUpdate = vi.fn((arg: { set: SetPayload }) => {
conflictSetCalls.push(arg.set);
return Promise.resolve(undefined);
}),
})),
})),
};
});
const mockDb = {
transaction: vi.fn(async (cb: (t: typeof tx) => Promise<void>) => cb(tx)),
insert: vi.fn(() => ({
values: vi.fn(() => ({ onConflictDoUpdate })),
})),
};
beforeEach(() => {
vi.clearAllMocks();
setCalls.length = 0;
conflictSetCalls.length = 0;
(database as ReturnType<typeof vi.fn>).mockReturnValue(mockDb);
});
describe("batchSaveFuturesOddsForSimulator", () => {
it("clears rating and sourceElo so odds drive the next run", async () => {
it("persists odds without destroying a stored Elo/rating", async () => {
await batchSaveFuturesOddsForSimulator([
{ participantId: "team-1", sportsSeasonId: "season-1", sourceOdds: 550 },
]);
// Pre-update clears the stale rating and sourceElo for these participants.
expect(setCalls).toHaveLength(1);
expect(setCalls[0]).toMatchObject({ rating: null, sourceElo: null });
// Metadata is rewritten (an SQL fragment that strips ratingMethod/sourceEloMethod).
expect(setCalls[0].metadata).toBeDefined();
// The upsert conflict path also nulls both so re-entering odds stays clean.
// The upsert writes the odds and leaves Elo/rating untouched — whether the
// odds override them is now decided by the configurable source priority,
// not by nulling stored values here.
expect(mockDb.insert).toHaveBeenCalledTimes(1);
expect(conflictSetCalls).toHaveLength(1);
expect(conflictSetCalls[0]).toMatchObject({ rating: null, sourceElo: null });
expect(conflictSetCalls[0].metadata).toBeDefined();
expect(conflictSetCalls[0]).toHaveProperty("sourceOdds");
expect(conflictSetCalls[0]).not.toHaveProperty("sourceElo");
expect(conflictSetCalls[0]).not.toHaveProperty("rating");
});
it("is a no-op when given no inputs", async () => {
await batchSaveFuturesOddsForSimulator([]);
expect(mockDb.transaction).not.toHaveBeenCalled();
expect(mockDb.insert).not.toHaveBeenCalled();
});
});

View file

@ -393,10 +393,12 @@ export async function batchSaveSourceOdds(
if (existing.length > 0) {
await tx
.update(seasonParticipantExpectedValues)
// Clear any stale Elo and mark the source as odds-driven so the
// simulator re-derives Elo from these odds and the elo-ratings page
// loader doesn't resurrect an outdated rating.
.set({ sourceOdds, sourceElo: null, source: "futures_odds", updatedAt: now })
// Persist the odds and label the source as odds-driven for display.
// We no longer null a stored Elo here: whether these odds override it
// is decided at run time by the configurable source priority
// (SimulatorInputPolicy.sourceEloPriority), and prepareSimulatorInputsForRun
// overwrites the derived Elo before the simulator reads it.
.set({ sourceOdds, source: "futures_odds", updatedAt: now })
.where(eq(seasonParticipantExpectedValues.id, existing[0].id));
} else {
// Insert a stub record — probabilities/EV will be filled in by the simulator

View file

@ -1,4 +1,4 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import { and, eq, inArray, isNotNull, sql } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import {
@ -8,6 +8,8 @@ import {
type SimulatorManifestProfile,
} from "~/services/simulations/manifest";
import {
getSimulatorInputPolicy,
prefersFuturesOdds,
ratingRequirementLabel,
resolveRatings,
resolveSourceElos,
@ -82,6 +84,12 @@ export interface SportsSeasonSimulatorSummary {
participantInputCount: number;
lastSimulatedDate: string | null;
readiness: SimulatorReadiness;
/** Whether this simulator exposes a Futures Odds setup section. */
supportsFuturesOdds: boolean;
/** Number of participants that currently have stored futures odds. */
oddsParticipantCount: number;
/** Whether the season's input policy makes futures odds override Elo/projections. */
prefersFuturesOdds: boolean;
}
function parseDecimal(value: string | null | undefined): number | null {
@ -359,33 +367,12 @@ export async function batchSaveFuturesOddsForSimulator(
if (inputs.length === 0) return;
const db = database();
const now = new Date();
const participantIds = inputs.map((input) => input.participantId);
const seasonIds = [...new Set(inputs.map((input) => input.sportsSeasonId))];
await db.transaction(async (tx) => {
// Clear ALL ratings AND sourceElo (both manual and generated) so the
// simulation re-derives them from the newly saved futures odds. For
// Elo-based simulators, a stale `sourceElo` (e.g. a manually entered
// "direct" Elo) would otherwise win the resolveSourceElos precedence and
// cause the futures odds to be silently ignored. Nulling it here lets the
// sourceOdds -> convertFuturesToElo branch drive the run.
await tx
.update(schema.seasonParticipantSimulatorInputs)
.set({
rating: null,
sourceElo: null,
metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod' - 'sourceEloMethod'`,
updatedAt: now,
})
.where(
and(
inArray(schema.seasonParticipantSimulatorInputs.sportsSeasonId, seasonIds),
inArray(schema.seasonParticipantSimulatorInputs.participantId, participantIds)
)
);
await tx
// Persist the odds non-destructively. Whether these odds override a stored
// Elo/rating is now governed by the season's configurable source priority
// (see resolveSourceElos / SimulatorInputPolicy.sourceEloPriority), so we no
// longer null out manually entered Elo here — that policy decides at run time.
await db
.insert(schema.seasonParticipantSimulatorInputs)
.values(
inputs.map((input) => ({
@ -403,13 +390,9 @@ export async function batchSaveFuturesOddsForSimulator(
],
set: {
sourceOdds: sql`excluded.source_odds`,
rating: null,
sourceElo: null,
metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod' - 'sourceEloMethod'`,
updatedAt: now,
},
});
});
}
export async function batchUpsertParticipantSimulatorInputs(
@ -669,6 +652,23 @@ export async function listSportsSeasonSimulatorSummaries(): Promise<SportsSeason
: [];
const lastSnapshotBySeason = new Map(snapshotRows.map((row) => [row.sportsSeasonId, row.lastSimulatedDate]));
const oddsCountRows = seasonIds.length > 0
? await db
.select({
sportsSeasonId: schema.seasonParticipantSimulatorInputs.sportsSeasonId,
oddsCount: sql<number>`count(*)::int`,
})
.from(schema.seasonParticipantSimulatorInputs)
.where(
and(
inArray(schema.seasonParticipantSimulatorInputs.sportsSeasonId, seasonIds),
isNotNull(schema.seasonParticipantSimulatorInputs.sourceOdds)
)
)
.groupBy(schema.seasonParticipantSimulatorInputs.sportsSeasonId)
: [];
const oddsCountBySeason = new Map(oddsCountRows.map((row) => [row.sportsSeasonId, Number(row.oddsCount)]));
// Each season calls getSimulatorProfile + validateSimulatorReadiness (~4 queries each).
// Acceptable for an admin-only page; revisit if season counts grow past ~50.
const summaries = await Promise.all(
@ -676,6 +676,8 @@ export async function listSportsSeasonSimulatorSummaries(): Promise<SportsSeason
const simulatorType = (season.simulatorConfig?.simulatorType ?? season.sport.simulatorType) as SimulatorType;
const profile = await getSimulatorProfile(simulatorType);
const readiness = await validateSimulatorReadiness(season.id);
const mergedConfig = { ...profile.defaultConfig, ...(season.simulatorConfig?.config ?? {}) };
const policy = getSimulatorInputPolicy(mergedConfig);
return {
sportsSeasonId: season.id,
seasonName: season.name,
@ -693,6 +695,9 @@ export async function listSportsSeasonSimulatorSummaries(): Promise<SportsSeason
participantInputCount: readiness.participantInputCount,
lastSimulatedDate: lastSnapshotBySeason.get(season.id) ?? null,
readiness,
supportsFuturesOdds: profile.setupSections.includes("futuresOdds"),
oddsParticipantCount: oddsCountBySeason.get(season.id) ?? 0,
prefersFuturesOdds: prefersFuturesOdds(policy.sourceEloPriority),
};
})
);

View file

@ -245,7 +245,14 @@ export default function AdminSimulators({ loaderData }: Route.ComponentProps) {
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{sim.participantInputCount}/{sim.participantCount}
<div>{sim.participantInputCount}/{sim.participantCount}</div>
{sim.supportsFuturesOdds && (
<Badge variant={sim.prefersFuturesOdds ? "default" : "outline"} className="mt-1 text-xs font-normal">
{sim.oddsParticipantCount > 0
? `Futures: ${sim.oddsParticipantCount}${sim.prefersFuturesOdds ? " (overrides Elo)" : " (fallback)"}`
: "No futures odds"}
</Badge>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{sim.lastSimulatedDate ?? "Never"}
@ -258,6 +265,11 @@ export default function AdminSimulators({ loaderData }: Route.ComponentProps) {
Setup
</Link>
</Button>
{sim.supportsFuturesOdds && (
<Button variant="ghost" size="sm" asChild>
<Link to={`/admin/sports-seasons/${sim.sportsSeasonId}/futures-odds`}>Futures</Link>
</Button>
)}
<Button variant="ghost" size="sm" asChild>
<Link to={`/admin/sports-seasons/${sim.sportsSeasonId}/expected-values`}>EVs</Link>
</Button>

View file

@ -25,7 +25,14 @@ import {
type UpsertParticipantSimulatorInput,
} from "~/models/simulator";
import { normalizeName } from "~/lib/fuzzy-match";
import { getSimulatorInputPolicy, type MissingEloStrategy, type MissingRatingStrategy } from "~/services/simulations/input-policy";
import {
getSimulatorInputPolicy,
prefersFuturesOdds,
ELO_FIRST_SOURCE_PRIORITY,
FUTURES_FIRST_SOURCE_PRIORITY,
type MissingEloStrategy,
type MissingRatingStrategy,
} from "~/services/simulations/input-policy";
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
@ -205,6 +212,11 @@ export async function action({ request, params }: Route.ActionArgs): Promise<Act
inputPolicy: {
missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")),
missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")),
sourceEloPriority:
formData.get("sourceStrategy") === "futuresFirst"
? FUTURES_FIRST_SOURCE_PRIORITY
: ELO_FIRST_SOURCE_PRIORITY,
oddsWeight: parsePolicyNumber(formData, "oddsWeight", currentPolicy.oddsWeight),
fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo),
fallbackEloDelta: parsePolicyNumber(formData, "fallbackEloDelta", currentPolicy.fallbackEloDelta),
eloMin: parsePolicyNumber(formData, "eloMin", currentPolicy.eloMin),
@ -361,6 +373,29 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
<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-3">
<Label htmlFor="sourceStrategy">Futures vs. Elo</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} />
<p className="text-xs text-muted-foreground">
For simulators that blend Elo and odds per match. 1.0 = pure odds; 0 = pure Elo.
</p>
</div>
{config.profile.requiredInputs.includes("sourceElo") && (
<>
<div className="space-y-2 md:col-span-2">

View file

@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
import { resolveRatings, resolveSourceElos } from "../input-policy";
import {
getSimulatorInputPolicy,
prefersFuturesOdds,
resolveRatings,
resolveSourceElos,
ELO_FIRST_SOURCE_PRIORITY,
FUTURES_FIRST_SOURCE_PRIORITY,
} from "../input-policy";
import type { SimulatorManifestProfile } from "../manifest";
const profile = {
@ -183,6 +190,86 @@ describe("simulator input policy", () => {
expect(resolved.get("missing-tail")).toMatchObject({ rating: -10, method: "worstKnownMinus" });
});
it("lets a futures-first priority override a stored direct Elo", () => {
const inputs = [
{ participantId: "favorite", sourceElo: 1800, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
{ participantId: "longshot", sourceElo: 1200, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
];
// Default (Elo-first): the stored direct Elo wins.
const eloFirst = resolveSourceElos(inputs, profile, {});
expect(eloFirst.get("favorite")).toMatchObject({ sourceElo: 1800, method: "direct" });
// Futures-first: odds win even though a direct Elo is present.
const futuresFirst = resolveSourceElos(inputs, profile, {
inputPolicy: { sourceEloPriority: FUTURES_FIRST_SOURCE_PRIORITY },
});
expect(futuresFirst.get("favorite")?.method).toBe("sourceOdds");
expect(futuresFirst.get("longshot")?.method).toBe("sourceOdds");
});
it("lets a futures-first priority override stored projected wins", () => {
const inputs = [
{ 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 },
];
// Default (Elo-first): projectedWins outranks odds.
expect(resolveSourceElos(inputs, profile, { seasonGames: 82 }).get("team-1")?.method).toBe("projectedWins");
// Futures-first: odds win over projections.
const futuresFirst = resolveSourceElos(inputs, profile, {
seasonGames: 82,
inputPolicy: { sourceEloPriority: FUTURES_FIRST_SOURCE_PRIORITY },
});
expect(futuresFirst.get("team-1")?.method).toBe("sourceOdds");
});
it("lets a futures-first priority override a stored direct rating", () => {
const ratingProfile = { derivableInputs: { rating: ["sourceOdds"] } } as Pick<SimulatorManifestProfile, "derivableInputs">;
const inputs = [
{ participantId: "favorite", sourceElo: null, rating: 30, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
{ participantId: "longshot", sourceElo: null, rating: -5, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
];
// Default: direct rating wins.
expect(resolveRatings(inputs, ratingProfile, { inputPolicy: { ratingMin: -10, ratingMax: 35 } }).get("favorite"))
.toMatchObject({ rating: 30, method: "direct" });
// Futures-first: odds win.
expect(
resolveRatings(inputs, ratingProfile, {
inputPolicy: { ratingMin: -10, ratingMax: 35, sourceEloPriority: FUTURES_FIRST_SOURCE_PRIORITY },
}).get("favorite")?.method
).toBe("sourceOdds");
});
it("parses and clamps the input policy source priority and odds weight", () => {
const defaults = getSimulatorInputPolicy({});
expect(defaults.sourceEloPriority).toEqual(ELO_FIRST_SOURCE_PRIORITY);
expect(defaults.oddsWeight).toBe(0.3);
// oddsWeight can be sourced from the top-level config or the input policy,
// and is clamped to [0, 1]; junk priority entries are dropped.
expect(getSimulatorInputPolicy({ oddsWeight: 0.6 }).oddsWeight).toBe(0.6);
expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: 5 } }).oddsWeight).toBe(1);
expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: -2 } }).oddsWeight).toBe(0);
expect(
getSimulatorInputPolicy({ inputPolicy: { sourceEloPriority: ["sourceOdds", "bogus", "sourceElo"] } }).sourceEloPriority
).toEqual(["sourceOdds", "sourceElo"]);
// An empty/all-invalid priority falls back to the default.
expect(getSimulatorInputPolicy({ inputPolicy: { sourceEloPriority: ["nope"] } }).sourceEloPriority).toEqual(
ELO_FIRST_SOURCE_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", () => {
const resolved = resolveRatings(
[

View file

@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import * as schema from "~/database/schema";
import { SIMULATOR_MANIFEST } from "../manifest";
import { SIMULATOR_TYPES } from "../registry";
import { getSimulatorInputPolicy, prefersFuturesOdds } from "../input-policy";
import { assertRegistrySchemaDriftFree } from "~/models/simulator";
describe("simulator manifest", () => {
@ -40,6 +41,21 @@ describe("simulator manifest", () => {
}
});
it("defaults futures-centric simulators to a futures-override source priority", () => {
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.world_cup.defaultConfig).oddsWeight).toBe(0.3);
// A non-futures team sport keeps the Elo-first default.
expect(prefersFuturesOdds(getSimulatorInputPolicy(SIMULATOR_MANIFEST.nba_bracket.defaultConfig).sourceEloPriority)).toBe(false);
});
it("keeps EPL projection and match parity as separate config knobs", () => {
expect(SIMULATOR_MANIFEST.epl_standings.defaultConfig).toMatchObject({
parityFactor: 400,

View file

@ -150,6 +150,35 @@ describe("NCAAFootballSimulator", () => {
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
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) ───────────────

View file

@ -21,9 +21,12 @@ vi.mock("~/models/ev-snapshot", () => ({
vi.mock("~/models/scoring-calculator", () => ({
recalculateStandings: vi.fn(),
}));
vi.mock("~/services/simulations/registry", () => ({
getSimulator: vi.fn(),
}));
vi.mock("~/services/simulations/registry", async (importOriginal) => {
// Keep the real SIMULATOR_TYPES / getSimulatorInfo so the manifest (pulled in
// transitively via input-policy) can build; only stub getSimulator.
const actual = await importOriginal<typeof import("~/services/simulations/registry")>();
return { ...actual, getSimulator: vi.fn() };
});
vi.mock("~/services/simulations/simulation-probabilities", () => ({
normalizeSimulationResultColumns: vi.fn(),
}));

View file

@ -5,9 +5,57 @@ import { logger } from "~/lib/logger";
export type MissingEloStrategy = "block" | "fallbackElo" | "averageKnown" | "worstKnownMinus";
export type MissingRatingStrategy = "block" | "fallbackRating" | "averageKnown" | "worstKnownMinus";
/**
* Sources a participant's strength input can be derived from, in the order the
* resolver should try them. `"sourceElo"` represents the simulator's primary
* direct input a manually entered Elo for Elo-based sims, or the direct
* `rating` for rating-based sims. Placing `"sourceOdds"` ahead of `"sourceElo"`
* is how an admin makes futures odds override stored Elo/ratings.
*/
export type SourceEloKey = "sourceElo" | "projectedWins" | "projectedTablePoints" | "sourceOdds";
export const SOURCE_ELO_KEYS: readonly SourceEloKey[] = [
"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",
"projectedWins",
"projectedTablePoints",
];
/** Whether a priority list prefers futures odds over the direct Elo/rating input. */
export function prefersFuturesOdds(priority: SourceEloKey[]): boolean {
const oddsIndex = priority.indexOf("sourceOdds");
const eloIndex = priority.indexOf("sourceElo");
if (oddsIndex === -1) return false;
return eloIndex === -1 || oddsIndex < eloIndex;
}
export interface SimulatorInputPolicy {
missingEloStrategy: MissingEloStrategy;
missingRatingStrategy: MissingRatingStrategy;
/** Ordered preference for deriving a participant's strength input. */
sourceEloPriority: SourceEloKey[];
/**
* Weight (01) given to the futures-odds signal in simulators that blend Elo
* and odds (UCL, World Cup, NCAA Football, MLB, college hockey). The Elo
* signal gets `1 - oddsWeight`. Set to 1.0 to make futures fully override Elo.
*/
oddsWeight: number;
fallbackElo: number;
fallbackEloDelta: number;
eloMin: number;
@ -39,9 +87,13 @@ export interface ResolvedRating {
method: "direct" | "sourceOdds" | MissingRatingStrategy;
}
const DEFAULT_ODDS_WEIGHT = 0.3;
const DEFAULT_POLICY: SimulatorInputPolicy = {
missingEloStrategy: "block",
missingRatingStrategy: "block",
sourceEloPriority: ELO_FIRST_SOURCE_PRIORITY,
oddsWeight: DEFAULT_ODDS_WEIGHT,
fallbackElo: 1400,
fallbackEloDelta: 25,
eloMin: 1100,
@ -80,6 +132,19 @@ function projectionToElo(
return clamp(Math.round(elo), policy.eloMin, policy.eloMax);
}
function parseSourceEloPriority(value: unknown, fallback: SourceEloKey[]): SourceEloKey[] {
if (!Array.isArray(value)) return fallback;
const seen = new Set<SourceEloKey>();
const parsed: SourceEloKey[] = [];
for (const entry of value) {
if (typeof entry === "string" && (SOURCE_ELO_KEYS as readonly string[]).includes(entry) && !seen.has(entry as SourceEloKey)) {
seen.add(entry as SourceEloKey);
parsed.push(entry as SourceEloKey);
}
}
return parsed.length > 0 ? parsed : fallback;
}
export function getSimulatorInputPolicy(config: Record<string, unknown>): SimulatorInputPolicy {
const rawPolicy = isRecord(config.inputPolicy) ? config.inputPolicy : {};
const eloStrategy = rawPolicy.missingEloStrategy;
@ -94,6 +159,14 @@ export function getSimulatorInputPolicy(config: Record<string, unknown>): Simula
ratingStrategy === "fallbackRating" || ratingStrategy === "averageKnown" || ratingStrategy === "worstKnownMinus"
? ratingStrategy
: "block",
sourceEloPriority: parseSourceEloPriority(rawPolicy.sourceEloPriority, DEFAULT_POLICY.sourceEloPriority),
// Allow oddsWeight to live either in inputPolicy (the unified knob) or at the
// top level of config (legacy per-profile defaultConfig), preferring the policy.
oddsWeight: clamp(
optionalNumber(rawPolicy.oddsWeight, optionalNumber(config.oddsWeight, DEFAULT_POLICY.oddsWeight)),
0,
1
),
fallbackElo: optionalNumber(rawPolicy.fallbackElo, DEFAULT_POLICY.fallbackElo),
fallbackEloDelta: optionalNumber(rawPolicy.fallbackEloDelta, DEFAULT_POLICY.fallbackEloDelta),
eloMin: optionalNumber(rawPolicy.eloMin, DEFAULT_POLICY.eloMin),
@ -128,7 +201,18 @@ export function resolveSourceElos(
const alternatives = new Set(profile.derivableInputs?.sourceElo ?? []);
const parityFactor = optionalNumber(config.parityFactor, 400);
// Resolve each participant from the first source in the configured priority
// that (a) is permitted for this simulator and (b) has a value. The direct
// `sourceElo` input is always permitted; the rest must be declared in the
// profile's `derivableInputs.sourceElo`. Reordering the priority so that
// `sourceOdds` precedes `sourceElo` makes freshly entered futures odds
// 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") {
for (const input of inputs) {
if (resolved.has(input.participantId)) continue;
if (input.sourceElo !== null && input.sourceElo !== undefined) {
resolved.set(input.participantId, {
participantId: input.participantId,
@ -137,8 +221,7 @@ export function resolveSourceElos(
});
}
}
if (alternatives.has("projectedWins")) {
} else if (source === "projectedWins") {
const seasonGames = optionalNumber(config.seasonGames, Math.max(...inputs.map((input) => input.projectedWins ?? 0), 1));
for (const input of inputs) {
if (resolved.has(input.participantId) || input.projectedWins === null || input.projectedWins === undefined) continue;
@ -148,9 +231,7 @@ export function resolveSourceElos(
method: "projectedWins",
});
}
}
if (alternatives.has("projectedTablePoints")) {
} else if (source === "projectedTablePoints") {
const seasonGames = optionalNumber(config.seasonGames, 38);
const maxPoints = optionalNumber(config.maxTablePoints, seasonGames * 3);
for (const input of inputs) {
@ -161,21 +242,17 @@ export function resolveSourceElos(
method: "projectedTablePoints",
});
}
}
if (alternatives.has("sourceOdds")) {
// Note: participants resolved above via direct Elo are excluded here, so a
// season can end up mixing direct Elo and odds-derived Elo. Those two
// scales are not jointly calibrated — odds map onto eloMin/eloMax via
// convertFuturesToElo independently of any direct values. This is
// pre-existing behavior of the mixed input case and intentionally left as-is.
} 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.
const oddsInputs = inputs
.filter((input) => !resolved.has(input.participantId) && input.sourceOdds !== null && input.sourceOdds !== undefined)
.map((input) => ({ participantId: input.participantId, odds: input.sourceOdds as number }));
if (oddsInputs.length === 1) {
logger.warn(
`resolveSourceElos: only 1 participant has sourceOdds (${oddsInputs[0].participantId}). ` +
`convertFuturesToElo requires at least 2 to derive a meaningful Elo spread — falling through to missing-Elo strategy.`
`convertFuturesToElo requires at least 2 to derive a meaningful Elo spread — falling through to the next source.`
);
}
if (oddsInputs.length >= 2) {
@ -189,6 +266,7 @@ export function resolveSourceElos(
}
}
}
}
const knownElos = [...resolved.values()].map((value) => value.sourceElo);
const averageKnown = knownElos.length > 0
@ -227,7 +305,14 @@ export function resolveRatings(
const resolved = new Map<string, ResolvedRating>();
const alternatives = new Set(profile.derivableInputs?.rating ?? []);
// Ratings share the configured priority list: the `"sourceElo"` slot stands
// for this simulator's direct strength input (here, `rating`), so ordering
// `"sourceOdds"` ahead of `"sourceElo"` makes futures override the rating too.
// Projection sources do not apply to rating-based simulators and are skipped.
for (const source of policy.sourceEloPriority) {
if (source === "sourceElo") {
for (const input of inputs) {
if (resolved.has(input.participantId)) continue;
if (input.rating !== null && input.rating !== undefined) {
resolved.set(input.participantId, {
participantId: input.participantId,
@ -236,15 +321,14 @@ export function resolveRatings(
});
}
}
if (alternatives.has("sourceOdds")) {
} else if (source === "sourceOdds" && alternatives.has("sourceOdds")) {
const oddsInputs = inputs
.filter((input) => !resolved.has(input.participantId) && input.sourceOdds !== null && input.sourceOdds !== undefined)
.map((input) => ({ participantId: input.participantId, odds: input.sourceOdds as number }));
if (oddsInputs.length === 1) {
logger.warn(
`resolveRatings: only 1 participant has sourceOdds (${oddsInputs[0].participantId}). ` +
`convertFuturesToElo requires at least 2 to derive a meaningful rating — falling through to missing-rating strategy.`
`convertFuturesToElo requires at least 2 to derive a meaningful rating — falling through to the next source.`
);
}
if (oddsInputs.length >= 2) {
@ -262,6 +346,7 @@ export function resolveRatings(
}
}
}
}
const knownRatings = [...resolved.values()].map((value) => value.rating);
const averageKnown = knownRatings.length > 0

View file

@ -40,6 +40,15 @@ const BASE_CONFIG = {
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">> = {
f1_standings: {
defaultConfig: { iterations: 10_000, raceNoise: 0.5, participantVolatility: 1.5 },
@ -72,14 +81,14 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "futuresOdds", "bracket"],
},
ncaam_bracket: {
defaultConfig: { ...BASE_CONFIG, ratingScaleFactor: 7.5, inputPolicy: { ratingMin: -10, ratingMax: 35, fallbackRatingDelta: 5 } },
defaultConfig: { ...BASE_CONFIG, ratingScaleFactor: 7.5, inputPolicy: { ratingMin: -10, ratingMax: 35, fallbackRatingDelta: 5, sourceEloPriority: FUTURES_FIRST_PRIORITY } },
requiredInputs: ["rating"],
optionalInputs: ["sourceOdds", "sourceElo", "seed", "region"],
derivableInputs: { rating: ["sourceOdds"] },
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
},
ncaaw_bracket: {
defaultConfig: { ...BASE_CONFIG, inputPolicy: { ratingMin: 0.70, ratingMax: 0.97, missingRatingStrategy: "worstKnownMinus", fallbackRatingDelta: 0.01 } },
defaultConfig: { ...BASE_CONFIG, inputPolicy: { ratingMin: 0.70, ratingMax: 0.97, missingRatingStrategy: "worstKnownMinus", fallbackRatingDelta: 0.01, sourceEloPriority: FUTURES_FIRST_PRIORITY } },
requiredInputs: ["rating"],
optionalInputs: ["sourceOdds", "seed", "region"],
derivableInputs: { rating: ["sourceOdds"] },
@ -156,7 +165,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "eloRatings", "regularStandings"],
},
world_cup: {
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, eloWeight: 0.7, oddsWeight: 0.3 },
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, eloWeight: 0.7, oddsWeight: 0.3, inputPolicy: { sourceEloPriority: FUTURES_FIRST_PRIORITY, oddsWeight: 0.3 } },
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "worldRanking"],
derivableInputs: { sourceElo: ["sourceOdds"] },
@ -175,7 +184,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"],
},
ncaa_football_bracket: {
defaultConfig: { ...BASE_CONFIG, bracketSize: 12, eloWeight: 0.6, oddsWeight: 0.4 },
defaultConfig: { ...BASE_CONFIG, bracketSize: 12, eloWeight: 0.6, oddsWeight: 0.4, inputPolicy: { sourceEloPriority: FUTURES_FIRST_PRIORITY, oddsWeight: 0.4 } },
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "worldRanking"],
derivableInputs: { sourceElo: ["sourceOdds"] },
@ -188,7 +197,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "futuresOdds"],
},
college_hockey_bracket: {
defaultConfig: { ...BASE_CONFIG, bracketSize: 16, parityFactor: 850 },
defaultConfig: { ...BASE_CONFIG, bracketSize: 16, parityFactor: 850, inputPolicy: { sourceEloPriority: FUTURES_FIRST_PRIORITY } },
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "worldRanking"],
derivableInputs: { sourceElo: ["sourceOdds"] },

View file

@ -73,7 +73,7 @@
import { database } from "~/database/context";
import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
import type { Simulator, SimulationResult, SimulationContext } from "./types";
import { logger } from "~/lib/logger";
import {
convertAmericanOddsToProbability,
@ -450,7 +450,11 @@ function simLeagueBracket(
// ─── Simulator ────────────────────────────────────────────────────────────────
export class MLBSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
async simulate(sportsSeasonId: string, context?: SimulationContext): 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();
// 1. Load all participants for this sports season.
@ -587,7 +591,7 @@ export class MLBSimulator implements Simulator {
// 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 RDIF_WEIGHT * rdifProb + ODDS_WEIGHT * oddsProb;
return rdifWeight * rdifProb + oddsWeight * oddsProb;
};
// ─── Placement count maps ──────────────────────────────────────────────────

View file

@ -64,7 +64,7 @@ import {
convertFuturesToElo,
eloWinProbability,
} from "~/services/probability-engine";
import type { Simulator, SimulationResult } from "./types";
import type { Simulator, SimulationResult, SimulationContext } from "./types";
// ─── Simulation parameters ────────────────────────────────────────────────────
@ -106,7 +106,7 @@ interface Team {
* Blended win probability for team1 vs team2.
* Falls back to pure Elo when no futures data is present.
*/
function blendedWinProb(team1: Team, team2: Team): number {
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) {
@ -116,11 +116,11 @@ function blendedWinProb(team1: Team, team2: Team): number {
const oddsSum = team1.oddsProb + team2.oddsProb;
const oddsProbValue = oddsSum > 0 ? team1.oddsProb / oddsSum : 0.5;
return ELO_WEIGHT * eloProbValue + ODDS_WEIGHT * oddsProbValue;
return (1 - oddsWeight) * eloProbValue + oddsWeight * oddsProbValue;
}
function simGame(team1: Team, team2: Team): { winner: Team; loser: Team } {
const p1Wins = Math.random() < blendedWinProb(team1, team2);
function simGame(team1: Team, team2: Team, oddsWeight: number = ODDS_WEIGHT): { winner: Team; loser: Team } {
const p1Wins = Math.random() < blendedWinProb(team1, team2, oddsWeight);
return p1Wins
? { winner: team1, loser: team2 }
: { winner: team2, loser: team1 };
@ -169,18 +169,18 @@ interface PlacementCounts {
* Semifinals: qf1w vs qf2w, qf3w vs qf4w
* Championship: sf1w vs sf2w
*/
function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>): void {
function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>, oddsWeight: number = ODDS_WEIGHT): void {
// ── First Round (seeds 512) ───────────────────────────────────────────────
const fr1 = simGame(teams[4], teams[11]); // 5 vs 12
const fr2 = simGame(teams[5], teams[10]); // 6 vs 11
const fr3 = simGame(teams[6], teams[9]); // 7 vs 10
const fr4 = simGame(teams[7], teams[8]); // 8 vs 9
const fr1 = simGame(teams[4], teams[11], oddsWeight); // 5 vs 12
const fr2 = simGame(teams[5], teams[10], oddsWeight); // 6 vs 11
const fr3 = simGame(teams[6], teams[9], oddsWeight); // 7 vs 10
const fr4 = simGame(teams[7], teams[8], oddsWeight); // 8 vs 9
// ── Quarterfinals (seeds 14 get byes) ────────────────────────────────────
const qf1 = simGame(teams[0], fr4.winner); // 1 vs 8/9 winner
const qf2 = simGame(teams[3], fr1.winner); // 4 vs 5/12 winner
const qf3 = simGame(teams[2], fr2.winner); // 3 vs 6/11 winner
const qf4 = simGame(teams[1], fr3.winner); // 2 vs 7/10 winner
const qf1 = simGame(teams[0], fr4.winner, oddsWeight); // 1 vs 8/9 winner
const qf2 = simGame(teams[3], fr1.winner, oddsWeight); // 4 vs 5/12 winner
const qf3 = simGame(teams[2], fr2.winner, oddsWeight); // 3 vs 6/11 winner
const qf4 = simGame(teams[1], fr3.winner, oddsWeight); // 2 vs 7/10 winner
const bump = (id: string, key: keyof PlacementCounts) => {
const entry = counts.get(id);
@ -193,14 +193,14 @@ function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>): v
bump(qf4.loser.participantId, "qfLoser");
// ── Semifinals ────────────────────────────────────────────────────────────
const sf1 = simGame(qf1.winner, qf2.winner);
const sf2 = simGame(qf3.winner, qf4.winner);
const sf1 = simGame(qf1.winner, qf2.winner, oddsWeight);
const sf2 = simGame(qf3.winner, qf4.winner, oddsWeight);
bump(sf1.loser.participantId, "sfLoser");
bump(sf2.loser.participantId, "sfLoser");
// ── National Championship ─────────────────────────────────────────────────
const final = simGame(sf1.winner, sf2.winner);
const final = simGame(sf1.winner, sf2.winner, oddsWeight);
bump(final.winner.participantId, "champion");
bump(final.loser.participantId, "finalist");
@ -209,8 +209,11 @@ function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>): v
// ─── Simulator ────────────────────────────────────────────────────────────────
export class NCAAFootballSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
async simulate(sportsSeasonId: string, context?: SimulationContext): Promise<SimulationResult[]> {
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.
const participants = await db
@ -318,7 +321,7 @@ export class NCAAFootballSimulator implements Simulator {
const field = preBracketMode
? sampleBracketField(allTeams, BRACKET_SIZE)
: (deterministicField ?? []);
simulateBracket(field, counts);
simulateBracket(field, counts, oddsWeight);
}
// 7. Convert counts to probability distributions.

View file

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

View file

@ -27,9 +27,22 @@ export interface SimulationResult {
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.
*/
export interface Simulator {
simulate(sportsSeasonId: string): Promise<SimulationResult[]>;
simulate(sportsSeasonId: string, context?: SimulationContext): Promise<SimulationResult[]>;
}

View file

@ -38,7 +38,7 @@ import { database } from "~/database/context";
import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema";
import { convertFuturesToElo, eloWinProbability } from "~/services/probability-engine";
import type { Simulator, SimulationResult } from "./types";
import type { Simulator, SimulationResult, SimulationContext } from "./types";
// ─── Simulation parameters ────────────────────────────────────────────────────
@ -62,8 +62,12 @@ export function americanToImpliedProb(odds: number): number {
// ─── Simulator ────────────────────────────────────────────────────────────────
export class UCLSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
async simulate(sportsSeasonId: string, context?: SimulationContext): Promise<SimulationResult[]> {
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.
// UCL has exactly one playoff_game event per season.
@ -205,7 +209,7 @@ export class UCLSimulator implements Simulator {
const o2 = normalizedOddsMap.get(p2) ?? fallbackProb;
const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5;
return ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb;
return eloWeight * eloProb + oddsWeight * oddsProb;
};
const simMatch = (p1: string, p2: string): { winner: string; loser: string } => {

View file

@ -44,7 +44,7 @@ import { normalizeSimulationResultColumns } from "./simulation-probabilities";
import { logger } from "~/lib/logger";
import { FIFA_48 } from "~/lib/bracket-templates";
import { FIFA_2026_R32_TEMPLATE, assignThirdPlaceSlots } from "~/lib/fifa-2026-bracket";
import type { Simulator, SimulationResult } from "./types";
import type { Simulator, SimulationResult, SimulationContext } from "./types";
// ─── Name normalisation (same logic as elo-ratings bulk-import fuzzy match) ──
@ -275,11 +275,12 @@ function simKnockout(
teamA: string,
teamB: string,
eloFn: (id: string) => number,
normalizedProb: (id: string) => number
normalizedProb: (id: string) => number,
oddsWeight: number = ODDS_WEIGHT
): { winner: string; loser: string } {
const eloProb = eloWinProbability(eloFn(teamA), eloFn(teamB));
const oddsProb = normalizedProb(teamA);
const blended = ELO_WEIGHT * eloProb + ODDS_WEIGHT * (0.5 + (oddsProb - 0.5));
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 };
}
@ -319,7 +320,8 @@ function runKnockoutRoundByNumber(
matches: Map<number, KnockoutMatch>,
completed: Map<string, { winnerId: string; loserId: string }>,
eloFn: (id: string) => number,
normalizedProb: (id: string) => number
normalizedProb: (id: string) => number,
oddsWeight: number = ODDS_WEIGHT
): {
winnersByNumber: Map<number, string>;
losersByNumber: Map<number, string>;
@ -348,7 +350,7 @@ function runKnockoutRoundByNumber(
winnerId = fixed.winnerId;
loserId = fixed.loserId;
} else if (m.p1 && m.p2) {
const result = simKnockout(m.p1, m.p2, eloFn, normalizedProb);
const result = simKnockout(m.p1, m.p2, eloFn, normalizedProb, oddsWeight);
winnerId = result.winner;
loserId = result.loser;
} else if (m.p1 || m.p2) {
@ -446,7 +448,10 @@ export class WorldCupSimulator implements Simulator {
this.numSimulations = numSimulations;
}
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
async simulate(sportsSeasonId: string, context?: SimulationContext): 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();
// 1. Load all participants for this season
@ -714,10 +719,10 @@ export class WorldCupSimulator implements Simulator {
// ── Knockout rounds ──────────────────────────────────────────
// R32 → R16 → QF → SF → Third Place Game + Final, advancing via the
// canonical ceil(n/2) tree. Completed matches lock in real results.
const r32 = runKnockoutRoundByNumber(R32, buildR32(), completedByRoundAndNumber, eloFn, normalizedProb);
const r16 = runKnockoutRoundByNumber(R16, r32.nextRound, completedByRoundAndNumber, eloFn, normalizedProb);
const qf = runKnockoutRoundByNumber(QF, r16.nextRound, completedByRoundAndNumber, eloFn, normalizedProb);
const sf = runKnockoutRoundByNumber(SF, qf.nextRound, completedByRoundAndNumber, eloFn, normalizedProb);
const r32 = runKnockoutRoundByNumber(R32, buildR32(), completedByRoundAndNumber, eloFn, normalizedProb, oddsWeight);
const r16 = runKnockoutRoundByNumber(R16, r32.nextRound, completedByRoundAndNumber, eloFn, normalizedProb, oddsWeight);
const qf = runKnockoutRoundByNumber(QF, r16.nextRound, completedByRoundAndNumber, eloFn, normalizedProb, oddsWeight);
const sf = runKnockoutRoundByNumber(SF, qf.nextRound, completedByRoundAndNumber, eloFn, normalizedProb, oddsWeight);
// QF losers (placements 58)
for (const id of qf.losersByNumber.values()) {
@ -734,7 +739,7 @@ export class WorldCupSimulator implements Simulator {
counts.fourthPlace.set(fixed3pg.loserId, (counts.fourthPlace.get(fixed3pg.loserId) ?? 0) + 1);
} else {
const { winner: thirdWinner, loser: thirdLoser } = simKnockout(
sf1Loser, sf2Loser, eloFn, normalizedProb
sf1Loser, sf2Loser, eloFn, normalizedProb, oddsWeight
);
counts.thirdPlace.set(thirdWinner, (counts.thirdPlace.get(thirdWinner) ?? 0) + 1);
counts.fourthPlace.set(thirdLoser, (counts.fourthPlace.get(thirdLoser) ?? 0) + 1);
@ -751,7 +756,7 @@ export class WorldCupSimulator implements Simulator {
counts.runnerUp.set(fixedFinal.loserId, (counts.runnerUp.get(fixedFinal.loserId) ?? 0) + 1);
} else {
const { winner: champion, loser: runnerUp } = simKnockout(
sfWinner1, sfWinner2, eloFn, normalizedProb
sfWinner1, sfWinner2, eloFn, normalizedProb, oddsWeight
);
counts.champion.set(champion, (counts.champion.get(champion) ?? 0) + 1);
counts.runnerUp.set(runnerUp, (counts.runnerUp.get(runnerUp) ?? 0) + 1);