claude/clever-archimedes-dvye42 (#110)
Some checks failed
🚀 Deploy / 🧪 Test (push) Successful in 3m7s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Failing after 50s
🚀 Deploy / 🐳 Build (push) Has been skipped
🚀 Deploy / 🚀 Deploy (push) Has been skipped

Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #110
This commit is contained in:
chrisp 2026-06-26 05:16:54 +00:00
parent ab74b19420
commit ee099c64cd
16 changed files with 485 additions and 400 deletions

View file

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

View file

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

View file

@ -1,4 +1,4 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import { and, eq, inArray, isNotNull, sql } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import {
@ -8,6 +8,7 @@ import {
type SimulatorManifestProfile,
} from "~/services/simulations/manifest";
import {
getSimulatorInputPolicy,
ratingRequirementLabel,
resolveRatings,
resolveSourceElos,
@ -82,6 +83,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;
/** Weight (01) the season's policy gives futures odds when blending into Elo. */
oddsWeight: number;
}
function parseDecimal(value: string | null | undefined): number | null {
@ -125,8 +132,8 @@ function isFallbackMethod(method: string): boolean {
return method === "fallbackElo" || method === "fallbackRating" || method === "averageKnown" || method === "worstKnownMinus";
}
const GENERATED_RATING_METHODS = ["sourceOdds", "fallbackRating", "averageKnown", "worstKnownMinus"] as const;
const GENERATED_SOURCE_ELO_METHODS = ["projectedWins", "projectedTablePoints", "sourceOdds", "fallbackElo", "averageKnown", "worstKnownMinus"] as const;
const GENERATED_RATING_METHODS = ["sourceOdds", "blend", "fallbackRating", "averageKnown", "worstKnownMinus"] as const;
const GENERATED_SOURCE_ELO_METHODS = ["projectedWins", "projectedTablePoints", "sourceOdds", "blend", "fallbackElo", "averageKnown", "worstKnownMinus"] as const;
function isGeneratedRatingMethod(method: unknown): boolean {
return typeof method === "string" && GENERATED_RATING_METHODS.includes(method as (typeof GENERATED_RATING_METHODS)[number]);
@ -359,57 +366,32 @@ 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'`,
// Persist the odds non-destructively. How much these odds move a stored
// Elo/rating is now governed by the season's blend weight (see resolveSourceElos
// / SimulatorInputPolicy.oddsWeight), 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) => ({
participantId: input.participantId,
sportsSeasonId: input.sportsSeasonId,
sourceOdds: input.sourceOdds,
createdAt: now,
updatedAt: now,
})
.where(
and(
inArray(schema.seasonParticipantSimulatorInputs.sportsSeasonId, seasonIds),
inArray(schema.seasonParticipantSimulatorInputs.participantId, participantIds)
)
);
await tx
.insert(schema.seasonParticipantSimulatorInputs)
.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,
},
});
});
}))
)
.onConflictDoUpdate({
target: [
schema.seasonParticipantSimulatorInputs.participantId,
schema.seasonParticipantSimulatorInputs.sportsSeasonId,
],
set: {
sourceOdds: sql`excluded.source_odds`,
updatedAt: now,
},
});
}
export async function batchUpsertParticipantSimulatorInputs(
@ -669,6 +651,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 +675,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 +694,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,
oddsWeight: policy.oddsWeight,
};
})
);

View file

