import { Form, redirect, useLoaderData, useActionData, useNavigation } from 'react-router'; import type { Route } from './+types/admin.sports-seasons.$id.futures-odds'; import { findSportsSeasonById } from '~/models/sports-season'; import { findParticipantsBySportsSeasonId } from '~/models/participant'; import { getAllParticipantEVsForSeason } from '~/models/participant-expected-value'; import { Button } from '~/components/ui/button'; import { Input } from '~/components/ui/input'; import { Label } from '~/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '~/components/ui/card'; import { useState } from 'react'; import { calculateICMFromOdds, icmResultToArray, } from '~/services/icm-calculator'; import { upsertParticipantEVWithNormalization, } from '~/models/participant-expected-value'; import { Loader2, Info } from 'lucide-react'; 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); // Create map of participant ID to existing odds (if source is futures_odds) const existingOdds = new Map( existingEVs .filter(ev => ev.source === 'futures_odds' && ev.sourceOdds !== null) .map(ev => [ev.participantId, ev.sourceOdds]) ); return { sportsSeason: sportsSeason as typeof sportsSeason & { sport: { id: string; name: string; type: string; slug: string } }, participants, existingOdds, }; } interface ActionData { success?: boolean; message?: string; preview?: { participantId: string; participantName: string; odds: number; probabilities: number[]; }[]; } export async function action({ request, params }: Route.ActionArgs) { const sportsSeasonId = params.id; const formData = await request.formData(); const intent = formData.get('intent') as string; 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' }; } try { // Calculate ICM probabilities from odds const icmResults = calculateICMFromOdds( futuresOdds.map(({ participantId, odds }) => ({ participantId, odds })) ); if (intent === 'preview') { // Return preview data const preview = futuresOdds.map(({ participantId, odds, name }) => { const icmResult = icmResults.get(participantId)!; return { participantId, participantName: name, odds, probabilities: icmResultToArray(icmResult), }; }); return { success: true, preview }; } if (intent === 'save') { // Use default scoring rules (EVs will be recalculated per league) const scoringRules = { pointsFor1st: 100, pointsFor2nd: 70, pointsFor3rd: 50, pointsFor4th: 40, pointsFor5th: 25, pointsFor6th: 25, pointsFor7th: 15, pointsFor8th: 15, }; // Save probabilities to database for (const { participantId, odds } of futuresOdds) { const icmResult = icmResults.get(participantId)!; const probDist = icmResultToArray(icmResult); await upsertParticipantEVWithNormalization({ participantId, sportsSeasonId: sportsSeasonId, probabilities: { probFirst: probDist[0] * 100, probSecond: probDist[1] * 100, probThird: probDist[2] * 100, probFourth: probDist[3] * 100, probFifth: probDist[4] * 100, probSixth: probDist[5] * 100, probSeventh: probDist[6] * 100, probEighth: probDist[7] * 100, }, scoringRules, source: 'futures_odds', sourceOdds: odds, }); } return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`); } return { success: false, message: 'Invalid intent' }; } catch (error) { console.error('Error generating probabilities:', error); return { success: false, message: error instanceof Error ? error.message : 'Failed to generate probabilities', }; } } export default function AdminSportsSeasonFuturesOdds() { const { sportsSeason, participants, existingOdds } = useLoaderData(); const actionData = useActionData(); const navigation = useNavigation(); // Initialize odds values from existing data const [oddsValues, setOddsValues] = useState>(() => { const initial: Record = {}; participants.forEach(p => { const existingOdd = existingOdds.get(p.id); if (existingOdd !== undefined && existingOdd !== null) { initial[p.id] = existingOdd.toString(); } }); return initial; }); const isSubmitting = navigation.state === 'submitting'; const isGenerating = isSubmitting && navigation.formData?.get('intent') === 'preview'; const isSaving = isSubmitting && navigation.formData?.get('intent') === 'save'; return (

Futures Odds Entry

{sportsSeason.sport.name} - {sportsSeason.name}

Championship Futures Odds Enter American odds (e.g., +550, -200) for each participant's championship probability. Generates probabilities for all participants using ICM (Independent Chip Model).
{participants.map((participant) => (
setOddsValues((prev) => ({ ...prev, [participant.id]: e.target.value, })) } />
))}
ICM Calculation
Uses Independent Chip Model from poker tournaments
Works with any number of participants
Every team gets probabilities for 1st-8th place, even longshots.
How It Works
  1. Converts American odds to championship win probabilities
  2. Removes bookmaker vig (normalizes to 100%)
  3. Uses ICM algorithm to distribute probabilities across all placements
  4. Generates probability distribution (1st through 8th place) for ALL participants
  5. Even teams with +100000 odds get non-zero probabilities
{actionData?.preview && ( ICM Results Probability distributions calculated using Independent Chip Model
{actionData.preview .sort((a, b) => b.probabilities[0] - a.probabilities[0]) .map((result) => ( ))}
Team Odds P(1st) P(2nd) P(3rd) P(4th) P(5th) P(6th) P(7th) P(8th)
{result.participantName} {result.odds > 0 ? '+' : ''} {result.odds} {(result.probabilities[0] * 100).toFixed(1)}% {(result.probabilities[1] * 100).toFixed(1)}% {(result.probabilities[2] * 100).toFixed(1)}% {(result.probabilities[3] * 100).toFixed(1)}% {(result.probabilities[4] * 100).toFixed(1)}% {(result.probabilities[5] * 100).toFixed(1)}% {(result.probabilities[6] * 100).toFixed(1)}% {(result.probabilities[7] * 100).toFixed(1)}%
{/* Re-submit all odds values */} {actionData.preview.map((result) => ( ))}
)} {actionData && !actionData.success && actionData.message && (
Error
{actionData.message}
)} {actionData && actionData.success && !actionData.preview && (
Success
{actionData.message}
)}
); }