/** * Golf / Qualifying Points Simulator * * Monte Carlo simulation of the 4 golf majors using a Plackett-Luce ranking model. * * Algorithm: * 1. Load participants and their actual QP from completed majors. * 2. For each incomplete major, build a simulated field of FIELD_SIZE players: * - Tracked participants: strength = exp(PL_BETA × SG_Total) * - Synthetic rest-of-field: strength = 1.0 (SG = 0, field average) * 3. Draw finishing positions using the Plackett-Luce model: * P(player i placed next) ∝ strength_i / sum(remaining strengths) * 4. Award QP for top placements per qualifyingPointConfig. * 5. Rank all tracked players by total QP; tally 1st–8th placement counts. * 6. Return normalized SimulationResult[]. * * Strength calibration (PL_BETA = 1.5, FIELD_SIZE = 156): * SG +3.0 → win prob ≈ 12% (elite major contender) * SG +2.0 → win prob ≈ 5.8% * SG 0.0 → win prob ≈ 0.6% (field average) * * Per-major odds (optional): * If American odds are stored for this major and a player has no SG: Total, * the odds are converted to an SG-equivalent skill for that major. * If SG: Total is available it always takes precedence. * * Major name → odds column mapping (matched case-insensitively): * "Masters" → mastersOdds * "PGA Championship" → pgaChampionshipOdds * "US Open" / "U.S. Open" → usOpenOdds * "The Open" / "Open" → openChampionshipOdds */ import { database } from "~/database/context"; import { eq, and, inArray } from "drizzle-orm"; import * as schema from "~/database/schema"; import { getGolfSkillsMap, type GolfSkillsRecord } from "~/models/golf-skills"; import { getQPConfig } from "~/models/qualifying-points"; import { getExcludedByEventMap } from "~/models/event-result"; import type { Simulator, SimulationResult } from "./types"; import { positiveConfigNumber } from "./config-access"; // ─── Simulation parameters ──────────────────────────────────────────────────── const DEFAULT_NUM_SIMULATIONS = 10_000; /** * Simulated field size for each major. * Major fields typically have 156 players. Tracked participants fill their slots; * the remainder are synthetic "rest of field" players at strength 1.0. */ const FIELD_SIZE = 156; /** * Plackett-Luce exponential scaling factor. * strength_i = exp(PL_BETA × sgTotal_i) * * Calibration (typical 50-player tracked field + 106 rest-of-field at SG=0): * SG +3.0 wins a single major ~12%, SG +2.0 ~6%, SG 0.0 ~0.6% * * Higher beta increases separation between skill levels, concentrating QP * accumulation toward the best players across all 4 majors. */ const PL_BETA = 1.5; // ─── Helpers ────────────────────────────────────────────────────────────────── /** Convert American odds to implied probability. Returns null for invalid input. */ export function americanToImplied(odds: number): number | null { if (odds === 0) return null; const p = odds > 0 ? 100 / (odds + 100) : -odds / (-odds + 100); return p > 0 && p <= 1 ? p : null; } /** * Determine which per-major odds column to use for a scoring event, based on its name. * Returns null if the name doesn't match any known major pattern. */ export function getMajorOddsKey( eventName: string ): keyof Pick< GolfSkillsRecord, "mastersOdds" | "usOpenOdds" | "openChampionshipOdds" | "pgaChampionshipOdds" > | null { const n = eventName.toLowerCase(); if (n.includes("masters")) return "mastersOdds"; if (n.includes("pga championship") || (n.includes("pga") && !n.includes("tour"))) return "pgaChampionshipOdds"; if (n.includes("us open") || n.includes("u.s. open")) return "usOpenOdds"; if (n.includes("open")) return "openChampionshipOdds"; return null; } /** * Resolve a player's effective skill score (in SG: Total units) for a specific major. * * Priority: * 1. sgTotal (if set — applies to all majors uniformly) * 2. Per-major odds converted to an SG-equivalent * 3. 0.0 (field average fallback) */ export function resolveSkill( skills: GolfSkillsRecord | undefined, oddsKey: keyof Pick | null ): number { if (skills?.sgTotal !== null && skills?.sgTotal !== undefined) { return skills.sgTotal; } if (oddsKey && skills) { const rawOdds = skills[oddsKey]; if (rawOdds !== null && rawOdds !== undefined) { const implied = americanToImplied(rawOdds); if (implied !== null) { // Convert implied win probability to SG-equivalent: // strength = exp(PL_BETA × sg) ≈ implied × FIELD_SIZE // sg = ln(implied × FIELD_SIZE) / PL_BETA const strength = Math.max(implied * FIELD_SIZE, 0.01); return Math.log(strength) / PL_BETA; } } } return 0; } interface FieldPlayer { id: string | null; strength: number } /** * Simulate one major using the Plackett-Luce model. * * Draws finishing positions for tracked players and the synthetic rest-of-field, * awarding QP to tracked players who land in scoring positions (top N per qpConfig). * * @returns Map from participantId → QP awarded (0 if outside scoring positions) */ export function simulateMajor( trackedPlayers: { id: string; strength: number }[], restCount: number, restStrength: number, qpConfig: Map ): Map { const maxScoringPosition = Math.max(...qpConfig.keys()); const fieldSize = trackedPlayers.length + restCount; const positionsToSimulate = Math.min(maxScoringPosition, fieldSize); // Pool of remaining players (tracked with real ids, rest-of-field with null ids) const remaining: FieldPlayer[] = [ ...trackedPlayers.map((p) => ({ id: p.id, strength: p.strength })), ...Array.from({ length: restCount }, () => ({ id: null, strength: restStrength })), ]; let totalStrength = remaining.reduce((sum, p) => sum + p.strength, 0); const result = new Map(); for (let placement = 1; placement <= positionsToSimulate; placement++) { // Sample a winner proportional to strength let r = Math.random() * totalStrength; let winnerIdx = remaining.length - 1; // fallback to last in case of floating-point drift for (let i = 0; i < remaining.length; i++) { r -= remaining[i].strength; if (r <= 0) { winnerIdx = i; break; } } const winner = remaining[winnerIdx]; if (winner.id !== null) { result.set(winner.id, qpConfig.get(placement) ?? 0); } totalStrength -= winner.strength; // Swap winner to end and pop — O(1) removal vs O(N) splice remaining[winnerIdx] = remaining[remaining.length - 1]; remaining.pop(); } // Tracked players not drawn in scoring positions get 0 QP for (const p of trackedPlayers) { if (!result.has(p.id)) result.set(p.id, 0); } return result; } // ─── Simulator ──────────────────────────────────────────────────────────────── export class GolfSimulator implements Simulator { async simulate(sportsSeasonId: string, config: Record = {}): Promise { const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS)); const db = database(); // Load participants, skills, QP config, and scoring events in parallel. const [allParticipants, skillsMap, qpConfigRows, events] = await Promise.all([ db .select({ id: schema.seasonParticipants.id }) .from(schema.seasonParticipants) .where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId)), getGolfSkillsMap(sportsSeasonId), getQPConfig(sportsSeasonId), db.query.scoringEvents.findMany({ where: and( eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), eq(schema.scoringEvents.eventType, "major_tournament") ), orderBy: (e, { asc }) => [asc(e.eventDate)], }), ]); if (allParticipants.length === 0) { throw new Error( `No participants found for sports season ${sportsSeasonId}. ` + `Add participants before running the simulation.` ); } const participantIds = allParticipants.map((p) => p.id); const qpConfig = new Map( qpConfigRows.map((row) => [row.placement, Number(row.points)]) ); if (events.length === 0) { throw new Error( `No major_tournament scoring events found for sports season ${sportsSeasonId}. ` + `Create the 4 major scoring events first (e.g. "Masters", "US Open", "The Open", "PGA Championship").` ); } // For completed majors, read actual QP from eventResults. const completedEventIds = events.filter((e) => e.isComplete).map((e) => e.id); const actualQPMap = new Map(participantIds.map((id) => [id, 0])); if (completedEventIds.length > 0) { const actualResults = await db .select({ participantId: schema.eventResults.seasonParticipantId, qualifyingPointsAwarded: schema.eventResults.qualifyingPointsAwarded, }) .from(schema.eventResults) .where(inArray(schema.eventResults.scoringEventId, completedEventIds)); for (const r of actualResults) { if (r.qualifyingPointsAwarded !== null) { const prev = actualQPMap.get(r.participantId) ?? 0; actualQPMap.set(r.participantId, prev + parseFloat(r.qualifyingPointsAwarded)); } } } const incompleteMajors = events.filter((e) => !e.isComplete); // Load not-participating exclusions for each incomplete major. const excludedByEvent = await getExcludedByEventMap(incompleteMajors.map((e) => e.id)); // Pre-compute per-player strengths per incomplete major (outside the Monte Carlo loop). // strength = exp(PL_BETA × effectiveSkill); minimum clamped to 0.01 to avoid division issues. // Participants marked notParticipating for a specific major are excluded from that event's field. const majorConfigs = incompleteMajors.map((event) => { const excluded = excludedByEvent.get(event.id) ?? new Set(); const oddsKey = getMajorOddsKey(event.name); const players = participantIds .filter((id) => !excluded.has(id)) .map((id) => ({ id, strength: Math.max(Math.exp(PL_BETA * resolveSkill(skillsMap.get(id), oddsKey)), 0.01), })); const restCount = Math.max(0, FIELD_SIZE - players.length); const restStrength = 1.0; // exp(PL_BETA * 0) = 1, representing SG = 0 return { players, restCount, restStrength }; }); // Monte Carlo loop. const counts: number[][] = Array.from({ length: participantIds.length }, () => Array(8).fill(0) ); const idToIndex = new Map(participantIds.map((id, i) => [id, i])); for (let sim = 0; sim < numSimulations; sim++) { const simQP = new Map(actualQPMap); for (const { players, restCount, restStrength } of majorConfigs) { const majorResult = simulateMajor(players, restCount, restStrength, qpConfig); for (const [pid, qp] of majorResult) { simQP.set(pid, (simQP.get(pid) ?? 0) + qp); } } // Rank all tracked participants by total QP descending. const ranked = [...simQP.entries()].toSorted((a, b) => b[1] - a[1]); for (let rank = 0; rank < Math.min(8, ranked.length); rank++) { const idx = idToIndex.get(ranked[rank][0]); if (idx !== undefined) counts[idx][rank]++; } } // Normalize counts to probabilities. return participantIds.map((participantId, i) => ({ participantId, probabilities: { probFirst: counts[i][0] / numSimulations, probSecond: counts[i][1] / numSimulations, probThird: counts[i][2] / numSimulations, probFourth: counts[i][3] / numSimulations, probFifth: counts[i][4] / numSimulations, probSixth: counts[i][5] / numSimulations, probSeventh: counts[i][6] / numSimulations, probEighth: counts[i][7] / numSimulations, }, source: "golf_qualifying_points_monte_carlo", })); } }