fix: remove probability sum validation and add batch save with total EV
- Remove the requirement that manual probabilities sum to 1.0, allowing partial distributions (e.g. when a participant has <100% chance of top 8) - Replace per-row save buttons with a single "Save All" button that submits all participants at once - Add a Total EV row at the bottom of the table summing all participant EVs - Update action to batch process all participants from a single form submission https://claude.ai/code/session_01WHRynBCcugSK7HHEmN6Yuy
This commit is contained in:
parent
eb260649b1
commit
5dd6d90193
3 changed files with 197 additions and 201 deletions
|
|
@ -9,7 +9,7 @@ import { database } from "~/database/context";
|
|||
import { participantExpectedValues, participants } from "~/database/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator";
|
||||
import { calculateEV, validateProbabilities, normalizeProbabilities } from "~/services/ev-calculator";
|
||||
import { calculateEV, normalizeProbabilities } from "~/services/ev-calculator";
|
||||
|
||||
export type ProbabilitySource = "manual" | "futures_odds" | "elo_simulation" | "performance_model";
|
||||
|
||||
|
|
@ -59,15 +59,6 @@ export async function upsertParticipantEV(
|
|||
): Promise<ParticipantEV> {
|
||||
const { participantId, sportsSeasonId, probabilities, scoringRules, source = "manual", sourceOdds } = input;
|
||||
|
||||
// Validate probabilities (only for manual entry)
|
||||
// For futures_odds/ICM, probabilities may sum to <1.0 for teams unlikely to finish top 8
|
||||
if (source === "manual" && !validateProbabilities(probabilities)) {
|
||||
const sum = Object.values(probabilities).reduce((a, b) => a + b, 0);
|
||||
throw new Error(
|
||||
`Probabilities must sum to 1.0 (±0.01). Current sum: ${sum.toFixed(4)} (${(sum * 100).toFixed(1)}%)`
|
||||
);
|
||||
}
|
||||
|
||||
// Always validate probabilities don't exceed 1.0
|
||||
const sum = Object.values(probabilities).reduce((a, b) => a + b, 0);
|
||||
if (sum > 1.01) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import {
|
|||
upsertParticipantEV,
|
||||
getAllParticipantEVsForSeason
|
||||
} from "~/models/participant-expected-value";
|
||||
import type { ProbabilitySource } from "~/models/participant-expected-value";
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const sportsSeason = await findSportsSeasonById(params.id);
|
||||
|
|
@ -27,62 +26,61 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
};
|
||||
}
|
||||
|
||||
const scoringRules = {
|
||||
pointsFor1st: 100,
|
||||
pointsFor2nd: 70,
|
||||
pointsFor3rd: 50,
|
||||
pointsFor4th: 40,
|
||||
pointsFor5th: 25,
|
||||
pointsFor6th: 25,
|
||||
pointsFor7th: 15,
|
||||
pointsFor8th: 15,
|
||||
};
|
||||
|
||||
export async function action({ request, params }: Route.ActionArgs) {
|
||||
const formData = await request.formData();
|
||||
const participantId = formData.get("participantId");
|
||||
const source = (formData.get("source") || "manual") as ProbabilitySource;
|
||||
|
||||
if (typeof participantId !== "string") {
|
||||
return { error: "Participant ID is required" };
|
||||
const participantIdsRaw = formData.get("participantIds");
|
||||
if (typeof participantIdsRaw !== "string" || !participantIdsRaw) {
|
||||
return { error: "No participant IDs provided" };
|
||||
}
|
||||
|
||||
// Get probability values (entered as percentages, convert to decimals)
|
||||
const probFirst = parseFloat(formData.get("probFirst") as string || "0") / 100;
|
||||
const probSecond = parseFloat(formData.get("probSecond") as string || "0") / 100;
|
||||
const probThird = parseFloat(formData.get("probThird") as string || "0") / 100;
|
||||
const probFourth = parseFloat(formData.get("probFourth") as string || "0") / 100;
|
||||
const probFifth = parseFloat(formData.get("probFifth") as string || "0") / 100;
|
||||
const probSixth = parseFloat(formData.get("probSixth") as string || "0") / 100;
|
||||
const probSeventh = parseFloat(formData.get("probSeventh") as string || "0") / 100;
|
||||
const probEighth = parseFloat(formData.get("probEighth") as string || "0") / 100;
|
||||
|
||||
// Use default scoring rules
|
||||
// Note: Actual league seasons may have different scoring rules
|
||||
// EVs will be recalculated when used in a specific league
|
||||
const scoringRules = {
|
||||
pointsFor1st: 100,
|
||||
pointsFor2nd: 70,
|
||||
pointsFor3rd: 50,
|
||||
pointsFor4th: 40,
|
||||
pointsFor5th: 25,
|
||||
pointsFor6th: 25,
|
||||
pointsFor7th: 15,
|
||||
pointsFor8th: 15,
|
||||
};
|
||||
const participantIds = participantIdsRaw.split(",").filter(Boolean);
|
||||
|
||||
try {
|
||||
const result = await upsertParticipantEV({
|
||||
participantId,
|
||||
sportsSeasonId: params.id,
|
||||
probabilities: {
|
||||
probFirst,
|
||||
probSecond,
|
||||
probThird,
|
||||
probFourth,
|
||||
probFifth,
|
||||
probSixth,
|
||||
probSeventh,
|
||||
probEighth,
|
||||
},
|
||||
scoringRules,
|
||||
source,
|
||||
});
|
||||
const results = await Promise.all(
|
||||
participantIds.map(async (participantId) => {
|
||||
const probFirst = parseFloat(formData.get(`probFirst_${participantId}`) as string || "0") / 100;
|
||||
const probSecond = parseFloat(formData.get(`probSecond_${participantId}`) as string || "0") / 100;
|
||||
const probThird = parseFloat(formData.get(`probThird_${participantId}`) as string || "0") / 100;
|
||||
const probFourth = parseFloat(formData.get(`probFourth_${participantId}`) as string || "0") / 100;
|
||||
const probFifth = parseFloat(formData.get(`probFifth_${participantId}`) as string || "0") / 100;
|
||||
const probSixth = parseFloat(formData.get(`probSixth_${participantId}`) as string || "0") / 100;
|
||||
const probSeventh = parseFloat(formData.get(`probSeventh_${participantId}`) as string || "0") / 100;
|
||||
const probEighth = parseFloat(formData.get(`probEighth_${participantId}`) as string || "0") / 100;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
participantId,
|
||||
expectedValue: parseFloat(result.expectedValue),
|
||||
};
|
||||
return upsertParticipantEV({
|
||||
participantId,
|
||||
sportsSeasonId: params.id,
|
||||
probabilities: {
|
||||
probFirst,
|
||||
probSecond,
|
||||
probThird,
|
||||
probFourth,
|
||||
probFifth,
|
||||
probSixth,
|
||||
probSeventh,
|
||||
probEighth,
|
||||
},
|
||||
scoringRules,
|
||||
source: "manual",
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const totalEV = results.reduce((sum, r) => sum + parseFloat(r.expectedValue), 0);
|
||||
|
||||
return { success: true, totalEV };
|
||||
} catch (error) {
|
||||
console.error("Error saving probabilities:", error);
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,15 @@ export { loader, action };
|
|||
export default function ExpectedValuesPage({ loaderData, actionData }: Route.ComponentProps) {
|
||||
const { sportsSeason, participants, existingEVs } = loaderData;
|
||||
|
||||
const totalEV = Array.from(existingEVs.values()).reduce(
|
||||
(sum, ev) => sum + parseFloat(ev.expectedValue),
|
||||
0
|
||||
);
|
||||
|
||||
const participantIds = participants
|
||||
.map((p: { id: string; name: string }) => p.id)
|
||||
.join(",");
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
|
|
@ -54,7 +63,7 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
|
|||
)}
|
||||
{actionData?.success && (
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded-md text-green-700">
|
||||
Successfully saved! Expected Value: {actionData.expectedValue?.toFixed(2)} points
|
||||
All participants saved successfully!
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -75,150 +84,148 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Participant</TableHead>
|
||||
<TableHead className="text-center">1st %</TableHead>
|
||||
<TableHead className="text-center">2nd %</TableHead>
|
||||
<TableHead className="text-center">3rd %</TableHead>
|
||||
<TableHead className="text-center">4th %</TableHead>
|
||||
<TableHead className="text-center">5th %</TableHead>
|
||||
<TableHead className="text-center">6th %</TableHead>
|
||||
<TableHead className="text-center">7th %</TableHead>
|
||||
<TableHead className="text-center">8th %</TableHead>
|
||||
<TableHead className="text-center">EV</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{participants.map((participant: { id: string; name: string }) => {
|
||||
const existingEV = existingEVs.get(participant.id);
|
||||
const formId = `form-${participant.id}`;
|
||||
<Form method="post">
|
||||
<input type="hidden" name="participantIds" value={participantIds} />
|
||||
|
||||
return (
|
||||
<TableRow key={participant.id}>
|
||||
<TableCell className="font-medium">{participant.name}</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
form={formId}
|
||||
type="number"
|
||||
name="probFirst"
|
||||
defaultValue={existingEV ? (parseFloat(existingEV.probFirst) * 100).toFixed(2) : "12.5"}
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
className="w-20 text-center"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
form={formId}
|
||||
type="number"
|
||||
name="probSecond"
|
||||
defaultValue={existingEV ? (parseFloat(existingEV.probSecond) * 100).toFixed(2) : "12.5"}
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
className="w-20 text-center"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
form={formId}
|
||||
type="number"
|
||||
name="probThird"
|
||||
defaultValue={existingEV ? (parseFloat(existingEV.probThird) * 100).toFixed(2) : "12.5"}
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
className="w-20 text-center"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
form={formId}
|
||||
type="number"
|
||||
name="probFourth"
|
||||
defaultValue={existingEV ? (parseFloat(existingEV.probFourth) * 100).toFixed(2) : "12.5"}
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
className="w-20 text-center"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
form={formId}
|
||||
type="number"
|
||||
name="probFifth"
|
||||
defaultValue={existingEV ? (parseFloat(existingEV.probFifth) * 100).toFixed(2) : "12.5"}
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
className="w-20 text-center"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
form={formId}
|
||||
type="number"
|
||||
name="probSixth"
|
||||
defaultValue={existingEV ? (parseFloat(existingEV.probSixth) * 100).toFixed(2) : "12.5"}
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
className="w-20 text-center"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
form={formId}
|
||||
type="number"
|
||||
name="probSeventh"
|
||||
defaultValue={existingEV ? (parseFloat(existingEV.probSeventh) * 100).toFixed(2) : "12.5"}
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
className="w-20 text-center"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
form={formId}
|
||||
type="number"
|
||||
name="probEighth"
|
||||
defaultValue={existingEV ? (parseFloat(existingEV.probEighth) * 100).toFixed(2) : "12.5"}
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
className="w-20 text-center"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center font-semibold">
|
||||
{existingEV ? parseFloat(existingEV.expectedValue).toFixed(2) : "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Form method="post" id={formId}>
|
||||
<input type="hidden" name="participantId" value={participant.id} />
|
||||
<input type="hidden" name="source" value="manual" />
|
||||
<Button type="submit" size="sm">
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
Save
|
||||
</Button>
|
||||
</Form>
|
||||
</TableCell>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Participant</TableHead>
|
||||
<TableHead className="text-center">1st %</TableHead>
|
||||
<TableHead className="text-center">2nd %</TableHead>
|
||||
<TableHead className="text-center">3rd %</TableHead>
|
||||
<TableHead className="text-center">4th %</TableHead>
|
||||
<TableHead className="text-center">5th %</TableHead>
|
||||
<TableHead className="text-center">6th %</TableHead>
|
||||
<TableHead className="text-center">7th %</TableHead>
|
||||
<TableHead className="text-center">8th %</TableHead>
|
||||
<TableHead className="text-center">EV</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{participants.map((participant: { id: string; name: string }) => {
|
||||
const existingEV = existingEVs.get(participant.id);
|
||||
const id = participant.id;
|
||||
|
||||
return (
|
||||
<TableRow key={id}>
|
||||
<TableCell className="font-medium">{participant.name}</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
name={`probFirst_${id}`}
|
||||
defaultValue={existingEV ? (parseFloat(existingEV.probFirst) * 100).toFixed(2) : "0"}
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
className="w-20 text-center"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
name={`probSecond_${id}`}
|
||||
defaultValue={existingEV ? (parseFloat(existingEV.probSecond) * 100).toFixed(2) : "0"}
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
className="w-20 text-center"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
name={`probThird_${id}`}
|
||||
defaultValue={existingEV ? (parseFloat(existingEV.probThird) * 100).toFixed(2) : "0"}
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
className="w-20 text-center"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
name={`probFourth_${id}`}
|
||||
defaultValue={existingEV ? (parseFloat(existingEV.probFourth) * 100).toFixed(2) : "0"}
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
className="w-20 text-center"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
name={`probFifth_${id}`}
|
||||
defaultValue={existingEV ? (parseFloat(existingEV.probFifth) * 100).toFixed(2) : "0"}
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
className="w-20 text-center"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
name={`probSixth_${id}`}
|
||||
defaultValue={existingEV ? (parseFloat(existingEV.probSixth) * 100).toFixed(2) : "0"}
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
className="w-20 text-center"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
name={`probSeventh_${id}`}
|
||||
defaultValue={existingEV ? (parseFloat(existingEV.probSeventh) * 100).toFixed(2) : "0"}
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
className="w-20 text-center"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
name={`probEighth_${id}`}
|
||||
defaultValue={existingEV ? (parseFloat(existingEV.probEighth) * 100).toFixed(2) : "0"}
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
className="w-20 text-center"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center font-semibold">
|
||||
{existingEV ? parseFloat(existingEV.expectedValue).toFixed(2) : "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{existingEVs.size > 0 && (
|
||||
<TableRow className="border-t-2 font-bold bg-muted/50">
|
||||
<TableCell colSpan={9} className="text-right">Total EV</TableCell>
|
||||
<TableCell className="text-center">{totalEV.toFixed(2)}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{participants.length === 0 && (
|
||||
<p className="text-center text-gray-500 py-8">
|
||||
No participants found. Please add participants to this sports season first.
|
||||
</p>
|
||||
)}
|
||||
{participants.length === 0 ? (
|
||||
<p className="text-center text-gray-500 py-8">
|
||||
No participants found. Please add participants to this sports season first.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button type="submit">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Save All
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue