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>;
|
type SetPayload = Record<string, unknown>;
|
||||||
|
|
||||||
const setCalls: SetPayload[] = [];
|
|
||||||
const conflictSetCalls: SetPayload[] = [];
|
const conflictSetCalls: SetPayload[] = [];
|
||||||
|
|
||||||
const tx = {
|
const onConflictDoUpdate = vi.fn((arg: { set: SetPayload }) => {
|
||||||
update: vi.fn(() => ({
|
conflictSetCalls.push(arg.set);
|
||||||
set: vi.fn((arg: SetPayload) => {
|
return Promise.resolve(undefined);
|
||||||
setCalls.push(arg);
|
});
|
||||||
return { where: vi.fn().mockResolvedValue(undefined) };
|
|
||||||
}),
|
|
||||||
})),
|
|
||||||
insert: vi.fn(() => ({
|
|
||||||
values: vi.fn(() => ({
|
|
||||||
onConflictDoUpdate: vi.fn((arg: { set: SetPayload }) => {
|
|
||||||
conflictSetCalls.push(arg.set);
|
|
||||||
return Promise.resolve(undefined);
|
|
||||||
}),
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockDb = {
|
const mockDb = {
|
||||||
transaction: vi.fn(async (cb: (t: typeof tx) => Promise<void>) => cb(tx)),
|
insert: vi.fn(() => ({
|
||||||
|
values: vi.fn(() => ({ onConflictDoUpdate })),
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
setCalls.length = 0;
|
|
||||||
conflictSetCalls.length = 0;
|
conflictSetCalls.length = 0;
|
||||||
(database as ReturnType<typeof vi.fn>).mockReturnValue(mockDb);
|
(database as ReturnType<typeof vi.fn>).mockReturnValue(mockDb);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("batchSaveFuturesOddsForSimulator", () => {
|
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([
|
await batchSaveFuturesOddsForSimulator([
|
||||||
{ participantId: "team-1", sportsSeasonId: "season-1", sourceOdds: 550 },
|
{ participantId: "team-1", sportsSeasonId: "season-1", sourceOdds: 550 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Pre-update clears the stale rating and sourceElo for these participants.
|
// The upsert writes the odds and leaves Elo/rating untouched — whether the
|
||||||
expect(setCalls).toHaveLength(1);
|
// odds override them is now decided by the configurable source priority,
|
||||||
expect(setCalls[0]).toMatchObject({ rating: null, sourceElo: null });
|
// not by nulling stored values here.
|
||||||
// Metadata is rewritten (an SQL fragment that strips ratingMethod/sourceEloMethod).
|
expect(mockDb.insert).toHaveBeenCalledTimes(1);
|
||||||
expect(setCalls[0].metadata).toBeDefined();
|
|
||||||
|
|
||||||
// The upsert conflict path also nulls both so re-entering odds stays clean.
|
|
||||||
expect(conflictSetCalls).toHaveLength(1);
|
expect(conflictSetCalls).toHaveLength(1);
|
||||||
expect(conflictSetCalls[0]).toMatchObject({ rating: null, sourceElo: null });
|
expect(conflictSetCalls[0]).toHaveProperty("sourceOdds");
|
||||||
expect(conflictSetCalls[0].metadata).toBeDefined();
|
expect(conflictSetCalls[0]).not.toHaveProperty("sourceElo");
|
||||||
|
expect(conflictSetCalls[0]).not.toHaveProperty("rating");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is a no-op when given no inputs", async () => {
|
it("is a no-op when given no inputs", async () => {
|
||||||
await batchSaveFuturesOddsForSimulator([]);
|
await batchSaveFuturesOddsForSimulator([]);
|
||||||
expect(mockDb.transaction).not.toHaveBeenCalled();
|
expect(mockDb.insert).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -393,10 +393,12 @@ export async function batchSaveSourceOdds(
|
||||||
if (existing.length > 0) {
|
if (existing.length > 0) {
|
||||||
await tx
|
await tx
|
||||||
.update(seasonParticipantExpectedValues)
|
.update(seasonParticipantExpectedValues)
|
||||||
// Clear any stale Elo and mark the source as odds-driven so the
|
// Persist the odds and label the source as odds-driven for display.
|
||||||
// simulator re-derives Elo from these odds and the elo-ratings page
|
// We no longer null a stored Elo here: whether these odds override it
|
||||||
// loader doesn't resurrect an outdated rating.
|
// is decided at run time by the configurable source priority
|
||||||
.set({ sourceOdds, sourceElo: null, source: "futures_odds", updatedAt: now })
|
// (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));
|
.where(eq(seasonParticipantExpectedValues.id, existing[0].id));
|
||||||
} else {
|
} else {
|
||||||
// Insert a stub record — probabilities/EV will be filled in by the simulator
|
// 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 { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import {
|
import {
|
||||||
|
|
@ -8,6 +8,8 @@ import {
|
||||||
type SimulatorManifestProfile,
|
type SimulatorManifestProfile,
|
||||||
} from "~/services/simulations/manifest";
|
} from "~/services/simulations/manifest";
|
||||||
import {
|
import {
|
||||||
|
getSimulatorInputPolicy,
|
||||||
|
prefersFuturesOdds,
|
||||||
ratingRequirementLabel,
|
ratingRequirementLabel,
|
||||||
resolveRatings,
|
resolveRatings,
|
||||||
resolveSourceElos,
|
resolveSourceElos,
|
||||||
|
|
@ -82,6 +84,12 @@ export interface SportsSeasonSimulatorSummary {
|
||||||
participantInputCount: number;
|
participantInputCount: number;
|
||||||
lastSimulatedDate: string | null;
|
lastSimulatedDate: string | null;
|
||||||
readiness: SimulatorReadiness;
|
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 {
|
function parseDecimal(value: string | null | undefined): number | null {
|
||||||
|
|
@ -359,57 +367,32 @@ export async function batchSaveFuturesOddsForSimulator(
|
||||||
if (inputs.length === 0) return;
|
if (inputs.length === 0) return;
|
||||||
const db = database();
|
const db = database();
|
||||||
const now = new Date();
|
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) => {
|
// Persist the odds non-destructively. Whether these odds override a stored
|
||||||
|
// Elo/rating is now governed by the season's configurable source priority
|
||||||
// Clear ALL ratings AND sourceElo (both manual and generated) so the
|
// (see resolveSourceElos / SimulatorInputPolicy.sourceEloPriority), so we no
|
||||||
// simulation re-derives them from the newly saved futures odds. For
|
// longer null out manually entered Elo here — that policy decides at run time.
|
||||||
// Elo-based simulators, a stale `sourceElo` (e.g. a manually entered
|
await db
|
||||||
// "direct" Elo) would otherwise win the resolveSourceElos precedence and
|
.insert(schema.seasonParticipantSimulatorInputs)
|
||||||
// cause the futures odds to be silently ignored. Nulling it here lets the
|
.values(
|
||||||
// sourceOdds -> convertFuturesToElo branch drive the run.
|
inputs.map((input) => ({
|
||||||
await tx
|
participantId: input.participantId,
|
||||||
.update(schema.seasonParticipantSimulatorInputs)
|
sportsSeasonId: input.sportsSeasonId,
|
||||||
.set({
|
sourceOdds: input.sourceOdds,
|
||||||
rating: null,
|
createdAt: now,
|
||||||
sourceElo: null,
|
|
||||||
metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod' - 'sourceEloMethod'`,
|
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
}))
|
||||||
.where(
|
)
|
||||||
and(
|
.onConflictDoUpdate({
|
||||||
inArray(schema.seasonParticipantSimulatorInputs.sportsSeasonId, seasonIds),
|
target: [
|
||||||
inArray(schema.seasonParticipantSimulatorInputs.participantId, participantIds)
|
schema.seasonParticipantSimulatorInputs.participantId,
|
||||||
)
|
schema.seasonParticipantSimulatorInputs.sportsSeasonId,
|
||||||
);
|
],
|
||||||
|
set: {
|
||||||
await tx
|
sourceOdds: sql`excluded.source_odds`,
|
||||||
.insert(schema.seasonParticipantSimulatorInputs)
|
updatedAt: now,
|
||||||
.values(
|
},
|
||||||
inputs.map((input) => ({
|
});
|
||||||
participantId: input.participantId,
|
|
||||||
sportsSeasonId: input.sportsSeasonId,
|
|
||||||
sourceOdds: input.sourceOdds,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: [
|
|
||||||
schema.seasonParticipantSimulatorInputs.participantId,
|
|
||||||
schema.seasonParticipantSimulatorInputs.sportsSeasonId,
|
|
||||||
],
|
|
||||||
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(
|
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 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).
|
// Each season calls getSimulatorProfile + validateSimulatorReadiness (~4 queries each).
|
||||||
// Acceptable for an admin-only page; revisit if season counts grow past ~50.
|
// Acceptable for an admin-only page; revisit if season counts grow past ~50.
|
||||||
const summaries = await Promise.all(
|
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 simulatorType = (season.simulatorConfig?.simulatorType ?? season.sport.simulatorType) as SimulatorType;
|
||||||
const profile = await getSimulatorProfile(simulatorType);
|
const profile = await getSimulatorProfile(simulatorType);
|
||||||
const readiness = await validateSimulatorReadiness(season.id);
|
const readiness = await validateSimulatorReadiness(season.id);
|
||||||
|
const mergedConfig = { ...profile.defaultConfig, ...(season.simulatorConfig?.config ?? {}) };
|
||||||
|
const policy = getSimulatorInputPolicy(mergedConfig);
|
||||||
return {
|
return {
|
||||||
sportsSeasonId: season.id,
|
sportsSeasonId: season.id,
|
||||||
seasonName: season.name,
|
seasonName: season.name,
|
||||||
|
|
@ -693,6 +695,9 @@ export async function listSportsSeasonSimulatorSummaries(): Promise<SportsSeason
|
||||||
participantInputCount: readiness.participantInputCount,
|
participantInputCount: readiness.participantInputCount,
|
||||||
lastSimulatedDate: lastSnapshotBySeason.get(season.id) ?? null,
|
lastSimulatedDate: lastSnapshotBySeason.get(season.id) ?? null,
|
||||||
readiness,
|
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>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
<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>
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
{sim.lastSimulatedDate ?? "Never"}
|
{sim.lastSimulatedDate ?? "Never"}
|
||||||
|
|
@ -258,6 +265,11 @@ export default function AdminSimulators({ loaderData }: Route.ComponentProps) {
|
||||||
Setup
|
Setup
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</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>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<Link to={`/admin/sports-seasons/${sim.sportsSeasonId}/expected-values`}>EVs</Link>
|
<Link to={`/admin/sports-seasons/${sim.sportsSeasonId}/expected-values`}>EVs</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,14 @@ import {
|
||||||
type UpsertParticipantSimulatorInput,
|
type UpsertParticipantSimulatorInput,
|
||||||
} from "~/models/simulator";
|
} from "~/models/simulator";
|
||||||
import { normalizeName } from "~/lib/fuzzy-match";
|
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";
|
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
|
||||||
|
|
||||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
|
|
@ -205,6 +212,11 @@ export async function action({ request, params }: Route.ActionArgs): Promise<Act
|
||||||
inputPolicy: {
|
inputPolicy: {
|
||||||
missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")),
|
missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")),
|
||||||
missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")),
|
missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")),
|
||||||
|
sourceEloPriority:
|
||||||
|
formData.get("sourceStrategy") === "futuresFirst"
|
||||||
|
? FUTURES_FIRST_SOURCE_PRIORITY
|
||||||
|
: ELO_FIRST_SOURCE_PRIORITY,
|
||||||
|
oddsWeight: parsePolicyNumber(formData, "oddsWeight", currentPolicy.oddsWeight),
|
||||||
fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo),
|
fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo),
|
||||||
fallbackEloDelta: parsePolicyNumber(formData, "fallbackEloDelta", currentPolicy.fallbackEloDelta),
|
fallbackEloDelta: parsePolicyNumber(formData, "fallbackEloDelta", currentPolicy.fallbackEloDelta),
|
||||||
eloMin: parsePolicyNumber(formData, "eloMin", currentPolicy.eloMin),
|
eloMin: parsePolicyNumber(formData, "eloMin", currentPolicy.eloMin),
|
||||||
|
|
@ -361,6 +373,29 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Form method="post" className="grid gap-4 md:grid-cols-5">
|
<Form method="post" className="grid gap-4 md:grid-cols-5">
|
||||||
<input type="hidden" name="intent" value="save-input-policy" />
|
<input type="hidden" name="intent" value="save-input-policy" />
|
||||||
|
<div className="space-y-2 md:col-span-3">
|
||||||
|
<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") && (
|
{config.profile.requiredInputs.includes("sourceElo") && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
import { describe, expect, it } from "vitest";
|
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";
|
import type { SimulatorManifestProfile } from "../manifest";
|
||||||
|
|
||||||
const profile = {
|
const profile = {
|
||||||
|
|
@ -183,6 +190,86 @@ describe("simulator input policy", () => {
|
||||||
expect(resolved.get("missing-tail")).toMatchObject({ rating: -10, method: "worstKnownMinus" });
|
expect(resolved.get("missing-tail")).toMatchObject({ rating: -10, method: "worstKnownMinus" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lets a futures-first priority override a stored direct Elo", () => {
|
||||||
|
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", () => {
|
it("uses NCAAW-style rating scale when deriving and falling back", () => {
|
||||||
const resolved = resolveRatings(
|
const resolved = resolveRatings(
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { SIMULATOR_MANIFEST } from "../manifest";
|
import { SIMULATOR_MANIFEST } from "../manifest";
|
||||||
import { SIMULATOR_TYPES } from "../registry";
|
import { SIMULATOR_TYPES } from "../registry";
|
||||||
|
import { getSimulatorInputPolicy, prefersFuturesOdds } from "../input-policy";
|
||||||
import { assertRegistrySchemaDriftFree } from "~/models/simulator";
|
import { assertRegistrySchemaDriftFree } from "~/models/simulator";
|
||||||
|
|
||||||
describe("simulator manifest", () => {
|
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", () => {
|
it("keeps EPL projection and match parity as separate config knobs", () => {
|
||||||
expect(SIMULATOR_MANIFEST.epl_standings.defaultConfig).toMatchObject({
|
expect(SIMULATOR_MANIFEST.epl_standings.defaultConfig).toMatchObject({
|
||||||
parityFactor: 400,
|
parityFactor: 400,
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,35 @@ describe("NCAAFootballSimulator", () => {
|
||||||
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
||||||
expect(total).toBeCloseTo(1.0, 1);
|
expect(total).toBeCloseTo(1.0, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("honors the oddsWeight context when odds and Elo disagree", async () => {
|
||||||
|
// team-12 has the worst Elo but by far the best futures odds.
|
||||||
|
function setupConflicting() {
|
||||||
|
const participants = makeParticipants(TEAM_IDS);
|
||||||
|
const evRows = TEAM_IDS.map((participantId, i) => ({
|
||||||
|
participantId,
|
||||||
|
sourceElo: 1700 - i * 50,
|
||||||
|
sourceOdds: i === 11 ? -100000 : 50000,
|
||||||
|
}));
|
||||||
|
mockDb.select.mockImplementation(() => {
|
||||||
|
const callIndex = selectCallCount++;
|
||||||
|
const data = callIndex === 0 ? participants : evRows;
|
||||||
|
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupConflicting();
|
||||||
|
const pureOdds = await new NCAAFootballSimulator().simulate("season-1", { config: {}, oddsWeight: 1 });
|
||||||
|
selectCallCount = 0;
|
||||||
|
setupConflicting();
|
||||||
|
const pureElo = await new NCAAFootballSimulator().simulate("season-1", { config: {}, oddsWeight: 0 });
|
||||||
|
|
||||||
|
const champByOdds = pureOdds.find((r) => r.participantId === "team-12")?.probabilities.probFirst ?? 0;
|
||||||
|
const champByElo = pureElo.find((r) => r.participantId === "team-12")?.probabilities.probFirst ?? 0;
|
||||||
|
// With the odds signal fully weighted, the odds-favored team wins far more
|
||||||
|
// often than when only its (worst) Elo drives the result.
|
||||||
|
expect(champByOdds).toBeGreaterThan(champByElo);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Pre-bracket mode (>12 teams — probabilistic selection) ───────────────
|
// ── Pre-bracket mode (>12 teams — probabilistic selection) ───────────────
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,12 @@ vi.mock("~/models/ev-snapshot", () => ({
|
||||||
vi.mock("~/models/scoring-calculator", () => ({
|
vi.mock("~/models/scoring-calculator", () => ({
|
||||||
recalculateStandings: vi.fn(),
|
recalculateStandings: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("~/services/simulations/registry", () => ({
|
vi.mock("~/services/simulations/registry", async (importOriginal) => {
|
||||||
getSimulator: vi.fn(),
|
// 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", () => ({
|
vi.mock("~/services/simulations/simulation-probabilities", () => ({
|
||||||
normalizeSimulationResultColumns: vi.fn(),
|
normalizeSimulationResultColumns: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,57 @@ import { logger } from "~/lib/logger";
|
||||||
export type MissingEloStrategy = "block" | "fallbackElo" | "averageKnown" | "worstKnownMinus";
|
export type MissingEloStrategy = "block" | "fallbackElo" | "averageKnown" | "worstKnownMinus";
|
||||||
export type MissingRatingStrategy = "block" | "fallbackRating" | "averageKnown" | "worstKnownMinus";
|
export type MissingRatingStrategy = "block" | "fallbackRating" | "averageKnown" | "worstKnownMinus";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sources a participant's strength input can be derived from, in the order the
|
||||||
|
* 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 {
|
export interface SimulatorInputPolicy {
|
||||||
missingEloStrategy: MissingEloStrategy;
|
missingEloStrategy: MissingEloStrategy;
|
||||||
missingRatingStrategy: MissingRatingStrategy;
|
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;
|
fallbackElo: number;
|
||||||
fallbackEloDelta: number;
|
fallbackEloDelta: number;
|
||||||
eloMin: number;
|
eloMin: number;
|
||||||
|
|
@ -39,9 +87,13 @@ export interface ResolvedRating {
|
||||||
method: "direct" | "sourceOdds" | MissingRatingStrategy;
|
method: "direct" | "sourceOdds" | MissingRatingStrategy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_ODDS_WEIGHT = 0.3;
|
||||||
|
|
||||||
const DEFAULT_POLICY: SimulatorInputPolicy = {
|
const DEFAULT_POLICY: SimulatorInputPolicy = {
|
||||||
missingEloStrategy: "block",
|
missingEloStrategy: "block",
|
||||||
missingRatingStrategy: "block",
|
missingRatingStrategy: "block",
|
||||||
|
sourceEloPriority: ELO_FIRST_SOURCE_PRIORITY,
|
||||||
|
oddsWeight: DEFAULT_ODDS_WEIGHT,
|
||||||
fallbackElo: 1400,
|
fallbackElo: 1400,
|
||||||
fallbackEloDelta: 25,
|
fallbackEloDelta: 25,
|
||||||
eloMin: 1100,
|
eloMin: 1100,
|
||||||
|
|
@ -80,6 +132,19 @@ function projectionToElo(
|
||||||
return clamp(Math.round(elo), policy.eloMin, policy.eloMax);
|
return clamp(Math.round(elo), policy.eloMin, policy.eloMax);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseSourceEloPriority(value: unknown, fallback: SourceEloKey[]): SourceEloKey[] {
|
||||||
|
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 {
|
export function getSimulatorInputPolicy(config: Record<string, unknown>): SimulatorInputPolicy {
|
||||||
const rawPolicy = isRecord(config.inputPolicy) ? config.inputPolicy : {};
|
const rawPolicy = isRecord(config.inputPolicy) ? config.inputPolicy : {};
|
||||||
const eloStrategy = rawPolicy.missingEloStrategy;
|
const eloStrategy = rawPolicy.missingEloStrategy;
|
||||||
|
|
@ -94,6 +159,14 @@ export function getSimulatorInputPolicy(config: Record<string, unknown>): Simula
|
||||||
ratingStrategy === "fallbackRating" || ratingStrategy === "averageKnown" || ratingStrategy === "worstKnownMinus"
|
ratingStrategy === "fallbackRating" || ratingStrategy === "averageKnown" || ratingStrategy === "worstKnownMinus"
|
||||||
? ratingStrategy
|
? ratingStrategy
|
||||||
: "block",
|
: "block",
|
||||||
|
sourceEloPriority: parseSourceEloPriority(rawPolicy.sourceEloPriority, DEFAULT_POLICY.sourceEloPriority),
|
||||||
|
// 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),
|
fallbackElo: optionalNumber(rawPolicy.fallbackElo, DEFAULT_POLICY.fallbackElo),
|
||||||
fallbackEloDelta: optionalNumber(rawPolicy.fallbackEloDelta, DEFAULT_POLICY.fallbackEloDelta),
|
fallbackEloDelta: optionalNumber(rawPolicy.fallbackEloDelta, DEFAULT_POLICY.fallbackEloDelta),
|
||||||
eloMin: optionalNumber(rawPolicy.eloMin, DEFAULT_POLICY.eloMin),
|
eloMin: optionalNumber(rawPolicy.eloMin, DEFAULT_POLICY.eloMin),
|
||||||
|
|
@ -128,65 +201,70 @@ export function resolveSourceElos(
|
||||||
const alternatives = new Set(profile.derivableInputs?.sourceElo ?? []);
|
const alternatives = new Set(profile.derivableInputs?.sourceElo ?? []);
|
||||||
const parityFactor = optionalNumber(config.parityFactor, 400);
|
const parityFactor = optionalNumber(config.parityFactor, 400);
|
||||||
|
|
||||||
for (const input of inputs) {
|
// Resolve each participant from the first source in the configured priority
|
||||||
if (input.sourceElo !== null && input.sourceElo !== undefined) {
|
// that (a) is permitted for this simulator and (b) has a value. The direct
|
||||||
resolved.set(input.participantId, {
|
// `sourceElo` input is always permitted; the rest must be declared in the
|
||||||
participantId: input.participantId,
|
// profile's `derivableInputs.sourceElo`. Reordering the priority so that
|
||||||
sourceElo: input.sourceElo,
|
// `sourceOdds` precedes `sourceElo` makes freshly entered futures odds
|
||||||
method: "direct",
|
// 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 (alternatives.has("projectedWins")) {
|
if (source === "sourceElo") {
|
||||||
const seasonGames = optionalNumber(config.seasonGames, Math.max(...inputs.map((input) => input.projectedWins ?? 0), 1));
|
for (const input of inputs) {
|
||||||
for (const input of inputs) {
|
if (resolved.has(input.participantId)) continue;
|
||||||
if (resolved.has(input.participantId) || input.projectedWins === null || input.projectedWins === undefined) continue;
|
if (input.sourceElo !== null && input.sourceElo !== undefined) {
|
||||||
resolved.set(input.participantId, {
|
resolved.set(input.participantId, {
|
||||||
participantId: input.participantId,
|
participantId: input.participantId,
|
||||||
sourceElo: projectionToElo(input.projectedWins, seasonGames, parityFactor, policy),
|
sourceElo: input.sourceElo,
|
||||||
method: "projectedWins",
|
method: "direct",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (source === "projectedWins") {
|
||||||
if (alternatives.has("projectedTablePoints")) {
|
const seasonGames = optionalNumber(config.seasonGames, Math.max(...inputs.map((input) => input.projectedWins ?? 0), 1));
|
||||||
const seasonGames = optionalNumber(config.seasonGames, 38);
|
for (const input of inputs) {
|
||||||
const maxPoints = optionalNumber(config.maxTablePoints, seasonGames * 3);
|
if (resolved.has(input.participantId) || input.projectedWins === null || input.projectedWins === undefined) continue;
|
||||||
for (const input of inputs) {
|
resolved.set(input.participantId, {
|
||||||
if (resolved.has(input.participantId) || input.projectedTablePoints === null || input.projectedTablePoints === undefined) continue;
|
participantId: input.participantId,
|
||||||
resolved.set(input.participantId, {
|
sourceElo: projectionToElo(input.projectedWins, seasonGames, parityFactor, policy),
|
||||||
participantId: input.participantId,
|
method: "projectedWins",
|
||||||
sourceElo: projectionToElo(input.projectedTablePoints, maxPoints, parityFactor, policy),
|
|
||||||
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.
|
|
||||||
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.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (oddsInputs.length >= 2) {
|
|
||||||
const converted = convertFuturesToElo(oddsInputs);
|
|
||||||
for (const [participantId, elo] of converted) {
|
|
||||||
resolved.set(participantId, {
|
|
||||||
participantId,
|
|
||||||
sourceElo: clamp(Math.round(elo), policy.eloMin, policy.eloMax),
|
|
||||||
method: "sourceOdds",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (source === "projectedTablePoints") {
|
||||||
|
const seasonGames = optionalNumber(config.seasonGames, 38);
|
||||||
|
const maxPoints = optionalNumber(config.maxTablePoints, seasonGames * 3);
|
||||||
|
for (const input of inputs) {
|
||||||
|
if (resolved.has(input.participantId) || input.projectedTablePoints === null || input.projectedTablePoints === undefined) continue;
|
||||||
|
resolved.set(input.participantId, {
|
||||||
|
participantId: input.participantId,
|
||||||
|
sourceElo: projectionToElo(input.projectedTablePoints, maxPoints, parityFactor, policy),
|
||||||
|
method: "projectedTablePoints",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (source === "sourceOdds") {
|
||||||
|
// Odds-derived Elo maps onto eloMin/eloMax via convertFuturesToElo; when
|
||||||
|
// mixed with direct Elo for other participants the two scales are not
|
||||||
|
// jointly calibrated. That mixed case is intentionally left as-is.
|
||||||
|
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 the next source.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (oddsInputs.length >= 2) {
|
||||||
|
const converted = convertFuturesToElo(oddsInputs);
|
||||||
|
for (const [participantId, elo] of converted) {
|
||||||
|
resolved.set(participantId, {
|
||||||
|
participantId,
|
||||||
|
sourceElo: clamp(Math.round(elo), policy.eloMin, policy.eloMax),
|
||||||
|
method: "sourceOdds",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -227,38 +305,45 @@ export function resolveRatings(
|
||||||
const resolved = new Map<string, ResolvedRating>();
|
const resolved = new Map<string, ResolvedRating>();
|
||||||
const alternatives = new Set(profile.derivableInputs?.rating ?? []);
|
const alternatives = new Set(profile.derivableInputs?.rating ?? []);
|
||||||
|
|
||||||
for (const input of inputs) {
|
// Ratings share the configured priority list: the `"sourceElo"` slot stands
|
||||||
if (input.rating !== null && input.rating !== undefined) {
|
// for this simulator's direct strength input (here, `rating`), so ordering
|
||||||
resolved.set(input.participantId, {
|
// `"sourceOdds"` ahead of `"sourceElo"` makes futures override the rating too.
|
||||||
participantId: input.participantId,
|
// Projection sources do not apply to rating-based simulators and are skipped.
|
||||||
rating: input.rating,
|
for (const source of policy.sourceEloPriority) {
|
||||||
method: "direct",
|
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, {
|
||||||
if (alternatives.has("sourceOdds")) {
|
participantId: input.participantId,
|
||||||
const oddsInputs = inputs
|
rating: input.rating,
|
||||||
.filter((input) => !resolved.has(input.participantId) && input.sourceOdds !== null && input.sourceOdds !== undefined)
|
method: "direct",
|
||||||
.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}). ` +
|
} else if (source === "sourceOdds" && alternatives.has("sourceOdds")) {
|
||||||
`convertFuturesToElo requires at least 2 to derive a meaningful rating — falling through to missing-rating strategy.`
|
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 >= 2) {
|
if (oddsInputs.length === 1) {
|
||||||
const converted = convertFuturesToElo(oddsInputs, "american", {
|
logger.warn(
|
||||||
exponent: 0.33,
|
`resolveRatings: only 1 participant has sourceOdds (${oddsInputs[0].participantId}). ` +
|
||||||
eloMin: policy.ratingMin,
|
`convertFuturesToElo requires at least 2 to derive a meaningful rating — falling through to the next source.`
|
||||||
eloMax: policy.ratingMax,
|
);
|
||||||
});
|
}
|
||||||
for (const [participantId, rating] of converted) {
|
if (oddsInputs.length >= 2) {
|
||||||
resolved.set(participantId, {
|
const converted = convertFuturesToElo(oddsInputs, "american", {
|
||||||
participantId,
|
exponent: 0.33,
|
||||||
rating: clamp(rating, policy.ratingMin, policy.ratingMax),
|
eloMin: policy.ratingMin,
|
||||||
method: "sourceOdds",
|
eloMax: policy.ratingMax,
|
||||||
});
|
});
|
||||||
|
for (const [participantId, rating] of converted) {
|
||||||
|
resolved.set(participantId, {
|
||||||
|
participantId,
|
||||||
|
rating: clamp(rating, policy.ratingMin, policy.ratingMax),
|
||||||
|
method: "sourceOdds",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,15 @@ const BASE_CONFIG = {
|
||||||
iterations: 50_000,
|
iterations: 50_000,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Source-resolution priority that makes freshly entered futures odds win over a
|
||||||
|
// stored Elo/rating (and projections). Used as the default for simulators where
|
||||||
|
// futures odds are a deliberate, primary input (those exposing a Futures Odds
|
||||||
|
// setup section). Admins can still override this per season in the Input Policy.
|
||||||
|
// Kept as a local literal (not imported from input-policy) to avoid a circular
|
||||||
|
// module dependency that would be evaluated before input-policy's consts exist.
|
||||||
|
// Mirrors FUTURES_FIRST_SOURCE_PRIORITY in input-policy.ts.
|
||||||
|
const FUTURES_FIRST_PRIORITY = ["sourceOdds", "sourceElo", "projectedWins", "projectedTablePoints"] as const;
|
||||||
|
|
||||||
const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorType" | "displayName" | "description">> = {
|
const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorType" | "displayName" | "description">> = {
|
||||||
f1_standings: {
|
f1_standings: {
|
||||||
defaultConfig: { iterations: 10_000, raceNoise: 0.5, participantVolatility: 1.5 },
|
defaultConfig: { iterations: 10_000, raceNoise: 0.5, participantVolatility: 1.5 },
|
||||||
|
|
@ -72,14 +81,14 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
setupSections: ["participants", "futuresOdds", "bracket"],
|
setupSections: ["participants", "futuresOdds", "bracket"],
|
||||||
},
|
},
|
||||||
ncaam_bracket: {
|
ncaam_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, ratingScaleFactor: 7.5, inputPolicy: { ratingMin: -10, ratingMax: 35, fallbackRatingDelta: 5 } },
|
defaultConfig: { ...BASE_CONFIG, ratingScaleFactor: 7.5, inputPolicy: { ratingMin: -10, ratingMax: 35, fallbackRatingDelta: 5, sourceEloPriority: FUTURES_FIRST_PRIORITY } },
|
||||||
requiredInputs: ["rating"],
|
requiredInputs: ["rating"],
|
||||||
optionalInputs: ["sourceOdds", "sourceElo", "seed", "region"],
|
optionalInputs: ["sourceOdds", "sourceElo", "seed", "region"],
|
||||||
derivableInputs: { rating: ["sourceOdds"] },
|
derivableInputs: { rating: ["sourceOdds"] },
|
||||||
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
|
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
|
||||||
},
|
},
|
||||||
ncaaw_bracket: {
|
ncaaw_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, inputPolicy: { ratingMin: 0.70, ratingMax: 0.97, missingRatingStrategy: "worstKnownMinus", fallbackRatingDelta: 0.01 } },
|
defaultConfig: { ...BASE_CONFIG, inputPolicy: { ratingMin: 0.70, ratingMax: 0.97, missingRatingStrategy: "worstKnownMinus", fallbackRatingDelta: 0.01, sourceEloPriority: FUTURES_FIRST_PRIORITY } },
|
||||||
requiredInputs: ["rating"],
|
requiredInputs: ["rating"],
|
||||||
optionalInputs: ["sourceOdds", "seed", "region"],
|
optionalInputs: ["sourceOdds", "seed", "region"],
|
||||||
derivableInputs: { rating: ["sourceOdds"] },
|
derivableInputs: { rating: ["sourceOdds"] },
|
||||||
|
|
@ -156,7 +165,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
setupSections: ["participants", "eloRatings", "regularStandings"],
|
setupSections: ["participants", "eloRatings", "regularStandings"],
|
||||||
},
|
},
|
||||||
world_cup: {
|
world_cup: {
|
||||||
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, eloWeight: 0.7, oddsWeight: 0.3 },
|
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, eloWeight: 0.7, oddsWeight: 0.3, inputPolicy: { sourceEloPriority: FUTURES_FIRST_PRIORITY, oddsWeight: 0.3 } },
|
||||||
requiredInputs: ["sourceElo"],
|
requiredInputs: ["sourceElo"],
|
||||||
optionalInputs: ["sourceOdds", "worldRanking"],
|
optionalInputs: ["sourceOdds", "worldRanking"],
|
||||||
derivableInputs: { sourceElo: ["sourceOdds"] },
|
derivableInputs: { sourceElo: ["sourceOdds"] },
|
||||||
|
|
@ -175,7 +184,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"],
|
setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"],
|
||||||
},
|
},
|
||||||
ncaa_football_bracket: {
|
ncaa_football_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, bracketSize: 12, eloWeight: 0.6, oddsWeight: 0.4 },
|
defaultConfig: { ...BASE_CONFIG, bracketSize: 12, eloWeight: 0.6, oddsWeight: 0.4, inputPolicy: { sourceEloPriority: FUTURES_FIRST_PRIORITY, oddsWeight: 0.4 } },
|
||||||
requiredInputs: ["sourceElo"],
|
requiredInputs: ["sourceElo"],
|
||||||
optionalInputs: ["sourceOdds", "worldRanking"],
|
optionalInputs: ["sourceOdds", "worldRanking"],
|
||||||
derivableInputs: { sourceElo: ["sourceOdds"] },
|
derivableInputs: { sourceElo: ["sourceOdds"] },
|
||||||
|
|
@ -188,7 +197,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
setupSections: ["participants", "futuresOdds"],
|
setupSections: ["participants", "futuresOdds"],
|
||||||
},
|
},
|
||||||
college_hockey_bracket: {
|
college_hockey_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, bracketSize: 16, parityFactor: 850 },
|
defaultConfig: { ...BASE_CONFIG, bracketSize: 16, parityFactor: 850, inputPolicy: { sourceEloPriority: FUTURES_FIRST_PRIORITY } },
|
||||||
requiredInputs: ["sourceElo"],
|
requiredInputs: ["sourceElo"],
|
||||||
optionalInputs: ["sourceOdds", "worldRanking"],
|
optionalInputs: ["sourceOdds", "worldRanking"],
|
||||||
derivableInputs: { sourceElo: ["sourceOdds"] },
|
derivableInputs: { sourceElo: ["sourceOdds"] },
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult, SimulationContext } from "./types";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import {
|
import {
|
||||||
convertAmericanOddsToProbability,
|
convertAmericanOddsToProbability,
|
||||||
|
|
@ -450,7 +450,11 @@ function simLeagueBracket(
|
||||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class MLBSimulator implements 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();
|
const db = database();
|
||||||
|
|
||||||
// 1. Load all participants for this sports season.
|
// 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%.
|
// 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;
|
if (o1 === null || o1 === undefined || o2 === null || o2 === undefined) return rdifProb;
|
||||||
const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5;
|
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 ──────────────────────────────────────────────────
|
// ─── Placement count maps ──────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ import {
|
||||||
convertFuturesToElo,
|
convertFuturesToElo,
|
||||||
eloWinProbability,
|
eloWinProbability,
|
||||||
} from "~/services/probability-engine";
|
} from "~/services/probability-engine";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult, SimulationContext } from "./types";
|
||||||
|
|
||||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -106,7 +106,7 @@ interface Team {
|
||||||
* Blended win probability for team1 vs team2.
|
* Blended win probability for team1 vs team2.
|
||||||
* Falls back to pure Elo when no futures data is present.
|
* 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);
|
const eloProbValue = eloWinProbability(team1.elo, team2.elo);
|
||||||
|
|
||||||
if (team1.oddsProb === 0 && team2.oddsProb === 0) {
|
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 oddsSum = team1.oddsProb + team2.oddsProb;
|
||||||
const oddsProbValue = oddsSum > 0 ? team1.oddsProb / oddsSum : 0.5;
|
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 } {
|
function simGame(team1: Team, team2: Team, oddsWeight: number = ODDS_WEIGHT): { winner: Team; loser: Team } {
|
||||||
const p1Wins = Math.random() < blendedWinProb(team1, team2);
|
const p1Wins = Math.random() < blendedWinProb(team1, team2, oddsWeight);
|
||||||
return p1Wins
|
return p1Wins
|
||||||
? { winner: team1, loser: team2 }
|
? { winner: team1, loser: team2 }
|
||||||
: { winner: team2, loser: team1 };
|
: { winner: team2, loser: team1 };
|
||||||
|
|
@ -169,18 +169,18 @@ interface PlacementCounts {
|
||||||
* Semifinals: qf1w vs qf2w, qf3w vs qf4w
|
* Semifinals: qf1w vs qf2w, qf3w vs qf4w
|
||||||
* Championship: sf1w vs sf2w
|
* Championship: sf1w vs sf2w
|
||||||
*/
|
*/
|
||||||
function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>): void {
|
function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>, oddsWeight: number = ODDS_WEIGHT): void {
|
||||||
// ── First Round (seeds 5–12) ───────────────────────────────────────────────
|
// ── First Round (seeds 5–12) ───────────────────────────────────────────────
|
||||||
const fr1 = simGame(teams[4], teams[11]); // 5 vs 12
|
const fr1 = simGame(teams[4], teams[11], oddsWeight); // 5 vs 12
|
||||||
const fr2 = simGame(teams[5], teams[10]); // 6 vs 11
|
const fr2 = simGame(teams[5], teams[10], oddsWeight); // 6 vs 11
|
||||||
const fr3 = simGame(teams[6], teams[9]); // 7 vs 10
|
const fr3 = simGame(teams[6], teams[9], oddsWeight); // 7 vs 10
|
||||||
const fr4 = simGame(teams[7], teams[8]); // 8 vs 9
|
const fr4 = simGame(teams[7], teams[8], oddsWeight); // 8 vs 9
|
||||||
|
|
||||||
// ── Quarterfinals (seeds 1–4 get byes) ────────────────────────────────────
|
// ── Quarterfinals (seeds 1–4 get byes) ────────────────────────────────────
|
||||||
const qf1 = simGame(teams[0], fr4.winner); // 1 vs 8/9 winner
|
const qf1 = simGame(teams[0], fr4.winner, oddsWeight); // 1 vs 8/9 winner
|
||||||
const qf2 = simGame(teams[3], fr1.winner); // 4 vs 5/12 winner
|
const qf2 = simGame(teams[3], fr1.winner, oddsWeight); // 4 vs 5/12 winner
|
||||||
const qf3 = simGame(teams[2], fr2.winner); // 3 vs 6/11 winner
|
const qf3 = simGame(teams[2], fr2.winner, oddsWeight); // 3 vs 6/11 winner
|
||||||
const qf4 = simGame(teams[1], fr3.winner); // 2 vs 7/10 winner
|
const qf4 = simGame(teams[1], fr3.winner, oddsWeight); // 2 vs 7/10 winner
|
||||||
|
|
||||||
const bump = (id: string, key: keyof PlacementCounts) => {
|
const bump = (id: string, key: keyof PlacementCounts) => {
|
||||||
const entry = counts.get(id);
|
const entry = counts.get(id);
|
||||||
|
|
@ -193,14 +193,14 @@ function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>): v
|
||||||
bump(qf4.loser.participantId, "qfLoser");
|
bump(qf4.loser.participantId, "qfLoser");
|
||||||
|
|
||||||
// ── Semifinals ────────────────────────────────────────────────────────────
|
// ── Semifinals ────────────────────────────────────────────────────────────
|
||||||
const sf1 = simGame(qf1.winner, qf2.winner);
|
const sf1 = simGame(qf1.winner, qf2.winner, oddsWeight);
|
||||||
const sf2 = simGame(qf3.winner, qf4.winner);
|
const sf2 = simGame(qf3.winner, qf4.winner, oddsWeight);
|
||||||
|
|
||||||
bump(sf1.loser.participantId, "sfLoser");
|
bump(sf1.loser.participantId, "sfLoser");
|
||||||
bump(sf2.loser.participantId, "sfLoser");
|
bump(sf2.loser.participantId, "sfLoser");
|
||||||
|
|
||||||
// ── National Championship ─────────────────────────────────────────────────
|
// ── National Championship ─────────────────────────────────────────────────
|
||||||
const final = simGame(sf1.winner, sf2.winner);
|
const final = simGame(sf1.winner, sf2.winner, oddsWeight);
|
||||||
|
|
||||||
bump(final.winner.participantId, "champion");
|
bump(final.winner.participantId, "champion");
|
||||||
bump(final.loser.participantId, "finalist");
|
bump(final.loser.participantId, "finalist");
|
||||||
|
|
@ -209,8 +209,11 @@ function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>): v
|
||||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class NCAAFootballSimulator implements Simulator {
|
export class NCAAFootballSimulator implements Simulator {
|
||||||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string, context?: SimulationContext): Promise<SimulationResult[]> {
|
||||||
const db = database();
|
const db = database();
|
||||||
|
// Elo-vs-odds blend weight: configurable via the season input policy, with
|
||||||
|
// the historical 60/40 split as the default when invoked without context.
|
||||||
|
const oddsWeight = context?.oddsWeight ?? ODDS_WEIGHT;
|
||||||
|
|
||||||
// 1. Load all participants for this sports season.
|
// 1. Load all participants for this sports season.
|
||||||
const participants = await db
|
const participants = await db
|
||||||
|
|
@ -318,7 +321,7 @@ export class NCAAFootballSimulator implements Simulator {
|
||||||
const field = preBracketMode
|
const field = preBracketMode
|
||||||
? sampleBracketField(allTeams, BRACKET_SIZE)
|
? sampleBracketField(allTeams, BRACKET_SIZE)
|
||||||
: (deterministicField ?? []);
|
: (deterministicField ?? []);
|
||||||
simulateBracket(field, counts);
|
simulateBracket(field, counts, oddsWeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Convert counts to probability distributions.
|
// 7. Convert counts to probability distributions.
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
prepareSimulatorInputsForRun,
|
prepareSimulatorInputsForRun,
|
||||||
validateSimulatorReadiness,
|
validateSimulatorReadiness,
|
||||||
} from "~/models/simulator";
|
} from "~/models/simulator";
|
||||||
|
import { getSimulatorInputPolicy } from "~/services/simulations/input-policy";
|
||||||
|
|
||||||
const ZERO_PROBS = {
|
const ZERO_PROBS = {
|
||||||
probFirst: 0,
|
probFirst: 0,
|
||||||
|
|
@ -105,7 +106,10 @@ export async function runSportsSeasonSimulation(
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const simulator = getSimulator(simulatorConfig.simulatorType);
|
const simulator = getSimulator(simulatorConfig.simulatorType);
|
||||||
const results = await simulator.simulate(sportsSeasonId);
|
const results = await simulator.simulate(sportsSeasonId, {
|
||||||
|
config: simulatorConfig.config,
|
||||||
|
oddsWeight: getSimulatorInputPolicy(simulatorConfig.config).oddsWeight,
|
||||||
|
});
|
||||||
|
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
throw new Error("Simulation returned no results. Check that participants have simulator input data.");
|
throw new Error("Simulation returned no results. Check that participants have simulator input data.");
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,22 @@ export interface SimulationResult {
|
||||||
source: string;
|
source: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional run-time context handed to a simulator by the runner. Carries the
|
||||||
|
* resolved season config and input policy so simulators can honor configurable
|
||||||
|
* knobs (e.g. the Elo-vs-odds blend weight) without re-querying. It is optional
|
||||||
|
* so simulators can still be invoked directly (e.g. in unit tests) with just a
|
||||||
|
* season id, in which case they fall back to their built-in defaults.
|
||||||
|
*/
|
||||||
|
export interface SimulationContext {
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
/** Weight (0–1) for the futures-odds signal in blended simulators. */
|
||||||
|
oddsWeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Common interface all sport simulators must implement.
|
* Common interface all sport simulators must implement.
|
||||||
*/
|
*/
|
||||||
export interface Simulator {
|
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 { eq, and } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { convertFuturesToElo, eloWinProbability } from "~/services/probability-engine";
|
import { convertFuturesToElo, eloWinProbability } from "~/services/probability-engine";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult, SimulationContext } from "./types";
|
||||||
|
|
||||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -62,8 +62,12 @@ export function americanToImpliedProb(odds: number): number {
|
||||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class UCLSimulator implements Simulator {
|
export class UCLSimulator implements Simulator {
|
||||||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string, context?: SimulationContext): Promise<SimulationResult[]> {
|
||||||
const db = database();
|
const db = database();
|
||||||
|
// Elo-vs-odds blend weight: configurable via the season input policy, with
|
||||||
|
// the historical 70/30 split as the default when invoked without context.
|
||||||
|
const oddsWeight = context?.oddsWeight ?? ODDS_WEIGHT;
|
||||||
|
const eloWeight = 1 - oddsWeight;
|
||||||
|
|
||||||
// 1. Find the bracket scoring event for this sports season.
|
// 1. Find the bracket scoring event for this sports season.
|
||||||
// UCL has exactly one playoff_game event per season.
|
// UCL has exactly one playoff_game event per season.
|
||||||
|
|
@ -205,7 +209,7 @@ export class UCLSimulator implements Simulator {
|
||||||
const o2 = normalizedOddsMap.get(p2) ?? fallbackProb;
|
const o2 = normalizedOddsMap.get(p2) ?? fallbackProb;
|
||||||
const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5;
|
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 } => {
|
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 { logger } from "~/lib/logger";
|
||||||
import { FIFA_48 } from "~/lib/bracket-templates";
|
import { FIFA_48 } from "~/lib/bracket-templates";
|
||||||
import { FIFA_2026_R32_TEMPLATE, assignThirdPlaceSlots } from "~/lib/fifa-2026-bracket";
|
import { FIFA_2026_R32_TEMPLATE, assignThirdPlaceSlots } from "~/lib/fifa-2026-bracket";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult, SimulationContext } from "./types";
|
||||||
|
|
||||||
// ─── Name normalisation (same logic as elo-ratings bulk-import fuzzy match) ──
|
// ─── Name normalisation (same logic as elo-ratings bulk-import fuzzy match) ──
|
||||||
|
|
||||||
|
|
@ -275,11 +275,12 @@ function simKnockout(
|
||||||
teamA: string,
|
teamA: string,
|
||||||
teamB: string,
|
teamB: string,
|
||||||
eloFn: (id: string) => number,
|
eloFn: (id: string) => number,
|
||||||
normalizedProb: (id: string) => number
|
normalizedProb: (id: string) => number,
|
||||||
|
oddsWeight: number = ODDS_WEIGHT
|
||||||
): { winner: string; loser: string } {
|
): { winner: string; loser: string } {
|
||||||
const eloProb = eloWinProbability(eloFn(teamA), eloFn(teamB));
|
const eloProb = eloWinProbability(eloFn(teamA), eloFn(teamB));
|
||||||
const oddsProb = normalizedProb(teamA);
|
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;
|
const winner = Math.random() < blended ? teamA : teamB;
|
||||||
return { winner, loser: winner === teamA ? teamB : teamA };
|
return { winner, loser: winner === teamA ? teamB : teamA };
|
||||||
}
|
}
|
||||||
|
|
@ -319,7 +320,8 @@ function runKnockoutRoundByNumber(
|
||||||
matches: Map<number, KnockoutMatch>,
|
matches: Map<number, KnockoutMatch>,
|
||||||
completed: Map<string, { winnerId: string; loserId: string }>,
|
completed: Map<string, { winnerId: string; loserId: string }>,
|
||||||
eloFn: (id: string) => number,
|
eloFn: (id: string) => number,
|
||||||
normalizedProb: (id: string) => number
|
normalizedProb: (id: string) => number,
|
||||||
|
oddsWeight: number = ODDS_WEIGHT
|
||||||
): {
|
): {
|
||||||
winnersByNumber: Map<number, string>;
|
winnersByNumber: Map<number, string>;
|
||||||
losersByNumber: Map<number, string>;
|
losersByNumber: Map<number, string>;
|
||||||
|
|
@ -348,7 +350,7 @@ function runKnockoutRoundByNumber(
|
||||||
winnerId = fixed.winnerId;
|
winnerId = fixed.winnerId;
|
||||||
loserId = fixed.loserId;
|
loserId = fixed.loserId;
|
||||||
} else if (m.p1 && m.p2) {
|
} else if (m.p1 && m.p2) {
|
||||||
const result = simKnockout(m.p1, m.p2, eloFn, normalizedProb);
|
const result = simKnockout(m.p1, m.p2, eloFn, normalizedProb, oddsWeight);
|
||||||
winnerId = result.winner;
|
winnerId = result.winner;
|
||||||
loserId = result.loser;
|
loserId = result.loser;
|
||||||
} else if (m.p1 || m.p2) {
|
} else if (m.p1 || m.p2) {
|
||||||
|
|
@ -446,7 +448,10 @@ export class WorldCupSimulator implements Simulator {
|
||||||
this.numSimulations = numSimulations;
|
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();
|
const db = database();
|
||||||
|
|
||||||
// 1. Load all participants for this season
|
// 1. Load all participants for this season
|
||||||
|
|
@ -714,10 +719,10 @@ export class WorldCupSimulator implements Simulator {
|
||||||
// ── Knockout rounds ──────────────────────────────────────────
|
// ── Knockout rounds ──────────────────────────────────────────
|
||||||
// R32 → R16 → QF → SF → Third Place Game + Final, advancing via the
|
// R32 → R16 → QF → SF → Third Place Game + Final, advancing via the
|
||||||
// canonical ceil(n/2) tree. Completed matches lock in real results.
|
// canonical ceil(n/2) tree. Completed matches lock in real results.
|
||||||
const r32 = runKnockoutRoundByNumber(R32, buildR32(), completedByRoundAndNumber, eloFn, normalizedProb);
|
const r32 = runKnockoutRoundByNumber(R32, buildR32(), completedByRoundAndNumber, eloFn, normalizedProb, oddsWeight);
|
||||||
const r16 = runKnockoutRoundByNumber(R16, r32.nextRound, completedByRoundAndNumber, eloFn, normalizedProb);
|
const r16 = runKnockoutRoundByNumber(R16, r32.nextRound, completedByRoundAndNumber, eloFn, normalizedProb, oddsWeight);
|
||||||
const qf = runKnockoutRoundByNumber(QF, r16.nextRound, completedByRoundAndNumber, eloFn, normalizedProb);
|
const qf = runKnockoutRoundByNumber(QF, r16.nextRound, completedByRoundAndNumber, eloFn, normalizedProb, oddsWeight);
|
||||||
const sf = runKnockoutRoundByNumber(SF, qf.nextRound, completedByRoundAndNumber, eloFn, normalizedProb);
|
const sf = runKnockoutRoundByNumber(SF, qf.nextRound, completedByRoundAndNumber, eloFn, normalizedProb, oddsWeight);
|
||||||
|
|
||||||
// QF losers (placements 5–8)
|
// QF losers (placements 5–8)
|
||||||
for (const id of qf.losersByNumber.values()) {
|
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);
|
counts.fourthPlace.set(fixed3pg.loserId, (counts.fourthPlace.get(fixed3pg.loserId) ?? 0) + 1);
|
||||||
} else {
|
} else {
|
||||||
const { winner: thirdWinner, loser: thirdLoser } = simKnockout(
|
const { winner: thirdWinner, loser: thirdLoser } = simKnockout(
|
||||||
sf1Loser, sf2Loser, eloFn, normalizedProb
|
sf1Loser, sf2Loser, eloFn, normalizedProb, oddsWeight
|
||||||
);
|
);
|
||||||
counts.thirdPlace.set(thirdWinner, (counts.thirdPlace.get(thirdWinner) ?? 0) + 1);
|
counts.thirdPlace.set(thirdWinner, (counts.thirdPlace.get(thirdWinner) ?? 0) + 1);
|
||||||
counts.fourthPlace.set(thirdLoser, (counts.fourthPlace.get(thirdLoser) ?? 0) + 1);
|
counts.fourthPlace.set(thirdLoser, (counts.fourthPlace.get(thirdLoser) ?? 0) + 1);
|
||||||
|
|
@ -751,7 +756,7 @@ export class WorldCupSimulator implements Simulator {
|
||||||
counts.runnerUp.set(fixedFinal.loserId, (counts.runnerUp.get(fixedFinal.loserId) ?? 0) + 1);
|
counts.runnerUp.set(fixedFinal.loserId, (counts.runnerUp.get(fixedFinal.loserId) ?? 0) + 1);
|
||||||
} else {
|
} else {
|
||||||
const { winner: champion, loser: runnerUp } = simKnockout(
|
const { winner: champion, loser: runnerUp } = simKnockout(
|
||||||
sfWinner1, sfWinner2, eloFn, normalizedProb
|
sfWinner1, sfWinner2, eloFn, normalizedProb, oddsWeight
|
||||||
);
|
);
|
||||||
counts.champion.set(champion, (counts.champion.get(champion) ?? 0) + 1);
|
counts.champion.set(champion, (counts.champion.get(champion) ?? 0) + 1);
|
||||||
counts.runnerUp.set(runnerUp, (counts.runnerUp.get(runnerUp) ?? 0) + 1);
|
counts.runnerUp.set(runnerUp, (counts.runnerUp.get(runnerUp) ?? 0) + 1);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue