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>
This commit is contained in:
Chris Parsons 2026-03-24 21:46:02 -07:00 committed by GitHub
parent b81089879c
commit e62e9554c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 5665 additions and 43 deletions

20
app/lib/fuzzy-match.ts Normal file
View file

@ -0,0 +1,20 @@
/** Normalize a display name for comparison: lowercase, strip punctuation, collapse whitespace. */
export function normalizeName(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, ' ').trim();
}
function bigrams(s: string): string[] {
const result: string[] = [];
for (let i = 0; i < s.length - 1; i++) result.push(s.slice(i, i + 2));
return result;
}
/** Bigram Dice coefficient — returns similarity in [0, 1]. */
export function diceCoefficient(a: string, b: string): number {
if (a === b) return 1;
if (a.length < 2 || b.length < 2) return 0;
const bigA = bigrams(a);
const bigB = new Set(bigrams(b));
const intersection = bigA.filter((bg) => bigB.has(bg)).length;
return (2 * intersection) / (bigA.length + bigB.size);
}

144
app/models/golf-skills.ts Normal file
View file

@ -0,0 +1,144 @@
/**
* Model for Participant Golf Skills
*
* Manages golf-specific skill data for qualifying-points golf seasons.
* Primary metric: SG: Total (strokes gained per round vs. field average).
* Optional per-major American odds allow major-specific probability blending.
*/
import { database } from "~/database/context";
import { participantGolfSkills, participants } from "~/database/schema";
import { eq, sql } from "drizzle-orm";
export interface GolfSkillsRecord {
id: string;
participantId: string;
sportsSeasonId: string;
sgTotal: number | null;
datagolfRank: number | null;
mastersOdds: number | null;
usOpenOdds: number | null;
openChampionshipOdds: number | null;
pgaChampionshipOdds: number | null;
updatedAt: Date;
}
export interface GolfSkillsWithName extends GolfSkillsRecord {
participantName: string;
}
export interface GolfSkillsInput {
participantId: string;
sportsSeasonId: string;
sgTotal?: number | null;
datagolfRank?: number | null;
mastersOdds?: number | null;
usOpenOdds?: number | null;
openChampionshipOdds?: number | null;
pgaChampionshipOdds?: number | null;
}
/**
* Get all golf skill records for a sports season, joined with participant names.
* Returns one record per participant (participants with no record are excluded).
*/
export async function getGolfSkillsForSeason(
sportsSeasonId: string
): Promise<GolfSkillsWithName[]> {
const db = database();
const rows = await db
.select({
id: participantGolfSkills.id,
participantId: participantGolfSkills.participantId,
sportsSeasonId: participantGolfSkills.sportsSeasonId,
sgTotal: participantGolfSkills.sgTotal,
datagolfRank: participantGolfSkills.datagolfRank,
mastersOdds: participantGolfSkills.mastersOdds,
usOpenOdds: participantGolfSkills.usOpenOdds,
openChampionshipOdds: participantGolfSkills.openChampionshipOdds,
pgaChampionshipOdds: participantGolfSkills.pgaChampionshipOdds,
updatedAt: participantGolfSkills.updatedAt,
participantName: participants.name,
})
.from(participantGolfSkills)
.innerJoin(participants, eq(participantGolfSkills.participantId, participants.id))
.where(eq(participantGolfSkills.sportsSeasonId, sportsSeasonId))
.orderBy(participants.name);
return rows.map((r) => ({
...r,
sgTotal: r.sgTotal !== null ? Number(r.sgTotal) : null,
}));
}
/**
* Returns a Map from participantId GolfSkillsRecord for use in the simulator.
* Participants with no record are absent from the map (simulator falls back to SG = 0).
*/
export async function getGolfSkillsMap(
sportsSeasonId: string
): Promise<Map<string, GolfSkillsRecord>> {
const db = database();
const rows = await db
.select()
.from(participantGolfSkills)
.where(eq(participantGolfSkills.sportsSeasonId, sportsSeasonId));
return new Map(rows.map((r) => [
r.participantId,
{
...r,
sgTotal: r.sgTotal !== null ? Number(r.sgTotal) : null,
},
]));
}
/**
* Upsert golf skill ratings for a batch of participants.
* Uses INSERT ON CONFLICT DO UPDATE so all columns are overwritten atomically.
*/
export async function batchUpsertGolfSkills(
inputs: GolfSkillsInput[]
): Promise<void> {
if (inputs.length === 0) return;
const db = database();
const now = new Date();
await db
.insert(participantGolfSkills)
.values(
inputs.map(({
participantId,
sportsSeasonId,
sgTotal,
datagolfRank,
mastersOdds,
usOpenOdds,
openChampionshipOdds,
pgaChampionshipOdds,
}) => ({
participantId,
sportsSeasonId,
// decimal columns are stored/passed as strings in Drizzle
sgTotal: sgTotal !== null && sgTotal !== undefined ? String(sgTotal) : null,
datagolfRank: datagolfRank ?? null,
mastersOdds: mastersOdds ?? null,
usOpenOdds: usOpenOdds ?? null,
openChampionshipOdds: openChampionshipOdds ?? null,
pgaChampionshipOdds: pgaChampionshipOdds ?? null,
updatedAt: now,
}))
)
.onConflictDoUpdate({
target: [participantGolfSkills.participantId, participantGolfSkills.sportsSeasonId],
set: {
sgTotal: sql`excluded.sg_total`,
datagolfRank: sql`excluded.datagolf_rank`,
mastersOdds: sql`excluded.masters_odds`,
usOpenOdds: sql`excluded.us_open_odds`,
openChampionshipOdds: sql`excluded.open_championship_odds`,
pgaChampionshipOdds: sql`excluded.pga_championship_odds`,
updatedAt: sql`excluded.updated_at`,
},
});
}

