Introduces three new schema tables (simulator_profiles, sports_season_simulator_configs, season_participant_simulator_inputs), a central model layer (app/models/simulator.ts), and a single runner entry point so every simulator run follows the same prepare → simulate → persist → snapshot → recalculate flow. Key additions: - manifest.ts: per-simulator display names, default configs, required/ optional inputs, derivable-input declarations, and setup sections - input-policy.ts: resolves sourceElo from projectedWins, projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds; supports block / fallbackElo / averageKnown / worstKnownMinus strategies - runner.ts: single entry point for admin simulation runs; materialises derived inputs, normalises result columns, zeroes omitted participants, snapshots EVs, and recalculates linked fantasy standings - /admin/simulators: inventory page with per-season readiness and bulk run - /admin/sports-seasons/:id/simulator: per-season setup page with readiness summary, input-policy editor, raw JSON config override, and CSV bulk input - NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs, falling back to the hardcoded name-keyed maps while DB data is being populated - Clone flow copies simulator config by default; volatile inputs (odds, Elo) only copied when explicitly requested Code-review fixes included in this commit: - source field in compatibility bridge checked with !== null instead of !== undefined - sourceEloRequirementLabel no longer appends "configured fallback" when the participant is already excluded from all resolved sources - Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel - save-config preserves existing inputPolicy when the submitted JSON omits it - Input table truncation label added (Showing 20 of N) - CSV description notes values must not contain commas - N+1 comment added to listSportsSeasonSimulatorSummaries - assertRegistrySchemaDriftFree called in manifest tests - Runner test suite added covering happy path, already-running guard, readiness failure, empty results, and error recovery with status reset Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
563 lines
18 KiB
TypeScript
563 lines
18 KiB
TypeScript
/**
|
||
* Model for Participant Expected Values
|
||
*
|
||
* Manages probability distributions and calculated EVs for seasonParticipants
|
||
* in sports seasons.
|
||
*/
|
||
|
||
import { database } from "~/database/context";
|
||
import { seasonParticipantExpectedValues, seasonParticipants } from "~/database/schema";
|
||
import { eq, and, count, sql } from "drizzle-orm";
|
||
import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator";
|
||
import { calculateEV, normalizeProbabilities, calculateReplacementLevel, calculateVORP } from "~/services/ev-calculator";
|
||
import { batchUpsertParticipantSimulatorInputs } from "~/models/simulator";
|
||
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Recalculate and persist VORP for every participant in a sports season.
|
||
*
|
||
* VORP = participant EV − replacement level EV
|
||
* Replacement level = average EV of seasonParticipants ranked 12th–14th in this season.
|
||
*
|
||
* Call this after any operation that changes EVs for seasonParticipants in the season.
|
||
*/
|
||
export async function syncVorpForSeason(
|
||
sportsSeasonId: string,
|
||
providedDb?: ReturnType<typeof database>
|
||
): Promise<void> {
|
||
const db = providedDb || database();
|
||
|
||
const allEvs = await getAllParticipantEVsForSeason(sportsSeasonId, db);
|
||
if (allEvs.length === 0) return;
|
||
|
||
const sorted = [...allEvs].toSorted(
|
||
(a, b) => parseFloat(b.expectedValue) - parseFloat(a.expectedValue)
|
||
);
|
||
|
||
const sortedEvNumbers = sorted.map((ev) => parseFloat(ev.expectedValue));
|
||
const replacementLevel = calculateReplacementLevel(sortedEvNumbers);
|
||
|
||
const now = new Date();
|
||
|
||
// Build a single CASE expression to update all seasonParticipants in one query
|
||
// instead of N individual UPDATE statements.
|
||
const vorpCaseExpr = sql`CASE ${sql.join(
|
||
sorted.map((ev) => {
|
||
const vorp = calculateVORP(parseFloat(ev.expectedValue), replacementLevel);
|
||
return sql`WHEN ${seasonParticipants.id} = ${ev.participantId} THEN ${vorp.toFixed(4)}::numeric`;
|
||
}),
|
||
sql` `
|
||
)} END`;
|
||
|
||
const ids = sorted.map((ev) => ev.participantId);
|
||
|
||
await db
|
||
.update(seasonParticipants)
|
||
.set({ vorpValue: vorpCaseExpr, updatedAt: now })
|
||
.where(sql`${seasonParticipants.id} = ANY(${sql`ARRAY[${sql.join(ids.map((id) => sql`${id}`), sql`, `)}]::uuid[]`})`);
|
||
}
|
||
|
||
/**
|
||
* 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(seasonParticipantExpectedValues)
|
||
.where(
|
||
and(
|
||
eq(seasonParticipantExpectedValues.participantId, participantId),
|
||
eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)
|
||
)
|
||
)
|
||
.limit(1);
|
||
|
||
const now = new Date();
|
||
|
||
let result: ParticipantEV;
|
||
|
||
if (existing.length > 0) {
|
||
// Update existing
|
||
const updated = await db
|
||
.update(seasonParticipantExpectedValues)
|
||
.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(seasonParticipantExpectedValues.id, existing[0].id))
|
||
.returning();
|
||
|
||
result = updated[0];
|
||
} else {
|
||
// Create new
|
||
const created = await db
|
||
.insert(seasonParticipantExpectedValues)
|
||
.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 seasonParticipants table for draft room ranking
|
||
await db
|
||
.update(seasonParticipants)
|
||
.set({ expectedValue: expectedValue.toString(), updatedAt: now })
|
||
.where(eq(seasonParticipants.id, participantId));
|
||
|
||
// Recalculate VORP for all seasonParticipants in this season (replacement level may have shifted)
|
||
await syncVorpForSeason(sportsSeasonId);
|
||
|
||
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(seasonParticipantExpectedValues);
|
||
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(seasonParticipantExpectedValues)
|
||
.where(
|
||
and(
|
||
eq(seasonParticipantExpectedValues.participantId, participantId),
|
||
eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)
|
||
)
|
||
)
|
||
.limit(1);
|
||
|
||
return result[0] || null;
|
||
}
|
||
|
||
/**
|
||
* Get all participant EVs for a sports season
|
||
*/
|
||
export async function getAllParticipantEVsForSeason(
|
||
sportsSeasonId: string,
|
||
providedDb?: ReturnType<typeof database>
|
||
): Promise<ParticipantEV[]> {
|
||
const db = providedDb || database();
|
||
return db
|
||
.select()
|
||
.from(seasonParticipantExpectedValues)
|
||
.where(eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
|
||
}
|
||
|
||
/**
|
||
* Delete participant EV
|
||
*/
|
||
export async function deleteParticipantEV(
|
||
participantId: string,
|
||
sportsSeasonId: string
|
||
): Promise<void> {
|
||
const db = database();
|
||
await db
|
||
.delete(seasonParticipantExpectedValues)
|
||
.where(
|
||
and(
|
||
eq(seasonParticipantExpectedValues.participantId, participantId),
|
||
eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)
|
||
)
|
||
);
|
||
|
||
// Reset this participant's EV and VORP to 0 (no longer ranked)
|
||
await db
|
||
.update(seasonParticipants)
|
||
.set({ expectedValue: "0", vorpValue: "0", updatedAt: new Date() })
|
||
.where(eq(seasonParticipants.id, participantId));
|
||
|
||
// Recalculate VORP for remaining seasonParticipants — replacement level may have shifted
|
||
await syncVorpForSeason(sportsSeasonId);
|
||
}
|
||
|
||
/**
|
||
* Batch upsert multiple participant EVs within a single transaction.
|
||
* All records succeed or all fail together.
|
||
*/
|
||
export async function batchUpsertParticipantEVs(
|
||
inputs: CreateProbabilityInput[],
|
||
providedDb?: ReturnType<typeof database>
|
||
): Promise<ParticipantEV[]> {
|
||
const db = providedDb || 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: seasonParticipantExpectedValues.id })
|
||
.from(seasonParticipantExpectedValues)
|
||
.where(
|
||
and(
|
||
eq(seasonParticipantExpectedValues.participantId, participantId),
|
||
eq(seasonParticipantExpectedValues.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(seasonParticipantExpectedValues)
|
||
// Only overwrite sourceOdds if explicitly provided; preserve existing value otherwise
|
||
.set(sourceOdds !== undefined ? { ...baseValues, sourceOdds } : baseValues)
|
||
.where(eq(seasonParticipantExpectedValues.id, existing[0].id))
|
||
.returning();
|
||
result = updated;
|
||
} else {
|
||
const [created] = await tx
|
||
.insert(seasonParticipantExpectedValues)
|
||
.values({ participantId, sportsSeasonId, ...baseValues, sourceOdds: sourceOdds ?? null })
|
||
.returning();
|
||
result = created;
|
||
}
|
||
|
||
await tx
|
||
.update(seasonParticipants)
|
||
.set({ expectedValue: expectedValue.toString(), updatedAt: now })
|
||
.where(eq(seasonParticipants.id, participantId));
|
||
|
||
results.push(result);
|
||
}
|
||
});
|
||
|
||
// Sync VORP for all affected seasons
|
||
const uniqueSeasonIds = [...new Set(inputs.map((i) => i.sportsSeasonId))];
|
||
await Promise.all(uniqueSeasonIds.map((id) => syncVorpForSeason(id, db)));
|
||
|
||
return results;
|
||
}
|
||
|
||
/**
|
||
* Save American odds for a batch of seasonParticipants 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: seasonParticipantExpectedValues.id })
|
||
.from(seasonParticipantExpectedValues)
|
||
.where(
|
||
and(
|
||
eq(seasonParticipantExpectedValues.participantId, participantId),
|
||
eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)
|
||
)
|
||
)
|
||
.limit(1);
|
||
|
||
if (existing.length > 0) {
|
||
await tx
|
||
.update(seasonParticipantExpectedValues)
|
||
.set({ sourceOdds, updatedAt: now })
|
||
.where(eq(seasonParticipantExpectedValues.id, existing[0].id));
|
||
} else {
|
||
// Insert a stub record — probabilities/EV will be filled in by the simulator
|
||
await tx.insert(seasonParticipantExpectedValues).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,
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
await batchUpsertParticipantSimulatorInputs(
|
||
inputs.map((input) => ({
|
||
participantId: input.participantId,
|
||
sportsSeasonId: input.sportsSeasonId,
|
||
sourceOdds: input.sourceOdds,
|
||
}))
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Persist raw Elo ratings (and optional world rankings) for a batch of seasonParticipants.
|
||
* 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 | null; worldRanking?: number | null }>
|
||
): Promise<void> {
|
||
if (inputs.length === 0) return;
|
||
const db = database();
|
||
const now = new Date();
|
||
|
||
await db
|
||
.insert(seasonParticipantExpectedValues)
|
||
.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: [seasonParticipantExpectedValues.participantId, seasonParticipantExpectedValues.sportsSeasonId],
|
||
set: {
|
||
sourceElo: sql`excluded.source_elo`,
|
||
worldRanking: sql`excluded.world_ranking`,
|
||
updatedAt: sql`excluded.updated_at`,
|
||
},
|
||
});
|
||
|
||
await batchUpsertParticipantSimulatorInputs(
|
||
inputs.map((input) => ({
|
||
participantId: input.participantId,
|
||
sportsSeasonId: input.sportsSeasonId,
|
||
sourceElo: input.sourceElo,
|
||
worldRanking: input.worldRanking ?? null,
|
||
}))
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
* NOTE: This function does NOT sync VORP. If calling this directly (outside
|
||
* of recalculateAllEVsForSeason), call syncVorpForSeason(sportsSeasonId)
|
||
* afterwards to keep draft order correct.
|
||
*/
|
||
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(seasonParticipantExpectedValues)
|
||
.set({
|
||
expectedValue: newEV.toString(),
|
||
calculatedAt: now,
|
||
updatedAt: now,
|
||
})
|
||
.where(eq(seasonParticipantExpectedValues.id, existing.id))
|
||
.returning();
|
||
|
||
// Sync recalculated EV to seasonParticipants table
|
||
await db
|
||
.update(seasonParticipants)
|
||
.set({ expectedValue: newEV.toString(), updatedAt: now })
|
||
.where(eq(seasonParticipants.id, participantId));
|
||
|
||
return updated[0];
|
||
}
|
||
|
||
/**
|
||
* Recalculate EVs for all seasonParticipants 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)
|
||
)
|
||
);
|
||
|
||
// Sync VORP once after all EVs are updated
|
||
await syncVorpForSeason(sportsSeasonId);
|
||
|
||
return allEVs.length;
|
||
}
|