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:
parent
ab74b19420
commit
dc4efe87cf
17 changed files with 523 additions and 221 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (0–1)</Label>
|
||||
<Input id="oddsWeight" name="oddsWeight" type="number" step="0.05" min="0" max="1" defaultValue={inputPolicy.oddsWeight} />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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">
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) ───────────────
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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 (0–1) 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
|
||||
|
|
|
|||
|
|
@ -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"] },
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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 5–12) ───────────────────────────────────────────────
|
||||
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 1–4 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.
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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 (0–1) 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[]>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } => {
|
||||
|
|
|
|||
|
|
@ -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 5–8)
|
||||
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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue