brackt/app/services/simulations/__tests__/golf-simulator.test.ts
Chris Parsons e62e9554c9
Add golf qualifying points simulator (Plackett-Luce Monte Carlo) (#223)
* 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>
2026-03-24 21:46:02 -07:00

261 lines
9.9 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
americanToImplied,
getMajorOddsKey,
resolveSkill,
simulateMajor,
} from "../golf-simulator";
import type { GolfSkillsRecord } from "~/models/golf-skills";
// ─── americanToImplied ────────────────────────────────────────────────────────
describe("americanToImplied", () => {
it("converts positive (underdog) American odds correctly", () => {
// +400 → 100 / (400 + 100) = 0.2
expect(americanToImplied(400)).toBeCloseTo(0.2, 5);
// +100 → 100 / 200 = 0.5
expect(americanToImplied(100)).toBeCloseTo(0.5, 5);
});
it("converts negative (favorite) American odds correctly", () => {
// -200 → 200 / 300 ≈ 0.6667
expect(americanToImplied(-200)).toBeCloseTo(0.6667, 3);
});
it("returns null for odds = 0", () => {
expect(americanToImplied(0)).toBeNull();
});
it("returns probability in (0, 1] for valid odds", () => {
const p = americanToImplied(200);
expect(p).not.toBeNull();
expect(p).toBeGreaterThan(0);
expect(p).toBeLessThanOrEqual(1);
});
});
// ─── getMajorOddsKey ──────────────────────────────────────────────────────────
describe("getMajorOddsKey", () => {
it("maps Masters names to mastersOdds", () => {
expect(getMajorOddsKey("The Masters")).toBe("mastersOdds");
expect(getMajorOddsKey("masters tournament")).toBe("mastersOdds");
});
it("maps PGA Championship to pgaChampionshipOdds", () => {
expect(getMajorOddsKey("PGA Championship")).toBe("pgaChampionshipOdds");
expect(getMajorOddsKey("pga championship 2025")).toBe("pgaChampionshipOdds");
});
it("maps US Open to usOpenOdds", () => {
expect(getMajorOddsKey("US Open")).toBe("usOpenOdds");
expect(getMajorOddsKey("U.S. Open Golf")).toBe("usOpenOdds");
expect(getMajorOddsKey("2025 US Open")).toBe("usOpenOdds");
});
it("maps Open Championship / British Open to openChampionshipOdds", () => {
expect(getMajorOddsKey("The Open Championship")).toBe("openChampionshipOdds");
expect(getMajorOddsKey("British Open")).toBe("openChampionshipOdds");
});
it("returns null for unrecognized names", () => {
expect(getMajorOddsKey("Ryder Cup")).toBeNull();
expect(getMajorOddsKey("Travelers Championship")).toBeNull();
});
});
// ─── resolveSkill ─────────────────────────────────────────────────────────────
function makeSkills(overrides: Partial<GolfSkillsRecord> = {}): GolfSkillsRecord {
return {
id: "skill-1",
participantId: "p1",
sportsSeasonId: "s1",
sgTotal: null,
datagolfRank: null,
mastersOdds: null,
usOpenOdds: null,
openChampionshipOdds: null,
pgaChampionshipOdds: null,
updatedAt: new Date(),
...overrides,
};
}
describe("resolveSkill", () => {
it("returns sgTotal when set, ignoring odds", () => {
const skills = makeSkills({ sgTotal: 2.5, mastersOdds: 400 });
expect(resolveSkill(skills, "mastersOdds")).toBe(2.5);
});
it("falls back to odds-derived skill when sgTotal is null", () => {
const skills = makeSkills({ sgTotal: null, mastersOdds: 400 });
const skill = resolveSkill(skills, "mastersOdds");
// +400 = 20% win prob; skill > 0 because 20% > 1/156 baseline
expect(skill).toBeGreaterThan(0);
});
it("returns 0 when no skills record", () => {
expect(resolveSkill(undefined, "mastersOdds")).toBe(0);
expect(resolveSkill(undefined, null)).toBe(0);
});
it("returns 0 when sgTotal is null and no matching odds key", () => {
const skills = makeSkills({ sgTotal: null, mastersOdds: 400 });
// oddsKey = null → no odds available for this major
expect(resolveSkill(skills, null)).toBe(0);
});
it("returns 0 when sgTotal is null and the odds column is null", () => {
const skills = makeSkills({ sgTotal: null, mastersOdds: null });
expect(resolveSkill(skills, "mastersOdds")).toBe(0);
});
it("better American odds → higher resolved skill", () => {
const skillGood = makeSkills({ sgTotal: null, mastersOdds: 200 }); // +200 = 33% win prob
const skillPoor = makeSkills({ sgTotal: null, mastersOdds: 2000 }); // +2000 = 4.8% win prob
const sg1 = resolveSkill(skillGood, "mastersOdds");
const sg2 = resolveSkill(skillPoor, "mastersOdds");
expect(sg1).toBeGreaterThan(sg2);
});
});
// ─── simulateMajor ────────────────────────────────────────────────────────────
function makeQPConfig(maxPlacement = 16): Map<number, number> {
// Default QP: 20, 14, 10, 8, 5, 5, 3, 3, 2, 2, 2, 2, 1, 1, 1, 1
const values = [20, 14, 10, 8, 5, 5, 3, 3, 2, 2, 2, 2, 1, 1, 1, 1];
const map = new Map<number, number>();
for (let i = 0; i < maxPlacement; i++) {
map.set(i + 1, values[i] ?? 0);
}
return map;
}
describe("simulateMajor", () => {
const qpConfig = makeQPConfig();
it("returns an entry for every tracked player", () => {
const players = [
{ id: "p1", strength: 2.0 },
{ id: "p2", strength: 1.0 },
];
const result = simulateMajor(players, 154, 1.0, qpConfig);
expect(result.has("p1")).toBe(true);
expect(result.has("p2")).toBe(true);
});
it("total QP awarded does not exceed sum of top-16 QP config", () => {
const players = Array.from({ length: 10 }, (_, i) => ({ id: `p${i}`, strength: 1.0 }));
const result = simulateMajor(players, 146, 1.0, qpConfig);
const totalQP = [...result.values()].reduce((s, v) => s + v, 0);
const maxQP = [...qpConfig.values()].reduce((s, v) => s + v, 0);
expect(totalQP).toBeLessThanOrEqual(maxQP);
});
it("all QP values are valid (>= 0 and in the config set or 0)", () => {
const players = [{ id: "p1", strength: 8.0 }, { id: "p2", strength: 1.0 }];
const result = simulateMajor(players, 154, 1.0, qpConfig);
const validQP = new Set([0, ...qpConfig.values()]);
for (const qp of result.values()) {
expect(validQP.has(qp)).toBe(true);
}
});
it("a stronger player wins more often than a weaker one (statistical)", () => {
const TRIALS = 5_000;
let eliteWins = 0;
const players = [
{ id: "elite", strength: 50.0 }, // very high strength
{ id: "average", strength: 1.0 },
];
const singleQP = new Map([[1, 20], [2, 14]]);
for (let i = 0; i < TRIALS; i++) {
const result = simulateMajor(players, 0, 1.0, singleQP);
if (result.get("elite") === 20) eliteWins++;
}
// Elite player should win at least 80% of the time with strength 50x average
expect(eliteWins / TRIALS).toBeGreaterThan(0.8);
});
it("works when tracked players outnumber field size (restCount = 0)", () => {
const players = Array.from({ length: 200 }, (_, i) => ({ id: `p${i}`, strength: 1.0 }));
const result = simulateMajor(players, 0, 1.0, qpConfig);
// All players should have an entry
expect(result.size).toBe(200);
// Only 16 can get QP
const scorers = [...result.values()].filter((v) => v > 0);
expect(scorers.length).toBeLessThanOrEqual(16);
});
it("when all players have equal strength, each wins approximately equally (statistical)", () => {
const TRIALS = 10_000;
const N = 3;
const wins: Record<string, number> = {};
const players = Array.from({ length: N }, (_, i) => {
const id = `p${i}`;
wins[id] = 0;
return { id, strength: 1.0 };
});
const singleQP = new Map([[1, 20]]);
for (let i = 0; i < TRIALS; i++) {
const result = simulateMajor(players, 0, 1.0, singleQP);
for (const [id, qp] of result) {
if (qp === 20) wins[id]++;
}
}
// Each of 3 equal players should win ~33% ± 5%
for (const id of Object.keys(wins)) {
expect(wins[id] / TRIALS).toBeGreaterThan(0.27);
expect(wins[id] / TRIALS).toBeLessThan(0.39);
}
});
});
// ─── Monte Carlo property test ─────────────────────────────────────────────────
describe("simulateMajor Monte Carlo properties", () => {
it("better SG player wins the head-to-head more often (no rest-of-field)", () => {
// With no rest-of-field, the win rate is purely determined by strength ratio:
// P(elite wins) = exp(0.7 * 3.0) / (exp(0.7 * 3.0) + exp(0.7 * 1.5))
// = 8.17 / (8.17 + 2.86) ≈ 74%
const TRIALS = 3_000;
const qpConfig = new Map([[1, 20], [2, 14]]);
let eliteFirst = 0;
for (let i = 0; i < TRIALS; i++) {
const players = [
{ id: "elite", strength: Math.exp(0.7 * 3.0) }, // SG = 3.0
{ id: "good", strength: Math.exp(0.7 * 1.5) }, // SG = 1.5
];
const result = simulateMajor(players, 0, 1.0, qpConfig);
if (result.get("elite") === 20) eliteFirst++;
}
const eliteWinRate = eliteFirst / TRIALS;
// Elite player wins ~74% in a head-to-head; allow generous margin for randomness
expect(eliteWinRate).toBeGreaterThan(0.65);
});
it("in a full 156-player field, a player with strength 8 wins ~5% of the time", () => {
// Calibration check: strength 8 vs 155 opponents at strength 1 → 8 / (8 + 155) ≈ 4.9%
const TRIALS = 5_000;
const qpConfig = new Map([[1, 20]]);
let eliteWins = 0;
for (let i = 0; i < TRIALS; i++) {
const players = [{ id: "elite", strength: 8 }];
const result = simulateMajor(players, 155, 1.0, qpConfig);
if (result.get("elite") === 20) eliteWins++;
}
const winRate = eliteWins / TRIALS;
// Should be approximately 4.9%; allow ±3% tolerance
expect(winRate).toBeGreaterThan(0.02);
expect(winRate).toBeLessThan(0.09);
});
});