- 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.
414 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|