From af64a29cfa65a6f9c2285361673ec7bd587c392c Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Wed, 13 May 2026 01:06:16 -0700 Subject: [PATCH] Fix NCAAW futures odds simulation and admin import UX (#420) - Revert ncaaw-simulator to Barthag win probability formula; set realistic rating bounds (ratingMin: 0.70, ratingMax: 0.97) so derived ratings stay in the range where the formula behaves well - Add batchSaveFuturesOddsForSimulator which clears all ratings (manual and generated) before upserting sourceOdds, so futures odds always drive the simulation rather than being silently overridden by existing Barthag ratings from Simulator Setup - Add clearSourceOddsForParticipants to zero out both tables for participants excluded from a bulk import - Add "Clear existing odds" checkbox to the bulk import card; applies client-side on match and server-side on submit - Fix missing sportsSeasonId filter in batchSaveFuturesOddsForSimulator pre-clear UPDATE (could have wiped ratings across other seasons) - Fix race condition: run batchSaveSourceOdds then batchSaveFuturesOddsForSimulator sequentially so the simulator inputs table always ends in the correct cleared state - Fix Math.round in convertFuturesToElo collapsing Barthag-scale ratings to 0 or 1; Elo callers already round after clamping - Handle all-identical-odds edge case in convertFuturesToElo (assign midpoint instead of throwing) - Add missingRatingStrategy: worstKnownMinus to ncaaw_bracket manifest so fallbackRatingDelta is live config, not dead - Log warning in resolveRatings when only 1 participant has odds - Reset clearExisting checkbox after applyMatches Co-authored-by: Claude Sonnet 4.6 --- app/models/participant-expected-value.ts | 38 ++++++++++++- app/models/simulator.ts | 53 +++++++++++++++++++ .../admin.sports-seasons.$id.futures-odds.tsx | 44 +++++++++++---- app/services/probability-engine.ts | 9 +++- app/services/simulations/input-policy.ts | 7 +++ app/services/simulations/manifest.ts | 2 +- app/services/simulations/ncaaw-simulator.ts | 4 -- 7 files changed, 140 insertions(+), 17 deletions(-) diff --git a/app/models/participant-expected-value.ts b/app/models/participant-expected-value.ts index 0400df1..1eea0ea 100644 --- a/app/models/participant-expected-value.ts +++ b/app/models/participant-expected-value.ts @@ -6,8 +6,8 @@ */ import { database } from "~/database/context"; -import { seasonParticipantExpectedValues, seasonParticipants } from "~/database/schema"; -import { eq, and, count, sql } from "drizzle-orm"; +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"; @@ -421,6 +421,40 @@ export async function batchSaveSourceOdds( await batchSaveParticipantSimulatorSourceOdds(inputs); } +export async function clearSourceOddsForParticipants( + sportsSeasonId: string, + participantIds: string[] +): Promise { + 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. diff --git a/app/models/simulator.ts b/app/models/simulator.ts index 57aa08c..5cc5029 100644 --- a/app/models/simulator.ts +++ b/app/models/simulator.ts @@ -342,6 +342,59 @@ export async function batchSaveParticipantSimulatorSourceOdds( }); } +export async function batchSaveFuturesOddsForSimulator( + inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }> +): Promise { + if (inputs.length === 0) return; + const db = database(); + const now = new Date(); + const participantIds = inputs.map((input) => input.participantId); + const seasonIds = [...new Set(inputs.map((input) => input.sportsSeasonId))]; + + await db.transaction(async (tx) => { + + // Clear ALL ratings (both manual and generated) so the simulation + // re-derives ratings from the newly saved futures odds. + await tx + .update(schema.seasonParticipantSimulatorInputs) + .set({ + rating: null, + metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`, + updatedAt: now, + }) + .where( + and( + inArray(schema.seasonParticipantSimulatorInputs.sportsSeasonId, seasonIds), + inArray(schema.seasonParticipantSimulatorInputs.participantId, participantIds) + ) + ); + + await tx + .insert(schema.seasonParticipantSimulatorInputs) + .values( + inputs.map((input) => ({ + participantId: input.participantId, + sportsSeasonId: input.sportsSeasonId, + sourceOdds: input.sourceOdds, + createdAt: now, + updatedAt: now, + })) + ) + .onConflictDoUpdate({ + target: [ + schema.seasonParticipantSimulatorInputs.participantId, + schema.seasonParticipantSimulatorInputs.sportsSeasonId, + ], + set: { + sourceOdds: sql`excluded.source_odds`, + rating: null, + metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`, + updatedAt: now, + }, + }); + }); +} + export async function batchUpsertParticipantSimulatorInputs( inputs: UpsertParticipantSimulatorInput[] ): Promise { diff --git a/app/routes/admin.sports-seasons.$id.futures-odds.tsx b/app/routes/admin.sports-seasons.$id.futures-odds.tsx index c01dd3f..e1ed62b 100644 --- a/app/routes/admin.sports-seasons.$id.futures-odds.tsx +++ b/app/routes/admin.sports-seasons.$id.futures-odds.tsx @@ -4,7 +4,8 @@ import type { Route } from './+types/admin.sports-seasons.$id.futures-odds'; import { logger } from '~/lib/logger'; import { findSportsSeasonById } from '~/models/sports-season'; import { findParticipantsBySportsSeasonId } from '~/models/season-participant'; -import { getAllParticipantEVsForSeason, batchSaveSourceOdds } from '~/models/participant-expected-value'; +import { getAllParticipantEVsForSeason, batchSaveSourceOdds, clearSourceOddsForParticipants } from '~/models/participant-expected-value'; +import { batchSaveFuturesOddsForSimulator } from '~/models/simulator'; import { Button } from '~/components/ui/button'; import { Input } from '~/components/ui/input'; import { Label } from '~/components/ui/label'; @@ -96,11 +97,22 @@ export async function action({ request, params }: Route.ActionArgs) { return { success: false, message: 'A simulation is already running. Please wait.' }; } + const shouldClearExisting = formData.get('clearExisting') === '1'; + try { - // Persist the odds first so the simulator can read them. - await batchSaveSourceOdds( - futuresOdds.map(({ participantId, odds }) => ({ participantId, sportsSeasonId, sourceOdds: odds })) - ); + const oddsInputs = futuresOdds.map(({ participantId, odds }) => ({ participantId, sportsSeasonId, sourceOdds: odds })); + + // Save to legacy table (EV-page display), then clear all ratings and save + // sourceOdds to the simulator inputs table so odds always drive the run. + // Sequential: batchSaveFuturesOddsForSimulator must win on the inputs table. + await batchSaveSourceOdds(oddsInputs); + await batchSaveFuturesOddsForSimulator(oddsInputs); + + if (shouldClearExisting) { + const keptIds = new Set(futuresOdds.map(f => f.participantId)); + const clearedIds = participants.filter(p => !keptIds.has(p.id)).map(p => p.id); + await clearSourceOddsForParticipants(sportsSeasonId, clearedIds); + } await runSportsSeasonSimulation(sportsSeasonId); } catch (error) { logger.error('Error running simulation:', error); @@ -135,6 +147,7 @@ export default function AdminSportsSeasonFuturesOdds() { }); // Bulk import state + const [clearExisting, setClearExisting] = useState(false); const [bulkText, setBulkText] = useState(''); const [parseResults, setParseResults] = useState<{ matched: Array<{ participantId: string; name: string; odds: number; inputName: string }>; @@ -194,13 +207,14 @@ export default function AdminSportsSeasonFuturesOdds() { function applyMatches() { if (!parseResults) return; - const newOdds = { ...oddsValues }; + const newOdds = clearExisting ? {} : { ...oddsValues }; for (const m of parseResults.matched) { newOdds[m.participantId] = m.odds.toString(); } setOddsValues(newOdds); setParseResults(null); setBulkText(''); + setClearExisting(false); } const isSubmitting = navigation.state === 'submitting'; @@ -232,9 +246,20 @@ export default function AdminSportsSeasonFuturesOdds() { rows={8} className="font-mono text-sm" /> - +
+ + +
{parseResults && (
@@ -300,6 +325,7 @@ export default function AdminSportsSeasonFuturesOdds() {
+
{participants.map((participant) => (
diff --git a/app/services/probability-engine.ts b/app/services/probability-engine.ts index 11008f2..313bb14 100644 --- a/app/services/probability-engine.ts +++ b/app/services/probability-engine.ts @@ -265,9 +265,16 @@ export function convertFuturesToElo( // Step 5: Map to Elo scale const eloRatings = new Map(); + // All participants have identical odds — assign the midpoint rating to everyone. + if (maxStrength === minStrength) { + const midpoint = (params.eloMin + params.eloMax) / 2; + futuresOdds.forEach(({ participantId }) => eloRatings.set(participantId, midpoint)); + return eloRatings; + } + futuresOdds.forEach(({ participantId }, index) => { const elo = mapToElo(strengths[index], minStrength, maxStrength, params); - eloRatings.set(participantId, Math.round(elo)); // Round to integer + eloRatings.set(participantId, elo); }); return eloRatings; diff --git a/app/services/simulations/input-policy.ts b/app/services/simulations/input-policy.ts index aa0512e..5360ae3 100644 --- a/app/services/simulations/input-policy.ts +++ b/app/services/simulations/input-policy.ts @@ -1,5 +1,6 @@ import { convertFuturesToElo } from "~/services/probability-engine"; import { simulatorInputLabel, type SimulatorManifestProfile } from "./manifest"; +import { logger } from "~/lib/logger"; export type MissingEloStrategy = "block" | "fallbackElo" | "averageKnown" | "worstKnownMinus"; export type MissingRatingStrategy = "block" | "fallbackRating" | "averageKnown" | "worstKnownMinus"; @@ -227,6 +228,12 @@ export function resolveRatings( const oddsInputs = inputs .filter((input) => !resolved.has(input.participantId) && input.sourceOdds !== null && input.sourceOdds !== undefined) .map((input) => ({ participantId: input.participantId, odds: input.sourceOdds as number })); + if (oddsInputs.length === 1) { + logger.warn( + `resolveRatings: only 1 participant has sourceOdds (${oddsInputs[0].participantId}). ` + + `convertFuturesToElo requires at least 2 to derive a meaningful rating — falling through to missing-rating strategy.` + ); + } if (oddsInputs.length >= 2) { const converted = convertFuturesToElo(oddsInputs, "american", { exponent: 0.33, diff --git a/app/services/simulations/manifest.ts b/app/services/simulations/manifest.ts index c0dcfef..34bbe62 100644 --- a/app/services/simulations/manifest.ts +++ b/app/services/simulations/manifest.ts @@ -79,7 +79,7 @@ const PROFILES: Record