Unify simulator strength on a single blended Elo
Replace the two parallel odds/Elo mechanisms with one model: every strength
source (raw Elo, projected wins, projected table points, futures odds) converts
to an Elo, those blend by weight into a single Elo, and that one Elo feeds every
simulator. This supersedes the earlier source-priority + per-match probability
blend approach.
Resolution (app/services/simulations/input-policy.ts):
- SimulatorInputPolicy gains baseEloPriority (order among the substitutable base
sources: raw Elo / projected wins / projected table points) and oddsWeight
(0–1). resolveSourceElos/resolveRatings now compute baseElo, derive an
odds Elo via convertFuturesToElo, and blend: 0 = base only, 1 = futures
override, between = weighted blend (method "blend").
- Drops the odds-inclusive sourceEloPriority and the prefersFuturesOdds helper.
Simulators consume the single resolved Elo:
- UCL, World Cup, NCAA Football, MLB drop their separate normalized-odds signal,
convertFuturesToElo calls, and per-match probability blend; they read the
resolved sourceElo (preserving each sim's hardcoded fallback Elo table). The
optional SimulationContext oddsWeight plumbing (types.ts/runner.ts) is removed.
- UCL is routed through the central blend (requiredInputs sourceElo, derivable
from sourceOdds) so any entered Elo and futures blend uniformly.
- College hockey already blends odds into Elo internally (and uses NPI rank the
central resolver can't), so its central oddsWeight is set to 0 to avoid
double-counting; the simulator is unchanged.
Manifest: per-profile oddsWeight defaults (World Cup/UCL/MLB 0.3, NCAA FB 0.4,
college hockey 0; global default 0.3).
UI: the Input Policy card exposes one "Futures vs. Elo — Odds Blend Weight"
control; the /admin/simulators inventory badge shows the effective blend
("Elo only" / "NN% blend" / "overrides Elo") with the odds participant count.
Tests: input-policy blend math (0/0.5/1) for Elo and ratings, baseEloPriority
and oddsWeight parsing/clamping, manifest per-profile weights; obsolete
source-priority and oddsWeight-context tests removed/replaced.
Note: this intentionally shifts the calibrated EV outputs of the four sims that
previously blended at the probability level (accepted in design discussion).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QhNeB7gN6VKene7sdbBVQT
This commit is contained in:
parent
dc4efe87cf
commit
24de966b3b
14 changed files with 328 additions and 559 deletions
|
|
@ -9,7 +9,6 @@ import {
|
||||||
} from "~/services/simulations/manifest";
|
} from "~/services/simulations/manifest";
|
||||||
import {
|
import {
|
||||||
getSimulatorInputPolicy,
|
getSimulatorInputPolicy,
|
||||||
prefersFuturesOdds,
|
|
||||||
ratingRequirementLabel,
|
ratingRequirementLabel,
|
||||||
resolveRatings,
|
resolveRatings,
|
||||||
resolveSourceElos,
|
resolveSourceElos,
|
||||||
|
|
@ -88,8 +87,8 @@ export interface SportsSeasonSimulatorSummary {
|
||||||
supportsFuturesOdds: boolean;
|
supportsFuturesOdds: boolean;
|
||||||
/** Number of participants that currently have stored futures odds. */
|
/** Number of participants that currently have stored futures odds. */
|
||||||
oddsParticipantCount: number;
|
oddsParticipantCount: number;
|
||||||
/** Whether the season's input policy makes futures odds override Elo/projections. */
|
/** Weight (0–1) the season's policy gives futures odds when blending into Elo. */
|
||||||
prefersFuturesOdds: boolean;
|
oddsWeight: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseDecimal(value: string | null | undefined): number | null {
|
function parseDecimal(value: string | null | undefined): number | null {
|
||||||
|
|
@ -133,8 +132,8 @@ function isFallbackMethod(method: string): boolean {
|
||||||
return method === "fallbackElo" || method === "fallbackRating" || method === "averageKnown" || method === "worstKnownMinus";
|
return method === "fallbackElo" || method === "fallbackRating" || method === "averageKnown" || method === "worstKnownMinus";
|
||||||
}
|
}
|
||||||
|
|
||||||
const GENERATED_RATING_METHODS = ["sourceOdds", "fallbackRating", "averageKnown", "worstKnownMinus"] as const;
|
const GENERATED_RATING_METHODS = ["sourceOdds", "blend", "fallbackRating", "averageKnown", "worstKnownMinus"] as const;
|
||||||
const GENERATED_SOURCE_ELO_METHODS = ["projectedWins", "projectedTablePoints", "sourceOdds", "fallbackElo", "averageKnown", "worstKnownMinus"] as const;
|
const GENERATED_SOURCE_ELO_METHODS = ["projectedWins", "projectedTablePoints", "sourceOdds", "blend", "fallbackElo", "averageKnown", "worstKnownMinus"] as const;
|
||||||
|
|
||||||
function isGeneratedRatingMethod(method: unknown): boolean {
|
function isGeneratedRatingMethod(method: unknown): boolean {
|
||||||
return typeof method === "string" && GENERATED_RATING_METHODS.includes(method as (typeof GENERATED_RATING_METHODS)[number]);
|
return typeof method === "string" && GENERATED_RATING_METHODS.includes(method as (typeof GENERATED_RATING_METHODS)[number]);
|
||||||
|
|
@ -697,7 +696,7 @@ export async function listSportsSeasonSimulatorSummaries(): Promise<SportsSeason
|
||||||
readiness,
|
readiness,
|
||||||
supportsFuturesOdds: profile.setupSections.includes("futuresOdds"),
|
supportsFuturesOdds: profile.setupSections.includes("futuresOdds"),
|
||||||
oddsParticipantCount: oddsCountBySeason.get(season.id) ?? 0,
|
oddsParticipantCount: oddsCountBySeason.get(season.id) ?? 0,
|
||||||
prefersFuturesOdds: prefersFuturesOdds(policy.sourceEloPriority),
|
oddsWeight: policy.oddsWeight,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,12 @@ export async function action({ request }: Route.ActionArgs): Promise<ActionData>
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function futuresBlendLabel(oddsWeight: number): string {
|
||||||
|
if (oddsWeight >= 1) return "overrides Elo";
|
||||||
|
if (oddsWeight <= 0) return "Elo only";
|
||||||
|
return `${Math.round(oddsWeight * 100)}% blend`;
|
||||||
|
}
|
||||||
|
|
||||||
function statusBadge(status: string) {
|
function statusBadge(status: string) {
|
||||||
if (status === "ready") return <Badge className="gap-1"><CheckCircle2 className="h-3 w-3" /> Ready</Badge>;
|
if (status === "ready") return <Badge className="gap-1"><CheckCircle2 className="h-3 w-3" /> Ready</Badge>;
|
||||||
return <Badge variant="secondary" className="gap-1"><XCircle className="h-3 w-3" /> Needs setup</Badge>;
|
return <Badge variant="secondary" className="gap-1"><XCircle className="h-3 w-3" /> Needs setup</Badge>;
|
||||||
|
|
@ -247,9 +253,9 @@ export default function AdminSimulators({ loaderData }: Route.ComponentProps) {
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
<div>{sim.participantInputCount}/{sim.participantCount}</div>
|
<div>{sim.participantInputCount}/{sim.participantCount}</div>
|
||||||
{sim.supportsFuturesOdds && (
|
{sim.supportsFuturesOdds && (
|
||||||
<Badge variant={sim.prefersFuturesOdds ? "default" : "outline"} className="mt-1 text-xs font-normal">
|
<Badge variant={sim.oddsWeight > 0 ? "default" : "outline"} className="mt-1 text-xs font-normal">
|
||||||
{sim.oddsParticipantCount > 0
|
{sim.oddsParticipantCount > 0
|
||||||
? `Futures: ${sim.oddsParticipantCount}${sim.prefersFuturesOdds ? " (overrides Elo)" : " (fallback)"}`
|
? `Futures: ${sim.oddsParticipantCount} · ${futuresBlendLabel(sim.oddsWeight)}`
|
||||||
: "No futures odds"}
|
: "No futures odds"}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,6 @@ import {
|
||||||
import { normalizeName } from "~/lib/fuzzy-match";
|
import { normalizeName } from "~/lib/fuzzy-match";
|
||||||
import {
|
import {
|
||||||
getSimulatorInputPolicy,
|
getSimulatorInputPolicy,
|
||||||
prefersFuturesOdds,
|
|
||||||
ELO_FIRST_SOURCE_PRIORITY,
|
|
||||||
FUTURES_FIRST_SOURCE_PRIORITY,
|
|
||||||
type MissingEloStrategy,
|
type MissingEloStrategy,
|
||||||
type MissingRatingStrategy,
|
type MissingRatingStrategy,
|
||||||
} from "~/services/simulations/input-policy";
|
} from "~/services/simulations/input-policy";
|
||||||
|
|
@ -212,10 +209,7 @@ export async function action({ request, params }: Route.ActionArgs): Promise<Act
|
||||||
inputPolicy: {
|
inputPolicy: {
|
||||||
missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")),
|
missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")),
|
||||||
missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")),
|
missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")),
|
||||||
sourceEloPriority:
|
// Stored as-is; getSimulatorInputPolicy clamps to [0,1] on read.
|
||||||
formData.get("sourceStrategy") === "futuresFirst"
|
|
||||||
? FUTURES_FIRST_SOURCE_PRIORITY
|
|
||||||
: ELO_FIRST_SOURCE_PRIORITY,
|
|
||||||
oddsWeight: parsePolicyNumber(formData, "oddsWeight", currentPolicy.oddsWeight),
|
oddsWeight: parsePolicyNumber(formData, "oddsWeight", currentPolicy.oddsWeight),
|
||||||
fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo),
|
fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo),
|
||||||
fallbackEloDelta: parsePolicyNumber(formData, "fallbackEloDelta", currentPolicy.fallbackEloDelta),
|
fallbackEloDelta: parsePolicyNumber(formData, "fallbackEloDelta", currentPolicy.fallbackEloDelta),
|
||||||
|
|
@ -373,27 +367,14 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Form method="post" className="grid gap-4 md:grid-cols-5">
|
<Form method="post" className="grid gap-4 md:grid-cols-5">
|
||||||
<input type="hidden" name="intent" value="save-input-policy" />
|
<input type="hidden" name="intent" value="save-input-policy" />
|
||||||
<div className="space-y-2 md:col-span-3">
|
<div className="space-y-2 md:col-span-5">
|
||||||
<Label htmlFor="sourceStrategy">Futures vs. Elo</Label>
|
<Label htmlFor="oddsWeight">Futures vs. Elo — Odds Blend Weight (0–1)</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} />
|
<Input id="oddsWeight" name="oddsWeight" type="number" step="0.05" min="0" max="1" defaultValue={inputPolicy.oddsWeight} />
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
For simulators that blend Elo and odds per match. 1.0 = pure odds; 0 = pure Elo.
|
Every source (raw Elo, projections, futures odds) becomes an Elo, then they blend into
|
||||||
|
the single Elo that feeds the simulator. This is the weight given to futures odds:
|
||||||
|
<strong> 0</strong> = Elo / projections only, <strong>1</strong> = futures fully override,
|
||||||
|
in between = blend (e.g. 0.3 = 70% Elo / 30% futures).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{config.profile.requiredInputs.includes("sourceElo") && (
|
{config.profile.requiredInputs.includes("sourceElo") && (
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
getSimulatorInputPolicy,
|
getSimulatorInputPolicy,
|
||||||
prefersFuturesOdds,
|
|
||||||
resolveRatings,
|
resolveRatings,
|
||||||
resolveSourceElos,
|
resolveSourceElos,
|
||||||
ELO_FIRST_SOURCE_PRIORITY,
|
DEFAULT_BASE_ELO_PRIORITY,
|
||||||
FUTURES_FIRST_SOURCE_PRIORITY,
|
|
||||||
} from "../input-policy";
|
} from "../input-policy";
|
||||||
import type { SimulatorManifestProfile } from "../manifest";
|
import type { SimulatorManifestProfile } from "../manifest";
|
||||||
|
|
||||||
|
|
@ -190,86 +188,95 @@ describe("simulator input policy", () => {
|
||||||
expect(resolved.get("missing-tail")).toMatchObject({ rating: -10, method: "worstKnownMinus" });
|
expect(resolved.get("missing-tail")).toMatchObject({ rating: -10, method: "worstKnownMinus" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lets a futures-first priority override a stored direct Elo", () => {
|
it("blends base Elo and futures odds into a single Elo by oddsWeight", () => {
|
||||||
const inputs = [
|
const inputs = [
|
||||||
{ participantId: "favorite", sourceElo: 1800, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
|
{ participantId: "favorite", sourceElo: 1800, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
|
||||||
{ participantId: "longshot", sourceElo: 1200, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
|
{ participantId: "longshot", sourceElo: 1200, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Default (Elo-first): the stored direct Elo wins.
|
// oddsWeight 0: the stored base Elo wins outright.
|
||||||
const eloFirst = resolveSourceElos(inputs, profile, {});
|
const eloOnly = resolveSourceElos(inputs, profile, { inputPolicy: { oddsWeight: 0 } });
|
||||||
expect(eloFirst.get("favorite")).toMatchObject({ sourceElo: 1800, method: "direct" });
|
expect(eloOnly.get("favorite")).toMatchObject({ sourceElo: 1800, method: "direct" });
|
||||||
|
|
||||||
// Futures-first: odds win even though a direct Elo is present.
|
// oddsWeight 1: futures fully override the base Elo.
|
||||||
const futuresFirst = resolveSourceElos(inputs, profile, {
|
const oddsOnly = resolveSourceElos(inputs, profile, { inputPolicy: { oddsWeight: 1 } });
|
||||||
inputPolicy: { sourceEloPriority: FUTURES_FIRST_SOURCE_PRIORITY },
|
expect(oddsOnly.get("favorite")?.method).toBe("sourceOdds");
|
||||||
});
|
expect(oddsOnly.get("longshot")?.method).toBe("sourceOdds");
|
||||||
expect(futuresFirst.get("favorite")?.method).toBe("sourceOdds");
|
|
||||||
expect(futuresFirst.get("longshot")?.method).toBe("sourceOdds");
|
// Between: a blend that sits strictly between the base and the odds-derived Elo.
|
||||||
|
const oddsElo = oddsOnly.get("favorite")!.sourceElo;
|
||||||
|
const blended = resolveSourceElos(inputs, profile, { inputPolicy: { oddsWeight: 0.5 } });
|
||||||
|
expect(blended.get("favorite")?.method).toBe("blend");
|
||||||
|
const blendedElo = blended.get("favorite")!.sourceElo;
|
||||||
|
expect(blendedElo).toBeGreaterThan(Math.min(1800, oddsElo));
|
||||||
|
expect(blendedElo).toBeLessThan(Math.max(1800, oddsElo));
|
||||||
|
expect(blendedElo).toBeCloseTo(Math.round(0.5 * 1800 + 0.5 * oddsElo), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lets a futures-first priority override stored projected wins", () => {
|
it("blends projected wins with futures odds when both are present", () => {
|
||||||
const inputs = [
|
const inputs = [
|
||||||
{ participantId: "team-1", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: 60, projectedTablePoints: null },
|
{ participantId: "team-1", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: 60, projectedTablePoints: null },
|
||||||
{ participantId: "team-2", sourceElo: null, rating: null, sourceOdds: 20000, projectedWins: 20, projectedTablePoints: null },
|
{ participantId: "team-2", sourceElo: null, rating: null, sourceOdds: 20000, projectedWins: 20, projectedTablePoints: null },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Default (Elo-first): projectedWins outranks odds.
|
// oddsWeight 0: projectedWins is the base.
|
||||||
expect(resolveSourceElos(inputs, profile, { seasonGames: 82 }).get("team-1")?.method).toBe("projectedWins");
|
expect(resolveSourceElos(inputs, profile, { seasonGames: 82, inputPolicy: { oddsWeight: 0 } }).get("team-1")?.method)
|
||||||
|
.toBe("projectedWins");
|
||||||
|
|
||||||
// Futures-first: odds win over projections.
|
// oddsWeight 1: futures override the projection.
|
||||||
const futuresFirst = resolveSourceElos(inputs, profile, {
|
expect(resolveSourceElos(inputs, profile, { seasonGames: 82, inputPolicy: { oddsWeight: 1 } }).get("team-1")?.method)
|
||||||
seasonGames: 82,
|
.toBe("sourceOdds");
|
||||||
inputPolicy: { sourceEloPriority: FUTURES_FIRST_SOURCE_PRIORITY },
|
|
||||||
});
|
// Between: blended.
|
||||||
expect(futuresFirst.get("team-1")?.method).toBe("sourceOdds");
|
expect(resolveSourceElos(inputs, profile, { seasonGames: 82, inputPolicy: { oddsWeight: 0.4 } }).get("team-1")?.method)
|
||||||
|
.toBe("blend");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lets a futures-first priority override a stored direct rating", () => {
|
it("blends a direct rating with futures odds by oddsWeight", () => {
|
||||||
const ratingProfile = { derivableInputs: { rating: ["sourceOdds"] } } as Pick<SimulatorManifestProfile, "derivableInputs">;
|
const ratingProfile = { derivableInputs: { rating: ["sourceOdds"] } } as Pick<SimulatorManifestProfile, "derivableInputs">;
|
||||||
const inputs = [
|
const inputs = [
|
||||||
{ participantId: "favorite", sourceElo: null, rating: 30, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
|
{ participantId: "favorite", sourceElo: null, rating: 30, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
|
||||||
{ participantId: "longshot", sourceElo: null, rating: -5, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
|
{ participantId: "longshot", sourceElo: null, rating: -5, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Default: direct rating wins.
|
// oddsWeight 0: direct rating wins.
|
||||||
expect(resolveRatings(inputs, ratingProfile, { inputPolicy: { ratingMin: -10, ratingMax: 35 } }).get("favorite"))
|
expect(resolveRatings(inputs, ratingProfile, { inputPolicy: { ratingMin: -10, ratingMax: 35, oddsWeight: 0 } }).get("favorite"))
|
||||||
.toMatchObject({ rating: 30, method: "direct" });
|
.toMatchObject({ rating: 30, method: "direct" });
|
||||||
|
|
||||||
// Futures-first: odds win.
|
// oddsWeight 1: odds override the rating.
|
||||||
expect(
|
expect(
|
||||||
resolveRatings(inputs, ratingProfile, {
|
resolveRatings(inputs, ratingProfile, {
|
||||||
inputPolicy: { ratingMin: -10, ratingMax: 35, sourceEloPriority: FUTURES_FIRST_SOURCE_PRIORITY },
|
inputPolicy: { ratingMin: -10, ratingMax: 35, oddsWeight: 1 },
|
||||||
}).get("favorite")?.method
|
}).get("favorite")?.method
|
||||||
).toBe("sourceOdds");
|
).toBe("sourceOdds");
|
||||||
|
|
||||||
|
// Between: blended.
|
||||||
|
expect(
|
||||||
|
resolveRatings(inputs, ratingProfile, {
|
||||||
|
inputPolicy: { ratingMin: -10, ratingMax: 35, oddsWeight: 0.5 },
|
||||||
|
}).get("favorite")?.method
|
||||||
|
).toBe("blend");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses and clamps the input policy source priority and odds weight", () => {
|
it("parses and clamps the base Elo priority and odds weight", () => {
|
||||||
const defaults = getSimulatorInputPolicy({});
|
const defaults = getSimulatorInputPolicy({});
|
||||||
expect(defaults.sourceEloPriority).toEqual(ELO_FIRST_SOURCE_PRIORITY);
|
expect(defaults.baseEloPriority).toEqual(DEFAULT_BASE_ELO_PRIORITY);
|
||||||
expect(defaults.oddsWeight).toBe(0.3);
|
expect(defaults.oddsWeight).toBe(0.3);
|
||||||
|
|
||||||
// oddsWeight can be sourced from the top-level config or the input policy,
|
// oddsWeight can be sourced from the top-level config or the input policy,
|
||||||
// and is clamped to [0, 1]; junk priority entries are dropped.
|
// and is clamped to [0, 1]; junk base-priority entries are dropped.
|
||||||
expect(getSimulatorInputPolicy({ oddsWeight: 0.6 }).oddsWeight).toBe(0.6);
|
expect(getSimulatorInputPolicy({ oddsWeight: 0.6 }).oddsWeight).toBe(0.6);
|
||||||
expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: 5 } }).oddsWeight).toBe(1);
|
expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: 5 } }).oddsWeight).toBe(1);
|
||||||
expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: -2 } }).oddsWeight).toBe(0);
|
expect(getSimulatorInputPolicy({ inputPolicy: { oddsWeight: -2 } }).oddsWeight).toBe(0);
|
||||||
expect(
|
expect(
|
||||||
getSimulatorInputPolicy({ inputPolicy: { sourceEloPriority: ["sourceOdds", "bogus", "sourceElo"] } }).sourceEloPriority
|
getSimulatorInputPolicy({ inputPolicy: { baseEloPriority: ["projectedWins", "sourceOdds", "sourceElo"] } }).baseEloPriority
|
||||||
).toEqual(["sourceOdds", "sourceElo"]);
|
).toEqual(["projectedWins", "sourceElo"]);
|
||||||
// An empty/all-invalid priority falls back to the default.
|
// An empty/all-invalid priority falls back to the default.
|
||||||
expect(getSimulatorInputPolicy({ inputPolicy: { sourceEloPriority: ["nope"] } }).sourceEloPriority).toEqual(
|
expect(getSimulatorInputPolicy({ inputPolicy: { baseEloPriority: ["nope"] } }).baseEloPriority).toEqual(
|
||||||
ELO_FIRST_SOURCE_PRIORITY
|
DEFAULT_BASE_ELO_PRIORITY
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects whether a priority prefers futures odds", () => {
|
|
||||||
expect(prefersFuturesOdds(ELO_FIRST_SOURCE_PRIORITY)).toBe(false);
|
|
||||||
expect(prefersFuturesOdds(FUTURES_FIRST_SOURCE_PRIORITY)).toBe(true);
|
|
||||||
expect(prefersFuturesOdds(["projectedWins", "sourceElo"])).toBe(false);
|
|
||||||
expect(prefersFuturesOdds(["sourceOdds"])).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses NCAAW-style rating scale when deriving and falling back", () => {
|
it("uses NCAAW-style rating scale when deriving and falling back", () => {
|
||||||
const resolved = resolveRatings(
|
const resolved = resolveRatings(
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { SIMULATOR_MANIFEST } from "../manifest";
|
import { SIMULATOR_MANIFEST } from "../manifest";
|
||||||
import { SIMULATOR_TYPES } from "../registry";
|
import { SIMULATOR_TYPES } from "../registry";
|
||||||
import { getSimulatorInputPolicy, prefersFuturesOdds } from "../input-policy";
|
import { getSimulatorInputPolicy } from "../input-policy";
|
||||||
import { assertRegistrySchemaDriftFree } from "~/models/simulator";
|
import { assertRegistrySchemaDriftFree } from "~/models/simulator";
|
||||||
|
|
||||||
describe("simulator manifest", () => {
|
describe("simulator manifest", () => {
|
||||||
|
|
@ -41,19 +41,15 @@ describe("simulator manifest", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults futures-centric simulators to a futures-override source priority", () => {
|
it("exposes the per-profile futures blend weight through the input policy", () => {
|
||||||
const futuresCentric = ["ncaam_bracket", "ncaaw_bracket", "world_cup", "ncaa_football_bracket", "college_hockey_bracket"] as const;
|
|
||||||
for (const simulatorType of futuresCentric) {
|
|
||||||
const policy = getSimulatorInputPolicy(SIMULATOR_MANIFEST[simulatorType].defaultConfig);
|
|
||||||
expect(prefersFuturesOdds(policy.sourceEloPriority)).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exposes the blended Elo/odds weight through the input policy", () => {
|
|
||||||
expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.ncaa_football_bracket.defaultConfig).oddsWeight).toBe(0.4);
|
expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.ncaa_football_bracket.defaultConfig).oddsWeight).toBe(0.4);
|
||||||
expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.world_cup.defaultConfig).oddsWeight).toBe(0.3);
|
expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.world_cup.defaultConfig).oddsWeight).toBe(0.3);
|
||||||
// A non-futures team sport keeps the Elo-first default.
|
expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.ucl_bracket.defaultConfig).oddsWeight).toBe(0.3);
|
||||||
expect(prefersFuturesOdds(getSimulatorInputPolicy(SIMULATOR_MANIFEST.nba_bracket.defaultConfig).sourceEloPriority)).toBe(false);
|
// College hockey blends odds internally, so the central blend is disabled.
|
||||||
|
expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.college_hockey_bracket.defaultConfig).oddsWeight).toBe(0);
|
||||||
|
// Every profile resolves to the default base-Elo priority unless overridden.
|
||||||
|
expect(getSimulatorInputPolicy(SIMULATOR_MANIFEST.nba_bracket.defaultConfig).baseEloPriority)
|
||||||
|
.toEqual(["sourceElo", "projectedWins", "projectedTablePoints"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps EPL projection and match parity as separate config knobs", () => {
|
it("keeps EPL projection and match parity as separate config knobs", () => {
|
||||||
|
|
|
||||||
|
|
@ -143,42 +143,14 @@ describe("NCAAFootballSimulator", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses futures odds when sourceOdds present", async () => {
|
it("runs from the resolved Elo (futures already blended upstream)", async () => {
|
||||||
setupMockDb(TEAM_IDS, { includeOdds: true });
|
// sourceElo is the single resolved Elo; the sim no longer reads sourceOdds.
|
||||||
|
setupMockDb(TEAM_IDS);
|
||||||
const results = await new NCAAFootballSimulator().simulate("season-1");
|
const results = await new NCAAFootballSimulator().simulate("season-1");
|
||||||
expect(results).toHaveLength(12);
|
expect(results).toHaveLength(12);
|
||||||
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
||||||
expect(total).toBeCloseTo(1.0, 1);
|
expect(total).toBeCloseTo(1.0, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("honors the oddsWeight context when odds and Elo disagree", async () => {
|
|
||||||
// team-12 has the worst Elo but by far the best futures odds.
|
|
||||||
function setupConflicting() {
|
|
||||||
const participants = makeParticipants(TEAM_IDS);
|
|
||||||
const evRows = TEAM_IDS.map((participantId, i) => ({
|
|
||||||
participantId,
|
|
||||||
sourceElo: 1700 - i * 50,
|
|
||||||
sourceOdds: i === 11 ? -100000 : 50000,
|
|
||||||
}));
|
|
||||||
mockDb.select.mockImplementation(() => {
|
|
||||||
const callIndex = selectCallCount++;
|
|
||||||
const data = callIndex === 0 ? participants : evRows;
|
|
||||||
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setupConflicting();
|
|
||||||
const pureOdds = await new NCAAFootballSimulator().simulate("season-1", { config: {}, oddsWeight: 1 });
|
|
||||||
selectCallCount = 0;
|
|
||||||
setupConflicting();
|
|
||||||
const pureElo = await new NCAAFootballSimulator().simulate("season-1", { config: {}, oddsWeight: 0 });
|
|
||||||
|
|
||||||
const champByOdds = pureOdds.find((r) => r.participantId === "team-12")?.probabilities.probFirst ?? 0;
|
|
||||||
const champByElo = pureElo.find((r) => r.participantId === "team-12")?.probabilities.probFirst ?? 0;
|
|
||||||
// With the odds signal fully weighted, the odds-favored team wins far more
|
|
||||||
// often than when only its (worst) Elo drives the result.
|
|
||||||
expect(champByOdds).toBeGreaterThan(champByElo);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Pre-bracket mode (>12 teams — probabilistic selection) ───────────────
|
// ── Pre-bracket mode (>12 teams — probabilistic selection) ───────────────
|
||||||
|
|
|
||||||
|
|
@ -6,54 +6,36 @@ export type MissingEloStrategy = "block" | "fallbackElo" | "averageKnown" | "wor
|
||||||
export type MissingRatingStrategy = "block" | "fallbackRating" | "averageKnown" | "worstKnownMinus";
|
export type MissingRatingStrategy = "block" | "fallbackRating" | "averageKnown" | "worstKnownMinus";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sources a participant's strength input can be derived from, in the order the
|
* "Base" strength sources — the ways to produce a participant's underlying Elo
|
||||||
* resolver should try them. `"sourceElo"` represents the simulator's primary
|
* before any futures-odds blend. These are *substitutes* (a season rarely has
|
||||||
* direct input — a manually entered Elo for Elo-based sims, or the direct
|
* more than one), so they are tried in order and the first available one wins.
|
||||||
* `rating` for rating-based sims. Placing `"sourceOdds"` ahead of `"sourceElo"`
|
* Futures odds are not in this list: they blend on top of the base Elo via
|
||||||
* is how an admin makes futures odds override stored Elo/ratings.
|
* `oddsWeight`, rather than being one more interchangeable base.
|
||||||
*/
|
*/
|
||||||
export type SourceEloKey = "sourceElo" | "projectedWins" | "projectedTablePoints" | "sourceOdds";
|
export type BaseEloKey = "sourceElo" | "projectedWins" | "projectedTablePoints";
|
||||||
|
|
||||||
export const SOURCE_ELO_KEYS: readonly SourceEloKey[] = [
|
export const BASE_ELO_KEYS: readonly BaseEloKey[] = [
|
||||||
"sourceElo",
|
|
||||||
"projectedWins",
|
|
||||||
"projectedTablePoints",
|
|
||||||
"sourceOdds",
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Default priority: stored Elo/projections win, futures odds are the fallback. */
|
|
||||||
export const ELO_FIRST_SOURCE_PRIORITY: SourceEloKey[] = [
|
|
||||||
"sourceElo",
|
|
||||||
"projectedWins",
|
|
||||||
"projectedTablePoints",
|
|
||||||
"sourceOdds",
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Futures-override priority: freshly entered odds win over stored Elo/projections. */
|
|
||||||
export const FUTURES_FIRST_SOURCE_PRIORITY: SourceEloKey[] = [
|
|
||||||
"sourceOdds",
|
|
||||||
"sourceElo",
|
"sourceElo",
|
||||||
"projectedWins",
|
"projectedWins",
|
||||||
"projectedTablePoints",
|
"projectedTablePoints",
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Whether a priority list prefers futures odds over the direct Elo/rating input. */
|
/** Default order for choosing the base Elo: raw Elo, then projections. */
|
||||||
export function prefersFuturesOdds(priority: SourceEloKey[]): boolean {
|
export const DEFAULT_BASE_ELO_PRIORITY: BaseEloKey[] = [
|
||||||
const oddsIndex = priority.indexOf("sourceOdds");
|
"sourceElo",
|
||||||
const eloIndex = priority.indexOf("sourceElo");
|
"projectedWins",
|
||||||
if (oddsIndex === -1) return false;
|
"projectedTablePoints",
|
||||||
return eloIndex === -1 || oddsIndex < eloIndex;
|
];
|
||||||
}
|
|
||||||
|
|
||||||
export interface SimulatorInputPolicy {
|
export interface SimulatorInputPolicy {
|
||||||
missingEloStrategy: MissingEloStrategy;
|
missingEloStrategy: MissingEloStrategy;
|
||||||
missingRatingStrategy: MissingRatingStrategy;
|
missingRatingStrategy: MissingRatingStrategy;
|
||||||
/** Ordered preference for deriving a participant's strength input. */
|
/** Order for choosing the base strength Elo from its substitutable sources. */
|
||||||
sourceEloPriority: SourceEloKey[];
|
baseEloPriority: BaseEloKey[];
|
||||||
/**
|
/**
|
||||||
* Weight (0–1) given to the futures-odds signal in simulators that blend Elo
|
* Weight (0–1) of the futures-odds-derived Elo when blending with the base Elo
|
||||||
* and odds (UCL, World Cup, NCAA Football, MLB, college hockey). The Elo
|
* into the single Elo that feeds every simulator. `0` = base only (Elo /
|
||||||
* signal gets `1 - oddsWeight`. Set to 1.0 to make futures fully override Elo.
|
* projections), `1` = futures fully override the base, in between = blend.
|
||||||
*/
|
*/
|
||||||
oddsWeight: number;
|
oddsWeight: number;
|
||||||
fallbackElo: number;
|
fallbackElo: number;
|
||||||
|
|
@ -78,13 +60,13 @@ export interface ParticipantInputForPolicy {
|
||||||
export interface ResolvedSourceElo {
|
export interface ResolvedSourceElo {
|
||||||
participantId: string;
|
participantId: string;
|
||||||
sourceElo: number;
|
sourceElo: number;
|
||||||
method: "direct" | "projectedWins" | "projectedTablePoints" | "sourceOdds" | MissingEloStrategy;
|
method: "direct" | "projectedWins" | "projectedTablePoints" | "sourceOdds" | "blend" | MissingEloStrategy;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolvedRating {
|
export interface ResolvedRating {
|
||||||
participantId: string;
|
participantId: string;
|
||||||
rating: number;
|
rating: number;
|
||||||
method: "direct" | "sourceOdds" | MissingRatingStrategy;
|
method: "direct" | "sourceOdds" | "blend" | MissingRatingStrategy;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_ODDS_WEIGHT = 0.3;
|
const DEFAULT_ODDS_WEIGHT = 0.3;
|
||||||
|
|
@ -92,7 +74,7 @@ const DEFAULT_ODDS_WEIGHT = 0.3;
|
||||||
const DEFAULT_POLICY: SimulatorInputPolicy = {
|
const DEFAULT_POLICY: SimulatorInputPolicy = {
|
||||||
missingEloStrategy: "block",
|
missingEloStrategy: "block",
|
||||||
missingRatingStrategy: "block",
|
missingRatingStrategy: "block",
|
||||||
sourceEloPriority: ELO_FIRST_SOURCE_PRIORITY,
|
baseEloPriority: DEFAULT_BASE_ELO_PRIORITY,
|
||||||
oddsWeight: DEFAULT_ODDS_WEIGHT,
|
oddsWeight: DEFAULT_ODDS_WEIGHT,
|
||||||
fallbackElo: 1400,
|
fallbackElo: 1400,
|
||||||
fallbackEloDelta: 25,
|
fallbackEloDelta: 25,
|
||||||
|
|
@ -132,14 +114,14 @@ function projectionToElo(
|
||||||
return clamp(Math.round(elo), policy.eloMin, policy.eloMax);
|
return clamp(Math.round(elo), policy.eloMin, policy.eloMax);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSourceEloPriority(value: unknown, fallback: SourceEloKey[]): SourceEloKey[] {
|
function parseBaseEloPriority(value: unknown, fallback: BaseEloKey[]): BaseEloKey[] {
|
||||||
if (!Array.isArray(value)) return fallback;
|
if (!Array.isArray(value)) return fallback;
|
||||||
const seen = new Set<SourceEloKey>();
|
const seen = new Set<BaseEloKey>();
|
||||||
const parsed: SourceEloKey[] = [];
|
const parsed: BaseEloKey[] = [];
|
||||||
for (const entry of value) {
|
for (const entry of value) {
|
||||||
if (typeof entry === "string" && (SOURCE_ELO_KEYS as readonly string[]).includes(entry) && !seen.has(entry as SourceEloKey)) {
|
if (typeof entry === "string" && (BASE_ELO_KEYS as readonly string[]).includes(entry) && !seen.has(entry as BaseEloKey)) {
|
||||||
seen.add(entry as SourceEloKey);
|
seen.add(entry as BaseEloKey);
|
||||||
parsed.push(entry as SourceEloKey);
|
parsed.push(entry as BaseEloKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return parsed.length > 0 ? parsed : fallback;
|
return parsed.length > 0 ? parsed : fallback;
|
||||||
|
|
@ -159,7 +141,7 @@ export function getSimulatorInputPolicy(config: Record<string, unknown>): Simula
|
||||||
ratingStrategy === "fallbackRating" || ratingStrategy === "averageKnown" || ratingStrategy === "worstKnownMinus"
|
ratingStrategy === "fallbackRating" || ratingStrategy === "averageKnown" || ratingStrategy === "worstKnownMinus"
|
||||||
? ratingStrategy
|
? ratingStrategy
|
||||||
: "block",
|
: "block",
|
||||||
sourceEloPriority: parseSourceEloPriority(rawPolicy.sourceEloPriority, DEFAULT_POLICY.sourceEloPriority),
|
baseEloPriority: parseBaseEloPriority(rawPolicy.baseEloPriority, DEFAULT_POLICY.baseEloPriority),
|
||||||
// Allow oddsWeight to live either in inputPolicy (the unified knob) or at the
|
// Allow oddsWeight to live either in inputPolicy (the unified knob) or at the
|
||||||
// top level of config (legacy per-profile defaultConfig), preferring the policy.
|
// top level of config (legacy per-profile defaultConfig), preferring the policy.
|
||||||
oddsWeight: clamp(
|
oddsWeight: clamp(
|
||||||
|
|
@ -201,33 +183,26 @@ export function resolveSourceElos(
|
||||||
const alternatives = new Set(profile.derivableInputs?.sourceElo ?? []);
|
const alternatives = new Set(profile.derivableInputs?.sourceElo ?? []);
|
||||||
const parityFactor = optionalNumber(config.parityFactor, 400);
|
const parityFactor = optionalNumber(config.parityFactor, 400);
|
||||||
|
|
||||||
// Resolve each participant from the first source in the configured priority
|
// 1. Base Elo: the first available *substitutable* source per baseEloPriority.
|
||||||
// that (a) is permitted for this simulator and (b) has a value. The direct
|
// `sourceElo` (raw) is always permitted; projections must be declared in
|
||||||
// `sourceElo` input is always permitted; the rest must be declared in the
|
// the profile's derivableInputs.sourceElo.
|
||||||
// profile's `derivableInputs.sourceElo`. Reordering the priority so that
|
const baseElo = new Map<string, { elo: number; method: ResolvedSourceElo["method"] }>();
|
||||||
// `sourceOdds` precedes `sourceElo` makes freshly entered futures odds
|
for (const source of policy.baseEloPriority) {
|
||||||
// override a stale Elo (and projections) without destroying the stored value.
|
|
||||||
for (const source of policy.sourceEloPriority) {
|
|
||||||
if (source !== "sourceElo" && !alternatives.has(source)) continue;
|
if (source !== "sourceElo" && !alternatives.has(source)) continue;
|
||||||
|
|
||||||
if (source === "sourceElo") {
|
if (source === "sourceElo") {
|
||||||
for (const input of inputs) {
|
for (const input of inputs) {
|
||||||
if (resolved.has(input.participantId)) continue;
|
if (baseElo.has(input.participantId)) continue;
|
||||||
if (input.sourceElo !== null && input.sourceElo !== undefined) {
|
if (input.sourceElo !== null && input.sourceElo !== undefined) {
|
||||||
resolved.set(input.participantId, {
|
baseElo.set(input.participantId, { elo: input.sourceElo, method: "direct" });
|
||||||
participantId: input.participantId,
|
|
||||||
sourceElo: input.sourceElo,
|
|
||||||
method: "direct",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (source === "projectedWins") {
|
} else if (source === "projectedWins") {
|
||||||
const seasonGames = optionalNumber(config.seasonGames, Math.max(...inputs.map((input) => input.projectedWins ?? 0), 1));
|
const seasonGames = optionalNumber(config.seasonGames, Math.max(...inputs.map((input) => input.projectedWins ?? 0), 1));
|
||||||
for (const input of inputs) {
|
for (const input of inputs) {
|
||||||
if (resolved.has(input.participantId) || input.projectedWins === null || input.projectedWins === undefined) continue;
|
if (baseElo.has(input.participantId) || input.projectedWins === null || input.projectedWins === undefined) continue;
|
||||||
resolved.set(input.participantId, {
|
baseElo.set(input.participantId, {
|
||||||
participantId: input.participantId,
|
elo: projectionToElo(input.projectedWins, seasonGames, parityFactor, policy),
|
||||||
sourceElo: projectionToElo(input.projectedWins, seasonGames, parityFactor, policy),
|
|
||||||
method: "projectedWins",
|
method: "projectedWins",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -235,39 +210,69 @@ export function resolveSourceElos(
|
||||||
const seasonGames = optionalNumber(config.seasonGames, 38);
|
const seasonGames = optionalNumber(config.seasonGames, 38);
|
||||||
const maxPoints = optionalNumber(config.maxTablePoints, seasonGames * 3);
|
const maxPoints = optionalNumber(config.maxTablePoints, seasonGames * 3);
|
||||||
for (const input of inputs) {
|
for (const input of inputs) {
|
||||||
if (resolved.has(input.participantId) || input.projectedTablePoints === null || input.projectedTablePoints === undefined) continue;
|
if (baseElo.has(input.participantId) || input.projectedTablePoints === null || input.projectedTablePoints === undefined) continue;
|
||||||
resolved.set(input.participantId, {
|
baseElo.set(input.participantId, {
|
||||||
participantId: input.participantId,
|
elo: projectionToElo(input.projectedTablePoints, maxPoints, parityFactor, policy),
|
||||||
sourceElo: projectionToElo(input.projectedTablePoints, maxPoints, parityFactor, policy),
|
|
||||||
method: "projectedTablePoints",
|
method: "projectedTablePoints",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (source === "sourceOdds") {
|
}
|
||||||
// Odds-derived Elo maps onto eloMin/eloMax via convertFuturesToElo; when
|
}
|
||||||
// mixed with direct Elo for other participants the two scales are not
|
|
||||||
// jointly calibrated. That mixed case is intentionally left as-is.
|
// 2. Odds-derived Elo (field-relative): only when futures odds are a permitted
|
||||||
const oddsInputs = inputs
|
// source and at least two participants have odds (a spread is required).
|
||||||
.filter((input) => !resolved.has(input.participantId) && input.sourceOdds !== null && input.sourceOdds !== undefined)
|
const oddsElo = new Map<string, number>();
|
||||||
.map((input) => ({ participantId: input.participantId, odds: input.sourceOdds as number }));
|
if (alternatives.has("sourceOdds")) {
|
||||||
if (oddsInputs.length === 1) {
|
const oddsInputs = inputs
|
||||||
logger.warn(
|
.filter((input) => input.sourceOdds !== null && input.sourceOdds !== undefined)
|
||||||
`resolveSourceElos: only 1 participant has sourceOdds (${oddsInputs[0].participantId}). ` +
|
.map((input) => ({ participantId: input.participantId, odds: input.sourceOdds as number }));
|
||||||
`convertFuturesToElo requires at least 2 to derive a meaningful Elo spread — falling through to the next source.`
|
if (oddsInputs.length === 1) {
|
||||||
);
|
logger.warn(
|
||||||
}
|
`resolveSourceElos: only 1 participant has sourceOdds (${oddsInputs[0].participantId}). ` +
|
||||||
if (oddsInputs.length >= 2) {
|
`convertFuturesToElo requires at least 2 to derive a meaningful Elo spread — ignoring the lone odds value.`
|
||||||
const converted = convertFuturesToElo(oddsInputs);
|
);
|
||||||
for (const [participantId, elo] of converted) {
|
}
|
||||||
resolved.set(participantId, {
|
if (oddsInputs.length >= 2) {
|
||||||
participantId,
|
for (const [participantId, elo] of convertFuturesToElo(oddsInputs)) {
|
||||||
sourceElo: clamp(Math.round(elo), policy.eloMin, policy.eloMax),
|
oddsElo.set(participantId, elo);
|
||||||
method: "sourceOdds",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Blend base + odds into the single Elo that feeds the simulator.
|
||||||
|
const w = policy.oddsWeight;
|
||||||
|
for (const input of inputs) {
|
||||||
|
const base = baseElo.get(input.participantId);
|
||||||
|
const odds = oddsElo.get(input.participantId);
|
||||||
|
let elo: number;
|
||||||
|
let method: ResolvedSourceElo["method"];
|
||||||
|
if (base !== undefined && odds !== undefined) {
|
||||||
|
if (w <= 0) {
|
||||||
|
elo = base.elo;
|
||||||
|
method = base.method;
|
||||||
|
} else if (w >= 1) {
|
||||||
|
elo = odds;
|
||||||
|
method = "sourceOdds";
|
||||||
|
} else {
|
||||||
|
elo = (1 - w) * base.elo + w * odds;
|
||||||
|
method = "blend";
|
||||||
|
}
|
||||||
|
} else if (base !== undefined) {
|
||||||
|
elo = base.elo;
|
||||||
|
method = base.method;
|
||||||
|
} else if (odds !== undefined) {
|
||||||
|
elo = odds;
|
||||||
|
method = "sourceOdds";
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
resolved.set(input.participantId, {
|
||||||
|
participantId: input.participantId,
|
||||||
|
sourceElo: clamp(Math.round(elo), policy.eloMin, policy.eloMax),
|
||||||
|
method,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const knownElos = [...resolved.values()].map((value) => value.sourceElo);
|
const knownElos = [...resolved.values()].map((value) => value.sourceElo);
|
||||||
const averageKnown = knownElos.length > 0
|
const averageKnown = knownElos.length > 0
|
||||||
? knownElos.reduce((sum, elo) => sum + elo, 0) / knownElos.length
|
? knownElos.reduce((sum, elo) => sum + elo, 0) / knownElos.length
|
||||||
|
|
@ -305,49 +310,72 @@ export function resolveRatings(
|
||||||
const resolved = new Map<string, ResolvedRating>();
|
const resolved = new Map<string, ResolvedRating>();
|
||||||
const alternatives = new Set(profile.derivableInputs?.rating ?? []);
|
const alternatives = new Set(profile.derivableInputs?.rating ?? []);
|
||||||
|
|
||||||
// Ratings share the configured priority list: the `"sourceElo"` slot stands
|
// Ratings follow the same single-value blend as Elo: the base is the direct
|
||||||
// for this simulator's direct strength input (here, `rating`), so ordering
|
// `rating`; futures odds (when permitted, scaled onto the rating range) blend
|
||||||
// `"sourceOdds"` ahead of `"sourceElo"` makes futures override the rating too.
|
// on top by oddsWeight. `0` = rating only, `1` = futures override.
|
||||||
// Projection sources do not apply to rating-based simulators and are skipped.
|
const baseRating = new Map<string, number>();
|
||||||
for (const source of policy.sourceEloPriority) {
|
for (const input of inputs) {
|
||||||
if (source === "sourceElo") {
|
if (input.rating !== null && input.rating !== undefined) {
|
||||||
for (const input of inputs) {
|
baseRating.set(input.participantId, input.rating);
|
||||||
if (resolved.has(input.participantId)) continue;
|
}
|
||||||
if (input.rating !== null && input.rating !== undefined) {
|
}
|
||||||
resolved.set(input.participantId, {
|
|
||||||
participantId: input.participantId,
|
const oddsRating = new Map<string, number>();
|
||||||
rating: input.rating,
|
if (alternatives.has("sourceOdds")) {
|
||||||
method: "direct",
|
const oddsInputs = inputs
|
||||||
});
|
.filter((input) => input.sourceOdds !== null && input.sourceOdds !== undefined)
|
||||||
}
|
.map((input) => ({ participantId: input.participantId, odds: input.sourceOdds as number }));
|
||||||
}
|
if (oddsInputs.length === 1) {
|
||||||
} else if (source === "sourceOdds" && alternatives.has("sourceOdds")) {
|
logger.warn(
|
||||||
const oddsInputs = inputs
|
`resolveRatings: only 1 participant has sourceOdds (${oddsInputs[0].participantId}). ` +
|
||||||
.filter((input) => !resolved.has(input.participantId) && input.sourceOdds !== null && input.sourceOdds !== undefined)
|
`convertFuturesToElo requires at least 2 to derive a meaningful rating — ignoring the lone odds value.`
|
||||||
.map((input) => ({ participantId: input.participantId, odds: input.sourceOdds as number }));
|
);
|
||||||
if (oddsInputs.length === 1) {
|
}
|
||||||
logger.warn(
|
if (oddsInputs.length >= 2) {
|
||||||
`resolveRatings: only 1 participant has sourceOdds (${oddsInputs[0].participantId}). ` +
|
const converted = convertFuturesToElo(oddsInputs, "american", {
|
||||||
`convertFuturesToElo requires at least 2 to derive a meaningful rating — falling through to the next source.`
|
exponent: 0.33,
|
||||||
);
|
eloMin: policy.ratingMin,
|
||||||
}
|
eloMax: policy.ratingMax,
|
||||||
if (oddsInputs.length >= 2) {
|
});
|
||||||
const converted = convertFuturesToElo(oddsInputs, "american", {
|
for (const [participantId, rating] of converted) {
|
||||||
exponent: 0.33,
|
oddsRating.set(participantId, rating);
|
||||||
eloMin: policy.ratingMin,
|
|
||||||
eloMax: policy.ratingMax,
|
|
||||||
});
|
|
||||||
for (const [participantId, rating] of converted) {
|
|
||||||
resolved.set(participantId, {
|
|
||||||
participantId,
|
|
||||||
rating: clamp(rating, policy.ratingMin, policy.ratingMax),
|
|
||||||
method: "sourceOdds",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const w = policy.oddsWeight;
|
||||||
|
for (const input of inputs) {
|
||||||
|
const base = baseRating.get(input.participantId);
|
||||||
|
const odds = oddsRating.get(input.participantId);
|
||||||
|
let rating: number;
|
||||||
|
let method: ResolvedRating["method"];
|
||||||
|
if (base !== undefined && odds !== undefined) {
|
||||||
|
if (w <= 0) {
|
||||||
|
rating = base;
|
||||||
|
method = "direct";
|
||||||
|
} else if (w >= 1) {
|
||||||
|
rating = odds;
|
||||||
|
method = "sourceOdds";
|
||||||
|
} else {
|
||||||
|
rating = (1 - w) * base + w * odds;
|
||||||
|
method = "blend";
|
||||||
|
}
|
||||||
|
} else if (base !== undefined) {
|
||||||
|
rating = base;
|
||||||
|
method = "direct";
|
||||||
|
} else if (odds !== undefined) {
|
||||||
|
rating = odds;
|
||||||
|
method = "sourceOdds";
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
resolved.set(input.participantId, {
|
||||||
|
participantId: input.participantId,
|
||||||
|
rating: clamp(rating, policy.ratingMin, policy.ratingMax),
|
||||||
|
method,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const knownRatings = [...resolved.values()].map((value) => value.rating);
|
const knownRatings = [...resolved.values()].map((value) => value.rating);
|
||||||
const averageKnown = knownRatings.length > 0
|
const averageKnown = knownRatings.length > 0
|
||||||
? knownRatings.reduce((sum, rating) => sum + rating, 0) / knownRatings.length
|
? knownRatings.reduce((sum, rating) => sum + rating, 0) / knownRatings.length
|
||||||
|
|
|
||||||
|
|
@ -40,15 +40,6 @@ const BASE_CONFIG = {
|
||||||
iterations: 50_000,
|
iterations: 50_000,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Source-resolution priority that makes freshly entered futures odds win over a
|
|
||||||
// stored Elo/rating (and projections). Used as the default for simulators where
|
|
||||||
// futures odds are a deliberate, primary input (those exposing a Futures Odds
|
|
||||||
// setup section). Admins can still override this per season in the Input Policy.
|
|
||||||
// Kept as a local literal (not imported from input-policy) to avoid a circular
|
|
||||||
// module dependency that would be evaluated before input-policy's consts exist.
|
|
||||||
// Mirrors FUTURES_FIRST_SOURCE_PRIORITY in input-policy.ts.
|
|
||||||
const FUTURES_FIRST_PRIORITY = ["sourceOdds", "sourceElo", "projectedWins", "projectedTablePoints"] as const;
|
|
||||||
|
|
||||||
const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorType" | "displayName" | "description">> = {
|
const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorType" | "displayName" | "description">> = {
|
||||||
f1_standings: {
|
f1_standings: {
|
||||||
defaultConfig: { iterations: 10_000, raceNoise: 0.5, participantVolatility: 1.5 },
|
defaultConfig: { iterations: 10_000, raceNoise: 0.5, participantVolatility: 1.5 },
|
||||||
|
|
@ -75,20 +66,21 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
setupSections: ["participants", "futuresOdds", "bracket"],
|
setupSections: ["participants", "futuresOdds", "bracket"],
|
||||||
},
|
},
|
||||||
ucl_bracket: {
|
ucl_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, eloWeight: 0.7, oddsWeight: 0.3 },
|
defaultConfig: { ...BASE_CONFIG, oddsWeight: 0.3 },
|
||||||
requiredInputs: ["sourceOdds"],
|
requiredInputs: ["sourceElo"],
|
||||||
optionalInputs: ["sourceElo"],
|
optionalInputs: ["sourceOdds"],
|
||||||
|
derivableInputs: { sourceElo: ["sourceOdds"] },
|
||||||
setupSections: ["participants", "futuresOdds", "bracket"],
|
setupSections: ["participants", "futuresOdds", "bracket"],
|
||||||
},
|
},
|
||||||
ncaam_bracket: {
|
ncaam_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, ratingScaleFactor: 7.5, inputPolicy: { ratingMin: -10, ratingMax: 35, fallbackRatingDelta: 5, sourceEloPriority: FUTURES_FIRST_PRIORITY } },
|
defaultConfig: { ...BASE_CONFIG, ratingScaleFactor: 7.5, inputPolicy: { ratingMin: -10, ratingMax: 35, fallbackRatingDelta: 5 } },
|
||||||
requiredInputs: ["rating"],
|
requiredInputs: ["rating"],
|
||||||
optionalInputs: ["sourceOdds", "sourceElo", "seed", "region"],
|
optionalInputs: ["sourceOdds", "sourceElo", "seed", "region"],
|
||||||
derivableInputs: { rating: ["sourceOdds"] },
|
derivableInputs: { rating: ["sourceOdds"] },
|
||||||
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
|
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
|
||||||
},
|
},
|
||||||
ncaaw_bracket: {
|
ncaaw_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, inputPolicy: { ratingMin: 0.70, ratingMax: 0.97, missingRatingStrategy: "worstKnownMinus", fallbackRatingDelta: 0.01, sourceEloPriority: FUTURES_FIRST_PRIORITY } },
|
defaultConfig: { ...BASE_CONFIG, inputPolicy: { ratingMin: 0.70, ratingMax: 0.97, missingRatingStrategy: "worstKnownMinus", fallbackRatingDelta: 0.01 } },
|
||||||
requiredInputs: ["rating"],
|
requiredInputs: ["rating"],
|
||||||
optionalInputs: ["sourceOdds", "seed", "region"],
|
optionalInputs: ["sourceOdds", "seed", "region"],
|
||||||
derivableInputs: { rating: ["sourceOdds"] },
|
derivableInputs: { rating: ["sourceOdds"] },
|
||||||
|
|
@ -165,7 +157,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
setupSections: ["participants", "eloRatings", "regularStandings"],
|
setupSections: ["participants", "eloRatings", "regularStandings"],
|
||||||
},
|
},
|
||||||
world_cup: {
|
world_cup: {
|
||||||
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, eloWeight: 0.7, oddsWeight: 0.3, inputPolicy: { sourceEloPriority: FUTURES_FIRST_PRIORITY, oddsWeight: 0.3 } },
|
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, oddsWeight: 0.3 },
|
||||||
requiredInputs: ["sourceElo"],
|
requiredInputs: ["sourceElo"],
|
||||||
optionalInputs: ["sourceOdds", "worldRanking"],
|
optionalInputs: ["sourceOdds", "worldRanking"],
|
||||||
derivableInputs: { sourceElo: ["sourceOdds"] },
|
derivableInputs: { sourceElo: ["sourceOdds"] },
|
||||||
|
|
@ -184,7 +176,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"],
|
setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"],
|
||||||
},
|
},
|
||||||
ncaa_football_bracket: {
|
ncaa_football_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, bracketSize: 12, eloWeight: 0.6, oddsWeight: 0.4, inputPolicy: { sourceEloPriority: FUTURES_FIRST_PRIORITY, oddsWeight: 0.4 } },
|
defaultConfig: { ...BASE_CONFIG, bracketSize: 12, oddsWeight: 0.4 },
|
||||||
requiredInputs: ["sourceElo"],
|
requiredInputs: ["sourceElo"],
|
||||||
optionalInputs: ["sourceOdds", "worldRanking"],
|
optionalInputs: ["sourceOdds", "worldRanking"],
|
||||||
derivableInputs: { sourceElo: ["sourceOdds"] },
|
derivableInputs: { sourceElo: ["sourceOdds"] },
|
||||||
|
|
@ -197,7 +189,10 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
setupSections: ["participants", "futuresOdds"],
|
setupSections: ["participants", "futuresOdds"],
|
||||||
},
|
},
|
||||||
college_hockey_bracket: {
|
college_hockey_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, bracketSize: 16, parityFactor: 850, inputPolicy: { sourceEloPriority: FUTURES_FIRST_PRIORITY } },
|
// College hockey already blends odds into Elo internally (and also uses NPI
|
||||||
|
// rank, which the central resolver can't), so the central blend is disabled
|
||||||
|
// (oddsWeight 0) to avoid double-counting the odds.
|
||||||
|
defaultConfig: { ...BASE_CONFIG, bracketSize: 16, parityFactor: 850, oddsWeight: 0 },
|
||||||
requiredInputs: ["sourceElo"],
|
requiredInputs: ["sourceElo"],
|
||||||
optionalInputs: ["sourceOdds", "worldRanking"],
|
optionalInputs: ["sourceOdds", "worldRanking"],
|
||||||
derivableInputs: { sourceElo: ["sourceOdds"] },
|
derivableInputs: { sourceElo: ["sourceOdds"] },
|
||||||
|
|
|
||||||
|
|
@ -73,12 +73,8 @@
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import type { Simulator, SimulationResult, SimulationContext } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import {
|
|
||||||
convertAmericanOddsToProbability,
|
|
||||||
normalizeProbabilities,
|
|
||||||
} from "~/services/probability-engine";
|
|
||||||
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
||||||
|
|
||||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||||
|
|
@ -103,12 +99,6 @@ const RDIF_DIVISOR = 8000;
|
||||||
*/
|
*/
|
||||||
const SEEDING_RDIF_SCALE = 1620;
|
const SEEDING_RDIF_SCALE = 1620;
|
||||||
|
|
||||||
/**
|
|
||||||
* Blend weights for RDif vs. Vegas futures odds when sourceOdds are available.
|
|
||||||
*/
|
|
||||||
const RDIF_WEIGHT = 0.7;
|
|
||||||
const ODDS_WEIGHT = 1 - RDIF_WEIGHT;
|
|
||||||
|
|
||||||
// ─── Team data (2026 pre-season — FanGraphs Depth Charts) ────────────────────
|
// ─── Team data (2026 pre-season — FanGraphs Depth Charts) ────────────────────
|
||||||
//
|
//
|
||||||
// rdif: Projected run differential from FanGraphs Depth Charts.
|
// rdif: Projected run differential from FanGraphs Depth Charts.
|
||||||
|
|
@ -450,11 +440,7 @@ function simLeagueBracket(
|
||||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class MLBSimulator implements Simulator {
|
export class MLBSimulator implements Simulator {
|
||||||
async simulate(sportsSeasonId: string, context?: SimulationContext): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
||||||
// RDif-vs-odds blend weight: configurable via the season input policy, with
|
|
||||||
// the historical 70/30 split as the default when invoked without context.
|
|
||||||
const oddsWeight = context?.oddsWeight ?? ODDS_WEIGHT;
|
|
||||||
const rdifWeight = 1 - oddsWeight;
|
|
||||||
const db = database();
|
const db = database();
|
||||||
|
|
||||||
// 1. Load all participants for this sports season.
|
// 1. Load all participants for this sports season.
|
||||||
|
|
@ -525,33 +511,19 @@ export class MLBSimulator implements Simulator {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Futures odds blending ─────────────────────────────────────────────────
|
// ─── Strength from the resolved Elo ────────────────────────────────────────
|
||||||
|
// sourceElo is the single Elo produced by the input policy — already a blend
|
||||||
|
// of any raw Elo / projections / futures odds — so the simulator just reads it
|
||||||
|
// and no longer blends odds itself.
|
||||||
|
|
||||||
const evRows = await db
|
const evRows = await db
|
||||||
.select({
|
.select({
|
||||||
participantId: schema.seasonParticipantExpectedValues.participantId,
|
participantId: schema.seasonParticipantExpectedValues.participantId,
|
||||||
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
|
|
||||||
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
|
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
|
||||||
})
|
})
|
||||||
.from(schema.seasonParticipantExpectedValues)
|
.from(schema.seasonParticipantExpectedValues)
|
||||||
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
|
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
|
||||||
|
|
||||||
const oddsRows = evRows.filter(
|
|
||||||
(r) => r.sourceOdds !== null && participantIdSet.has(r.participantId)
|
|
||||||
);
|
|
||||||
const hasOdds = oddsRows.length > 0;
|
|
||||||
|
|
||||||
const normalizedOddsMap = new Map<string, number>();
|
|
||||||
if (hasOdds) {
|
|
||||||
const rawProbs = oddsRows.map((r) =>
|
|
||||||
convertAmericanOddsToProbability(r.sourceOdds ?? 0)
|
|
||||||
);
|
|
||||||
const normalized = normalizeProbabilities(rawProbs);
|
|
||||||
oddsRows.forEach(({ participantId }, i) => {
|
|
||||||
normalizedOddsMap.set(participantId, normalized[i]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a map of sourceElo-derived RDif values (overrides hardcoded TEAMS_DATA.rdif)
|
// Build a map of sourceElo-derived RDif values (overrides hardcoded TEAMS_DATA.rdif)
|
||||||
// for playoff series win probability.
|
// for playoff series win probability.
|
||||||
const sourceEloRDifMap = new Map<string, number>();
|
const sourceEloRDifMap = new Map<string, number>();
|
||||||
|
|
@ -579,20 +551,11 @@ export class MLBSimulator implements Simulator {
|
||||||
rawWinRateMap.get(entry.id) ?? rawWinRateFromRDif(getEntryRDif(entry));
|
rawWinRateMap.get(entry.id) ?? rawWinRateFromRDif(getEntryRDif(entry));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Blended per-game win probability for team A over team B in a playoff series.
|
* Per-game win probability for team A over team B in a playoff series, from
|
||||||
* When odds are available: 70% RDif log5 + 30% vig-removed futures head-to-head.
|
* the (resolved-Elo-derived) run differential.
|
||||||
*/
|
*/
|
||||||
const gameWinProb = (a: TeamEntry, b: TeamEntry): number => {
|
const gameWinProb = (a: TeamEntry, b: TeamEntry): number =>
|
||||||
const rdifProb = rdifWinProbability(effectiveRDif(a), effectiveRDif(b));
|
rdifWinProbability(effectiveRDif(a), effectiveRDif(b));
|
||||||
if (!hasOdds) return rdifProb;
|
|
||||||
|
|
||||||
const o1 = normalizedOddsMap.get(a.id);
|
|
||||||
const o2 = normalizedOddsMap.get(b.id);
|
|
||||||
// Fall back to RDif if either team lacks odds — avoids inflating one side to 100%.
|
|
||||||
if (o1 === null || o1 === undefined || o2 === null || o2 === undefined) return rdifProb;
|
|
||||||
const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5;
|
|
||||||
return rdifWeight * rdifProb + oddsWeight * oddsProb;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Placement count maps ──────────────────────────────────────────────────
|
// ─── Placement count maps ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,12 +59,8 @@
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import {
|
import { eloWinProbability } from "~/services/probability-engine";
|
||||||
convertAmericanOddsToProbability,
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
convertFuturesToElo,
|
|
||||||
eloWinProbability,
|
|
||||||
} from "~/services/probability-engine";
|
|
||||||
import type { Simulator, SimulationResult, SimulationContext } from "./types";
|
|
||||||
|
|
||||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -72,15 +68,7 @@ const NUM_SIMULATIONS = 50_000;
|
||||||
const BRACKET_SIZE = 12;
|
const BRACKET_SIZE = 12;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Blend weights for per-game win probability when sourceOdds are present.
|
* Softmax temperature for Elo-based selection weights (pre-bracket mode).
|
||||||
* Lower Elo weight than other sports (0.7) gives more influence to Vegas
|
|
||||||
* championship odds, which are highly informative in college football.
|
|
||||||
*/
|
|
||||||
const ELO_WEIGHT = 0.6;
|
|
||||||
const ODDS_WEIGHT = 1 - ELO_WEIGHT;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Softmax temperature for Elo-based selection weights (pre-bracket mode, no odds).
|
|
||||||
* At T=100, a 200-point Elo gap produces ~7× weight difference — enough to strongly
|
* At T=100, a 200-point Elo gap produces ~7× weight difference — enough to strongly
|
||||||
* favour the top teams while still giving bubble teams meaningful selection probability.
|
* favour the top teams while still giving bubble teams meaningful selection probability.
|
||||||
*/
|
*/
|
||||||
|
|
@ -90,37 +78,21 @@ const SELECTION_TEMP = 100;
|
||||||
|
|
||||||
interface Team {
|
interface Team {
|
||||||
participantId: string;
|
participantId: string;
|
||||||
|
/** Single resolved Elo (already a blend of any raw Elo/FPI and futures odds). */
|
||||||
elo: number;
|
elo: number;
|
||||||
/** Normalized futures win probability (0–1). Used for blending per-game win prob. */
|
/** Weight used for probabilistic CFP field selection (pre-bracket mode only). */
|
||||||
oddsProb: number;
|
|
||||||
/**
|
|
||||||
* Weight used for probabilistic CFP field selection (pre-bracket mode only).
|
|
||||||
* Derived from oddsProb when available; otherwise softmax on Elo.
|
|
||||||
*/
|
|
||||||
selectionWeight: number;
|
selectionWeight: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/** Win probability for team1 vs team2 from the single resolved Elo. */
|
||||||
* Blended win probability for team1 vs team2.
|
function gameWinProb(team1: Team, team2: Team): number {
|
||||||
* Falls back to pure Elo when no futures data is present.
|
return eloWinProbability(team1.elo, team2.elo);
|
||||||
*/
|
|
||||||
function blendedWinProb(team1: Team, team2: Team, oddsWeight: number = ODDS_WEIGHT): number {
|
|
||||||
const eloProbValue = eloWinProbability(team1.elo, team2.elo);
|
|
||||||
|
|
||||||
if (team1.oddsProb === 0 && team2.oddsProb === 0) {
|
|
||||||
return eloProbValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const oddsSum = team1.oddsProb + team2.oddsProb;
|
|
||||||
const oddsProbValue = oddsSum > 0 ? team1.oddsProb / oddsSum : 0.5;
|
|
||||||
|
|
||||||
return (1 - oddsWeight) * eloProbValue + oddsWeight * oddsProbValue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function simGame(team1: Team, team2: Team, oddsWeight: number = ODDS_WEIGHT): { winner: Team; loser: Team } {
|
function simGame(team1: Team, team2: Team): { winner: Team; loser: Team } {
|
||||||
const p1Wins = Math.random() < blendedWinProb(team1, team2, oddsWeight);
|
const p1Wins = Math.random() < gameWinProb(team1, team2);
|
||||||
return p1Wins
|
return p1Wins
|
||||||
? { winner: team1, loser: team2 }
|
? { winner: team1, loser: team2 }
|
||||||
: { winner: team2, loser: team1 };
|
: { winner: team2, loser: team1 };
|
||||||
|
|
@ -169,18 +141,18 @@ interface PlacementCounts {
|
||||||
* Semifinals: qf1w vs qf2w, qf3w vs qf4w
|
* Semifinals: qf1w vs qf2w, qf3w vs qf4w
|
||||||
* Championship: sf1w vs sf2w
|
* Championship: sf1w vs sf2w
|
||||||
*/
|
*/
|
||||||
function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>, oddsWeight: number = ODDS_WEIGHT): void {
|
function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>): void {
|
||||||
// ── First Round (seeds 5–12) ───────────────────────────────────────────────
|
// ── First Round (seeds 5–12) ───────────────────────────────────────────────
|
||||||
const fr1 = simGame(teams[4], teams[11], oddsWeight); // 5 vs 12
|
const fr1 = simGame(teams[4], teams[11]); // 5 vs 12
|
||||||
const fr2 = simGame(teams[5], teams[10], oddsWeight); // 6 vs 11
|
const fr2 = simGame(teams[5], teams[10]); // 6 vs 11
|
||||||
const fr3 = simGame(teams[6], teams[9], oddsWeight); // 7 vs 10
|
const fr3 = simGame(teams[6], teams[9]); // 7 vs 10
|
||||||
const fr4 = simGame(teams[7], teams[8], oddsWeight); // 8 vs 9
|
const fr4 = simGame(teams[7], teams[8]); // 8 vs 9
|
||||||
|
|
||||||
// ── Quarterfinals (seeds 1–4 get byes) ────────────────────────────────────
|
// ── Quarterfinals (seeds 1–4 get byes) ────────────────────────────────────
|
||||||
const qf1 = simGame(teams[0], fr4.winner, oddsWeight); // 1 vs 8/9 winner
|
const qf1 = simGame(teams[0], fr4.winner); // 1 vs 8/9 winner
|
||||||
const qf2 = simGame(teams[3], fr1.winner, oddsWeight); // 4 vs 5/12 winner
|
const qf2 = simGame(teams[3], fr1.winner); // 4 vs 5/12 winner
|
||||||
const qf3 = simGame(teams[2], fr2.winner, oddsWeight); // 3 vs 6/11 winner
|
const qf3 = simGame(teams[2], fr2.winner); // 3 vs 6/11 winner
|
||||||
const qf4 = simGame(teams[1], fr3.winner, oddsWeight); // 2 vs 7/10 winner
|
const qf4 = simGame(teams[1], fr3.winner); // 2 vs 7/10 winner
|
||||||
|
|
||||||
const bump = (id: string, key: keyof PlacementCounts) => {
|
const bump = (id: string, key: keyof PlacementCounts) => {
|
||||||
const entry = counts.get(id);
|
const entry = counts.get(id);
|
||||||
|
|
@ -193,14 +165,14 @@ function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>, od
|
||||||
bump(qf4.loser.participantId, "qfLoser");
|
bump(qf4.loser.participantId, "qfLoser");
|
||||||
|
|
||||||
// ── Semifinals ────────────────────────────────────────────────────────────
|
// ── Semifinals ────────────────────────────────────────────────────────────
|
||||||
const sf1 = simGame(qf1.winner, qf2.winner, oddsWeight);
|
const sf1 = simGame(qf1.winner, qf2.winner);
|
||||||
const sf2 = simGame(qf3.winner, qf4.winner, oddsWeight);
|
const sf2 = simGame(qf3.winner, qf4.winner);
|
||||||
|
|
||||||
bump(sf1.loser.participantId, "sfLoser");
|
bump(sf1.loser.participantId, "sfLoser");
|
||||||
bump(sf2.loser.participantId, "sfLoser");
|
bump(sf2.loser.participantId, "sfLoser");
|
||||||
|
|
||||||
// ── National Championship ─────────────────────────────────────────────────
|
// ── National Championship ─────────────────────────────────────────────────
|
||||||
const final = simGame(sf1.winner, sf2.winner, oddsWeight);
|
const final = simGame(sf1.winner, sf2.winner);
|
||||||
|
|
||||||
bump(final.winner.participantId, "champion");
|
bump(final.winner.participantId, "champion");
|
||||||
bump(final.loser.participantId, "finalist");
|
bump(final.loser.participantId, "finalist");
|
||||||
|
|
@ -209,12 +181,8 @@ function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>, od
|
||||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class NCAAFootballSimulator implements Simulator {
|
export class NCAAFootballSimulator implements Simulator {
|
||||||
async simulate(sportsSeasonId: string, context?: SimulationContext): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
||||||
const db = database();
|
const db = database();
|
||||||
// Elo-vs-odds blend weight: configurable via the season input policy, with
|
|
||||||
// the historical 60/40 split as the default when invoked without context.
|
|
||||||
const oddsWeight = context?.oddsWeight ?? ODDS_WEIGHT;
|
|
||||||
|
|
||||||
// 1. Load all participants for this sports season.
|
// 1. Load all participants for this sports season.
|
||||||
const participants = await db
|
const participants = await db
|
||||||
.select({ id: schema.seasonParticipants.id })
|
.select({ id: schema.seasonParticipants.id })
|
||||||
|
|
@ -228,79 +196,35 @@ export class NCAAFootballSimulator implements Simulator {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Load Elo/FPI ratings and optional futures odds in a single query.
|
// 2. Load the resolved single Elo (the input policy already blended any
|
||||||
|
// raw Elo/FPI and futures odds into sourceElo before the run).
|
||||||
const evRows = await db
|
const evRows = await db
|
||||||
.select({
|
.select({
|
||||||
participantId: schema.seasonParticipantExpectedValues.participantId,
|
participantId: schema.seasonParticipantExpectedValues.participantId,
|
||||||
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
|
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
|
||||||
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
|
|
||||||
})
|
})
|
||||||
.from(schema.seasonParticipantExpectedValues)
|
.from(schema.seasonParticipantExpectedValues)
|
||||||
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
|
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
|
||||||
|
|
||||||
// Build Elo and raw odds maps in a single pass.
|
|
||||||
const eloFromDb = new Map<string, number>();
|
const eloFromDb = new Map<string, number>();
|
||||||
const rawOddsProbs = new Map<string, number>();
|
|
||||||
|
|
||||||
for (const row of evRows) {
|
for (const row of evRows) {
|
||||||
if (row.sourceElo !== null) {
|
if (row.sourceElo !== null) {
|
||||||
eloFromDb.set(row.participantId, row.sourceElo);
|
eloFromDb.set(row.participantId, row.sourceElo);
|
||||||
}
|
}
|
||||||
if (row.sourceOdds !== null) {
|
|
||||||
rawOddsProbs.set(row.participantId, convertAmericanOddsToProbability(row.sourceOdds));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Build normalized odds probability map (vig removed).
|
// 3. Build team list; field-selection weight is a softmax on the resolved Elo.
|
||||||
const normalizedOddsMap = new Map<string, number>();
|
|
||||||
|
|
||||||
if (rawOddsProbs.size > 0) {
|
|
||||||
const rawSum = [...rawOddsProbs.values()].reduce((a, b) => a + b, 0);
|
|
||||||
for (const [id, prob] of rawOddsProbs) {
|
|
||||||
normalizedOddsMap.set(id, rawSum > 0 ? prob / rawSum : 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backfill Elo from futures for any team missing sourceElo.
|
|
||||||
if (eloFromDb.size < participants.length) {
|
|
||||||
const oddsInput = evRows
|
|
||||||
.filter((r) => r.sourceOdds !== null && !eloFromDb.has(r.participantId))
|
|
||||||
.map((r) => ({ participantId: r.participantId, odds: r.sourceOdds ?? 0 }));
|
|
||||||
|
|
||||||
if (oddsInput.length > 0) {
|
|
||||||
const oddsEloMap = convertFuturesToElo(oddsInput, "american");
|
|
||||||
for (const [id, elo] of oddsEloMap) {
|
|
||||||
eloFromDb.set(id, elo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Build team list with Elo, oddsProb, and selectionWeight.
|
|
||||||
const hasOdds = normalizedOddsMap.size > 0;
|
|
||||||
|
|
||||||
const allTeams: Team[] = participants.map((p) => ({
|
const allTeams: Team[] = participants.map((p) => ({
|
||||||
participantId: p.id,
|
participantId: p.id,
|
||||||
elo: eloFromDb.get(p.id) ?? 1500,
|
elo: eloFromDb.get(p.id) ?? 1500,
|
||||||
oddsProb: normalizedOddsMap.get(p.id) ?? 0,
|
|
||||||
selectionWeight: 0, // computed below
|
selectionWeight: 0, // computed below
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (hasOdds) {
|
const maxElo = Math.max(...allTeams.map((t) => t.elo));
|
||||||
// Selection weight = normalized championship implied probability.
|
const expWeights = allTeams.map((t) => Math.exp((t.elo - maxElo) / SELECTION_TEMP));
|
||||||
// This encodes both "probability of making the field" and "strength once there."
|
const expSum = expWeights.reduce((a, b) => a + b, 0);
|
||||||
for (const team of allTeams) {
|
for (let i = 0; i < allTeams.length; i++) {
|
||||||
team.selectionWeight = normalizedOddsMap.get(team.participantId) ?? 0;
|
allTeams[i].selectionWeight = expSum > 0 ? expWeights[i] / expSum : 1 / allTeams.length;
|
||||||
}
|
|
||||||
} 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 preBracketMode = participants.length > BRACKET_SIZE;
|
const preBracketMode = participants.length > BRACKET_SIZE;
|
||||||
|
|
@ -321,7 +245,7 @@ export class NCAAFootballSimulator implements Simulator {
|
||||||
const field = preBracketMode
|
const field = preBracketMode
|
||||||
? sampleBracketField(allTeams, BRACKET_SIZE)
|
? sampleBracketField(allTeams, BRACKET_SIZE)
|
||||||
: (deterministicField ?? []);
|
: (deterministicField ?? []);
|
||||||
simulateBracket(field, counts, oddsWeight);
|
simulateBracket(field, counts);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Convert counts to probability distributions.
|
// 7. Convert counts to probability distributions.
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import {
|
||||||
prepareSimulatorInputsForRun,
|
prepareSimulatorInputsForRun,
|
||||||
validateSimulatorReadiness,
|
validateSimulatorReadiness,
|
||||||
} from "~/models/simulator";
|
} from "~/models/simulator";
|
||||||
import { getSimulatorInputPolicy } from "~/services/simulations/input-policy";
|
|
||||||
|
|
||||||
const ZERO_PROBS = {
|
const ZERO_PROBS = {
|
||||||
probFirst: 0,
|
probFirst: 0,
|
||||||
|
|
@ -106,10 +105,7 @@ export async function runSportsSeasonSimulation(
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const simulator = getSimulator(simulatorConfig.simulatorType);
|
const simulator = getSimulator(simulatorConfig.simulatorType);
|
||||||
const results = await simulator.simulate(sportsSeasonId, {
|
const results = await simulator.simulate(sportsSeasonId);
|
||||||
config: simulatorConfig.config,
|
|
||||||
oddsWeight: getSimulatorInputPolicy(simulatorConfig.config).oddsWeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
throw new Error("Simulation returned no results. Check that participants have simulator input data.");
|
throw new Error("Simulation returned no results. Check that participants have simulator input data.");
|
||||||
|
|
|
||||||
|
|
@ -27,22 +27,13 @@ export interface SimulationResult {
|
||||||
source: string;
|
source: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional run-time context handed to a simulator by the runner. Carries the
|
|
||||||
* resolved season config and input policy so simulators can honor configurable
|
|
||||||
* knobs (e.g. the Elo-vs-odds blend weight) without re-querying. It is optional
|
|
||||||
* so simulators can still be invoked directly (e.g. in unit tests) with just a
|
|
||||||
* season id, in which case they fall back to their built-in defaults.
|
|
||||||
*/
|
|
||||||
export interface SimulationContext {
|
|
||||||
config: Record<string, unknown>;
|
|
||||||
/** Weight (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.
|
||||||
|
*
|
||||||
|
* Simulators consume a single resolved Elo per participant (already blended from
|
||||||
|
* its sources — raw Elo, projections, futures odds — by the input policy and
|
||||||
|
* persisted before the run), so they need only the season id.
|
||||||
*/
|
*/
|
||||||
export interface Simulator {
|
export interface Simulator {
|
||||||
simulate(sportsSeasonId: string, context?: SimulationContext): Promise<SimulationResult[]>;
|
simulate(sportsSeasonId: string): Promise<SimulationResult[]>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,20 +37,13 @@
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { convertFuturesToElo, eloWinProbability } from "~/services/probability-engine";
|
import { eloWinProbability } from "~/services/probability-engine";
|
||||||
import type { Simulator, SimulationResult, SimulationContext } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
|
|
||||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const NUM_SIMULATIONS = 50000;
|
const NUM_SIMULATIONS = 50000;
|
||||||
|
|
||||||
/**
|
|
||||||
* Weight given to the Elo-based win probability (derived from futures).
|
|
||||||
* Remaining weight (1 - ELO_WEIGHT) goes to the normalized futures odds component.
|
|
||||||
*/
|
|
||||||
const ELO_WEIGHT = 0.7;
|
|
||||||
const ODDS_WEIGHT = 1 - ELO_WEIGHT;
|
|
||||||
|
|
||||||
// ─── Odds helper ──────────────────────────────────────────────────────────────
|
// ─── Odds helper ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Convert American odds to implied probability (with vig). Exported for testing. */
|
/** Convert American odds to implied probability (with vig). Exported for testing. */
|
||||||
|
|
@ -62,12 +55,8 @@ export function americanToImpliedProb(odds: number): number {
|
||||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class UCLSimulator implements Simulator {
|
export class UCLSimulator implements Simulator {
|
||||||
async simulate(sportsSeasonId: string, context?: SimulationContext): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
||||||
const db = database();
|
const db = database();
|
||||||
// Elo-vs-odds blend weight: configurable via the season input policy, with
|
|
||||||
// the historical 70/30 split as the default when invoked without context.
|
|
||||||
const oddsWeight = context?.oddsWeight ?? ODDS_WEIGHT;
|
|
||||||
const eloWeight = 1 - oddsWeight;
|
|
||||||
|
|
||||||
// 1. Find the bracket scoring event for this sports season.
|
// 1. Find the bracket scoring event for this sports season.
|
||||||
// UCL has exactly one playoff_game event per season.
|
// UCL has exactly one playoff_game event per season.
|
||||||
|
|
@ -146,52 +135,26 @@ export class UCLSimulator implements Simulator {
|
||||||
participantIds.push(m.participant1Id ?? "", m.participant2Id ?? "");
|
participantIds.push(m.participant1Id ?? "", m.participant2Id ?? "");
|
||||||
}
|
}
|
||||||
const participantSet = new Set(participantIds);
|
const participantSet = new Set(participantIds);
|
||||||
const fallbackProb = 1 / participantIds.length;
|
|
||||||
|
|
||||||
// 5. Load futures odds from participantExpectedValues.
|
// 5. Load the resolved single Elo (input policy already blended any raw Elo
|
||||||
|
// and futures odds into sourceElo before the run).
|
||||||
const evRows = await db
|
const evRows = await db
|
||||||
.select({
|
.select({
|
||||||
participantId: schema.seasonParticipantExpectedValues.participantId,
|
participantId: schema.seasonParticipantExpectedValues.participantId,
|
||||||
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
|
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
|
||||||
})
|
})
|
||||||
.from(schema.seasonParticipantExpectedValues)
|
.from(schema.seasonParticipantExpectedValues)
|
||||||
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
|
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
|
||||||
|
|
||||||
const evMap = new Map(evRows.map((r) => [r.participantId, r]));
|
// 6. Build the Elo map; teams without a resolved Elo fall back to 1500.
|
||||||
|
const eloMap = new Map<string, number>(participantIds.map((id) => [id, 1500]));
|
||||||
// 6. Build Elo map via the futures → Elo pipeline.
|
for (const r of evRows) {
|
||||||
const hasOdds = evRows.some(
|
if (r.sourceElo !== null && r.sourceElo !== undefined && participantSet.has(r.participantId)) {
|
||||||
(r) => r.sourceOdds !== null && participantSet.has(r.participantId)
|
eloMap.set(r.participantId, r.sourceElo);
|
||||||
);
|
}
|
||||||
|
|
||||||
let eloMap: Map<string, number>;
|
|
||||||
if (hasOdds) {
|
|
||||||
const oddsInput = evRows
|
|
||||||
.filter((r) => r.sourceOdds !== null && participantSet.has(r.participantId))
|
|
||||||
.map((r) => ({ participantId: r.participantId, odds: r.sourceOdds ?? 0 }));
|
|
||||||
eloMap = convertFuturesToElo(oddsInput, "american");
|
|
||||||
} else {
|
|
||||||
// No odds stored — all teams get equal Elo (coin-flip bracket)
|
|
||||||
eloMap = new Map(participantIds.map((id) => [id, 1500]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Build normalized futures win-probability map (vig removed).
|
// 7. Build per-round lookup maps keyed by matchNumber for O(1) access in the hot loop.
|
||||||
// Used as the second signal in the blended per-match probability.
|
|
||||||
const rawProbs = new Map<string, number>();
|
|
||||||
for (const id of participantIds) {
|
|
||||||
const ev = evMap.get(id);
|
|
||||||
rawProbs.set(
|
|
||||||
id,
|
|
||||||
ev !== undefined && ev.sourceOdds !== null && ev.sourceOdds !== undefined ? americanToImpliedProb(ev.sourceOdds) : fallbackProb
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const rawSum = [...rawProbs.values()].reduce((a, b) => a + b, 0);
|
|
||||||
const normalizedOddsMap = new Map<string, number>();
|
|
||||||
for (const [id, prob] of rawProbs) {
|
|
||||||
normalizedOddsMap.set(id, prob / rawSum);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8. Build per-round lookup maps keyed by matchNumber for O(1) access in the hot loop.
|
|
||||||
const r16ByNum = new Map(r16Matches.map((m) => [m.matchNumber, m]));
|
const r16ByNum = new Map(r16Matches.map((m) => [m.matchNumber, m]));
|
||||||
const qfByNum = new Map(qfMatches.map((m) => [m.matchNumber, m]));
|
const qfByNum = new Map(qfMatches.map((m) => [m.matchNumber, m]));
|
||||||
const sfByNum = new Map(sfMatches.map((m) => [m.matchNumber, m]));
|
const sfByNum = new Map(sfMatches.map((m) => [m.matchNumber, m]));
|
||||||
|
|
@ -199,17 +162,11 @@ export class UCLSimulator implements Simulator {
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Blended Elo + normalized-odds win probability for p1 vs p2. */
|
/** Pure-Elo win probability for p1 vs p2 (single resolved Elo per team). */
|
||||||
const blendedWinProb = (p1: string, p2: string): number => {
|
const blendedWinProb = (p1: string, p2: string): number => {
|
||||||
const elo1 = eloMap.get(p1) ?? 1500;
|
const elo1 = eloMap.get(p1) ?? 1500;
|
||||||
const elo2 = eloMap.get(p2) ?? 1500;
|
const elo2 = eloMap.get(p2) ?? 1500;
|
||||||
const eloProb = eloWinProbability(elo1, elo2);
|
return eloWinProbability(elo1, elo2);
|
||||||
|
|
||||||
const o1 = normalizedOddsMap.get(p1) ?? fallbackProb;
|
|
||||||
const o2 = normalizedOddsMap.get(p2) ?? fallbackProb;
|
|
||||||
const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5;
|
|
||||||
|
|
||||||
return eloWeight * eloProb + oddsWeight * oddsProb;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const simMatch = (p1: string, p2: string): { winner: string; loser: string } => {
|
const simMatch = (p1: string, p2: string): { winner: string; loser: string } => {
|
||||||
|
|
|
||||||
|
|
@ -38,13 +38,13 @@
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { convertFuturesToElo, eloWinProbability } from "~/services/probability-engine";
|
import { eloWinProbability } from "~/services/probability-engine";
|
||||||
import { simulateEloSoccerMatch } from "./soccer-helpers";
|
import { simulateEloSoccerMatch } from "./soccer-helpers";
|
||||||
import { normalizeSimulationResultColumns } from "./simulation-probabilities";
|
import { normalizeSimulationResultColumns } from "./simulation-probabilities";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import { FIFA_48 } from "~/lib/bracket-templates";
|
import { FIFA_48 } from "~/lib/bracket-templates";
|
||||||
import { FIFA_2026_R32_TEMPLATE, assignThirdPlaceSlots } from "~/lib/fifa-2026-bracket";
|
import { FIFA_2026_R32_TEMPLATE, assignThirdPlaceSlots } from "~/lib/fifa-2026-bracket";
|
||||||
import type { Simulator, SimulationResult, SimulationContext } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
|
|
||||||
// ─── Name normalisation (same logic as elo-ratings bulk-import fuzzy match) ──
|
// ─── Name normalisation (same logic as elo-ratings bulk-import fuzzy match) ──
|
||||||
|
|
||||||
|
|
@ -55,8 +55,6 @@ function normalizeTeamName(name: string): string {
|
||||||
// ─── Parameters ──────────────────────────────────────────────────────────────
|
// ─── Parameters ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const NUM_SIMULATIONS = 10_000;
|
const NUM_SIMULATIONS = 10_000;
|
||||||
const ELO_WEIGHT = 0.7;
|
|
||||||
const ODDS_WEIGHT = 1 - ELO_WEIGHT;
|
|
||||||
|
|
||||||
/** Base draw rate when teams are evenly matched. Decays with Elo difference. */
|
/** Base draw rate when teams are evenly matched. Decays with Elo difference. */
|
||||||
const BASE_DRAW_RATE = 0.28;
|
const BASE_DRAW_RATE = 0.28;
|
||||||
|
|
@ -274,14 +272,10 @@ function sortTeams(a: TeamStats, b: TeamStats): number {
|
||||||
function simKnockout(
|
function simKnockout(
|
||||||
teamA: string,
|
teamA: string,
|
||||||
teamB: string,
|
teamB: string,
|
||||||
eloFn: (id: string) => number,
|
eloFn: (id: string) => number
|
||||||
normalizedProb: (id: string) => number,
|
|
||||||
oddsWeight: number = ODDS_WEIGHT
|
|
||||||
): { winner: string; loser: string } {
|
): { winner: string; loser: string } {
|
||||||
const eloProb = eloWinProbability(eloFn(teamA), eloFn(teamB));
|
const winProb = eloWinProbability(eloFn(teamA), eloFn(teamB));
|
||||||
const oddsProb = normalizedProb(teamA);
|
const winner = Math.random() < winProb ? teamA : teamB;
|
||||||
const blended = (1 - oddsWeight) * eloProb + oddsWeight * (0.5 + (oddsProb - 0.5));
|
|
||||||
const winner = Math.random() < blended ? teamA : teamB;
|
|
||||||
return { winner, loser: winner === teamA ? teamB : teamA };
|
return { winner, loser: winner === teamA ? teamB : teamA };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -319,9 +313,7 @@ function runKnockoutRoundByNumber(
|
||||||
roundName: string,
|
roundName: string,
|
||||||
matches: Map<number, KnockoutMatch>,
|
matches: Map<number, KnockoutMatch>,
|
||||||
completed: Map<string, { winnerId: string; loserId: string }>,
|
completed: Map<string, { winnerId: string; loserId: string }>,
|
||||||
eloFn: (id: string) => number,
|
eloFn: (id: string) => number
|
||||||
normalizedProb: (id: string) => number,
|
|
||||||
oddsWeight: number = ODDS_WEIGHT
|
|
||||||
): {
|
): {
|
||||||
winnersByNumber: Map<number, string>;
|
winnersByNumber: Map<number, string>;
|
||||||
losersByNumber: Map<number, string>;
|
losersByNumber: Map<number, string>;
|
||||||
|
|
@ -350,7 +342,7 @@ function runKnockoutRoundByNumber(
|
||||||
winnerId = fixed.winnerId;
|
winnerId = fixed.winnerId;
|
||||||
loserId = fixed.loserId;
|
loserId = fixed.loserId;
|
||||||
} else if (m.p1 && m.p2) {
|
} else if (m.p1 && m.p2) {
|
||||||
const result = simKnockout(m.p1, m.p2, eloFn, normalizedProb, oddsWeight);
|
const result = simKnockout(m.p1, m.p2, eloFn);
|
||||||
winnerId = result.winner;
|
winnerId = result.winner;
|
||||||
loserId = result.loser;
|
loserId = result.loser;
|
||||||
} else if (m.p1 || m.p2) {
|
} else if (m.p1 || m.p2) {
|
||||||
|
|
@ -448,10 +440,7 @@ export class WorldCupSimulator implements Simulator {
|
||||||
this.numSimulations = numSimulations;
|
this.numSimulations = numSimulations;
|
||||||
}
|
}
|
||||||
|
|
||||||
async simulate(sportsSeasonId: string, context?: SimulationContext): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
||||||
// Elo-vs-odds blend weight for knockout matches: configurable via the season
|
|
||||||
// input policy, defaulting to the historical 70/30 split without context.
|
|
||||||
const oddsWeight = context?.oddsWeight ?? ODDS_WEIGHT;
|
|
||||||
const db = database();
|
const db = database();
|
||||||
|
|
||||||
// 1. Load all participants for this season
|
// 1. Load all participants for this season
|
||||||
|
|
@ -466,20 +455,19 @@ export class WorldCupSimulator implements Simulator {
|
||||||
const participantIds = participantRows.map((p) => p.id);
|
const participantIds = participantRows.map((p) => p.id);
|
||||||
const participantNames = new Map(participantRows.map((p) => [p.id, p.name]));
|
const participantNames = new Map(participantRows.map((p) => [p.id, p.name]));
|
||||||
|
|
||||||
// 2. Load stored ratings (sourceElo from Elo page, sourceOdds from futures page)
|
// 2. Load the resolved single Elo (the input policy already blended any raw
|
||||||
|
// Elo and futures odds into sourceElo before the run).
|
||||||
const evRows = await db
|
const evRows = await db
|
||||||
.select({
|
.select({
|
||||||
participantId: schema.seasonParticipantExpectedValues.participantId,
|
participantId: schema.seasonParticipantExpectedValues.participantId,
|
||||||
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
|
|
||||||
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
|
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
|
||||||
})
|
})
|
||||||
.from(schema.seasonParticipantExpectedValues)
|
.from(schema.seasonParticipantExpectedValues)
|
||||||
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
|
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
|
||||||
|
|
||||||
const evMap = new Map(evRows.map((r) => [r.participantId, r]));
|
|
||||||
const participantSet = new Set(participantIds);
|
const participantSet = new Set(participantIds);
|
||||||
|
|
||||||
// 3. Build Elo map — priority: sourceElo (direct) > sourceOdds (converted) > hardcoded
|
// 3. Build the Elo map — resolved sourceElo, else hardcoded national-team Elo.
|
||||||
const sourceEloMap = new Map<string, number>();
|
const sourceEloMap = new Map<string, number>();
|
||||||
for (const r of evRows) {
|
for (const r of evRows) {
|
||||||
if (r.sourceElo !== null && r.sourceElo !== undefined && participantSet.has(r.participantId)) {
|
if (r.sourceElo !== null && r.sourceElo !== undefined && participantSet.has(r.participantId)) {
|
||||||
|
|
@ -487,47 +475,13 @@ export class WorldCupSimulator implements Simulator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasOdds = evRows.some(
|
|
||||||
(r) => r.sourceOdds !== null && participantSet.has(r.participantId)
|
|
||||||
);
|
|
||||||
|
|
||||||
let eloFromOdds: Map<string, number>;
|
|
||||||
if (hasOdds) {
|
|
||||||
const oddsInput = evRows
|
|
||||||
.filter((r) => r.sourceOdds !== null && participantSet.has(r.participantId))
|
|
||||||
.map((r) => ({ participantId: r.participantId, odds: r.sourceOdds ?? 0 }));
|
|
||||||
eloFromOdds = convertFuturesToElo(oddsInput, "american");
|
|
||||||
} else {
|
|
||||||
eloFromOdds = new Map();
|
|
||||||
}
|
|
||||||
|
|
||||||
const eloFn = (id: string): number => {
|
const eloFn = (id: string): number => {
|
||||||
if (sourceEloMap.has(id)) return sourceEloMap.get(id) ?? 1500;
|
if (sourceEloMap.has(id)) return sourceEloMap.get(id) ?? 1500;
|
||||||
if (eloFromOdds.has(id)) return eloFromOdds.get(id) ?? 1500;
|
|
||||||
const name = participantNames.get(id) ?? "";
|
const name = participantNames.get(id) ?? "";
|
||||||
return getTeamElo(name, 1500);
|
return getTeamElo(name, 1500);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 4. Build normalized futures win-probability map (vig removed)
|
// 4. Resolve the bracket scoring event. Prefer a fifa_48 playoff event; if
|
||||||
const rawProbs = new Map<string, number>();
|
|
||||||
for (const id of participantIds) {
|
|
||||||
const ev = evMap.get(id);
|
|
||||||
if (ev?.sourceOdds !== null && ev?.sourceOdds !== undefined) {
|
|
||||||
rawProbs.set(id, Math.abs(ev.sourceOdds) > 0
|
|
||||||
? ev.sourceOdds > 0
|
|
||||||
? 100 / (ev.sourceOdds + 100)
|
|
||||||
: Math.abs(ev.sourceOdds) / (Math.abs(ev.sourceOdds) + 100)
|
|
||||||
: 1 / participantIds.length);
|
|
||||||
} else {
|
|
||||||
rawProbs.set(id, 1 / participantIds.length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalRawProb = [...rawProbs.values()].reduce((s, p) => s + p, 0);
|
|
||||||
const normalizedProb = (id: string): number =>
|
|
||||||
totalRawProb > 0 ? (rawProbs.get(id) ?? 0) / totalRawProb : 1 / participantIds.length;
|
|
||||||
|
|
||||||
// 5. Resolve the bracket scoring event. Prefer a fifa_48 playoff event; if
|
|
||||||
// several playoff_game events exist, take the most recent so a re-created
|
// several playoff_game events exist, take the most recent so a re-created
|
||||||
// event wins over a stale one.
|
// event wins over a stale one.
|
||||||
const playoffEvents = await db.query.scoringEvents.findMany({
|
const playoffEvents = await db.query.scoringEvents.findMany({
|
||||||
|
|
@ -719,10 +673,10 @@ export class WorldCupSimulator implements Simulator {
|
||||||
// ── Knockout rounds ──────────────────────────────────────────
|
// ── Knockout rounds ──────────────────────────────────────────
|
||||||
// R32 → R16 → QF → SF → Third Place Game + Final, advancing via the
|
// R32 → R16 → QF → SF → Third Place Game + Final, advancing via the
|
||||||
// canonical ceil(n/2) tree. Completed matches lock in real results.
|
// canonical ceil(n/2) tree. Completed matches lock in real results.
|
||||||
const r32 = runKnockoutRoundByNumber(R32, buildR32(), completedByRoundAndNumber, eloFn, normalizedProb, oddsWeight);
|
const r32 = runKnockoutRoundByNumber(R32, buildR32(), completedByRoundAndNumber, eloFn);
|
||||||
const r16 = runKnockoutRoundByNumber(R16, r32.nextRound, completedByRoundAndNumber, eloFn, normalizedProb, oddsWeight);
|
const r16 = runKnockoutRoundByNumber(R16, r32.nextRound, completedByRoundAndNumber, eloFn);
|
||||||
const qf = runKnockoutRoundByNumber(QF, r16.nextRound, completedByRoundAndNumber, eloFn, normalizedProb, oddsWeight);
|
const qf = runKnockoutRoundByNumber(QF, r16.nextRound, completedByRoundAndNumber, eloFn);
|
||||||
const sf = runKnockoutRoundByNumber(SF, qf.nextRound, completedByRoundAndNumber, eloFn, normalizedProb, oddsWeight);
|
const sf = runKnockoutRoundByNumber(SF, qf.nextRound, completedByRoundAndNumber, eloFn);
|
||||||
|
|
||||||
// QF losers (placements 5–8)
|
// QF losers (placements 5–8)
|
||||||
for (const id of qf.losersByNumber.values()) {
|
for (const id of qf.losersByNumber.values()) {
|
||||||
|
|
@ -739,7 +693,7 @@ export class WorldCupSimulator implements Simulator {
|
||||||
counts.fourthPlace.set(fixed3pg.loserId, (counts.fourthPlace.get(fixed3pg.loserId) ?? 0) + 1);
|
counts.fourthPlace.set(fixed3pg.loserId, (counts.fourthPlace.get(fixed3pg.loserId) ?? 0) + 1);
|
||||||
} else {
|
} else {
|
||||||
const { winner: thirdWinner, loser: thirdLoser } = simKnockout(
|
const { winner: thirdWinner, loser: thirdLoser } = simKnockout(
|
||||||
sf1Loser, sf2Loser, eloFn, normalizedProb, oddsWeight
|
sf1Loser, sf2Loser, eloFn
|
||||||
);
|
);
|
||||||
counts.thirdPlace.set(thirdWinner, (counts.thirdPlace.get(thirdWinner) ?? 0) + 1);
|
counts.thirdPlace.set(thirdWinner, (counts.thirdPlace.get(thirdWinner) ?? 0) + 1);
|
||||||
counts.fourthPlace.set(thirdLoser, (counts.fourthPlace.get(thirdLoser) ?? 0) + 1);
|
counts.fourthPlace.set(thirdLoser, (counts.fourthPlace.get(thirdLoser) ?? 0) + 1);
|
||||||
|
|
@ -756,7 +710,7 @@ export class WorldCupSimulator implements Simulator {
|
||||||
counts.runnerUp.set(fixedFinal.loserId, (counts.runnerUp.get(fixedFinal.loserId) ?? 0) + 1);
|
counts.runnerUp.set(fixedFinal.loserId, (counts.runnerUp.get(fixedFinal.loserId) ?? 0) + 1);
|
||||||
} else {
|
} else {
|
||||||
const { winner: champion, loser: runnerUp } = simKnockout(
|
const { winner: champion, loser: runnerUp } = simKnockout(
|
||||||
sfWinner1, sfWinner2, eloFn, normalizedProb, oddsWeight
|
sfWinner1, sfWinner2, eloFn
|
||||||
);
|
);
|
||||||
counts.champion.set(champion, (counts.champion.get(champion) ?? 0) + 1);
|
counts.champion.set(champion, (counts.champion.get(champion) ?? 0) + 1);
|
||||||
counts.runnerUp.set(runnerUp, (counts.runnerUp.get(runnerUp) ?? 0) + 1);
|
counts.runnerUp.set(runnerUp, (counts.runnerUp.get(runnerUp) ?? 0) + 1);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue