- 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>
394 lines
15 KiB
TypeScript
394 lines
15 KiB
TypeScript
import { Form, redirect, useLoaderData, useActionData, useNavigation } from 'react-router';
|
|
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, 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';
|
|
import { Textarea } from '~/components/ui/textarea';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '~/components/ui/card';
|
|
import { useState } from 'react';
|
|
import { Loader2, Info, CheckCircle2, AlertCircle } from 'lucide-react';
|
|
import { runSportsSeasonSimulation } from '~/services/simulations/runner';
|
|
|
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
|
return [{ title: `Futures Odds — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
|
}
|
|
|
|
export async function loader({ params }: Route.LoaderArgs) {
|
|
const sportsSeasonId = params.id;
|
|
|
|
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
|
|
|
|
if (!sportsSeason) {
|
|
throw new Response('Sports season not found', { status: 404 });
|
|
}
|
|
|
|
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
|
|
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
|
|
|
|
// Show any saved odds regardless of what simulation ran afterwards
|
|
const existingOdds = new Map(
|
|
existingEVs
|
|
.filter(ev => ev.sourceOdds !== null)
|
|
.map(ev => [ev.participantId, ev.sourceOdds])
|
|
);
|
|
|
|
return {
|
|
sportsSeason,
|
|
participants,
|
|
existingOdds,
|
|
};
|
|
}
|
|
|
|
interface ActionData {
|
|
success?: boolean;
|
|
message?: string;
|
|
}
|
|
|
|
export async function action({ request, params }: Route.ActionArgs) {
|
|
const sportsSeasonId = params.id;
|
|
const formData = await request.formData();
|
|
|
|
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
|
|
|
|
if (!sportsSeason) {
|
|
return { success: false, message: 'Sports season not found' };
|
|
}
|
|
|
|
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
|
|
|
|
// Parse odds from form
|
|
const futuresOdds: Array<{ participantId: string; odds: number; name: string }> = [];
|
|
|
|
for (const participant of participants) {
|
|
const oddsValue = formData.get(`odds_${participant.id}`) as string;
|
|
if (oddsValue && oddsValue.trim() !== '') {
|
|
const odds = Number(oddsValue);
|
|
if (!isNaN(odds)) {
|
|
futuresOdds.push({
|
|
participantId: participant.id,
|
|
odds,
|
|
name: participant.name,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (futuresOdds.length === 0) {
|
|
return { success: false, message: 'Please enter odds for at least one participant' };
|
|
}
|
|
|
|
if (!sportsSeason.sport?.simulatorType) {
|
|
return { success: false, message: 'This sport has no simulator type configured. Set one on the sport in the admin panel.' };
|
|
}
|
|
|
|
if (sportsSeason.simulationStatus === 'running') {
|
|
return { success: false, message: 'A simulation is already running. Please wait.' };
|
|
}
|
|
|
|
const shouldClearExisting = formData.get('clearExisting') === '1';
|
|
|
|
try {
|
|
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);
|
|
return {
|
|
success: false,
|
|
message: error instanceof Error ? error.message : 'Simulation failed',
|
|
};
|
|
}
|
|
|
|
return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`);
|
|
}
|
|
|
|
function normalizeName(name: string): string {
|
|
return name.toLowerCase().replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
export default function AdminSportsSeasonFuturesOdds() {
|
|
const { sportsSeason, participants, existingOdds } = useLoaderData<typeof loader>();
|
|
const actionData = useActionData<ActionData>();
|
|
const navigation = useNavigation();
|
|
|
|
// Initialize odds values from existing data
|
|
const [oddsValues, setOddsValues] = useState<Record<string, string>>(() => {
|
|
const initial: Record<string, string> = {};
|
|
participants.forEach(p => {
|
|
const existingOdd = existingOdds.get(p.id);
|
|
if (existingOdd !== undefined && existingOdd !== null) {
|
|
initial[p.id] = existingOdd.toString();
|
|
}
|
|
});
|
|
return initial;
|
|
});
|
|
|
|
// 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 }>;
|
|
unmatched: Array<{ inputName: string; odds: number }>;
|
|
} | null>(null);
|
|
|
|
function findParticipantMatch(inputName: string) {
|
|
const normalizedInput = normalizeName(inputName);
|
|
const normalized = participants.map(p => ({ p, n: normalizeName(p.name) }));
|
|
|
|
const exact = normalized.find(({ n }) => n === normalizedInput);
|
|
if (exact) return exact.p;
|
|
|
|
const contains = normalized.find(({ n }) => n.includes(normalizedInput) || normalizedInput.includes(n));
|
|
if (contains) return contains.p;
|
|
|
|
const inputWords = normalizedInput.split(' ').filter(w => w.length > 2);
|
|
const overlap = normalized.find(({ n }) => {
|
|
const pWords = n.split(' ').filter(w => w.length > 2);
|
|
const shared = inputWords.filter(w => pWords.includes(w));
|
|
return shared.length > 0 && shared.length >= Math.min(inputWords.length, pWords.length) * 0.5;
|
|
});
|
|
|
|
return overlap?.p ?? null;
|
|
}
|
|
|
|
function parseBulkText() {
|
|
const lines = bulkText.split('\n');
|
|
const oddsPattern = /^(.+?)\s+([+-]\d{2,6})\s*$/;
|
|
|
|
const matched: Array<{ participantId: string; name: string; odds: number; inputName: string }> = [];
|
|
const unmatched: Array<{ inputName: string; odds: number }> = [];
|
|
const seenParticipants = new Set<string>();
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.length < 3) continue;
|
|
|
|
const match = oddsPattern.exec(trimmed);
|
|
if (!match) continue;
|
|
|
|
const inputName = match[1].trim();
|
|
const odds = parseInt(match[2], 10);
|
|
if (isNaN(odds)) continue;
|
|
|
|
const participant = findParticipantMatch(inputName);
|
|
if (participant && !seenParticipants.has(participant.id)) {
|
|
seenParticipants.add(participant.id);
|
|
matched.push({ participantId: participant.id, name: participant.name, odds, inputName });
|
|
} else if (!participant) {
|
|
unmatched.push({ inputName, odds });
|
|
}
|
|
}
|
|
|
|
setParseResults({ matched, unmatched });
|
|
}
|
|
|
|
function applyMatches() {
|
|
if (!parseResults) return;
|
|
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';
|
|
|
|
return (
|
|
<div className="container mx-auto py-8">
|
|
<div className="mb-8">
|
|
<h1 className="text-3xl font-bold mb-2">Futures Odds Entry</h1>
|
|
<p className="text-muted-foreground">
|
|
{sportsSeason.sport.name} - {sportsSeason.name}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Bulk Import */}
|
|
<Card className="mb-6">
|
|
<CardHeader>
|
|
<CardTitle>Bulk Import</CardTitle>
|
|
<CardDescription>
|
|
Paste odds from FanDuel, DraftKings, OddsChecker, or any site. Each line should end with
|
|
American odds (e.g. <code>Kansas City Chiefs +450</code>). Team names are fuzzy-matched to
|
|
participants.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<Textarea
|
|
placeholder={`Paste odds here, one team per line:\nKansas City Chiefs +450\nSan Francisco 49ers +600\nBaltimore Ravens +700`}
|
|
value={bulkText}
|
|
onChange={e => { setBulkText(e.target.value); setParseResults(null); }}
|
|
rows={8}
|
|
className="font-mono text-sm"
|
|
/>
|
|
<div className="flex items-center gap-4">
|
|
<Button type="button" variant="outline" onClick={parseBulkText} disabled={!bulkText.trim()}>
|
|
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 && (
|
|
<div className="space-y-3">
|
|
{parseResults.matched.length > 0 && (
|
|
<div>
|
|
<div className="flex items-center gap-2 text-sm font-medium text-emerald-400 mb-2">
|
|
<CheckCircle2 className="h-4 w-4" />
|
|
Matched ({parseResults.matched.length})
|
|
</div>
|
|
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/10 divide-y divide-emerald-500/20 text-sm">
|
|
{parseResults.matched.map(m => (
|
|
<div key={m.participantId} className="flex justify-between px-3 py-1.5">
|
|
<span className="text-muted-foreground">{m.inputName}</span>
|
|
<span className="font-medium">
|
|
{m.name} → {m.odds > 0 ? '+' : ''}{m.odds}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{parseResults.unmatched.length > 0 && (
|
|
<div>
|
|
<div className="flex items-center gap-2 text-sm font-medium text-amber-700 mb-2">
|
|
<AlertCircle className="h-4 w-4" />
|
|
Not matched ({parseResults.unmatched.length}) — enter manually below
|
|
</div>
|
|
<div className="rounded-md border border-amber-200 bg-amber-50 divide-y divide-amber-100 text-sm">
|
|
{parseResults.unmatched.map((u) => (
|
|
<div key={u.inputName} className="flex justify-between px-3 py-1.5">
|
|
<span>{u.inputName}</span>
|
|
<span className="font-medium">{u.odds > 0 ? '+' : ''}{u.odds}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{parseResults.matched.length === 0 && parseResults.unmatched.length === 0 && (
|
|
<p className="text-sm text-muted-foreground">No odds found. Make sure each line ends with a value like +450 or -200.</p>
|
|
)}
|
|
|
|
{parseResults.matched.length > 0 && (
|
|
<Button type="button" onClick={applyMatches}>
|
|
Apply {parseResults.matched.length} matched odds to form
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
<div>
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Championship Futures Odds</CardTitle>
|
|
<CardDescription>
|
|
Enter American odds (e.g., +550, -200) for each participant's championship probability.
|
|
Enter American odds, then run the ICM simulation to compute and save probability distributions.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Form method="post" className="space-y-4">
|
|
<input type="hidden" name="clearExisting" value={clearExisting ? "1" : ""} />
|
|
<div className="space-y-3">
|
|
{participants.map((participant) => (
|
|
<div key={participant.id} className="grid grid-cols-2 gap-4 items-center">
|
|
<Label htmlFor={`odds_${participant.id}`}>{participant.name}</Label>
|
|
<Input
|
|
type="number"
|
|
id={`odds_${participant.id}`}
|
|
name={`odds_${participant.id}`}
|
|
placeholder="+550"
|
|
value={oddsValues[participant.id] ?? ''}
|
|
onChange={(e) =>
|
|
setOddsValues((prev) => ({
|
|
...prev,
|
|
[participant.id]: e.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="bg-muted p-4 rounded-lg space-y-2">
|
|
<div className="flex items-center gap-2 font-medium">
|
|
<Info className="h-4 w-4" />
|
|
ICM Calculation
|
|
</div>
|
|
<div className="space-y-1 text-sm">
|
|
<div>Uses Independent Chip Model from poker tournaments</div>
|
|
<div>Works with any number of participants</div>
|
|
<div className="text-xs text-muted-foreground mt-2">
|
|
Every team gets probabilities for 1st-8th place, even longshots.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{actionData && !actionData.success && actionData.message && (
|
|
<div className="text-sm text-destructive">{actionData.message}</div>
|
|
)}
|
|
|
|
<Button type="submit" disabled={isSubmitting}>
|
|
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
{isSubmitting ? 'Running...' : 'Run Simulation'}
|
|
</Button>
|
|
</Form>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="mt-4">
|
|
<CardHeader>
|
|
<CardTitle>How It Works</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="text-sm space-y-2">
|
|
<ol className="list-decimal list-inside space-y-2">
|
|
<li>Converts American odds to championship win probabilities</li>
|
|
<li>Removes bookmaker vig (normalizes to 100%)</li>
|
|
<li>Uses ICM algorithm to distribute probabilities across all placements</li>
|
|
<li>Generates probability distribution (1st through 8th place) for ALL participants</li>
|
|
<li>Even teams with +100000 odds get non-zero probabilities</li>
|
|
</ol>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|