brackt/app/services/simulations/ncaa-football-simulator.ts
Claude dc4efe87cf
Unify futures odds with the simulation system
Make the futures-vs-Elo relationship an explicit, configurable rule and fix
futures odds failing to override stored Elo.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QhNeB7gN6VKene7sdbBVQT
2026-06-25 22:11:32 +00:00

353 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* NCAA Football CFP Simulator
*
* Monte Carlo simulation of the College Football Playoff (12-team format, 2024present).
*
* Algorithm:
* 1. Load all participants for the sports season from DB
* 2. Load Elo/FPI ratings from participantExpectedValues.sourceElo
* (entered via Admin → Elo Ratings page; use FPI, S&P+, or any Elo-scale rating)
* 3. If sourceOdds (American format) are also stored, build a normalized selection
* weight from implied championship probability (used for field selection in step 4)
* and blend into per-game win probability (ELO_WEIGHT=0.6 / ODDS_WEIGHT=0.4).
* 4. Per simulation, select 12 teams for the CFP field:
* - If the pool has exactly 12 teams: use all of them (post-bracket mode).
* - If the pool has >12 teams: weighted sample without replacement using each
* team's selection weight — odds-derived if available, Elo-based otherwise.
* Teams with stronger championship odds are sampled more often, naturally
* encoding both selection probability and bracket strength into one signal.
* 5. Seed the 12 selected teams by Elo (best Elo = seed 1).
* 6. Simulate the CFP bracket:
* First Round (not scoring): 5v12, 6v11, 7v10, 8v9
* Quarterfinals (scoring): 1 vs 8/9w, 4 vs 5/12w, 3 vs 6/11w, 2 vs 7/10w
* Semifinals (scoring): QF1w vs QF2w, QF3w vs QF4w
* National Championship: SF1w vs SF2w
* 7. Track placement counts per scoring tier across all simulations.
* 8. Convert counts to probability distributions.
*
* Pre-bracket vs post-bracket mode:
* Pre-bracket (>12 participants): probabilities reflect both selection uncertainty
* and bracket performance. A bubble team might appear in only 40% of simulated
* fields, so its champion probability accounts for that.
* Post-bracket (exactly 12 participants): deterministic field, bracket-only sim.
*
* Win probability (per game): eloWinProbability() from probability-engine (400-divisor).
* Blended win probability: ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb (when odds present).
*
* Selection weight (pre-bracket mode):
* With sourceOdds: normalized implied championship probability (vig removed, sums to 1).
* Without sourceOdds: softmax on Elo with temperature SELECTION_TEMP (sharply favors
* higher-rated teams — a 200-point Elo gap yields ~7× selection weight difference).
*
* Placement tiers → SimulationProbabilities mapping:
* probFirst = National Champion (1 per sim)
* probSecond = Championship game loser (1 per sim)
* probThird / probFourth = Semifinal losers (2 per sim — split evenly)
* probFifthprobEighth = Quarterfinal losers (4 per sim — split evenly)
* First Round losers → all 0 (score 0 fantasy points)
* Teams not selected → all 0 (not in field for that sim)
*
* Admin setup:
* 1. Create a Sport with simulatorType = "ncaa_football_bracket"
* 2. Create a Sports Season and add all contender participants (12 or more)
* 3. Enter FPI ratings via Admin → Elo Ratings (stored as sourceElo)
* 4. Optionally enter championship futures odds via Admin → Futures Odds (sourceOdds)
* — strongly recommended for pre-bracket mode; drives both selection and bracket strength
* 5. Run simulation via Admin → Simulate
*/
import { database } from "~/database/context";
import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import {
convertAmericanOddsToProbability,
convertFuturesToElo,
eloWinProbability,
} from "~/services/probability-engine";
import type { Simulator, SimulationResult, SimulationContext } from "./types";
// ─── Simulation parameters ────────────────────────────────────────────────────
const NUM_SIMULATIONS = 50_000;
const BRACKET_SIZE = 12;
/**
* Blend weights for per-game win probability when sourceOdds are present.
* Lower Elo weight than other sports (0.7) gives more influence to Vegas
* championship odds, which are highly informative in college football.
*/
const ELO_WEIGHT = 0.6;
const ODDS_WEIGHT = 1 - ELO_WEIGHT;
/**
* Softmax temperature for Elo-based selection weights (pre-bracket mode, no odds).
* 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.
*/
const SELECTION_TEMP = 100;
// ─── Types ────────────────────────────────────────────────────────────────────
interface Team {
participantId: string;
elo: number;
/** Normalized futures win probability (01). Used for blending per-game win prob. */
oddsProb: number;
/**
* Weight used for probabilistic CFP field selection (pre-bracket mode only).
* Derived from oddsProb when available; otherwise softmax on Elo.
*/
selectionWeight: number;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Blended win probability for team1 vs team2.
* Falls back to pure Elo when no futures data is present.
*/
function blendedWinProb(team1: Team, team2: Team, 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 } {
const p1Wins = Math.random() < blendedWinProb(team1, team2, oddsWeight);
return p1Wins
? { winner: team1, loser: team2 }
: { winner: team2, loser: team1 };
}
/**
* Weighted sample without replacement — selects `n` teams from `pool` where each
* team's probability of being drawn is proportional to its selectionWeight.
* Returns the selected teams sorted by Elo descending (seed 1 = best Elo).
*/
function sampleBracketField(pool: Team[], n: number): Team[] {
const remaining = [...pool];
const selected: Team[] = [];
for (let i = 0; i < n; i++) {
const totalWeight = remaining.reduce((sum, t) => sum + t.selectionWeight, 0);
let r = Math.random() * totalWeight;
let j = 0;
for (; j < remaining.length - 1; j++) {
r -= remaining[j].selectionWeight;
if (r <= 0) break;
}
selected.push(remaining[j]);
remaining.splice(j, 1);
}
// Seed by Elo so that the best team in the sampled field is always seed 1.
return selected.toSorted((a, b) => b.elo - a.elo);
}
// ─── Bracket simulation ───────────────────────────────────────────────────────
interface PlacementCounts {
champion: number;
finalist: number;
sfLoser: number;
qfLoser: number;
}
/**
* Simulate one full 12-team CFP bracket.
*
* Seeding (teams sorted best→worst Elo, index 0 = seed 1):
* First Round: [4]v[11], [5]v[10], [6]v[9], [7]v[8]
* Quarterfinals: [0] vs fr4w, [3] vs fr1w, [2] vs fr2w, [1] vs fr3w
* Semifinals: qf1w vs qf2w, qf3w vs qf4w
* Championship: sf1w vs sf2w
*/
function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>, oddsWeight: number = ODDS_WEIGHT): void {
// ── First Round (seeds 512) ───────────────────────────────────────────────
const fr1 = simGame(teams[4], teams[11], oddsWeight); // 5 vs 12
const fr2 = simGame(teams[5], teams[10], oddsWeight); // 6 vs 11
const fr3 = simGame(teams[6], teams[9], oddsWeight); // 7 vs 10
const fr4 = simGame(teams[7], teams[8], oddsWeight); // 8 vs 9
// ── Quarterfinals (seeds 14 get byes) ────────────────────────────────────
const qf1 = simGame(teams[0], fr4.winner, oddsWeight); // 1 vs 8/9 winner
const qf2 = simGame(teams[3], fr1.winner, oddsWeight); // 4 vs 5/12 winner
const qf3 = simGame(teams[2], fr2.winner, oddsWeight); // 3 vs 6/11 winner
const qf4 = simGame(teams[1], fr3.winner, oddsWeight); // 2 vs 7/10 winner
const bump = (id: string, key: keyof PlacementCounts) => {
const entry = counts.get(id);
if (entry) entry[key]++;
};
bump(qf1.loser.participantId, "qfLoser");
bump(qf2.loser.participantId, "qfLoser");
bump(qf3.loser.participantId, "qfLoser");
bump(qf4.loser.participantId, "qfLoser");
// ── Semifinals ────────────────────────────────────────────────────────────
const sf1 = simGame(qf1.winner, qf2.winner, oddsWeight);
const sf2 = simGame(qf3.winner, qf4.winner, oddsWeight);
bump(sf1.loser.participantId, "sfLoser");
bump(sf2.loser.participantId, "sfLoser");
// ── National Championship ─────────────────────────────────────────────────
const final = simGame(sf1.winner, sf2.winner, oddsWeight);
bump(final.winner.participantId, "champion");
bump(final.loser.participantId, "finalist");
}
// ─── Simulator ────────────────────────────────────────────────────────────────
export class NCAAFootballSimulator implements Simulator {
async simulate(sportsSeasonId: string, context?: SimulationContext): Promise<SimulationResult[]> {
const db = database();
// Elo-vs-odds blend weight: configurable via the season input policy, with
// the historical 60/40 split as the default when invoked without context.
const oddsWeight = context?.oddsWeight ?? ODDS_WEIGHT;
// 1. Load all participants for this sports season.
const participants = await db
.select({ id: schema.seasonParticipants.id })
.from(schema.seasonParticipants)
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId));
if (participants.length < BRACKET_SIZE) {
throw new Error(
`CFP simulator requires at least ${BRACKET_SIZE} participants, ` +
`found ${participants.length}. Add all contender teams to the sports season.`
);
}
// 2. Load Elo/FPI ratings and optional futures odds in a single query.
const evRows = await db
.select({
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
})
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
// Build Elo and raw odds maps in a single pass.
const eloFromDb = new Map<string, number>();
const rawOddsProbs = new Map<string, number>();
for (const row of evRows) {
if (row.sourceElo !== null) {
eloFromDb.set(row.participantId, row.sourceElo);
}
if (row.sourceOdds !== null) {
rawOddsProbs.set(row.participantId, convertAmericanOddsToProbability(row.sourceOdds));
}
}
// 3. Build normalized odds probability map (vig removed).
const normalizedOddsMap = new Map<string, number>();
if (rawOddsProbs.size > 0) {
const rawSum = [...rawOddsProbs.values()].reduce((a, b) => a + b, 0);
for (const [id, prob] of rawOddsProbs) {
normalizedOddsMap.set(id, rawSum > 0 ? prob / rawSum : 0);
}
// Backfill Elo from futures for any team missing sourceElo.
if (eloFromDb.size < participants.length) {
const oddsInput = evRows
.filter((r) => r.sourceOdds !== null && !eloFromDb.has(r.participantId))
.map((r) => ({ participantId: r.participantId, odds: r.sourceOdds ?? 0 }));
if (oddsInput.length > 0) {
const oddsEloMap = convertFuturesToElo(oddsInput, "american");
for (const [id, elo] of oddsEloMap) {
eloFromDb.set(id, elo);
}
}
}
}
// 4. Build team list with Elo, oddsProb, and selectionWeight.
const hasOdds = normalizedOddsMap.size > 0;
const allTeams: Team[] = participants.map((p) => ({
participantId: p.id,
elo: eloFromDb.get(p.id) ?? 1500,
oddsProb: normalizedOddsMap.get(p.id) ?? 0,
selectionWeight: 0, // computed below
}));
if (hasOdds) {
// Selection weight = normalized championship implied probability.
// This encodes both "probability of making the field" and "strength once there."
for (const team of allTeams) {
team.selectionWeight = normalizedOddsMap.get(team.participantId) ?? 0;
}
} else {
// No odds: softmax on Elo so top-rated teams are strongly favoured.
const eloValues = allTeams.map((t) => t.elo);
const maxElo = Math.max(...eloValues);
// Subtract max for numerical stability before exp().
const expWeights = allTeams.map((t) => Math.exp((t.elo - maxElo) / SELECTION_TEMP));
const expSum = expWeights.reduce((a, b) => a + b, 0);
for (let i = 0; i < allTeams.length; i++) {
allTeams[i].selectionWeight = expWeights[i] / expSum;
}
}
const preBracketMode = participants.length > BRACKET_SIZE;
// In post-bracket mode (exactly 12), sort once and reuse the same field every sim.
const deterministicField = preBracketMode
? null
: [...allTeams].toSorted((a, b) => b.elo - a.elo);
// 5. Initialise placement count accumulators for all participants.
const allParticipantIds = participants.map((p) => p.id);
const counts = new Map<string, PlacementCounts>(
allParticipantIds.map((id) => [id, { champion: 0, finalist: 0, sfLoser: 0, qfLoser: 0 }])
);
// 6. Run Monte Carlo simulations.
for (let s = 0; s < NUM_SIMULATIONS; s++) {
const field = preBracketMode
? sampleBracketField(allTeams, BRACKET_SIZE)
: (deterministicField ?? []);
simulateBracket(field, counts, oddsWeight);
}
// 7. Convert counts to probability distributions.
// SF losers: 2 per sim → each team's share = sfLoser / (2 * N).
// QF losers: 4 per sim → each team's share = qfLoser / (4 * N).
const sfDivisor = 2 * NUM_SIMULATIONS;
const qfDivisor = 4 * NUM_SIMULATIONS;
return allParticipantIds.map((id) => {
const c = counts.get(id) ?? { champion: 0, finalist: 0, sfLoser: 0, qfLoser: 0 };
const sfProb = c.sfLoser / sfDivisor;
const qfProb = c.qfLoser / qfDivisor;
return {
participantId: id,
probabilities: {
probFirst: c.champion / NUM_SIMULATIONS,
probSecond: c.finalist / NUM_SIMULATIONS,
probThird: sfProb,
probFourth: sfProb,
probFifth: qfProb,
probSixth: qfProb,
probSeventh: qfProb,
probEighth: qfProb,
},
source: "cfp_monte_carlo",
};
});
}
}