brackt/app/models/participant-expected-value.ts
Claude dc4efe87cf
Unify futures odds with the simulation system
Make the futures-vs-Elo relationship an explicit, configurable rule and fix
futures odds failing to override stored Elo.

Root causes addressed:
- resolveSourceElos/resolveRatings used a hardcoded precedence (direct Elo ->
  projectedWins -> projectedTablePoints -> odds). The prior fix nulled sourceElo
  on futures entry but not projections, which still outranked odds.
- The override relied on a destructive null-on-save hack that also wiped
  manually entered Elo.
- Blended simulators buried their Elo/odds weight in module constants.
- Futures odds were not surfaced on the /admin/simulators inventory.

Changes:
- Add a configurable source policy: sourceEloPriority (ordered) and oddsWeight,
  parsed/clamped in getSimulatorInputPolicy. resolveSourceElos/resolveRatings now
  resolve each participant by the configured priority instead of a fixed order.
  Futures-centric simulators (ncaam, ncaaw, world_cup, ncaa_football,
  college_hockey) default to a futures-override priority.
- Drop the destructive nulling in batchSaveFuturesOddsForSimulator and
  batchSaveSourceOdds; override is now governed by policy, preserving stored Elo.
- Thread an optional SimulationContext (oddsWeight) through the Simulator
  interface so blended sims (UCL, World Cup, NCAA FB, MLB) read the blend weight
  from the season policy; defaults preserve prior calibration when no context is
  passed.
- Add a "Futures vs. Elo" strategy control and Odds Blend Weight input to the
  Simulator Setup input-policy card, persisted via save-input-policy.
- Surface futures on /admin/simulators: a source badge and a Futures Odds quick
  link; extend listSportsSeasonSimulatorSummaries with odds source info.
- Tests: configurable priority override (Elo/projections/rating), oddsWeight
  parsing/clamping, prefersFuturesOdds, manifest defaults, an NCAA Football
  context-blend behavioral test, and an updated non-destructive save test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QhNeB7gN6VKene7sdbBVQT
2026-06-25 22:11:32 +00:00

607 lines
19 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, seasonParticipantSimulatorInputs } from "~/database/schema";
import { eq, and, count, inArray, sql } from "drizzle-orm";
import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator";
import { calculateEV, normalizeProbabilities, calculateReplacementLevel, calculateVORP } from "~/services/ev-calculator";
import { batchSaveParticipantSimulatorSourceOdds, 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)
// Persist the odds and label the source as odds-driven for display.
// We no longer null a stored Elo here: whether these odds override it
// is decided at run time by the configurable source priority
// (SimulatorInputPolicy.sourceEloPriority), and prepareSimulatorInputsForRun
// overwrites the derived Elo before the simulator reads it.
.set({ sourceOdds, source: "futures_odds", 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 batchSaveParticipantSimulatorSourceOdds(inputs);
}
export async function clearSourceOddsForParticipants(
sportsSeasonId: string,
participantIds: string[]
): Promise<void> {
if (participantIds.length === 0) return;
const db = database();
const now = new Date();
await db.transaction(async (tx) => {
await tx
.update(seasonParticipantExpectedValues)
.set({ sourceOdds: null, updatedAt: now })
.where(
and(
eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId),
inArray(seasonParticipantExpectedValues.participantId, participantIds)
)
);
await tx
.update(seasonParticipantSimulatorInputs)
.set({
sourceOdds: null,
rating: null,
metadata: sql`coalesce(${seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`,
updatedAt: now,
})
.where(
and(
eq(seasonParticipantSimulatorInputs.sportsSeasonId, sportsSeasonId),
inArray(seasonParticipantSimulatorInputs.participantId, participantIds)
)
);
});
}
/**
* 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;
projectedWins?: number | null;
projectedTablePoints?: number | null;
metadata?: Record<string, unknown> | 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,
projectedWins: input.projectedWins ?? null,
projectedTablePoints: input.projectedTablePoints ?? null,
metadata: input.metadata ?? 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;
}