brackt/app/models/participant-expected-value.ts
Chris Parsons 4a167d8cad Formalize simulator system with manifest, input-policy, runner, and admin UI
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>
2026-05-11 21:02:56 -07:00

563 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 12th14th 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;
}