View file

@ -89,6 +89,10 @@ export default [
"sports-seasons/:id/surface-elo", "sports-seasons/:id/surface-elo",
"routes/admin.sports-seasons.$id.surface-elo.tsx" "routes/admin.sports-seasons.$id.surface-elo.tsx"
), ),
route(
"sports-seasons/:id/golf-skills",
"routes/admin.sports-seasons.$id.golf-skills.tsx"
),
route( route(
"sports-seasons/:id/standings", "sports-seasons/:id/standings",
"routes/admin.sports-seasons.$id.standings.tsx" "routes/admin.sports-seasons.$id.standings.tsx"

View file

@ -0,0 +1,699 @@
import { Form, redirect, useLoaderData, useActionData, useNavigation, useFetcher } from 'react-router';
import type { Route } from './+types/admin.sports-seasons.$id.golf-skills';
import { logger } from '~/lib/logger';
import { findSportsSeasonById, updateSportsSeason } from '~/models/sports-season';
import { findParticipantsBySportsSeasonId, createParticipant } from '~/models/participant';
import { batchUpsertParticipantEVs } from '~/models/participant-expected-value';
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
import { getGolfSkillsForSeason, batchUpsertGolfSkills } from '~/models/golf-skills';
import { getSimulator, type SimulatorType } from '~/services/simulations/registry';
import { calculateEV, type ScoringRules } from '~/services/ev-calculator';
import { recalculateStandings } from '~/models/scoring-calculator';
import { database } from '~/database/context';
import * as schema from '~/database/schema';
import { eq } from 'drizzle-orm';
import { Button } from '~/components/ui/button';
import { Input } from '~/components/ui/input';
import { Label } from '~/components/ui/label';
import { Textarea } from '~/components/ui/textarea';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '~/components/ui/card';
import { useEffect, useRef, useState } from 'react';
import { Loader2, CheckCircle2, AlertCircle, UserPlus } from 'lucide-react';
import { normalizeName, diceCoefficient } from '~/lib/fuzzy-match';
const DEFAULT_SCORING_RULES: ScoringRules = {
pointsFor1st: 100,
pointsFor2nd: 70,
pointsFor3rd: 45,
pointsFor4th: 45,
pointsFor5th: 20,
pointsFor6th: 20,
pointsFor7th: 20,
pointsFor8th: 20,
};
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Golf Skills — ${data?.sportsSeason?.name ?? 'Sports Season'} - Brackt Admin` }];
}
export async function loader({ params }: Route.LoaderArgs) {
const sportsSeasonId = params.id;
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
if (!sportsSeason) {
throw new Response('Sports season not found', { status: 404 });
}
const [participants, existingSkills] = await Promise.all([
findParticipantsBySportsSeasonId(sportsSeasonId),
getGolfSkillsForSeason(sportsSeasonId),
]);
const skillsMap: Record<
string,
{ sgTotal: string; datagolfRank: string; mastersOdds: string; usOpenOdds: string; openChampionshipOdds: string; pgaChampionshipOdds: string }
> = {};
for (const r of existingSkills) {
skillsMap[r.participantId] = {
sgTotal: r.sgTotal !== null ? String(r.sgTotal) : '',
datagolfRank: r.datagolfRank !== null ? String(r.datagolfRank) : '',
mastersOdds: r.mastersOdds !== null ? String(r.mastersOdds) : '',
usOpenOdds: r.usOpenOdds !== null ? String(r.usOpenOdds) : '',
openChampionshipOdds: r.openChampionshipOdds !== null ? String(r.openChampionshipOdds) : '',
pgaChampionshipOdds: r.pgaChampionshipOdds !== null ? String(r.pgaChampionshipOdds) : '',
};
}
return { sportsSeason, participants, skillsMap };
}
type ActionData =
| { intent: 'create-participant'; success: true; participant: { id: string; name: string } }
| { intent: 'create-participant'; success: false; message: string }
| { success?: boolean; message?: string };
export async function action({ request, params }: Route.ActionArgs) {
const sportsSeasonId = params.id;
const formData = await request.formData();
const intent = formData.get('intent');
if (intent === 'create-participant') {
const name = (formData.get('name') as string)?.trim();
if (!name) {
return { intent: 'create-participant', success: false, message: 'Name is required' } satisfies ActionData;
}
const participant = await createParticipant({ sportsSeasonId, name });
return {
intent: 'create-participant',
success: true,
participant: { id: participant.id, name: participant.name },
} satisfies ActionData;
}
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
if (!sportsSeason) {
return { success: false, message: 'Sports season not found' };
}
if (!sportsSeason.sport?.simulatorType) {
return { success: false, message: 'This sport has no simulator type configured.' };
}
if (sportsSeason.simulationStatus === 'running') {
return { success: false, message: 'A simulation is already running. Please wait.' };
}
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
// Parse golf skill fields: sgTotal_{id}, datagolfRank_{id}, mastersOdds_{id}, etc.
const skillInputs = participants
.map((p) => ({
participantId: p.id,
sportsSeasonId,
sgTotal: parseDecimalOrNull(formData.get(`sgTotal_${p.id}`) as string),
datagolfRank: parseIntOrNull(formData.get(`datagolfRank_${p.id}`) as string),
mastersOdds: parseIntOrNull(formData.get(`mastersOdds_${p.id}`) as string),
usOpenOdds: parseIntOrNull(formData.get(`usOpenOdds_${p.id}`) as string),
openChampionshipOdds: parseIntOrNull(formData.get(`openChampionshipOdds_${p.id}`) as string),
pgaChampionshipOdds: parseIntOrNull(formData.get(`pgaChampionshipOdds_${p.id}`) as string),
}))
.filter((r) =>
r.sgTotal !== null ||
r.datagolfRank !== null ||
r.mastersOdds !== null ||
r.usOpenOdds !== null ||
r.openChampionshipOdds !== null ||
r.pgaChampionshipOdds !== null
);
if (skillInputs.length === 0) {
return { success: false, message: 'Please enter at least one skill rating.' };
}
await batchUpsertGolfSkills(skillInputs);
// Auto-run simulation after saving
await updateSportsSeason(sportsSeasonId, { simulationStatus: 'running' });
try {
const simulator = getSimulator(sportsSeason.sport.simulatorType as SimulatorType);
const results = await simulator.simulate(sportsSeasonId);
if (results.length === 0) {
throw new Error('Simulation returned no results.');
}
const simulatedIds = new Set(results.map((r) => r.participantId));
const ZERO_PROBS = {
probFirst: 0, probSecond: 0, probThird: 0, probFourth: 0,
probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0,
};
const evInputs = [
...results.map((r) => ({
participantId: r.participantId,
sportsSeasonId,
probabilities: r.probabilities,
scoringRules: DEFAULT_SCORING_RULES,
source: 'performance_model' as const,
})),
...participants
.filter((p) => !simulatedIds.has(p.id))
.map((p) => ({
participantId: p.id,
sportsSeasonId,
probabilities: ZERO_PROBS,
scoringRules: DEFAULT_SCORING_RULES,
source: 'performance_model' as const,
})),
];
await batchUpsertParticipantEVs(evInputs);
// Refresh projected points in team standings
const seasonSports = await database().query.seasonSports.findMany({
where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId),
});
await Promise.all(seasonSports.map(({ seasonId }) => recalculateStandings(seasonId)));
// EV snapshot
const today = new Date().toISOString().slice(0, 10);
await batchUpsertParticipantEvSnapshots(
results.map((r) => ({
participantId: r.participantId,
sportsSeasonId,
snapshotDate: today,
probFirst: r.probabilities.probFirst,
probSecond: r.probabilities.probSecond,
probThird: r.probabilities.probThird,
probFourth: r.probabilities.probFourth,
probFifth: r.probabilities.probFifth,
probSixth: r.probabilities.probSixth,
probSeventh: r.probabilities.probSeventh,
probEighth: r.probabilities.probEighth,
calculatedEV: calculateEV(r.probabilities, DEFAULT_SCORING_RULES),
source: r.source,
}))
);
await updateSportsSeason(sportsSeasonId, { simulationStatus: 'idle' });
} catch (error) {
await updateSportsSeason(sportsSeasonId, { simulationStatus: 'failed' });
logger.error('Error running golf simulation:', error);
return {
success: false,
message: error instanceof Error ? error.message : 'Simulation failed',
};
}
return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`);
}
function parseIntOrNull(val: string | null | undefined): number | null {
if (!val || val.trim() === '') return null;
const n = parseInt(val.trim(), 10);
return isNaN(n) ? null : n;
}
function parseDecimalOrNull(val: string | null | undefined): number | null {
if (!val || val.trim() === '') return null;
const n = parseFloat(val.trim());
return isNaN(n) ? null : n;
}
type SkillValues = Record<
string,
{ sgTotal: string; datagolfRank: string; mastersOdds: string; usOpenOdds: string; openChampionshipOdds: string; pgaChampionshipOdds: string }
>;
interface ParsedSkill {
sgTotal: number | null;
datagolfRank: number | null;
mastersOdds: number | null;
usOpenOdds: number | null;
openChampionshipOdds: number | null;
pgaChampionshipOdds: number | null;
}
interface MatchedItem extends ParsedSkill {
participantId: string;
name: string;
inputName: string;
}
interface Suggestion {
participantId: string;
name: string;
score: number;
}
interface UnmatchedItem extends ParsedSkill {
inputName: string;
suggestions: Suggestion[];
}
interface ParseResults {
matched: MatchedItem[];
unmatched: UnmatchedItem[];
}
type LocalParticipant = { id: string; name: string };
export default function AdminSportsSeasonGolfSkills() {
const { sportsSeason, participants: loaderParticipants, skillsMap } = useLoaderData<typeof loader>();
const actionData = useActionData<ActionData>();
const navigation = useNavigation();
const createFetcher = useFetcher<ActionData>();
const [localParticipants, setLocalParticipants] = useState<LocalParticipant[]>(loaderParticipants);
const [skillValues, setSkillValues] = useState<SkillValues>(() => {
const initial: SkillValues = {};
loaderParticipants.forEach((p) => {
const existing = skillsMap[p.id];
initial[p.id] = existing ?? {
sgTotal: '', datagolfRank: '', mastersOdds: '', usOpenOdds: '',
openChampionshipOdds: '', pgaChampionshipOdds: '',
};
});
return initial;
});
const [bulkText, setBulkText] = useState('');
const [parseResults, setParseResults] = useState<ParseResults | null>(null);
const pendingSkillsByName = useRef<Map<string, ParsedSkill>>(new Map());
useEffect(() => {
if (
createFetcher.state !== 'idle' ||
!createFetcher.data ||
!('intent' in createFetcher.data) ||
createFetcher.data.intent !== 'create-participant' ||
!createFetcher.data.success
) return;
const { participant } = createFetcher.data as Extract<ActionData, { intent: 'create-participant'; success: true }>;
setLocalParticipants((prev) => {
if (prev.some((p) => p.id === participant.id)) return prev;
return [...prev, participant];
});
const pending = pendingSkillsByName.current.get(participant.name);
if (pending) {
setSkillValues((prev) => ({
...prev,
[participant.id]: {
sgTotal: pending.sgTotal !== null ? String(pending.sgTotal) : '',
datagolfRank: pending.datagolfRank !== null ? String(pending.datagolfRank) : '',
mastersOdds: pending.mastersOdds !== null ? String(pending.mastersOdds) : '',
usOpenOdds: pending.usOpenOdds !== null ? String(pending.usOpenOdds) : '',
openChampionshipOdds: pending.openChampionshipOdds !== null ? String(pending.openChampionshipOdds) : '',
pgaChampionshipOdds: pending.pgaChampionshipOdds !== null ? String(pending.pgaChampionshipOdds) : '',
},
}));
pendingSkillsByName.current.delete(participant.name);
}
setParseResults((prev) => {
if (!prev) return prev;
return { ...prev, unmatched: prev.unmatched.filter((u) => u.inputName !== participant.name) };
});
}, [createFetcher.state, createFetcher.data]);
function findParticipantMatch(inputName: string, pool: LocalParticipant[]) {
const normalizedInput = normalizeName(inputName);
const normalized = pool.map((p) => ({ p, n: normalizeName(p.name) }));
const exact = normalized.find(({ n }) => n === normalizedInput);
if (exact) return exact.p;
const contains = normalized.find(({ n }) => n.includes(normalizedInput) || normalizedInput.includes(n));
if (contains) return contains.p;
const inputWords = normalizedInput.split(' ').filter((w) => w.length > 2);
const overlap = normalized.find(({ n }) => {
const pWords = n.split(' ').filter((w) => w.length > 2);
const shared = inputWords.filter((w) => pWords.includes(w));
return shared.length > 0 && shared.length >= Math.min(inputWords.length, pWords.length) * 0.5;
});
return overlap?.p ?? null;
}
function getFuzzySuggestions(inputName: string, pool: LocalParticipant[], exclude: Set<string>): Suggestion[] {
const normalizedInput = normalizeName(inputName);
return pool
.filter((p) => !exclude.has(p.id))
.map((p) => ({ participantId: p.id, name: p.name, score: diceCoefficient(normalizedInput, normalizeName(p.name)) }))
.filter((s) => s.score >= 0.3)
.toSorted((a, b) => b.score - a.score)
.slice(0, 3);
}
/**
* Parse bulk import text.
* Format (CSV, one per line): Player Name, SG_Total
* Optional additional columns: Player Name, SG_Total, Masters, USOpen, TheOpen, PGA
*/
function parseBulkText() {
const lines = bulkText.split('\n');
const matched: MatchedItem[] = [];
const unmatched: UnmatchedItem[] = [];
const seen = new Set<string>();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const parts = trimmed.split(/[,\t]/).map((s) => s.trim());
if (parts.length < 2) continue;
const inputName = parts[0];
const sgTotal = parts[1] ? parseDecimalOrNull(parts[1]) : null;
const mastersOdds = parts[2] ? parseIntOrNull(parts[2]) : null;
const usOpenOdds = parts[3] ? parseIntOrNull(parts[3]) : null;
const openChampionshipOdds = parts[4] ? parseIntOrNull(parts[4]) : null;
const pgaChampionshipOdds = parts[5] ? parseIntOrNull(parts[5]) : null;
if (sgTotal === null && mastersOdds === null) continue; // no useful data
const skill: ParsedSkill = {
sgTotal,
datagolfRank: null,
mastersOdds,
usOpenOdds,
openChampionshipOdds,
pgaChampionshipOdds,
};
const participant = findParticipantMatch(inputName, localParticipants);
if (participant && !seen.has(participant.id)) {
seen.add(participant.id);
matched.push({ participantId: participant.id, name: participant.name, inputName, ...skill });
} else {
const suggestions = getFuzzySuggestions(inputName, localParticipants, seen);
unmatched.push({ inputName, suggestions, ...skill });
}
}
setParseResults({ matched, unmatched });
}
function assignSuggestion(item: UnmatchedItem, suggestion: Suggestion) {
setParseResults((prev) => {
if (!prev) return prev;
return {
matched: [
...prev.matched,
{ participantId: suggestion.participantId, name: suggestion.name, inputName: item.inputName,
sgTotal: item.sgTotal, datagolfRank: item.datagolfRank, mastersOdds: item.mastersOdds,
usOpenOdds: item.usOpenOdds, openChampionshipOdds: item.openChampionshipOdds,
pgaChampionshipOdds: item.pgaChampionshipOdds },
],
unmatched: prev.unmatched.filter((u) => u.inputName !== item.inputName),
};
});
}
function handleCreateParticipant(item: UnmatchedItem) {
pendingSkillsByName.current.set(item.inputName, {
sgTotal: item.sgTotal, datagolfRank: item.datagolfRank, mastersOdds: item.mastersOdds,
usOpenOdds: item.usOpenOdds, openChampionshipOdds: item.openChampionshipOdds,
pgaChampionshipOdds: item.pgaChampionshipOdds,
});
const fd = new FormData();
fd.set('intent', 'create-participant');
fd.set('name', item.inputName);
createFetcher.submit(fd, { method: 'post' });
}
function applyMatches() {
if (!parseResults) return;
const newValues = { ...skillValues };
for (const m of parseResults.matched) {
newValues[m.participantId] = {
sgTotal: m.sgTotal !== null ? String(m.sgTotal) : '',
datagolfRank: m.datagolfRank !== null ? String(m.datagolfRank) : '',
mastersOdds: m.mastersOdds !== null ? String(m.mastersOdds) : '',
usOpenOdds: m.usOpenOdds !== null ? String(m.usOpenOdds) : '',
openChampionshipOdds: m.openChampionshipOdds !== null ? String(m.openChampionshipOdds) : '',
pgaChampionshipOdds: m.pgaChampionshipOdds !== null ? String(m.pgaChampionshipOdds) : '',
};
}
setSkillValues(newValues);
setParseResults(null);
setBulkText('');
}
const setField = (participantId: string, field: keyof SkillValues[string], value: string) => {
setSkillValues((prev) => ({
...prev,
[participantId]: { ...prev[participantId], [field]: value },
}));
};
const isSubmitting = navigation.state === 'submitting';
return (
<div className="container mx-auto py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">Golf Skills</h1>
<p className="text-muted-foreground">
{sportsSeason.sport.name} {sportsSeason.name}
</p>
</div>
{/* Bulk Import */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Bulk Import</CardTitle>
<CardDescription>
Paste one player per line. Format:{' '}
<code>Player Name, SG_Total</code>
{' '}(optional extras: <code>Masters odds, US Open odds, Open Championship odds, PGA odds</code>).
American odds format (e.g. 400 for +400). Player names are fuzzy-matched to participants.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Textarea
placeholder={`Scottie Scheffler, 2.91\nRory McIlroy, 2.45, 450, 600, 350, 800\nXander Schauffele, 2.12`}
value={bulkText}
onChange={(e) => { setBulkText(e.target.value); setParseResults(null); }}
rows={8}
className="font-mono text-sm"
/>
<Button type="button" variant="outline" onClick={parseBulkText} disabled={!bulkText.trim()}>
Parse Players
</Button>
{parseResults && (
<div className="space-y-3">
{parseResults.matched.length > 0 && (
<div>
<div className="flex items-center gap-2 text-sm font-medium text-emerald-400 mb-2">
<CheckCircle2 className="h-4 w-4" />
Matched ({parseResults.matched.length})
</div>
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/10 divide-y divide-emerald-500/20 text-sm">
{parseResults.matched.map((m) => (
<div key={m.participantId} className="flex justify-between px-3 py-1.5 gap-4">
<span className="text-muted-foreground">{m.inputName}</span>
<span className="font-medium">
{m.name} SG: {m.sgTotal ?? '—'}
{m.mastersOdds ? ` | Masters: +${m.mastersOdds}` : ''}
</span>
</div>
))}
</div>
</div>
)}
{parseResults.unmatched.length > 0 && (
<div>
<div className="flex items-center gap-2 text-sm font-medium text-amber-400 mb-2">
<AlertCircle className="h-4 w-4" />
Not matched ({parseResults.unmatched.length})
</div>
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 divide-y divide-amber-500/20 text-sm">
{parseResults.unmatched.map((u) => {
const isCreating =
createFetcher.state !== 'idle' &&
pendingSkillsByName.current.has(u.inputName);
return (
<div key={u.inputName} className="px-3 py-2 space-y-2">
<div className="font-medium text-amber-300">{u.inputName}</div>
{u.suggestions.length > 0 ? (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Did you mean</div>
{u.suggestions.map((s) => (
<div key={s.participantId} className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="h-6 text-xs px-2"
onClick={() => assignSuggestion(u, s)}
>
Use this
</Button>
<span className="text-muted-foreground">{s.name}</span>
<span className="text-xs text-muted-foreground/60">
({Math.round(s.score * 100)}% match)
</span>
</div>
))}
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 text-xs px-2 text-amber-400 hover:text-amber-300"
disabled={isCreating}
onClick={() => handleCreateParticipant(u)}
>
{isCreating ? (
<><Loader2 className="mr-1 h-3 w-3 animate-spin" /> Creating</>
) : (
<><UserPlus className="mr-1 h-3 w-3" /> Create new participant</>
)}
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">No close matches found.</span>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 text-xs px-2"
disabled={isCreating}
onClick={() => handleCreateParticipant(u)}
>
{isCreating ? (
<><Loader2 className="mr-1 h-3 w-3 animate-spin" /> Creating</>
) : (
<><UserPlus className="mr-1 h-3 w-3" /> Create participant</>
)}
</Button>
</div>
)}
</div>
);
})}
</div>
</div>
)}
{parseResults.matched.length > 0 && (
<Button type="button" onClick={applyMatches}>
Apply {parseResults.matched.length} matched players to form
</Button>
)}
</div>
)}
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<Card>
<CardHeader>
<CardTitle>Player Golf Skills</CardTitle>
<CardDescription>
Enter SG: Total (strokes gained per round vs. field average, e.g. 2.5) and
optionally per-major American odds. Saving will run the simulation and update
expected values. Leave fields blank to use field-average (SG = 0).
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-4">
<div className="space-y-1">
<div className="grid grid-cols-7 gap-2 text-xs font-semibold text-muted-foreground uppercase tracking-wide pb-1 border-b">
<span className="col-span-2">Player</span>
<span>SG Total</span>
<span>Masters</span>
<span>US Open</span>
<span>The Open</span>
<span>PGA</span>
</div>
{localParticipants.map((p) => (
<div key={p.id} className="grid grid-cols-7 gap-2 items-center py-1">
<Label htmlFor={`sgTotal_${p.id}`} className="col-span-2 truncate text-sm">
{p.name}
</Label>
<Input
type="number"
step="0.01"
id={`sgTotal_${p.id}`}
name={`sgTotal_${p.id}`}
placeholder="2.50"
value={skillValues[p.id]?.sgTotal ?? ''}
onChange={(e) => setField(p.id, 'sgTotal', e.target.value)}
className="h-8 text-sm"
/>
{(['mastersOdds', 'usOpenOdds', 'openChampionshipOdds', 'pgaChampionshipOdds'] as const).map((field) => (
<Input
key={field}
type="number"
name={`${field}_${p.id}`}
placeholder="400"
value={skillValues[p.id]?.[field] ?? ''}
onChange={(e) => setField(p.id, field, e.target.value)}
className="h-8 text-sm"
/>
))}
{/* Hidden rank field */}
<input type="hidden" name={`datagolfRank_${p.id}`} value={skillValues[p.id]?.datagolfRank ?? ''} />
</div>
))}
</div>
{actionData && !('intent' in actionData) && !actionData.success && actionData.message && (
<div className="text-sm text-destructive">{actionData.message}</div>
)}
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isSubmitting ? 'Saving & Running Simulation...' : 'Save Skills & Run Simulation'}
</Button>
</Form>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>How It Works</CardTitle>
</CardHeader>
<CardContent className="text-sm space-y-2 text-muted-foreground">
<ol className="list-decimal list-inside space-y-2">
<li>Enter SG: Total for each player (strokes gained per round vs. average field)</li>
<li>Optionally enter American odds per major (e.g. 400 for +400) for players without SG data</li>
<li>For each of 10,000 simulations, simulate each incomplete major using a Plackett-Luce model</li>
<li>A synthetic rest-of-field fills the 156-player field at SG = 0 (average)</li>
<li>QP awarded per finishing position per the season QP config (1st = 20, 2nd = 14, etc.)</li>
<li>Players ranked by total QP across all 4 majors</li>
<li>Placement probabilities (1st8th) determine expected fantasy value</li>
</ol>
<div className="mt-4 text-xs space-y-1">
<div className="font-medium text-foreground">SG: Total reference points:</div>
<div>+3.0 Elite (5% win prob per major)</div>
<div>+2.0 Very good (2.6% win prob)</div>
<div>+1.0 Good (1.3% win prob)</div>
<div>0.0 Field average (0.6% win prob)</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View file

@ -28,6 +28,7 @@ import {
} from '~/components/ui/card'; } from '~/components/ui/card';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Loader2, CheckCircle2, AlertCircle, UserPlus } from 'lucide-react'; import { Loader2, CheckCircle2, AlertCircle, UserPlus } from 'lucide-react';
import { normalizeName, diceCoefficient } from '~/lib/fuzzy-match';
const DEFAULT_SCORING_RULES: ScoringRules = { const DEFAULT_SCORING_RULES: ScoringRules = {
pointsFor1st: 100, pointsFor1st: 100,
@ -206,25 +207,6 @@ function parseIntOrNull(val: string | null | undefined): number | null {
return isNaN(n) || n <= 0 ? null : n; return isNaN(n) || n <= 0 ? null : n;
} }
function normalizeName(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, ' ').trim();
}
// Bigram Dice coefficient for fuzzy name matching
function bigrams(s: string): string[] {
const result: string[] = [];
for (let i = 0; i < s.length - 1; i++) result.push(s.slice(i, i + 2));
return result;
}
function diceCoefficient(a: string, b: string): number {
if (a === b) return 1;
if (a.length < 2 || b.length < 2) return 0;
const bigA = bigrams(a);
const bigB = new Set(bigrams(b));
const intersection = bigA.filter((bg) => bigB.has(bg)).length;
return (2 * intersection) / (bigA.length + bigB.size);
}
type EloValues = Record<string, { ranking: string; hard: string; clay: string; grass: string }>; type EloValues = Record<string, { ranking: string; hard: string; clay: string; grass: string }>;

