brackt/app/routes/admin.sports-seasons.$id.futures-odds.tsx
Chris Parsons 840212c4f8 feat: implement sports season expected value calculations and probability updates
- Added loader and action functions for managing expected values in sports seasons.
- Implemented UI for recalculating probabilities based on participant results.
- Created a service to update probabilities after results are finalized, including handling finished and unfinished participants.
- Developed tests for the probability updater service to ensure correct functionality.
- Introduced a preview feature to show potential changes before applying updates.
2025-11-21 22:05:50 -08:00

414 lines
17 KiB
TypeScript

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 {
upsertParticipantEV,
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 from preview (passed through form)
// This ensures saved values match exactly what user saw in preview
for (const { participantId, odds } of futuresOdds) {
// Debug: check what's in form data
const probFirstRaw = formData.get(`prob_first_${participantId}`);
const probSecondRaw = formData.get(`prob_second_${participantId}`);
console.log(`[Futures Odds Save DEBUG] Looking for participant ${participantId}`);
console.log(` prob_first_${participantId} = ${probFirstRaw} (type: ${typeof probFirstRaw})`);
console.log(` prob_second_${participantId} = ${probSecondRaw} (type: ${typeof probSecondRaw})`);
// Read probabilities from form data (preview results)
const probFirst = parseFloat(formData.get(`prob_first_${participantId}`) as string) || 0;
const probSecond = parseFloat(formData.get(`prob_second_${participantId}`) as string) || 0;
const probThird = parseFloat(formData.get(`prob_third_${participantId}`) as string) || 0;
const probFourth = parseFloat(formData.get(`prob_fourth_${participantId}`) as string) || 0;
const probFifth = parseFloat(formData.get(`prob_fifth_${participantId}`) as string) || 0;
const probSixth = parseFloat(formData.get(`prob_sixth_${participantId}`) as string) || 0;
const probSeventh = parseFloat(formData.get(`prob_seventh_${participantId}`) as string) || 0;
const probEighth = parseFloat(formData.get(`prob_eighth_${participantId}`) as string) || 0;
console.log(`[Futures Odds Save] ${participantId}: P(1st)=${probFirst}, P(2nd)=${probSecond}, P(3rd)=${probThird}, P(4th)=${probFourth}, P(5th)=${probFifth}, P(6th)=${probSixth}, P(7th)=${probSeventh}, P(8th)=${probEighth}, sum=${probFirst+probSecond+probThird+probFourth+probFifth+probSixth+probSeventh+probEighth}`);
// Use upsertParticipantEV (not WithNormalization) because ICM probabilities
// are already correct - they sum to <1.0 for teams unlikely to finish top 8
const result = await upsertParticipantEV({
participantId,
sportsSeasonId: sportsSeasonId,
probabilities: {
probFirst,
probSecond,
probThird,
probFourth,
probFifth,
probSixth,
probSeventh,
probEighth,
},
scoringRules,
source: 'futures_odds',
sourceOdds: odds,
});
console.log(`[Futures Odds Save] Saved ${participantId}: DB probFirst=${result.probFirst}, EV=${result.expectedValue}`);
}
console.log('[Futures Odds Save] All probabilities saved, redirecting...');
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<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;
});
const isSubmitting = navigation.state === 'submitting';
const isGenerating = isSubmitting && navigation.formData?.get('intent') === 'preview';
const isSaving = isSubmitting && navigation.formData?.get('intent') === 'save';
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>
<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.
Generates probabilities for all participants using ICM (Independent Chip Model).
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-4">
<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>
<div className="flex gap-2">
<Button
type="submit"
name="intent"
value="preview"
variant="outline"
disabled={isSubmitting}
>
{isGenerating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Generate Preview
</Button>
</div>
</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>
{actionData?.preview && (
<Card>
<CardHeader>
<CardTitle>ICM Results</CardTitle>
<CardDescription>
Probability distributions calculated using Independent Chip Model
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2">Team</th>
<th className="text-right py-2">Odds</th>
<th className="text-right py-2">P(1st)</th>
<th className="text-right py-2">P(2nd)</th>
<th className="text-right py-2">P(3rd)</th>
<th className="text-right py-2">P(4th)</th>
<th className="text-right py-2">P(5th)</th>
<th className="text-right py-2">P(6th)</th>
<th className="text-right py-2">P(7th)</th>
<th className="text-right py-2">P(8th)</th>
</tr>
</thead>
<tbody>
{actionData.preview
.sort((a, b) => b.probabilities[0] - a.probabilities[0])
.map((result) => (
<tr key={result.participantId} className="border-b">
<td className="py-2">{result.participantName}</td>
<td className="text-right">
{result.odds > 0 ? '+' : ''}
{result.odds}
</td>
<td className="text-right">
{(result.probabilities[0] * 100).toFixed(1)}%
</td>
<td className="text-right">
{(result.probabilities[1] * 100).toFixed(1)}%
</td>
<td className="text-right">
{(result.probabilities[2] * 100).toFixed(1)}%
</td>
<td className="text-right">
{(result.probabilities[3] * 100).toFixed(1)}%
</td>
<td className="text-right">
{(result.probabilities[4] * 100).toFixed(1)}%
</td>
<td className="text-right">
{(result.probabilities[5] * 100).toFixed(1)}%
</td>
<td className="text-right">
{(result.probabilities[6] * 100).toFixed(1)}%
</td>
<td className="text-right">
{(result.probabilities[7] * 100).toFixed(1)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
<Form method="post">
{/* Pass through odds and preview probabilities */}
{actionData.preview.map((result) => (
<div key={result.participantId}>
<input type="hidden" name={`odds_${result.participantId}`} value={result.odds} />
<input type="hidden" name={`prob_first_${result.participantId}`} value={result.probabilities[0]} />
<input type="hidden" name={`prob_second_${result.participantId}`} value={result.probabilities[1]} />
<input type="hidden" name={`prob_third_${result.participantId}`} value={result.probabilities[2]} />
<input type="hidden" name={`prob_fourth_${result.participantId}`} value={result.probabilities[3]} />
<input type="hidden" name={`prob_fifth_${result.participantId}`} value={result.probabilities[4]} />
<input type="hidden" name={`prob_sixth_${result.participantId}`} value={result.probabilities[5]} />
<input type="hidden" name={`prob_seventh_${result.participantId}`} value={result.probabilities[6]} />
<input type="hidden" name={`prob_eighth_${result.participantId}`} value={result.probabilities[7]} />
</div>
))}
<Button type="submit" name="intent" value="save" className="w-full" disabled={isSaving}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isSaving ? 'Saving...' : 'Save Probabilities'}
</Button>
</Form>
</div>
</CardContent>
</Card>
)}
{actionData && !actionData.success && actionData.message && (
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="text-destructive font-medium">Error</div>
<div className="text-sm mt-2">{actionData.message}</div>
</CardContent>
</Card>
)}
{actionData && actionData.success && !actionData.preview && (
<Card className="border-green-500">
<CardContent className="pt-6">
<div className="text-green-700 font-medium">Success</div>
<div className="text-sm mt-2">{actionData.message}</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}