* Add golf QP simulator with Plackett-Luce model, fixes #120 - New `participant_golf_skills` table (migration 0061) for SG: Total and per-major American odds per player/season - New `app/models/golf-skills.ts` with getGolfSkillsMap, getGolfSkillsForSeason, batchUpsertGolfSkills - Full `GolfSimulator` implementation replacing the TODO stub: Plackett-Luce ranking model (PL_BETA=1.5, FIELD_SIZE=156), 10k Monte Carlo iterations, awards QP by finishing position, ranks by total QP across all 4 majors - New admin route `sports-seasons/:id/golf-skills` with bulk CSV import, fuzzy name matching, per-player SG + per-major odds inputs; saves skills and auto-runs simulation on submit - Simulator dropdown on sport admin sorted alphabetically; renamed to "Golf Qualifying Points Monte Carlo" - Golf Skills button shown on sports season admin when simulator type is golf_qualifying_points - Extract normalizeName/diceCoefficient to shared `app/lib/fuzzy-match.ts`, removing duplication from surface-elo and golf-skills routes - Parallelize 4 DB queries in GolfSimulator.simulate() with Promise.all - O(1) field array removal via swap-to-end + pop (was O(N) splice) - Fix source tag: performance_model (not elo_simulation) for SG-based model - 23 unit tests covering americanToImplied, getMajorOddsKey, resolveSkill, simulateMajor, and Monte Carlo calibration properties Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix oxlint errors: no-non-null-assertion and eqeqeq Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
299 lines
12 KiB
TypeScript
299 lines
12 KiB
TypeScript
/**
|
||
* 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 type { Simulator, SimulationResult } from "./types";
|
||
|
||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||
|
||
const 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<GolfSkillsRecord, "mastersOdds" | "usOpenOdds" | "openChampionshipOdds" | "pgaChampionshipOdds"> | 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<number, number>
|
||
): Map<string, number> {
|
||
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<unknown, FieldPlayer>({ length: restCount }, () => ({ id: null, strength: restStrength })),
|
||
];
|
||
|
||
let totalStrength = remaining.reduce((sum, p) => sum + p.strength, 0);
|
||
const result = new Map<string, number>();
|
||
|
||
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): Promise<SimulationResult[]> {
|
||
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.participants.id })
|
||
.from(schema.participants)
|
||
.where(eq(schema.participants.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<number, number>(
|
||
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<string, number>(participantIds.map((id) => [id, 0]));
|
||
|
||
if (completedEventIds.length > 0) {
|
||
const actualResults = await db
|
||
.select({
|
||
participantId: schema.eventResults.participantId,
|
||
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);
|
||
|
||
// 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.
|
||
const majorConfigs = incompleteMajors.map((event) => {
|
||
const oddsKey = getMajorOddsKey(event.name);
|
||
const players = participantIds.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<number>(8).fill(0)
|
||
);
|
||
const idToIndex = new Map<string, number>(participantIds.map((id, i) => [id, i]));
|
||
|
||
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
|
||
const simQP = new Map<string, number>(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] / NUM_SIMULATIONS,
|
||
probSecond: counts[i][1] / NUM_SIMULATIONS,
|
||
probThird: counts[i][2] / NUM_SIMULATIONS,
|
||
probFourth: counts[i][3] / NUM_SIMULATIONS,
|
||
probFifth: counts[i][4] / NUM_SIMULATIONS,
|
||
probSixth: counts[i][5] / NUM_SIMULATIONS,
|
||
probSeventh: counts[i][6] / NUM_SIMULATIONS,
|
||
probEighth: counts[i][7] / NUM_SIMULATIONS,
|
||
},
|
||
source: "golf_qualifying_points_monte_carlo",
|
||
}));
|
||
}
|
||
}
|