2025-11-17 22:19:46 -08:00
|
|
|
/**
|
|
|
|
|
* Model for Participant Expected Values
|
|
|
|
|
*
|
|
|
|
|
* Manages probability distributions and calculated EVs for participants
|
|
|
|
|
* in sports seasons.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { database } from "~/database/context";
|
2026-02-19 11:26:40 -08:00
|
|
|
import { participantExpectedValues, participants } from "~/database/schema";
|
2026-03-23 08:24:28 -07:00
|
|
|
import { eq, and, count, sql } from "drizzle-orm";
|
2025-11-17 22:19:46 -08:00
|
|
|
import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator";
|
2026-02-19 15:57:05 -08:00
|
|
|
import { calculateEV, normalizeProbabilities } from "~/services/ev-calculator";
|
2025-11-17 22:19:46 -08:00
|
|
|
|
|
|
|
|
export type ProbabilitySource = "manual" | "futures_odds" | "elo_simulation" | "performance_model";
|
|
|
|
|
|
|
|
|
|
export interface ParticipantEV {
|
|
|
|
|
id: string;
|
|
|
|
|
participantId: string;
|
|
|
|
|
sportsSeasonId: string;
|
|
|
|
|
probFirst: string;
|
|
|
|
|
probSecond: string;
|
|
|
|
|
probThird: string;
|
|
|
|
|
probFourth: string;
|
|
|
|
|
probFifth: string;
|
|
|
|
|
probSixth: string;
|
|
|
|
|
probSeventh: string;
|
|
|
|
|
probEighth: string;
|
|
|
|
|
expectedValue: string;
|
|
|
|
|
source: ProbabilitySource | null;
|
|
|
|
|
sourceOdds: number | null;
|
2026-03-23 08:24:28 -07:00
|
|
|
sourceElo?: number | null;
|
Add PDC World Darts Championship simulator with Elo-based bracket (#248)
Fixes #123
* Add PDC World Darts Championship simulator (128-player bracket)
- New DartsSimulator: 128-player single-elimination, 7 rounds with
PDC-accurate best-of-sets formats (bo3/bo5/bo5/bo7/bo7/bo11/bo13).
ELO_DIVISOR=500 via set-level Bernoulli model. Two simulation paths:
Path A (bracket drawn) simulates from actual DB matches; Path B
(pre-bracket) seeds top 32 by world ranking in fixed positions and
randomly draws the remaining 96 per simulation run (50,000 iterations).
- darts_bracket added to simulatorTypeEnum and simulator registry.
- world_ranking nullable integer column added to participant_expected_values
(migration 0067); batchSaveSourceElos now accepts and persists it.
- Admin route /admin/sports-seasons/:id/darts-elo: bulk import with format
"Player Name, 2099, 1" (name, Elo, optional world ranking), fuzzy name
matching, auto-runs simulation and updates EV/snapshots on save.
- DARTS_128 bracket template added (scoring starts at Quarterfinals).
- 30 unit tests: math helpers, bracket seeding structure, Path A/B integration.
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
* Review fixes: pre-compute hot-loop invariants, import normalizeName
- Pre-compute seededSlots and getSeededMatchOrder(32) before the 50k
simulation loop — was being recomputed every iteration
- Pad unseeded pool to 96 once before the loop instead of inside it
- Inline bracket-building in the hot loop using pre-computed seededSlots;
removes the per-iteration call to buildR1Bracket/getSeededMatchOrder
- Remove dead SEEDED_R1_PAIRS constant (was never referenced)
- Import normalizeName from ~/lib/fuzzy-match instead of redefining it locally
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
* Fix lint failures: unused vars, eqeqeq, toSorted
- Remove unused seededSet/unseededSet variables in test
- Replace != null with !== null && !== undefined (eqeqeq rule)
- Replace .sort() with .toSorted() in simulator and route (unicorn/no-array-sort)
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
* Fix TS2552: restore seededSet declaration removed during lint fix
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-31 15:37:28 -07:00
|
|
|
worldRanking?: number | null;
|
2025-11-17 22:19:46 -08:00
|
|
|
calculatedAt: Date;
|
|
|
|
|
updatedAt: Date;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface CreateProbabilityInput {
|
|
|
|
|
participantId: string;
|
|
|
|
|
sportsSeasonId: string;
|
|
|
|
|
probabilities: ProbabilityDistribution;
|
|
|
|
|
scoringRules: ScoringRules;
|
|
|
|
|
source?: ProbabilitySource;
|
|
|
|
|
sourceOdds?: number; // American odds if source is futures_odds
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface UpdateProbabilityInput {
|
|
|
|
|
probabilities: ProbabilityDistribution;
|
|
|
|
|
scoringRules: ScoringRules;
|
|
|
|
|
source?: ProbabilitySource;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create or update participant probabilities and calculate EV
|
|
|
|
|
*
|
|
|
|
|
* @param input - Probabilities, scoring rules, and metadata
|
|
|
|
|
* @returns Created/updated participant EV record
|
2025-11-21 22:05:50 -08:00
|
|
|
* @throws Error if probabilities don't sum to 1.0 (within tolerance)
|
2025-11-17 22:19:46 -08:00
|
|
|
*/
|
|
|
|
|
export async function upsertParticipantEV(
|
|
|
|
|
input: CreateProbabilityInput
|
|
|
|
|
): Promise<ParticipantEV> {
|
|
|
|
|
const { participantId, sportsSeasonId, probabilities, scoringRules, source = "manual", sourceOdds } = input;
|
|
|
|
|
|
2025-11-21 22:05:50 -08:00
|
|
|
// Always validate probabilities don't exceed 1.0
|
|
|
|
|
const sum = Object.values(probabilities).reduce((a, b) => a + b, 0);
|
|
|
|
|
if (sum > 1.01) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Probabilities cannot sum to more than 1.0. Current sum: ${sum.toFixed(4)} (${(sum * 100).toFixed(1)}%)`
|
2025-11-17 22:19:46 -08:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate EV
|
|
|
|
|
const expectedValue = calculateEV(probabilities, scoringRules);
|
|
|
|
|
|
|
|
|
|
const db = database();
|
|
|
|
|
|
|
|
|
|
// Check if record exists
|
|
|
|
|
const existing = await db
|
|
|
|
|
.select()
|
|
|
|
|
.from(participantExpectedValues)
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(participantExpectedValues.participantId, participantId),
|
|
|
|
|
eq(participantExpectedValues.sportsSeasonId, sportsSeasonId)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
.limit(1);
|
|
|
|
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
2026-02-19 11:26:40 -08:00
|
|
|
let result: ParticipantEV;
|
|
|
|
|
|
2025-11-17 22:19:46 -08:00
|
|
|
if (existing.length > 0) {
|
|
|
|
|
// Update existing
|
|
|
|
|
const updated = await db
|
|
|
|
|
.update(participantExpectedValues)
|
|
|
|
|
.set({
|
|
|
|
|
probFirst: probabilities.probFirst.toString(),
|
|
|
|
|
probSecond: probabilities.probSecond.toString(),
|
|
|
|
|
probThird: probabilities.probThird.toString(),
|
|
|
|
|
probFourth: probabilities.probFourth.toString(),
|
|
|
|
|
probFifth: probabilities.probFifth.toString(),
|
|
|
|
|
probSixth: probabilities.probSixth.toString(),
|
|
|
|
|
probSeventh: probabilities.probSeventh.toString(),
|
|
|
|
|
probEighth: probabilities.probEighth.toString(),
|
|
|
|
|
expectedValue: expectedValue.toString(),
|
|
|
|
|
source,
|
2026-03-09 15:34:31 -07:00
|
|
|
...(sourceOdds !== undefined ? { sourceOdds } : {}),
|
2025-11-17 22:19:46 -08:00
|
|
|
calculatedAt: now,
|
|
|
|
|
updatedAt: now,
|
|
|
|
|
})
|
|
|
|
|
.where(eq(participantExpectedValues.id, existing[0].id))
|
|
|
|
|
.returning();
|
|
|
|
|
|
2026-02-19 11:26:40 -08:00
|
|
|
result = updated[0];
|
2025-11-17 22:19:46 -08:00
|
|
|
} else {
|
|
|
|
|
// Create new
|
|
|
|
|
const created = await db
|
|
|
|
|
.insert(participantExpectedValues)
|
|
|
|
|
.values({
|
|
|
|
|
participantId,
|
|
|
|
|
sportsSeasonId,
|
|
|
|
|
probFirst: probabilities.probFirst.toString(),
|
|
|
|
|
probSecond: probabilities.probSecond.toString(),
|
|
|
|
|
probThird: probabilities.probThird.toString(),
|
|
|
|
|
probFourth: probabilities.probFourth.toString(),
|
|
|
|
|
probFifth: probabilities.probFifth.toString(),
|
|
|
|
|
probSixth: probabilities.probSixth.toString(),
|
|
|
|
|
probSeventh: probabilities.probSeventh.toString(),
|
|
|
|
|
probEighth: probabilities.probEighth.toString(),
|
|
|
|
|
expectedValue: expectedValue.toString(),
|
|
|
|
|
source,
|
|
|
|
|
sourceOdds: sourceOdds ?? null,
|
|
|
|
|
calculatedAt: now,
|
|
|
|
|
updatedAt: now,
|
|
|
|
|
})
|
|
|
|
|
.returning();
|
|
|
|
|
|
2026-02-19 11:26:40 -08:00
|
|
|
result = created[0];
|
2025-11-17 22:19:46 -08:00
|
|
|
}
|
2026-02-19 11:26:40 -08:00
|
|
|
|
|
|
|
|
// Sync calculated EV to participants table for draft room ranking
|
|
|
|
|
await db
|
|
|
|
|
.update(participants)
|
|
|
|
|
.set({ expectedValue: expectedValue.toString(), updatedAt: now })
|
|
|
|
|
.where(eq(participants.id, participantId));
|
|
|
|
|
|
|
|
|
|
return result;
|
2025-11-17 22:19:46 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create or update with auto-normalization
|
|
|
|
|
* Automatically normalizes probabilities if they don't sum to 100%
|
|
|
|
|
*/
|
|
|
|
|
export async function upsertParticipantEVWithNormalization(
|
|
|
|
|
input: CreateProbabilityInput
|
|
|
|
|
): Promise<ParticipantEV> {
|
|
|
|
|
const normalized = normalizeProbabilities(input.probabilities);
|
|
|
|
|
|
|
|
|
|
return upsertParticipantEV({
|
|
|
|
|
...input,
|
|
|
|
|
probabilities: normalized,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
Add Zod validation and expand export/import for EV and results data (#14)
* Include participant EV/futures data in data sync export/import
The export now includes the full participantExpectedValues records
(placement probabilities, source, sourceOdds) alongside participants.
On import, EV records are upserted in merge mode and created fresh in
replace mode (cascade handles cleanup). Backward-compatible with older
exports that lack the participantExpectedValues field.
https://claude.ai/code/session_01TVzuuDR7jPtmihaXTrsX7B
* Address all data-sync code review findings
- Wrap importSportsDataFromJSON in a db.transaction() so any mid-import
failure rolls back cleanly instead of leaving partial data
- Add zod validation of imported JSON before any DB writes, giving a
clear error on malformed/incompatible files
- Fix N+1 queries: build participantIdMap during participant import so
the EV and results sections resolve IDs from memory, not extra queries
- Fix merge mode silently skipping existing participants — now updates
shortName, externalId, and expectedValue
- Add participantResults to export/import (was deleted in replace mode
but never exported, so results were permanently lost)
- Restore calculatedAt timestamp on EV import instead of defaulting to
now(); stored as ISO string in the export for portability
- Document that onDelete:cascade covers participantExpectedValues and
participantResults when participants is deleted; remove the now-
redundant explicit participantResults delete from replace mode
- Bump export version to 1.1; old v1.0 files still import cleanly since
participantExpectedValues, participantResults, and calculatedAt are
all optional in the zod schema
- Collapse all six DB fetches in exportSportsDataToJSON into one
Promise.all for parallel execution
- Add countAllParticipants() and countAllParticipantEVs() model funcs
- Show Participants and EV Records counts on the dashboard stat cards
- Replace raw DOM form creation/submit in handleImport with useFetcher,
giving proper loading state, no full-page reload, and type-safe data
- Remove dead export intent handler from the action function
https://claude.ai/code/session_01TVzuuDR7jPtmihaXTrsX7B
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 20:25:25 -08:00
|
|
|
export async function countAllParticipantEVs(): Promise<number> {
|
|
|
|
|
const db = database();
|
|
|
|
|
const result = await db.select({ value: count() }).from(participantExpectedValues);
|
|
|
|
|
return result[0].value;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-17 22:19:46 -08:00
|
|
|
/**
|
|
|
|
|
* Get participant EV for a specific sports season
|
|
|
|
|
*/
|
|
|
|
|
export async function getParticipantEV(
|
|
|
|
|
participantId: string,
|
|
|
|
|
sportsSeasonId: string
|
|
|
|
|
): Promise<ParticipantEV | null> {
|
|
|
|
|
const db = database();
|
|
|
|
|
const result = await db
|
|
|
|
|
.select()
|
|
|
|
|
.from(participantExpectedValues)
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(participantExpectedValues.participantId, participantId),
|
|
|
|
|
eq(participantExpectedValues.sportsSeasonId, sportsSeasonId)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
.limit(1);
|
|
|
|
|
|
|
|
|
|
return result[0] || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get all participant EVs for a sports season
|
|
|
|
|
*/
|
|
|
|
|
export async function getAllParticipantEVsForSeason(
|
|
|
|
|
sportsSeasonId: string
|
|
|
|
|
): Promise<ParticipantEV[]> {
|
|
|
|
|
const db = database();
|
|
|
|
|
return db
|
|
|
|
|
.select()
|
|
|
|
|
.from(participantExpectedValues)
|
|
|
|
|
.where(eq(participantExpectedValues.sportsSeasonId, sportsSeasonId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Delete participant EV
|
|
|
|
|
*/
|
|
|
|
|
export async function deleteParticipantEV(
|
|
|
|
|
participantId: string,
|
|
|
|
|
sportsSeasonId: string
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const db = database();
|
|
|
|
|
await db
|
|
|
|
|
.delete(participantExpectedValues)
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(participantExpectedValues.participantId, participantId),
|
|
|
|
|
eq(participantExpectedValues.sportsSeasonId, sportsSeasonId)
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-02-19 11:26:40 -08:00
|
|
|
|
|
|
|
|
// Reset participant EV to 0
|
|
|
|
|
await db
|
|
|
|
|
.update(participants)
|
|
|
|
|
.set({ expectedValue: "0", updatedAt: new Date() })
|
|
|
|
|
.where(eq(participants.id, participantId));
|
2025-11-17 22:19:46 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-09 15:34:31 -07:00
|
|
|
* Batch upsert multiple participant EVs within a single transaction.
|
|
|
|
|
* All records succeed or all fail together.
|
2025-11-17 22:19:46 -08:00
|
|
|
*/
|
|
|
|
|
export async function batchUpsertParticipantEVs(
|
|
|
|
|
inputs: CreateProbabilityInput[]
|
|
|
|
|
): Promise<ParticipantEV[]> {
|
2026-03-09 15:34:31 -07:00
|
|
|
const db = database();
|
2025-11-17 22:19:46 -08:00
|
|
|
const results: ParticipantEV[] = [];
|
|
|
|
|
|
2026-03-09 15:34:31 -07:00
|
|
|
await db.transaction(async (tx) => {
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
|
|
|
|
for (const input of inputs) {
|
|
|
|
|
const { participantId, sportsSeasonId, probabilities, scoringRules, source = "manual", sourceOdds } = input;
|
|
|
|
|
|
|
|
|
|
const sum = Object.values(probabilities).reduce((a, b) => a + b, 0);
|
|
|
|
|
if (sum > 1.01) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Probabilities for participant ${participantId} cannot sum to more than 1.0. Current sum: ${sum.toFixed(4)}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const expectedValue = calculateEV(probabilities, scoringRules);
|
|
|
|
|
|
|
|
|
|
const existing = await tx
|
|
|
|
|
.select({ id: participantExpectedValues.id })
|
|
|
|
|
.from(participantExpectedValues)
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(participantExpectedValues.participantId, participantId),
|
|
|
|
|
eq(participantExpectedValues.sportsSeasonId, sportsSeasonId)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
.limit(1);
|
|
|
|
|
|
|
|
|
|
const baseValues = {
|
|
|
|
|
probFirst: probabilities.probFirst.toString(),
|
|
|
|
|
probSecond: probabilities.probSecond.toString(),
|
|
|
|
|
probThird: probabilities.probThird.toString(),
|
|
|
|
|
probFourth: probabilities.probFourth.toString(),
|
|
|
|
|
probFifth: probabilities.probFifth.toString(),
|
|
|
|
|
probSixth: probabilities.probSixth.toString(),
|
|
|
|
|
probSeventh: probabilities.probSeventh.toString(),
|
|
|
|
|
probEighth: probabilities.probEighth.toString(),
|
|
|
|
|
expectedValue: expectedValue.toString(),
|
|
|
|
|
source,
|
|
|
|
|
calculatedAt: now,
|
|
|
|
|
updatedAt: now,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let result: ParticipantEV;
|
|
|
|
|
|
|
|
|
|
if (existing.length > 0) {
|
|
|
|
|
const [updated] = await tx
|
|
|
|
|
.update(participantExpectedValues)
|
|
|
|
|
// Only overwrite sourceOdds if explicitly provided; preserve existing value otherwise
|
|
|
|
|
.set(sourceOdds !== undefined ? { ...baseValues, sourceOdds } : baseValues)
|
|
|
|
|
.where(eq(participantExpectedValues.id, existing[0].id))
|
|
|
|
|
.returning();
|
|
|
|
|
result = updated;
|
|
|
|
|
} else {
|
|
|
|
|
const [created] = await tx
|
|
|
|
|
.insert(participantExpectedValues)
|
|
|
|
|
.values({ participantId, sportsSeasonId, ...baseValues, sourceOdds: sourceOdds ?? null })
|
|
|
|
|
.returning();
|
|
|
|
|
result = created;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await tx
|
|
|
|
|
.update(participants)
|
|
|
|
|
.set({ expectedValue: expectedValue.toString(), updatedAt: now })
|
|
|
|
|
.where(eq(participants.id, participantId));
|
|
|
|
|
|
|
|
|
|
results.push(result);
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-11-17 22:19:46 -08:00
|
|
|
|
|
|
|
|
return results;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 15:34:31 -07:00
|
|
|
/**
|
|
|
|
|
* Save American odds for a batch of participants without touching probabilities or EV.
|
|
|
|
|
* Used by the futures-odds admin page to persist odds before running the full simulation.
|
|
|
|
|
*/
|
|
|
|
|
export async function batchSaveSourceOdds(
|
|
|
|
|
inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }>
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const db = database();
|
|
|
|
|
|
|
|
|
|
await db.transaction(async (tx) => {
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
|
|
|
|
for (const { participantId, sportsSeasonId, sourceOdds } of inputs) {
|
|
|
|
|
const existing = await tx
|
|
|
|
|
.select({ id: participantExpectedValues.id })
|
|
|
|
|
.from(participantExpectedValues)
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(participantExpectedValues.participantId, participantId),
|
|
|
|
|
eq(participantExpectedValues.sportsSeasonId, sportsSeasonId)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
.limit(1);
|
|
|
|
|
|
|
|
|
|
if (existing.length > 0) {
|
|
|
|
|
await tx
|
|
|
|
|
.update(participantExpectedValues)
|
|
|
|
|
.set({ sourceOdds, updatedAt: now })
|
|
|
|
|
.where(eq(participantExpectedValues.id, existing[0].id));
|
|
|
|
|
} else {
|
|
|
|
|
// Insert a stub record — probabilities/EV will be filled in by the simulator
|
|
|
|
|
await tx.insert(participantExpectedValues).values({
|
|
|
|
|
participantId,
|
|
|
|
|
sportsSeasonId,
|
|
|
|
|
probFirst: "0",
|
|
|
|
|
probSecond: "0",
|
|
|
|
|
probThird: "0",
|
|
|
|
|
probFourth: "0",
|
|
|
|
|
probFifth: "0",
|
|
|
|
|
probSixth: "0",
|
|
|
|
|
probSeventh: "0",
|
|
|
|
|
probEighth: "0",
|
|
|
|
|
expectedValue: "0",
|
|
|
|
|
source: "futures_odds",
|
|
|
|
|
sourceOdds,
|
|
|
|
|
calculatedAt: now,
|
|
|
|
|
updatedAt: now,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 08:24:28 -07:00
|
|
|
/**
|
Add PDC World Darts Championship simulator with Elo-based bracket (#248)
Fixes #123
* Add PDC World Darts Championship simulator (128-player bracket)
- New DartsSimulator: 128-player single-elimination, 7 rounds with
PDC-accurate best-of-sets formats (bo3/bo5/bo5/bo7/bo7/bo11/bo13).
ELO_DIVISOR=500 via set-level Bernoulli model. Two simulation paths:
Path A (bracket drawn) simulates from actual DB matches; Path B
(pre-bracket) seeds top 32 by world ranking in fixed positions and
randomly draws the remaining 96 per simulation run (50,000 iterations).
- darts_bracket added to simulatorTypeEnum and simulator registry.
- world_ranking nullable integer column added to participant_expected_values
(migration 0067); batchSaveSourceElos now accepts and persists it.
- Admin route /admin/sports-seasons/:id/darts-elo: bulk import with format
"Player Name, 2099, 1" (name, Elo, optional world ranking), fuzzy name
matching, auto-runs simulation and updates EV/snapshots on save.
- DARTS_128 bracket template added (scoring starts at Quarterfinals).
- 30 unit tests: math helpers, bracket seeding structure, Path A/B integration.
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
* Review fixes: pre-compute hot-loop invariants, import normalizeName
- Pre-compute seededSlots and getSeededMatchOrder(32) before the 50k
simulation loop — was being recomputed every iteration
- Pad unseeded pool to 96 once before the loop instead of inside it
- Inline bracket-building in the hot loop using pre-computed seededSlots;
removes the per-iteration call to buildR1Bracket/getSeededMatchOrder
- Remove dead SEEDED_R1_PAIRS constant (was never referenced)
- Import normalizeName from ~/lib/fuzzy-match instead of redefining it locally
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
* Fix lint failures: unused vars, eqeqeq, toSorted
- Remove unused seededSet/unseededSet variables in test
- Replace != null with !== null && !== undefined (eqeqeq rule)
- Replace .sort() with .toSorted() in simulator and route (unicorn/no-array-sort)
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
* Fix TS2552: restore seededSet declaration removed during lint fix
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-31 15:37:28 -07:00
|
|
|
* Persist raw Elo ratings (and optional world rankings) for a batch of participants.
|
|
|
|
|
* Used by Elo-based simulators like snooker_bracket and darts_bracket.
|
2026-03-23 08:24:28 -07:00
|
|
|
* Creates stub records if none exist; does not touch probabilities or EV.
|
|
|
|
|
* Uses INSERT ... ON CONFLICT DO UPDATE to avoid N+1 queries.
|
|
|
|
|
*/
|
|
|
|
|
export async function batchSaveSourceElos(
|
Add PDC World Darts Championship simulator with Elo-based bracket (#248)
Fixes #123
* Add PDC World Darts Championship simulator (128-player bracket)
- New DartsSimulator: 128-player single-elimination, 7 rounds with
PDC-accurate best-of-sets formats (bo3/bo5/bo5/bo7/bo7/bo11/bo13).
ELO_DIVISOR=500 via set-level Bernoulli model. Two simulation paths:
Path A (bracket drawn) simulates from actual DB matches; Path B
(pre-bracket) seeds top 32 by world ranking in fixed positions and
randomly draws the remaining 96 per simulation run (50,000 iterations).
- darts_bracket added to simulatorTypeEnum and simulator registry.
- world_ranking nullable integer column added to participant_expected_values
(migration 0067); batchSaveSourceElos now accepts and persists it.
- Admin route /admin/sports-seasons/:id/darts-elo: bulk import with format
"Player Name, 2099, 1" (name, Elo, optional world ranking), fuzzy name
matching, auto-runs simulation and updates EV/snapshots on save.
- DARTS_128 bracket template added (scoring starts at Quarterfinals).
- 30 unit tests: math helpers, bracket seeding structure, Path A/B integration.
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
* Review fixes: pre-compute hot-loop invariants, import normalizeName
- Pre-compute seededSlots and getSeededMatchOrder(32) before the 50k
simulation loop — was being recomputed every iteration
- Pad unseeded pool to 96 once before the loop instead of inside it
- Inline bracket-building in the hot loop using pre-computed seededSlots;
removes the per-iteration call to buildR1Bracket/getSeededMatchOrder
- Remove dead SEEDED_R1_PAIRS constant (was never referenced)
- Import normalizeName from ~/lib/fuzzy-match instead of redefining it locally
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
* Fix lint failures: unused vars, eqeqeq, toSorted
- Remove unused seededSet/unseededSet variables in test
- Replace != null with !== null && !== undefined (eqeqeq rule)
- Replace .sort() with .toSorted() in simulator and route (unicorn/no-array-sort)
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
* Fix TS2552: restore seededSet declaration removed during lint fix
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-31 15:37:28 -07:00
|
|
|
inputs: Array<{ participantId: string; sportsSeasonId: string; sourceElo: number; worldRanking?: number | null }>
|
2026-03-23 08:24:28 -07:00
|
|
|
): Promise<void> {
|
|
|
|
|
if (inputs.length === 0) return;
|
|
|
|
|
const db = database();
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
|
|
|
|
await db
|
|
|
|
|
.insert(participantExpectedValues)
|
|
|
|
|
.values(
|
Add PDC World Darts Championship simulator with Elo-based bracket (#248)
Fixes #123
* Add PDC World Darts Championship simulator (128-player bracket)
- New DartsSimulator: 128-player single-elimination, 7 rounds with
PDC-accurate best-of-sets formats (bo3/bo5/bo5/bo7/bo7/bo11/bo13).
ELO_DIVISOR=500 via set-level Bernoulli model. Two simulation paths:
Path A (bracket drawn) simulates from actual DB matches; Path B
(pre-bracket) seeds top 32 by world ranking in fixed positions and
randomly draws the remaining 96 per simulation run (50,000 iterations).
- darts_bracket added to simulatorTypeEnum and simulator registry.
- world_ranking nullable integer column added to participant_expected_values
(migration 0067); batchSaveSourceElos now accepts and persists it.
- Admin route /admin/sports-seasons/:id/darts-elo: bulk import with format
"Player Name, 2099, 1" (name, Elo, optional world ranking), fuzzy name
matching, auto-runs simulation and updates EV/snapshots on save.
- DARTS_128 bracket template added (scoring starts at Quarterfinals).
- 30 unit tests: math helpers, bracket seeding structure, Path A/B integration.
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
* Review fixes: pre-compute hot-loop invariants, import normalizeName
- Pre-compute seededSlots and getSeededMatchOrder(32) before the 50k
simulation loop — was being recomputed every iteration
- Pad unseeded pool to 96 once before the loop instead of inside it
- Inline bracket-building in the hot loop using pre-computed seededSlots;
removes the per-iteration call to buildR1Bracket/getSeededMatchOrder
- Remove dead SEEDED_R1_PAIRS constant (was never referenced)
- Import normalizeName from ~/lib/fuzzy-match instead of redefining it locally
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
* Fix lint failures: unused vars, eqeqeq, toSorted
- Remove unused seededSet/unseededSet variables in test
- Replace != null with !== null && !== undefined (eqeqeq rule)
- Replace .sort() with .toSorted() in simulator and route (unicorn/no-array-sort)
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
* Fix TS2552: restore seededSet declaration removed during lint fix
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-31 15:37:28 -07:00
|
|
|
inputs.map(({ participantId, sportsSeasonId, sourceElo, worldRanking }) => ({
|
2026-03-23 08:24:28 -07:00
|
|
|
participantId,
|
|
|
|
|
sportsSeasonId,
|
|
|
|
|
probFirst: "0",
|
|
|
|
|
probSecond: "0",
|
|
|
|
|
probThird: "0",
|
|
|
|
|
probFourth: "0",
|
|
|
|
|
probFifth: "0",
|
|
|
|
|
probSixth: "0",
|
|
|
|
|
probSeventh: "0",
|
|
|
|
|
probEighth: "0",
|
|
|
|
|
expectedValue: "0",
|
|
|
|
|
source: "elo_simulation" as const,
|
|
|
|
|
sourceElo,
|
Add PDC World Darts Championship simulator with Elo-based bracket (#248)
Fixes #123
* Add PDC World Darts Championship simulator (128-player bracket)
- New DartsSimulator: 128-player single-elimination, 7 rounds with
PDC-accurate best-of-sets formats (bo3/bo5/bo5/bo7/bo7/bo11/bo13).
ELO_DIVISOR=500 via set-level Bernoulli model. Two simulation paths:
Path A (bracket drawn) simulates from actual DB matches; Path B
(pre-bracket) seeds top 32 by world ranking in fixed positions and
randomly draws the remaining 96 per simulation run (50,000 iterations).
- darts_bracket added to simulatorTypeEnum and simulator registry.
- world_ranking nullable integer column added to participant_expected_values
(migration 0067); batchSaveSourceElos now accepts and persists it.
- Admin route /admin/sports-seasons/:id/darts-elo: bulk import with format
"Player Name, 2099, 1" (name, Elo, optional world ranking), fuzzy name
matching, auto-runs simulation and updates EV/snapshots on save.
- DARTS_128 bracket template added (scoring starts at Quarterfinals).
- 30 unit tests: math helpers, bracket seeding structure, Path A/B integration.
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
* Review fixes: pre-compute hot-loop invariants, import normalizeName
- Pre-compute seededSlots and getSeededMatchOrder(32) before the 50k
simulation loop — was being recomputed every iteration
- Pad unseeded pool to 96 once before the loop instead of inside it
- Inline bracket-building in the hot loop using pre-computed seededSlots;
removes the per-iteration call to buildR1Bracket/getSeededMatchOrder
- Remove dead SEEDED_R1_PAIRS constant (was never referenced)
- Import normalizeName from ~/lib/fuzzy-match instead of redefining it locally
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
* Fix lint failures: unused vars, eqeqeq, toSorted
- Remove unused seededSet/unseededSet variables in test
- Replace != null with !== null && !== undefined (eqeqeq rule)
- Replace .sort() with .toSorted() in simulator and route (unicorn/no-array-sort)
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
* Fix TS2552: restore seededSet declaration removed during lint fix
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-31 15:37:28 -07:00
|
|
|
worldRanking: worldRanking ?? null,
|
2026-03-23 08:24:28 -07:00
|
|
|
calculatedAt: now,
|
|
|
|
|
updatedAt: now,
|
|
|
|
|
}))
|
|
|
|
|
)
|
|
|
|
|
.onConflictDoUpdate({
|
|
|
|
|
target: [participantExpectedValues.participantId, participantExpectedValues.sportsSeasonId],
|
|
|
|
|
set: {
|
|
|
|
|
sourceElo: sql`excluded.source_elo`,
|
Add PDC World Darts Championship simulator with Elo-based bracket (#248)
Fixes #123
* Add PDC World Darts Championship simulator (128-player bracket)
- New DartsSimulator: 128-player single-elimination, 7 rounds with
PDC-accurate best-of-sets formats (bo3/bo5/bo5/bo7/bo7/bo11/bo13).
ELO_DIVISOR=500 via set-level Bernoulli model. Two simulation paths:
Path A (bracket drawn) simulates from actual DB matches; Path B
(pre-bracket) seeds top 32 by world ranking in fixed positions and
randomly draws the remaining 96 per simulation run (50,000 iterations).
- darts_bracket added to simulatorTypeEnum and simulator registry.
- world_ranking nullable integer column added to participant_expected_values
(migration 0067); batchSaveSourceElos now accepts and persists it.
- Admin route /admin/sports-seasons/:id/darts-elo: bulk import with format
"Player Name, 2099, 1" (name, Elo, optional world ranking), fuzzy name
matching, auto-runs simulation and updates EV/snapshots on save.
- DARTS_128 bracket template added (scoring starts at Quarterfinals).
- 30 unit tests: math helpers, bracket seeding structure, Path A/B integration.
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
* Review fixes: pre-compute hot-loop invariants, import normalizeName
- Pre-compute seededSlots and getSeededMatchOrder(32) before the 50k
simulation loop — was being recomputed every iteration
- Pad unseeded pool to 96 once before the loop instead of inside it
- Inline bracket-building in the hot loop using pre-computed seededSlots;
removes the per-iteration call to buildR1Bracket/getSeededMatchOrder
- Remove dead SEEDED_R1_PAIRS constant (was never referenced)
- Import normalizeName from ~/lib/fuzzy-match instead of redefining it locally
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
* Fix lint failures: unused vars, eqeqeq, toSorted
- Remove unused seededSet/unseededSet variables in test
- Replace != null with !== null && !== undefined (eqeqeq rule)
- Replace .sort() with .toSorted() in simulator and route (unicorn/no-array-sort)
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
* Fix TS2552: restore seededSet declaration removed during lint fix
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-31 15:37:28 -07:00
|
|
|
worldRanking: sql`excluded.world_ranking`,
|
2026-03-23 08:24:28 -07:00
|
|
|
updatedAt: sql`excluded.updated_at`,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-17 22:19:46 -08:00
|
|
|
/**
|
|
|
|
|
* Convert database record to ProbabilityDistribution
|
|
|
|
|
*/
|
|
|
|
|
export function toProbabilityDistribution(ev: ParticipantEV): ProbabilityDistribution {
|
|
|
|
|
return {
|
|
|
|
|
probFirst: parseFloat(ev.probFirst),
|
|
|
|
|
probSecond: parseFloat(ev.probSecond),
|
|
|
|
|
probThird: parseFloat(ev.probThird),
|
|
|
|
|
probFourth: parseFloat(ev.probFourth),
|
|
|
|
|
probFifth: parseFloat(ev.probFifth),
|
|
|
|
|
probSixth: parseFloat(ev.probSixth),
|
|
|
|
|
probSeventh: parseFloat(ev.probSeventh),
|
|
|
|
|
probEighth: parseFloat(ev.probEighth),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Recalculate EV for a participant with new scoring rules
|
|
|
|
|
* Keeps probabilities the same, only updates EV based on new scoring
|
|
|
|
|
*/
|
|
|
|
|
export async function recalculateEV(
|
|
|
|
|
participantId: string,
|
|
|
|
|
sportsSeasonId: string,
|
|
|
|
|
newScoringRules: ScoringRules
|
|
|
|
|
): Promise<ParticipantEV | null> {
|
|
|
|
|
const existing = await getParticipantEV(participantId, sportsSeasonId);
|
|
|
|
|
|
|
|
|
|
if (!existing) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const probabilities = toProbabilityDistribution(existing);
|
|
|
|
|
const newEV = calculateEV(probabilities, newScoringRules);
|
|
|
|
|
|
|
|
|
|
const db = database();
|
2026-02-19 11:26:40 -08:00
|
|
|
const now = new Date();
|
2025-11-17 22:19:46 -08:00
|
|
|
const updated = await db
|
|
|
|
|
.update(participantExpectedValues)
|
|
|
|
|
.set({
|
|
|
|
|
expectedValue: newEV.toString(),
|
2026-02-19 11:26:40 -08:00
|
|
|
calculatedAt: now,
|
|
|
|
|
updatedAt: now,
|
2025-11-17 22:19:46 -08:00
|
|
|
})
|
|
|
|
|
.where(eq(participantExpectedValues.id, existing.id))
|
|
|
|
|
.returning();
|
|
|
|
|
|
2026-02-19 11:26:40 -08:00
|
|
|
// Sync recalculated EV to participants table
|
|
|
|
|
await db
|
|
|
|
|
.update(participants)
|
|
|
|
|
.set({ expectedValue: newEV.toString(), updatedAt: now })
|
|
|
|
|
.where(eq(participants.id, participantId));
|
|
|
|
|
|
2025-11-17 22:19:46 -08:00
|
|
|
return updated[0];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Recalculate EVs for all participants in a sports season
|
|
|
|
|
* Used when scoring rules change
|
|
|
|
|
*/
|
|
|
|
|
export async function recalculateAllEVsForSeason(
|
|
|
|
|
sportsSeasonId: string,
|
|
|
|
|
newScoringRules: ScoringRules
|
|
|
|
|
): Promise<number> {
|
|
|
|
|
const allEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
|
|
|
|
|
|
|
|
|
|
await Promise.all(
|
|
|
|
|
allEVs.map((ev) =>
|
|
|
|
|
recalculateEV(ev.participantId, sportsSeasonId, newScoringRules)
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return allEVs.length;
|
|
|
|
|
}
|