Fix NCAAW futures odds simulation and admin import UX

- 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 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-05-13 00:42:39 -07:00
parent c88ae6b745
commit a61bb19f71
7 changed files with 140 additions and 17 deletions

View file

@ -6,8 +6,8 @@
*/ */
import { database } from "~/database/context"; import { database } from "~/database/context";
import { seasonParticipantExpectedValues, seasonParticipants } from "~/database/schema"; import { seasonParticipantExpectedValues, seasonParticipants, seasonParticipantSimulatorInputs } from "~/database/schema";
import { eq, and, count, sql } from "drizzle-orm"; import { eq, and, count, inArray, sql } from "drizzle-orm";
import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator"; import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator";
import { calculateEV, normalizeProbabilities, calculateReplacementLevel, calculateVORP } from "~/services/ev-calculator"; import { calculateEV, normalizeProbabilities, calculateReplacementLevel, calculateVORP } from "~/services/ev-calculator";
import { batchSaveParticipantSimulatorSourceOdds, batchUpsertParticipantSimulatorInputs } from "~/models/simulator"; import { batchSaveParticipantSimulatorSourceOdds, batchUpsertParticipantSimulatorInputs } from "~/models/simulator";
@ -421,6 +421,40 @@ export async function batchSaveSourceOdds(
await batchSaveParticipantSimulatorSourceOdds(inputs); 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. * Persist raw Elo ratings (and optional world rankings) for a batch of seasonParticipants.
* Used by Elo-based simulators like snooker_bracket and darts_bracket. * Used by Elo-based simulators like snooker_bracket and darts_bracket.

View file

@ -342,6 +342,59 @@ export async function batchSaveParticipantSimulatorSourceOdds(
}); });
} }
export async function batchSaveFuturesOddsForSimulator(
inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }>
): Promise<void> {
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( export async function batchUpsertParticipantSimulatorInputs(
inputs: UpsertParticipantSimulatorInput[] inputs: UpsertParticipantSimulatorInput[]
): Promise<void> { ): Promise<void> {

View file

@ -4,7 +4,8 @@ import type { Route } from './+types/admin.sports-seasons.$id.futures-odds';
import { logger } from '~/lib/logger'; import { logger } from '~/lib/logger';
import { findSportsSeasonById } from '~/models/sports-season'; import { findSportsSeasonById } from '~/models/sports-season';
import { findParticipantsBySportsSeasonId } from '~/models/season-participant'; 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 { Button } from '~/components/ui/button';
import { Input } from '~/components/ui/input'; import { Input } from '~/components/ui/input';
import { Label } from '~/components/ui/label'; 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.' }; return { success: false, message: 'A simulation is already running. Please wait.' };
} }
const shouldClearExisting = formData.get('clearExisting') === '1';
try { try {
// Persist the odds first so the simulator can read them. const oddsInputs = futuresOdds.map(({ participantId, odds }) => ({ participantId, sportsSeasonId, sourceOdds: odds }));
await batchSaveSourceOdds(
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); await runSportsSeasonSimulation(sportsSeasonId);
} catch (error) { } catch (error) {
logger.error('Error running simulation:', error); logger.error('Error running simulation:', error);
@ -135,6 +147,7 @@ export default function AdminSportsSeasonFuturesOdds() {
}); });
// Bulk import state // Bulk import state
const [clearExisting, setClearExisting] = useState(false);
const [bulkText, setBulkText] = useState(''); const [bulkText, setBulkText] = useState('');
const [parseResults, setParseResults] = useState<{ const [parseResults, setParseResults] = useState<{
matched: Array<{ participantId: string; name: string; odds: number; inputName: string }>; matched: Array<{ participantId: string; name: string; odds: number; inputName: string }>;
@ -194,13 +207,14 @@ export default function AdminSportsSeasonFuturesOdds() {
function applyMatches() { function applyMatches() {
if (!parseResults) return; if (!parseResults) return;
const newOdds = { ...oddsValues }; const newOdds = clearExisting ? {} : { ...oddsValues };
for (const m of parseResults.matched) { for (const m of parseResults.matched) {
newOdds[m.participantId] = m.odds.toString(); newOdds[m.participantId] = m.odds.toString();
} }
setOddsValues(newOdds); setOddsValues(newOdds);
setParseResults(null); setParseResults(null);
setBulkText(''); setBulkText('');
setClearExisting(false);
} }
const isSubmitting = navigation.state === 'submitting'; const isSubmitting = navigation.state === 'submitting';
@ -232,9 +246,20 @@ export default function AdminSportsSeasonFuturesOdds() {
rows={8} rows={8}
className="font-mono text-sm" className="font-mono text-sm"
/> />
<Button type="button" variant="outline" onClick={parseBulkText} disabled={!bulkText.trim()}> <div className="flex items-center gap-4">
Parse Odds <Button type="button" variant="outline" onClick={parseBulkText} disabled={!bulkText.trim()}>
</Button> Parse Odds
</Button>
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
<input
type="checkbox"
checked={clearExisting}
onChange={e => setClearExisting(e.target.checked)}
className="h-4 w-4"
/>
Clear existing odds not in this import
</label>
</div>
{parseResults && ( {parseResults && (
<div className="space-y-3"> <div className="space-y-3">
@ -300,6 +325,7 @@ export default function AdminSportsSeasonFuturesOdds() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Form method="post" className="space-y-4"> <Form method="post" className="space-y-4">
<input type="hidden" name="clearExisting" value={clearExisting ? "1" : ""} />
<div className="space-y-3"> <div className="space-y-3">
{participants.map((participant) => ( {participants.map((participant) => (
<div key={participant.id} className="grid grid-cols-2 gap-4 items-center"> <div key={participant.id} className="grid grid-cols-2 gap-4 items-center">

View file

@ -265,9 +265,16 @@ export function convertFuturesToElo(
// Step 5: Map to Elo scale // Step 5: Map to Elo scale
const eloRatings = new Map<string, number>(); const eloRatings = new Map<string, number>();
// 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) => { futuresOdds.forEach(({ participantId }, index) => {
const elo = mapToElo(strengths[index], minStrength, maxStrength, params); const elo = mapToElo(strengths[index], minStrength, maxStrength, params);
eloRatings.set(participantId, Math.round(elo)); // Round to integer eloRatings.set(participantId, elo);
}); });
return eloRatings; return eloRatings;

View file

@ -1,5 +1,6 @@
import { convertFuturesToElo } from "~/services/probability-engine"; import { convertFuturesToElo } from "~/services/probability-engine";
import { simulatorInputLabel, type SimulatorManifestProfile } from "./manifest"; import { simulatorInputLabel, type SimulatorManifestProfile } from "./manifest";
import { logger } from "~/lib/logger";
export type MissingEloStrategy = "block" | "fallbackElo" | "averageKnown" | "worstKnownMinus"; export type MissingEloStrategy = "block" | "fallbackElo" | "averageKnown" | "worstKnownMinus";
export type MissingRatingStrategy = "block" | "fallbackRating" | "averageKnown" | "worstKnownMinus"; export type MissingRatingStrategy = "block" | "fallbackRating" | "averageKnown" | "worstKnownMinus";
@ -227,6 +228,12 @@ export function resolveRatings(
const oddsInputs = inputs const oddsInputs = inputs
.filter((input) => !resolved.has(input.participantId) && input.sourceOdds !== null && input.sourceOdds !== undefined) .filter((input) => !resolved.has(input.participantId) && input.sourceOdds !== null && input.sourceOdds !== undefined)
.map((input) => ({ participantId: input.participantId, odds: input.sourceOdds as number })); .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) { if (oddsInputs.length >= 2) {
const converted = convertFuturesToElo(oddsInputs, "american", { const converted = convertFuturesToElo(oddsInputs, "american", {
exponent: 0.33, exponent: 0.33,

View file

@ -79,7 +79,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "ratings", "futuresOdds", "bracket"], setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
}, },
ncaaw_bracket: { ncaaw_bracket: {
defaultConfig: { ...BASE_CONFIG, ratingType: "barthag", inputPolicy: { ratingMin: 0.15, ratingMax: 0.99, fallbackRatingDelta: 0.05 } }, defaultConfig: { ...BASE_CONFIG, inputPolicy: { ratingMin: 0.70, ratingMax: 0.97, missingRatingStrategy: "worstKnownMinus", fallbackRatingDelta: 0.01 } },
requiredInputs: ["rating"], requiredInputs: ["rating"],
optionalInputs: ["sourceOdds", "seed", "region"], optionalInputs: ["sourceOdds", "seed", "region"],
derivableInputs: { rating: ["sourceOdds"] }, derivableInputs: { rating: ["sourceOdds"] },

View file

@ -1,10 +1,6 @@
import { NCAABasketballSimulator } from "./ncaa-basketball-simulator"; import { NCAABasketballSimulator } from "./ncaa-basketball-simulator";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
/**
* NCAAW neutral-court win probability using a Barthag-style Log5 formula.
* Ratings are season-scoped simulator inputs, not hardcoded data.
*/
export function barthagWinProbability(barthagA: number, barthagB: number): number { export function barthagWinProbability(barthagA: number, barthagB: number): number {
const pA = barthagA * (1 - barthagB); const pA = barthagA * (1 - barthagB);
const pB = barthagB * (1 - barthagA); const pB = barthagB * (1 - barthagA);