* 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>
261 lines
9.9 KiB
TypeScript
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);
|
|
});
|
|
});
|