View file

@ -547,6 +547,16 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
Surface Elo Surface Elo
</Button> </Button>
)} )}
{sportsSeason.sport?.simulatorType === "golf_qualifying_points" && (
<Button
size="sm"
variant="outline"
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/golf-skills`)}
>
<Calculator className="mr-2 h-4 w-4" />
Golf Skills
</Button>
)}
{simulatorInfo && ( {simulatorInfo && (
<Form method="post" action={`/admin/sports-seasons/${sportsSeason.id}/simulate`}> <Form method="post" action={`/admin/sports-seasons/${sportsSeason.id}/simulate`}>
<Button <Button

View file

@ -206,11 +206,17 @@ export default function EditSport({ loaderData, actionData }: Route.ComponentPro
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none">No simulator</SelectItem> <SelectItem value="none">No simulator</SelectItem>
{SIMULATOR_TYPES.map((type) => ( {[...SIMULATOR_TYPES]
<SelectItem key={type} value={type}> .toSorted((a, b) => {
{getSimulatorInfo(type)?.name ?? type} const nameA = getSimulatorInfo(a)?.name ?? a;
</SelectItem> const nameB = getSimulatorInfo(b)?.name ?? b;
))} return nameA.localeCompare(nameB);
})
.map((type) => (
<SelectItem key={type} value={type}>
{getSimulatorInfo(type)?.name ?? type}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">

View file

@ -0,0 +1,261 @@
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);
});
});

View file

@ -1,32 +1,299 @@
/** /**
* Golf / Qualifying Points Simulator * Golf / Qualifying Points Simulator
* *
* TODO: Port the Python golf/majors simulator here. * Monte Carlo simulation of the 4 golf majors using a Plackett-Luce ranking model.
* *
* Input data available: * Algorithm:
* - Current QP standings: getQPStandings(sportsSeasonId) * 1. Load participants and their actual QP from completed majors.
* Returns { participant.id, participant.name, totalQualifyingPoints, eventsScored } * 2. For each incomplete major, build a simulated field of FIELD_SIZE players:
* - Remaining majors: sportsSeason.totalMajors - sportsSeason.majorsCompleted * - Tracked participants: strength = exp(PL_BETA × SG_Total)
* - Per-major QP config: qualifyingPointConfig table (points per placement for each major) * - 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 1st8th placement counts.
* 6. Return normalized SimulationResult[].
* *
* Expected output: SimulationResult[] one entry per participant with * Strength calibration (PL_BETA = 1.5, FIELD_SIZE = 156):
* probabilities (01) for finishing 1st through 8th in the final QP standings. * 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)
* *
* Algorithm sketch (replace with the Python model logic): * Per-major odds (optional):
* 1. Get current QP totals for all participants * If American odds are stored for this major and a player has no SG: Total,
* 2. For each remaining major, model each participant's probability of * the odds are converted to an SG-equivalent skill for that major.
* finishing at each placement (using world rankings, recent form, etc.) * If SG: Total is available it always takes precedence.
* 3. Monte Carlo: simulate remaining majors N times, add QP, tally final standings *
* 4. Convert tally counts probabilities * 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"; 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 { export class GolfSimulator implements Simulator {
async simulate(_sportsSeasonId: string): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
throw new Error( const db = database();
"GolfSimulator not yet implemented. " +
"Port the Python golf/majors model to TypeScript and implement this method." // 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",
}));
} }
} }

View file

@ -65,7 +65,7 @@ const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => Simul
create: () => new AutoRacingSimulator(INDYCAR_RACE_POINTS, "indycar_standings_model"), create: () => new AutoRacingSimulator(INDYCAR_RACE_POINTS, "indycar_standings_model"),
}, },
golf_qualifying_points: { golf_qualifying_points: {
info: { name: "Qualifying Points Model", description: "Simulates remaining majors using current QP standings and futures odds" }, info: { name: "Golf Qualifying Points Monte Carlo", description: "Simulates remaining majors using a Plackett-Luce model with SG: Total ratings. Awards QP by finishing position; ranks players by total QP across all 4 majors." },
create: () => new GolfSimulator(), create: () => new GolfSimulator(),
}, },
playoff_bracket: { playoff_bracket: {

View file

@ -1106,3 +1106,43 @@ export const participantSurfaceElosRelations = relations(participantSurfaceElos,
references: [sportsSeasons.id], references: [sportsSeasons.id],
}), }),
})); }));
// ─── Participant Golf Skills ───────────────────────────────────────────────────
// Stores golf-specific skill data for qualifying-points golf seasons.
// Primary metric: SG: Total (strokes gained per round vs. field average).
// Optional per-major American odds allow major-specific probability blending.
// One row per (participantId, sportsSeasonId).
export const participantGolfSkills = pgTable("participant_golf_skills", {
id: uuid("id").primaryKey().defaultRandom(),
participantId: uuid("participant_id")
.notNull()
.references(() => participants.id, { onDelete: "cascade" }),
sportsSeasonId: uuid("sports_season_id")
.notNull()
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
// Strokes gained total per round vs. field (e.g. +2.1 means 2.1 strokes/round better than avg)
sgTotal: decimal("sg_total", { precision: 5, scale: 2 }),
// DataGolf / OWGR rank for reference and admin ordering
datagolfRank: integer("datagolf_rank"),
// Per-major American odds (e.g. +400 = 20% implied win prob). All nullable.
mastersOdds: integer("masters_odds"),
usOpenOdds: integer("us_open_odds"),
openChampionshipOdds: integer("open_championship_odds"),
pgaChampionshipOdds: integer("pga_championship_odds"),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
}, (t) => ({
uniqueParticipantSeason: uniqueIndex("participant_golf_skills_unique")
.on(t.participantId, t.sportsSeasonId),
}));
export const participantGolfSkillsRelations = relations(participantGolfSkills, ({ one }) => ({
participant: one(participants, {
fields: [participantGolfSkills.participantId],
references: [participants.id],
}),
sportsSeason: one(sportsSeasons, {
fields: [participantGolfSkills.sportsSeasonId],
references: [sportsSeasons.id],
}),
}));

View file

@ -0,0 +1,26 @@
CREATE TABLE IF NOT EXISTS "participant_golf_skills" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"participant_id" uuid NOT NULL,
"sports_season_id" uuid NOT NULL,
"sg_total" numeric(5, 2),
"datagolf_rank" integer,
"masters_odds" integer,
"us_open_odds" integer,
"open_championship_odds" integer,
"pga_championship_odds" integer,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "participant_golf_skills" ADD CONSTRAINT "participant_golf_skills_participant_id_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."participants"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "participant_golf_skills" ADD CONSTRAINT "participant_golf_skills_sports_season_id_sports_seasons_id_fk" FOREIGN KEY ("sports_season_id") REFERENCES "public"."sports_seasons"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "participant_golf_skills_unique" ON "participant_golf_skills" USING btree ("participant_id","sports_season_id");

File diff suppressed because it is too large Load diff

View file

@ -428,6 +428,13 @@
"when": 1774330910098, "when": 1774330910098,
"tag": "0060_omniscient_outlaw_kid", "tag": "0060_omniscient_outlaw_kid",
"breakpoints": true "breakpoints": true
},
{
"idx": 61,
"version": "7",
"when": 1774410244601,
"tag": "0061_violet_mephistopheles",
"breakpoints": true
} }
] ]
} }