@ -77,6 +77,12 @@ export async function action({ request }: Route.ActionArgs): Promise<ActionData>
};
}
function futuresBlendLabel(oddsWeight: number): string {
if (oddsWeight >= 1) return "overrides Elo";
if (oddsWeight <= 0) return "Elo only";
return `${Math.round(oddsWeight * 100)}% blend`;
}
function statusBadge(status: string) {
if (status === "ready") return <Badge className="gap-1"><CheckCircle2 className="h-3 w-3" /> Ready</Badge>;
return <Badge variant="secondary" className="gap-1"><XCircle className="h-3 w-3" /> Needs setup</Badge>;
@ -245,7 +251,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.oddsWeight > 0 ? "default" : "outline"} className="mt-1 text-xs font-normal">
{sim.oddsParticipantCount > 0
? `Futures: ${sim.oddsParticipantCount} · ${futuresBlendLabel(sim.oddsWeight)}`
: "No futures odds"}
</Badge>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{sim.lastSimulatedDate ?? "Never"}
@ -258,6 +271,11 @@ export default function AdminSimulators({ loaderData }: Route.ComponentProps) {
Setup
</Link>
</Button>
{sim.supportsFuturesOdds && (
<Button variant="ghost" size="sm" asChild>
<Link to={`/admin/sports-seasons/${sim.sportsSeasonId}/futures-odds`}>Futures</Link>
</Button>
)}
<Button variant="ghost" size="sm" asChild>
<Link to={`/admin/sports-seasons/${sim.sportsSeasonId}/expected-values`}>EVs</Link>
</Button>

View file

@ -25,7 +25,11 @@ 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,
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 +209,8 @@ export async function action({ request, params }: Route.ActionArgs): Promise<Act
inputPolicy: {
missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")),
missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")),
// Stored as-is; getSimulatorInputPolicy clamps to [0,1] on read.
oddsWeight: parsePolicyNumber(formData, "oddsWeight", currentPolicy.oddsWeight),
fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo),
fallbackEloDelta: parsePolicyNumber(formData, "fallbackEloDelta", currentPolicy.fallbackEloDelta),
eloMin: parsePolicyNumber(formData, "eloMin", currentPolicy.eloMin),
@ -361,6 +367,16 @@ 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-5">
<Label htmlFor="oddsWeight">Futures vs. Elo Odds Blend Weight (01)</Label>
<Input id="oddsWeight" name="oddsWeight" type="number" step="0.05" min="0" max="1" defaultValue={inputPolicy.oddsWeight} />
<p className="text-xs text-muted-foreground">
Every source (raw Elo, projections, futures odds) becomes an Elo, then they blend into
the single Elo that feeds the simulator. This is the weight given to futures odds:
<strong> 0</strong> = Elo / projections only, <strong>1</strong> = futures fully override,
in between = blend (e.g. 0.3 = 70% Elo / 30% futures).
</p>
</div>
{config.profile.requiredInputs.includes("sourceElo") && (
<>
<div className="space-y-2 md:col-span-2">

View file

@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { resolveRatings, resolveSourceElos } from "../input-policy";
import {
getSimulatorInputPolicy,
resolveRatings,
resolveSourceElos,
DEFAULT_BASE_ELO_PRIORITY,
} from "../input-policy";
import type { SimulatorManifestProfile } from "../manifest";
const profile = {
@ -183,6 +188,113 @@ describe("simulator input policy", () => {
expect(resolved.get("missing-tail")).toMatchObject({ rating: -10, method: "worstKnownMinus" });
});
it("does not clamp a directly entered Elo above the policy ceiling", () => {
// Snooker/darts enter manual Elos well above the default 1900 ceiling and
// have no odds source — the direct value must pass through unclamped.
const eloOnlyProfile = {} as Pick<SimulatorManifestProfile, "derivableInputs">;
const resolved = resolveSourceElos(
[
{ participantId: "ronnie", sourceElo: 2450, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
{ participantId: "judd", sourceElo: 2300, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
],
eloOnlyProfile,
{}
);
expect(resolved.get("ronnie")).toMatchObject({ sourceElo: 2450, method: "direct" });
expect(resolved.get("judd")).toMatchObject({ sourceElo: 2300, method: "direct" });
});
it("blends base Elo and futures odds into a single Elo by oddsWeight", () => {
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 },
];
// oddsWeight 0: the stored base Elo wins outright.
const eloOnly = resolveSourceElos(inputs, profile, { inputPolicy: { oddsWeight: 0 } });
expect(eloOnly.get("favorite")).toMatchObject({ sourceElo: 1800, method: "direct" });
// oddsWeight 1: futures fully override the base Elo.
const oddsOnly = resolveSourceElos(inputs, profile, { inputPolicy: { oddsWeight: 1 } });
expect(oddsOnly.get("favorite")?.method).toBe("sourceOdds");
expect(oddsOnly.get("longshot")?.method).toBe("sourceOdds");
// Between: a blend that sits strictly between the base and the odds-derived Elo.
const oddsElo = oddsOnly.get("favorite")!.sourceElo;
const blended = resolveSourceElos(inputs, profile, { inputPolicy: { oddsWeight: 0.5 } });
expect(blended.get("favorite")?.method).toBe("blend");
const blendedElo = blended.get("favorite")!.sourceElo;
expect(blendedElo).toBeGreaterThan(Math.min(1800, oddsElo));
expect(blendedElo).toBeLessThan(Math.max(1800, oddsElo));
expect(blendedElo).toBeCloseTo(Math.round(0.5 * 1800 + 0.5 * oddsElo), 0);
});
it("blends projected wins with futures odds when both are present", () => {
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 },
];
// oddsWeight 0: projectedWins is the base.
expect(resolveSourceElos(inputs, profile, { seasonGames: 82, inputPolicy: { oddsWeight: 0 } }).get("team-1")?.method)
.toBe("projectedWins");
// oddsWeight 1: futures override the projection.
expect(resolveSourceElos(inputs, profile, { seasonGames: 82, inputPolicy: { oddsWeight: 1 } }).get("team-1")?.method)
.toBe("sourceOdds");
// Between: blended.
expect(resolveSourceElos(inputs, profile, { seasonGames: 82, inputPolicy: { oddsWeight: 0.4 } }).get("team-1")?.method)
.toBe("blend");
});
it("blends a direct rating with futures odds by oddsWeight", () => {
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 },
];
// oddsWeight 0: direct rating wins.
expect(resolveRatings(inputs, ratingProfile, { inputPolicy: { ratingMin: -10, ratingMax: 35, oddsWeight: 0 } }).get("favorite"))
.toMatchObject({ rating: 30, method: "direct" });
// oddsWeight 1: odds override the rating.
expect(
resolveRatings(inputs, ratingProfile, {
inputPolicy: { ratingMin: -10, ratingMax: 35, oddsWeight: 1 },
}).get("favorite")?.method
).toBe("sourceOdds");
// Between: blended.
expect(
resolveRatings(inputs, ratingProfile, {
inputPolicy: { ratingMin: -10, ratingMax: 35, oddsWeight: 0.5 },
}).get("favorite")?.method
).toBe("blend");
});
it("parses and clamps the base Elo priority and odds weight", () => {
const defaults = getSimulatorInputPolicy({});
expect(defaults.baseEloPriority).toEqual(DEFAULT_BASE_ELO_PRIORITY);
expect(defaults.oddsWeight).toBe(0.3);
// oddsWeight lives under inputPolicy and is clamped to [0, 1]; a top-level
// config.oddsWeight is ignored (single home); junk base-priority entries drop.
expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: 0.6 } }).oddsWeight).toBe(0.6);
expect(getSimulatorInputPolicy({ oddsWeight: 0.6 }).oddsWeight).toBe(0.3);
expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: 5 } }).oddsWeight).toBe(1);
expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: -2 } }).oddsWeight).toBe(0);
expect(
getSimulatorInputPolicy({ inputPolicy: { baseEloPriority: ["projectedWins", "sourceOdds", "sourceElo"] } }).baseEloPriority
).toEqual(["projectedWins", "sourceElo"]);
// An empty/all-invalid priority falls back to the default.
expect(getSimulatorInputPolicy({ inputPolicy: { baseEloPriority: ["nope"] } }).baseEloPriority).toEqual(
DEFAULT_BASE_ELO_PRIORITY
);
});
it("uses NCAAW-style rating scale when deriving and falling back", () => {
const resolved = resolveRatings(
[

View file

@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import * as schema from "~/database/schema";
import { SIMULATOR_MANIFEST } from "../manifest";
import { SIMULATOR_TYPES } from "../registry";
import { getSimulatorInputPolicy } from "../input-policy";
import { assertRegistrySchemaDriftFree } from "~/models/simulator";
describe("simulator manifest", () => {
@ -40,6 +41,17 @@ describe("simulator manifest", () => {
}
});
it("exposes the per-profile futures blend 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);
expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.ucl_bracket.defaultConfig).oddsWeight).toBe(0.3);
// College hockey blends odds internally, so the central blend is disabled.
expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.college_hockey_bracket.defaultConfig).oddsWeight).toBe(0);
// Every profile resolves to the default base-Elo priority unless overridden.
expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.nba_bracket.defaultConfig).baseEloPriority)
.toEqual(["sourceElo", "projectedWins", "projectedTablePoints"]);
});
it("keeps EPL projection and match parity as separate config knobs", () => {
expect(SIMULATOR_MANIFEST.epl_standings.defaultConfig).toMatchObject({
parityFactor: 400,

View file

@ -143,8 +143,9 @@ describe("NCAAFootballSimulator", () => {
}
});
it("uses futures odds when sourceOdds present", async () => {
setupMockDb(TEAM_IDS, { includeOdds: true });
it("runs from the resolved Elo (futures already blended upstream)", async () => {
// sourceElo is the single resolved Elo; the sim no longer reads sourceOdds.
setupMockDb(TEAM_IDS);
const results = await new NCAAFootballSimulator().simulate("season-1");
expect(results).toHaveLength(12);
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);

View file

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

View file

@ -5,9 +5,39 @@ import { logger } from "~/lib/logger";
export type MissingEloStrategy = "block" | "fallbackElo" | "averageKnown" | "worstKnownMinus";
export type MissingRatingStrategy = "block" | "fallbackRating" | "averageKnown" | "worstKnownMinus";
/**
* "Base" strength sources the ways to produce a participant's underlying Elo
* before any futures-odds blend. These are *substitutes* (a season rarely has
* more than one), so they are tried in order and the first available one wins.
* Futures odds are not in this list: they blend on top of the base Elo via
* `oddsWeight`, rather than being one more interchangeable base.
*/
export type BaseEloKey = "sourceElo" | "projectedWins" | "projectedTablePoints";
export const BASE_ELO_KEYS: readonly BaseEloKey[] = [
"sourceElo",
"projectedWins",
"projectedTablePoints",
];
/** Default order for choosing the base Elo: raw Elo, then projections. */
export const DEFAULT_BASE_ELO_PRIORITY: BaseEloKey[] = [
"sourceElo",
"projectedWins",
"projectedTablePoints",
];
export interface SimulatorInputPolicy {
missingEloStrategy: MissingEloStrategy;
missingRatingStrategy: MissingRatingStrategy;
/** Order for choosing the base strength Elo from its substitutable sources. */
baseEloPriority: BaseEloKey[];
/**
* Weight (01) of the futures-odds-derived Elo when blending with the base Elo
* into the single Elo that feeds every simulator. `0` = base only (Elo /
* projections), `1` = futures fully override the base, in between = blend.
*/
oddsWeight: number;
fallbackElo: number;
fallbackEloDelta: number;
eloMin: number;
@ -30,18 +60,22 @@ export interface ParticipantInputForPolicy {
export interface ResolvedSourceElo {
participantId: string;
sourceElo: number;
method: "direct" | "projectedWins" | "projectedTablePoints" | "sourceOdds" | MissingEloStrategy;
method: "direct" | "projectedWins" | "projectedTablePoints" | "sourceOdds" | "blend" | MissingEloStrategy;
}
export interface ResolvedRating {
participantId: string;
rating: number;
method: "direct" | "sourceOdds" | MissingRatingStrategy;
method: "direct" | "sourceOdds" | "blend" | MissingRatingStrategy;
}
const DEFAULT_ODDS_WEIGHT = 0.3;
const DEFAULT_POLICY: SimulatorInputPolicy = {
missingEloStrategy: "block",
missingRatingStrategy: "block",
baseEloPriority: DEFAULT_BASE_ELO_PRIORITY,
oddsWeight: DEFAULT_ODDS_WEIGHT,
fallbackElo: 1400,
fallbackEloDelta: 25,
eloMin: 1100,
@ -80,6 +114,19 @@ function projectionToElo(
return clamp(Math.round(elo), policy.eloMin, policy.eloMax);
}
function parseBaseEloPriority(value: unknown, fallback: BaseEloKey[]): BaseEloKey[] {
if (!Array.isArray(value)) return fallback;
const seen = new Set<BaseEloKey>();
const parsed: BaseEloKey[] = [];
for (const entry of value) {
if (typeof entry === "string" && (BASE_ELO_KEYS as readonly string[]).includes(entry) && !seen.has(entry as BaseEloKey)) {
seen.add(entry as BaseEloKey);
parsed.push(entry as BaseEloKey);
}
}
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 +141,9 @@ export function getSimulatorInputPolicy(config: Record<string, unknown>): Simula
ratingStrategy === "fallbackRating" || ratingStrategy === "averageKnown" || ratingStrategy === "worstKnownMinus"
? ratingStrategy
: "block",
baseEloPriority: parseBaseEloPriority(rawPolicy.baseEloPriority, DEFAULT_POLICY.baseEloPriority),
// The futures blend weight lives only under inputPolicy (single home).
oddsWeight: clamp(optionalNumber(rawPolicy.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),
@ -118,6 +168,32 @@ export function ratingRequirementLabel(profile: SimulatorManifestProfile): strin
return ["rating", ...alternatives.map(simulatorInputLabel)].join(" / ");
}
/**
* Combine a participant's base strength value and its odds-derived value into the
* single value that feeds the simulator, by `oddsWeight`: 0 = base only,
* 1 = odds only, in between = weighted blend. Odds-derived and blended results
* pass through `clampDerived` (they are mapped onto the configured band by
* design); a base value is returned untouched (a directly entered Elo/rating is
* trusted as-is, a projection-derived Elo was already clamped upstream). Returns
* undefined when neither source is present (the caller falls back to its
* missing-input strategy). Shared by Elo and rating resolution.
*/
function blendBaseAndOdds<M extends string>(
base: { value: number; method: M } | undefined,
odds: number | undefined,
oddsWeight: number,
clampDerived: (value: number) => number
): { value: number; method: M | "sourceOdds" | "blend" } | undefined {
if (base !== undefined && odds !== undefined) {
if (oddsWeight <= 0) return { value: base.value, method: base.method };
if (oddsWeight >= 1) return { value: clampDerived(odds), method: "sourceOdds" };
return { value: clampDerived((1 - oddsWeight) * base.value + oddsWeight * odds), method: "blend" };
}
if (base !== undefined) return { value: base.value, method: base.method };
if (odds !== undefined) return { value: clampDerived(odds), method: "sourceOdds" };
return undefined;
}
export function resolveSourceElos(
inputs: ParticipantInputForPolicy[],
profile: Pick<SimulatorManifestProfile, "derivableInputs">,
@ -128,68 +204,83 @@ export function resolveSourceElos(
const alternatives = new Set(profile.derivableInputs?.sourceElo ?? []);
const parityFactor = optionalNumber(config.parityFactor, 400);
for (const input of inputs) {
if (input.sourceElo !== null && input.sourceElo !== undefined) {
resolved.set(input.participantId, {
participantId: input.participantId,
sourceElo: input.sourceElo,
method: "direct",
});
}
}
if (alternatives.has("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;
resolved.set(input.participantId, {
participantId: input.participantId,
sourceElo: projectionToElo(input.projectedWins, seasonGames, parityFactor, policy),
method: "projectedWins",
});
}
}
if (alternatives.has("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",
});
// 1. Base Elo: the first available *substitutable* source per baseEloPriority.
// `sourceElo` (raw) is always permitted; projections must be declared in
// the profile's derivableInputs.sourceElo.
const baseElo = new Map<string, { elo: number; method: ResolvedSourceElo["method"] }>();
for (const source of policy.baseEloPriority) {
if (source !== "sourceElo" && !alternatives.has(source)) continue;
if (source === "sourceElo") {
for (const input of inputs) {
if (baseElo.has(input.participantId)) continue;
if (input.sourceElo !== null && input.sourceElo !== undefined) {
baseElo.set(input.participantId, { elo: input.sourceElo, method: "direct" });
}
}
} else if (source === "projectedWins") {
const seasonGames = optionalNumber(config.seasonGames, Math.max(...inputs.map((input) => input.projectedWins ?? 0), 1));
for (const input of inputs) {
if (baseElo.has(input.participantId) || input.projectedWins === null || input.projectedWins === undefined) continue;
baseElo.set(input.participantId, {
elo: projectionToElo(input.projectedWins, seasonGames, parityFactor, policy),
method: "projectedWins",
});
}
} else if (source === "projectedTablePoints") {
const seasonGames = optionalNumber(config.seasonGames, 38);
const maxPoints = optionalNumber(config.maxTablePoints, seasonGames * 3);
for (const input of inputs) {
if (baseElo.has(input.participantId) || input.projectedTablePoints === null || input.projectedTablePoints === undefined) continue;
baseElo.set(input.participantId, {
elo: projectionToElo(input.projectedTablePoints, maxPoints, parityFactor, policy),
method: "projectedTablePoints",
});
}
}
}
// 2. Odds-derived Elo (field-relative): only when futures odds are a permitted
// source and at least two participants have odds (a spread is required).
const oddsElo = new Map<string, number>();
if (alternatives.has("sourceOdds")) {
// 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)
.filter((input) => 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 — ignoring the lone odds value.`
);
}
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",
});
for (const [participantId, elo] of convertFuturesToElo(oddsInputs)) {
oddsElo.set(participantId, elo);
}
}
}
// 3. Blend base + odds into the single Elo that feeds the simulator.
// Directly entered Elos (some sports use ratings above the default ceiling,
// e.g. snooker ~2450) pass through unclamped; only derived values are
// clamped onto [eloMin, eloMax].
const clampDerived = (elo: number): number => clamp(Math.round(elo), policy.eloMin, policy.eloMax);
for (const input of inputs) {
const base = baseElo.get(input.participantId);
const blended = blendBaseAndOdds(
base ? { value: base.elo, method: base.method } : undefined,
oddsElo.get(input.participantId),
policy.oddsWeight,
clampDerived
);
if (!blended) continue;
resolved.set(input.participantId, {
participantId: input.participantId,
sourceElo: blended.value,
method: blended.method,
});
}
const knownElos = [...resolved.values()].map((value) => value.sourceElo);
const averageKnown = knownElos.length > 0
? knownElos.reduce((sum, elo) => sum + elo, 0) / knownElos.length
@ -227,24 +318,25 @@ export function resolveRatings(
const resolved = new Map<string, ResolvedRating>();
const alternatives = new Set(profile.derivableInputs?.rating ?? []);
// Ratings follow the same single-value blend as Elo: the base is the direct
// `rating`; futures odds (when permitted, scaled onto the rating range) blend
// on top by oddsWeight. `0` = rating only, `1` = futures override.
const baseRating = new Map<string, number>();
for (const input of inputs) {
if (input.rating !== null && input.rating !== undefined) {
resolved.set(input.participantId, {
participantId: input.participantId,
rating: input.rating,
method: "direct",
});
baseRating.set(input.participantId, input.rating);
}
}
const oddsRating = new Map<string, number>();
if (alternatives.has("sourceOdds")) {
const oddsInputs = inputs
.filter((input) => !resolved.has(input.participantId) && input.sourceOdds !== null && input.sourceOdds !== undefined)
.filter((input) => input.sourceOdds !== null && input.sourceOdds !== undefined)
.map((input) => ({ participantId: input.participantId, odds: input.sourceOdds as number }));
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 — ignoring the lone odds value.`
);
}
if (oddsInputs.length >= 2) {
@ -254,15 +346,30 @@ export function resolveRatings(
eloMax: policy.ratingMax,
});
for (const [participantId, rating] of converted) {
resolved.set(participantId, {
participantId,
rating: clamp(rating, policy.ratingMin, policy.ratingMax),
method: "sourceOdds",
});
oddsRating.set(participantId, rating);
}
}
}
// As with Elo: a directly entered rating passes through untouched; only
// derived values are clamped onto the rating band.
const clampDerived = (rating: number): number => clamp(rating, policy.ratingMin, policy.ratingMax);
for (const input of inputs) {
const base = baseRating.get(input.participantId);
const blended = blendBaseAndOdds(
base !== undefined ? { value: base, method: "direct" as const } : undefined,
oddsRating.get(input.participantId),
policy.oddsWeight,
clampDerived
);
if (!blended) continue;
resolved.set(input.participantId, {
participantId: input.participantId,
rating: blended.value,
method: blended.method,
});
}
const knownRatings = [...resolved.values()].map((value) => value.rating);
const averageKnown = knownRatings.length > 0
? knownRatings.reduce((sum, rating) => sum + rating, 0) / knownRatings.length

View file

@ -66,9 +66,10 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "futuresOdds", "bracket"],
},
ucl_bracket: {
defaultConfig: { ...BASE_CONFIG, eloWeight: 0.7, oddsWeight: 0.3 },
requiredInputs: ["sourceOdds"],
optionalInputs: ["sourceElo"],
defaultConfig: { ...BASE_CONFIG, inputPolicy: { oddsWeight: 0.3 } },
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds"],
derivableInputs: { sourceElo: ["sourceOdds"] },
setupSections: ["participants", "futuresOdds", "bracket"],
},
ncaam_bracket: {
@ -142,7 +143,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "surfaceElo", "events"],
},
mlb_bracket: {
defaultConfig: { ...BASE_CONFIG, rDiffWeight: 0.7, oddsWeight: 0.3, seasonGames: 162 },
defaultConfig: { ...BASE_CONFIG, seasonGames: 162, inputPolicy: { oddsWeight: 0.3 } },
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "projectedWins"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
@ -156,7 +157,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, inputPolicy: { oddsWeight: 0.3 } },
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "worldRanking"],
derivableInputs: { sourceElo: ["sourceOdds"] },
@ -175,7 +176,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, inputPolicy: { oddsWeight: 0.4 } },
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "worldRanking"],
derivableInputs: { sourceElo: ["sourceOdds"] },
@ -188,7 +189,12 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "futuresOdds"],
},
college_hockey_bracket: {
defaultConfig: { ...BASE_CONFIG, bracketSize: 16, parityFactor: 850 },
// College hockey blends odds into Elo internally (and also uses NPI rank,
// which the central resolver can't represent). oddsWeight 0 makes the central
// resolver leave a present base Elo untouched (no double-count), while still
// letting odds resolve a participant's Elo when they are the only source — so
// readiness and the sim's own internal odds blend both keep working.
defaultConfig: { ...BASE_CONFIG, bracketSize: 16, parityFactor: 850, inputPolicy: { oddsWeight: 0 } },
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "worldRanking"],
derivableInputs: { sourceElo: ["sourceOdds"] },

View file

@ -75,10 +75,6 @@ import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
import { logger } from "~/lib/logger";
import {
convertAmericanOddsToProbability,
normalizeProbabilities,
} from "~/services/probability-engine";
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
// ─── Simulation parameters ────────────────────────────────────────────────────
@ -103,12 +99,6 @@ const RDIF_DIVISOR = 8000;
*/
const SEEDING_RDIF_SCALE = 1620;
/**
* Blend weights for RDif vs. Vegas futures odds when sourceOdds are available.
*/
const RDIF_WEIGHT = 0.7;
const ODDS_WEIGHT = 1 - RDIF_WEIGHT;
// ─── Team data (2026 pre-season — FanGraphs Depth Charts) ────────────────────
//
// rdif: Projected run differential from FanGraphs Depth Charts.
@ -521,33 +511,19 @@ export class MLBSimulator implements Simulator {
);
}
// ─── Futures odds blending ─────────────────────────────────────────────────
// ─── Strength from the resolved Elo ────────────────────────────────────────
// sourceElo is the single Elo produced by the input policy — already a blend
// of any raw Elo / projections / futures odds — so the simulator just reads it
// and no longer blends odds itself.
const evRows = await db
.select({
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
})
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
const oddsRows = evRows.filter(
(r) => r.sourceOdds !== null && participantIdSet.has(r.participantId)
);
const hasOdds = oddsRows.length > 0;
const normalizedOddsMap = new Map<string, number>();
if (hasOdds) {
const rawProbs = oddsRows.map((r) =>
convertAmericanOddsToProbability(r.sourceOdds ?? 0)
);
const normalized = normalizeProbabilities(rawProbs);
oddsRows.forEach(({ participantId }, i) => {
normalizedOddsMap.set(participantId, normalized[i]);
});
}
// Build a map of sourceElo-derived RDif values (overrides hardcoded TEAMS_DATA.rdif)
// for playoff series win probability.
const sourceEloRDifMap = new Map<string, number>();
@ -575,20 +551,11 @@ export class MLBSimulator implements Simulator {
rawWinRateMap.get(entry.id) ?? rawWinRateFromRDif(getEntryRDif(entry));
/**
* Blended per-game win probability for team A over team B in a playoff series.
* When odds are available: 70% RDif log5 + 30% vig-removed futures head-to-head.
* Per-game win probability for team A over team B in a playoff series, from
* the (resolved-Elo-derived) run differential.
*/
const gameWinProb = (a: TeamEntry, b: TeamEntry): number => {
const rdifProb = rdifWinProbability(effectiveRDif(a), effectiveRDif(b));
if (!hasOdds) return rdifProb;
const o1 = normalizedOddsMap.get(a.id);
const o2 = normalizedOddsMap.get(b.id);
// Fall back to RDif if either team lacks odds — avoids inflating one side to 100%.
if (o1 === null || o1 === undefined || o2 === null || o2 === undefined) return rdifProb;
const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5;
return RDIF_WEIGHT * rdifProb + ODDS_WEIGHT * oddsProb;
};
const gameWinProb = (a: TeamEntry, b: TeamEntry): number =>
rdifWinProbability(effectiveRDif(a), effectiveRDif(b));
// ─── Placement count maps ──────────────────────────────────────────────────

View file

@ -59,11 +59,7 @@
import { database } from "~/database/context";
import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import {
convertAmericanOddsToProbability,
convertFuturesToElo,
eloWinProbability,
} from "~/services/probability-engine";
import { eloWinProbability } from "~/services/probability-engine";
import type { Simulator, SimulationResult } from "./types";
// ─── Simulation parameters ────────────────────────────────────────────────────
@ -72,15 +68,7 @@ const NUM_SIMULATIONS = 50_000;
const BRACKET_SIZE = 12;
/**
* Blend weights for per-game win probability when sourceOdds are present.
* Lower Elo weight than other sports (0.7) gives more influence to Vegas
* championship odds, which are highly informative in college football.
*/
const ELO_WEIGHT = 0.6;
const ODDS_WEIGHT = 1 - ELO_WEIGHT;
/**
* Softmax temperature for Elo-based selection weights (pre-bracket mode, no odds).
* Softmax temperature for Elo-based selection weights (pre-bracket mode).
* At T=100, a 200-point Elo gap produces ~7× weight difference enough to strongly
* favour the top teams while still giving bubble teams meaningful selection probability.
*/
@ -90,37 +78,21 @@ const SELECTION_TEMP = 100;
interface Team {
participantId: string;
/** Single resolved Elo (already a blend of any raw Elo/FPI and futures odds). */
elo: number;
/** Normalized futures win probability (01). Used for blending per-game win prob. */
oddsProb: number;
/**
* Weight used for probabilistic CFP field selection (pre-bracket mode only).
* Derived from oddsProb when available; otherwise softmax on Elo.
*/
/** Weight used for probabilistic CFP field selection (pre-bracket mode only). */
selectionWeight: number;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* 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 {
const eloProbValue = eloWinProbability(team1.elo, team2.elo);
if (team1.oddsProb === 0 && team2.oddsProb === 0) {
return eloProbValue;
}
const oddsSum = team1.oddsProb + team2.oddsProb;
const oddsProbValue = oddsSum > 0 ? team1.oddsProb / oddsSum : 0.5;
return ELO_WEIGHT * eloProbValue + ODDS_WEIGHT * oddsProbValue;
/** Win probability for team1 vs team2 from the single resolved Elo. */
function gameWinProb(team1: Team, team2: Team): number {
return eloWinProbability(team1.elo, team2.elo);
}
function simGame(team1: Team, team2: Team): { winner: Team; loser: Team } {
const p1Wins = Math.random() < blendedWinProb(team1, team2);
const p1Wins = Math.random() < gameWinProb(team1, team2);
return p1Wins
? { winner: team1, loser: team2 }
: { winner: team2, loser: team1 };
@ -211,7 +183,6 @@ function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>): v
export class NCAAFootballSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
// 1. Load all participants for this sports season.
const participants = await db
.select({ id: schema.seasonParticipants.id })
@ -225,79 +196,35 @@ export class NCAAFootballSimulator implements Simulator {
);
}
// 2. Load Elo/FPI ratings and optional futures odds in a single query.
// 2. Load the resolved single Elo (the input policy already blended any
// raw Elo/FPI and futures odds into sourceElo before the run).
const evRows = await db
.select({
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
})
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
// Build Elo and raw odds maps in a single pass.
const eloFromDb = new Map<string, number>();
const rawOddsProbs = new Map<string, number>();
for (const row of evRows) {
if (row.sourceElo !== null) {
eloFromDb.set(row.participantId, row.sourceElo);
}
if (row.sourceOdds !== null) {
rawOddsProbs.set(row.participantId, convertAmericanOddsToProbability(row.sourceOdds));
}
}
// 3. Build normalized odds probability map (vig removed).
const normalizedOddsMap = new Map<string, number>();
if (rawOddsProbs.size > 0) {
const rawSum = [...rawOddsProbs.values()].reduce((a, b) => a + b, 0);
for (const [id, prob] of rawOddsProbs) {
normalizedOddsMap.set(id, rawSum > 0 ? prob / rawSum : 0);
}
// Backfill Elo from futures for any team missing sourceElo.
if (eloFromDb.size < participants.length) {
const oddsInput = evRows
.filter((r) => r.sourceOdds !== null && !eloFromDb.has(r.participantId))
.map((r) => ({ participantId: r.participantId, odds: r.sourceOdds ?? 0 }));
if (oddsInput.length > 0) {
const oddsEloMap = convertFuturesToElo(oddsInput, "american");
for (const [id, elo] of oddsEloMap) {
eloFromDb.set(id, elo);
}
}
}
}
// 4. Build team list with Elo, oddsProb, and selectionWeight.
const hasOdds = normalizedOddsMap.size > 0;
// 3. Build team list; field-selection weight is a softmax on the resolved Elo.
const allTeams: Team[] = participants.map((p) => ({
participantId: p.id,
elo: eloFromDb.get(p.id) ?? 1500,
oddsProb: normalizedOddsMap.get(p.id) ?? 0,
selectionWeight: 0, // computed below
}));
if (hasOdds) {
// Selection weight = normalized championship implied probability.
// This encodes both "probability of making the field" and "strength once there."
for (const team of allTeams) {
team.selectionWeight = normalizedOddsMap.get(team.participantId) ?? 0;
}
} else {
// No odds: softmax on Elo so top-rated teams are strongly favoured.
const eloValues = allTeams.map((t) => t.elo);
const maxElo = Math.max(...eloValues);
// Subtract max for numerical stability before exp().
const expWeights = allTeams.map((t) => Math.exp((t.elo - maxElo) / SELECTION_TEMP));
const expSum = expWeights.reduce((a, b) => a + b, 0);
for (let i = 0; i < allTeams.length; i++) {
allTeams[i].selectionWeight = expWeights[i] / expSum;
}
const maxElo = Math.max(...allTeams.map((t) => t.elo));
const expWeights = allTeams.map((t) => Math.exp((t.elo - maxElo) / SELECTION_TEMP));
const expSum = expWeights.reduce((a, b) => a + b, 0);
for (let i = 0; i < allTeams.length; i++) {
allTeams[i].selectionWeight = expSum > 0 ? expWeights[i] / expSum : 1 / allTeams.length;
}
const preBracketMode = participants.length > BRACKET_SIZE;

View file

@ -29,6 +29,10 @@ export interface SimulationResult {
/**
* Common interface all sport simulators must implement.
*
* Simulators consume a single resolved Elo per participant (already blended from
* its sources raw Elo, projections, futures odds by the input policy and
* persisted before the run), so they need only the season id.
*/
export interface Simulator {
simulate(sportsSeasonId: string): Promise<SimulationResult[]>;

View file

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

View file

@ -38,7 +38,7 @@
import { database } from "~/database/context";
import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema";
import { convertFuturesToElo, eloWinProbability } from "~/services/probability-engine";
import { eloWinProbability } from "~/services/probability-engine";
import { simulateEloSoccerMatch } from "./soccer-helpers";
import { normalizeSimulationResultColumns } from "./simulation-probabilities";
import { logger } from "~/lib/logger";
@ -55,8 +55,6 @@ function normalizeTeamName(name: string): string {
// ─── Parameters ──────────────────────────────────────────────────────────────
const NUM_SIMULATIONS = 10_000;
const ELO_WEIGHT = 0.7;
const ODDS_WEIGHT = 1 - ELO_WEIGHT;
/** Base draw rate when teams are evenly matched. Decays with Elo difference. */
const BASE_DRAW_RATE = 0.28;
@ -274,13 +272,10 @@ function sortTeams(a: TeamStats, b: TeamStats): number {
function simKnockout(
teamA: string,
teamB: string,
eloFn: (id: string) => number,
normalizedProb: (id: string) => number
eloFn: (id: string) => number
): { 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 winner = Math.random() < blended ? teamA : teamB;
const winProb = eloWinProbability(eloFn(teamA), eloFn(teamB));
const winner = Math.random() < winProb ? teamA : teamB;
return { winner, loser: winner === teamA ? teamB : teamA };
}
@ -318,8 +313,7 @@ function runKnockoutRoundByNumber(
roundName: string,
matches: Map<number, KnockoutMatch>,
completed: Map<string, { winnerId: string; loserId: string }>,
eloFn: (id: string) => number,
normalizedProb: (id: string) => number
eloFn: (id: string) => number
): {
winnersByNumber: Map<number, string>;
losersByNumber: Map<number, string>;
@ -348,7 +342,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);
winnerId = result.winner;
loserId = result.loser;
} else if (m.p1 || m.p2) {
@ -461,20 +455,19 @@ export class WorldCupSimulator implements Simulator {
const participantIds = participantRows.map((p) => p.id);
const participantNames = new Map(participantRows.map((p) => [p.id, p.name]));
// 2. Load stored ratings (sourceElo from Elo page, sourceOdds from futures page)
// 2. Load the resolved single Elo (the input policy already blended any raw
// Elo and futures odds into sourceElo before the run).
const evRows = await db
.select({
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
})
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
const evMap = new Map(evRows.map((r) => [r.participantId, r]));
const participantSet = new Set(participantIds);
// 3. Build Elo map — priority: sourceElo (direct) > sourceOdds (converted) > hardcoded
// 3. Build the Elo map — resolved sourceElo, else hardcoded national-team Elo.
const sourceEloMap = new Map<string, number>();
for (const r of evRows) {
if (r.sourceElo !== null && r.sourceElo !== undefined && participantSet.has(r.participantId)) {
@ -482,47 +475,13 @@ export class WorldCupSimulator implements Simulator {
}
}
const hasOdds = evRows.some(
(r) => r.sourceOdds !== null && participantSet.has(r.participantId)
);
let eloFromOdds: Map<string, number>;
if (hasOdds) {
const oddsInput = evRows
.filter((r) => r.sourceOdds !== null && participantSet.has(r.participantId))
.map((r) => ({ participantId: r.participantId, odds: r.sourceOdds ?? 0 }));
eloFromOdds = convertFuturesToElo(oddsInput, "american");
} else {
eloFromOdds = new Map();
}
const eloFn = (id: string): number => {
if (sourceEloMap.has(id)) return sourceEloMap.get(id) ?? 1500;
if (eloFromOdds.has(id)) return eloFromOdds.get(id) ?? 1500;
const name = participantNames.get(id) ?? "";
return getTeamElo(name, 1500);
};
// 4. Build normalized futures win-probability map (vig removed)
const rawProbs = new Map<string, number>();
for (const id of participantIds) {
const ev = evMap.get(id);
if (ev?.sourceOdds !== null && ev?.sourceOdds !== undefined) {
rawProbs.set(id, Math.abs(ev.sourceOdds) > 0
? ev.sourceOdds > 0
? 100 / (ev.sourceOdds + 100)
: Math.abs(ev.sourceOdds) / (Math.abs(ev.sourceOdds) + 100)
: 1 / participantIds.length);
} else {
rawProbs.set(id, 1 / participantIds.length);
}
}
const totalRawProb = [...rawProbs.values()].reduce((s, p) => s + p, 0);
const normalizedProb = (id: string): number =>
totalRawProb > 0 ? (rawProbs.get(id) ?? 0) / totalRawProb : 1 / participantIds.length;
// 5. Resolve the bracket scoring event. Prefer a fifa_48 playoff event; if
// 4. Resolve the bracket scoring event. Prefer a fifa_48 playoff event; if
// several playoff_game events exist, take the most recent so a re-created
// event wins over a stale one.
const playoffEvents = await db.query.scoringEvents.findMany({
@ -714,10 +673,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);
const r16 = runKnockoutRoundByNumber(R16, r32.nextRound, completedByRoundAndNumber, eloFn);
const qf = runKnockoutRoundByNumber(QF, r16.nextRound, completedByRoundAndNumber, eloFn);
const sf = runKnockoutRoundByNumber(SF, qf.nextRound, completedByRoundAndNumber, eloFn);
// QF losers (placements 58)
for (const id of qf.losersByNumber.values()) {
@ -734,7 +693,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
);
counts.thirdPlace.set(thirdWinner, (counts.thirdPlace.get(thirdWinner) ?? 0) + 1);
counts.fourthPlace.set(thirdLoser, (counts.fourthPlace.get(thirdLoser) ?? 0) + 1);
@ -751,7 +710,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
);
counts.champion.set(champion, (counts.champion.get(champion) ?? 0) + 1);
counts.runnerUp.set(runnerUp, (counts.runnerUp.get(runnerUp) ?? 0) + 1);