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