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:
parent
b81089879c
commit
e62e9554c9
14 changed files with 5665 additions and 43 deletions
20
app/lib/fuzzy-match.ts
Normal file
20
app/lib/fuzzy-match.ts
Normal 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
144
app/models/golf-skills.ts
Normal 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`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
699
app/routes/admin.sports-seasons.$id.golf-skills.tsx
Normal file
699
app/routes/admin.sports-seasons.$id.golf-skills.tsx
Normal 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 (1st–8th) 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 }>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
261
app/services/simulations/__tests__/golf-simulator.test.ts
Normal file
261
app/services/simulations/__tests__/golf-simulator.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 1st–8th placement counts.
|
||||||
|
* 6. Return normalized SimulationResult[].
|
||||||
*
|
*
|
||||||
* Expected output: SimulationResult[] — one entry per participant with
|
* Strength calibration (PL_BETA = 1.5, FIELD_SIZE = 156):
|
||||||
* probabilities (0–1) 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",
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
|
||||||
26
drizzle/0061_violet_mephistopheles.sql
Normal file
26
drizzle/0061_violet_mephistopheles.sql
Normal 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");
|
||||||
4156
drizzle/meta/0061_snapshot.json
Normal file
4156
drizzle/meta/0061_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue