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>
482 lines
14 KiB
TypeScript
482 lines
14 KiB
TypeScript
/**
|
|
* Model for Participant Expected Values
|
|
*
|
|
* Manages probability distributions and calculated EVs for participants
|
|
* in sports seasons.
|
|
*/
|
|
|
|
import { database } from "~/database/context";
|
|
import { participantExpectedValues, participants } from "~/database/schema";
|
|
import { eq, and, count, sql } from "drizzle-orm";
|
|
import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator";
|
|
import { calculateEV, normalizeProbabilities } from "~/services/ev-calculator";
|
|
|
|
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;
|
|
sourceElo?: number | null;
|
|
worldRanking?: number | null;
|
|
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
|
|
* @throws Error if probabilities don't sum to 1.0 (within tolerance)
|
|
*/
|
|
export async function upsertParticipantEV(
|
|
input: CreateProbabilityInput
|
|
): Promise<ParticipantEV> {
|
|
const { participantId, sportsSeasonId, probabilities, scoringRules, source = "manual", sourceOdds } = input;
|
|
|
|
// 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)}%)`
|
|
);
|
|
}
|
|
|
|
// 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();
|
|
|
|
let result: ParticipantEV;
|
|
|
|
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,
|
|
...(sourceOdds !== undefined ? { sourceOdds } : {}),
|
|
calculatedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(participantExpectedValues.id, existing[0].id))
|
|
.returning();
|
|
|
|
result = updated[0];
|
|
} 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();
|
|
|
|
result = created[0];
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
});
|
|
}
|
|
|
|
export async function countAllParticipantEVs(): Promise<number> {
|
|
const db = database();
|
|
const result = await db.select({ value: count() }).from(participantExpectedValues);
|
|
return result[0].value;
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
)
|
|
);
|
|
|
|
// Reset participant EV to 0
|
|
await db
|
|
.update(participants)
|
|
.set({ expectedValue: "0", updatedAt: new Date() })
|
|
.where(eq(participants.id, participantId));
|
|
}
|
|
|
|
/**
|
|
* Batch upsert multiple participant EVs within a single transaction.
|
|
* All records succeed or all fail together.
|
|
*/
|
|
export async function batchUpsertParticipantEVs(
|
|
inputs: CreateProbabilityInput[]
|
|
): Promise<ParticipantEV[]> {
|
|
const db = database();
|
|
const results: ParticipantEV[] = [];
|
|
|
|
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);
|
|
}
|
|
});
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Persist raw Elo ratings (and optional world rankings) for a batch of participants.
|
|
* Used by Elo-based simulators like snooker_bracket and darts_bracket.
|
|
* 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(
|
|
inputs: Array<{ participantId: string; sportsSeasonId: string; sourceElo: number; worldRanking?: number | null }>
|
|
): Promise<void> {
|
|
if (inputs.length === 0) return;
|
|
const db = database();
|
|
const now = new Date();
|
|
|
|
await db
|
|
.insert(participantExpectedValues)
|
|
.values(
|
|
inputs.map(({ participantId, sportsSeasonId, sourceElo, worldRanking }) => ({
|
|
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,
|
|
worldRanking: worldRanking ?? null,
|
|
calculatedAt: now,
|
|
updatedAt: now,
|
|
}))
|
|
)
|
|
.onConflictDoUpdate({
|
|
target: [participantExpectedValues.participantId, participantExpectedValues.sportsSeasonId],
|
|
set: {
|
|
sourceElo: sql`excluded.source_elo`,
|
|
worldRanking: sql`excluded.world_ranking`,
|
|
updatedAt: sql`excluded.updated_at`,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
const now = new Date();
|
|
const updated = await db
|
|
.update(participantExpectedValues)
|
|
.set({
|
|
expectedValue: newEV.toString(),
|
|
calculatedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(participantExpectedValues.id, existing.id))
|
|
.returning();
|
|
|
|
// Sync recalculated EV to participants table
|
|
await db
|
|
.update(participants)
|
|
.set({ expectedValue: newEV.toString(), updatedAt: now })
|
|
.where(eq(participants.id, participantId));
|
|
|
|
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;
|
|
}
|