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 { participantExpectedValues, participants } from "~/database/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator";
|
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";
|
export type ProbabilitySource = "manual" | "futures_odds" | "elo_simulation" | "performance_model";
|
||||||
|
|
||||||
|
|
@ -59,15 +59,6 @@ export async function upsertParticipantEV(
|
||||||
): Promise<ParticipantEV> {
|
): Promise<ParticipantEV> {
|
||||||
const { participantId, sportsSeasonId, probabilities, scoringRules, source = "manual", sourceOdds } = input;
|
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
|
// Always validate probabilities don't exceed 1.0
|
||||||
const sum = Object.values(probabilities).reduce((a, b) => a + b, 0);
|
const sum = Object.values(probabilities).reduce((a, b) => a + b, 0);
|
||||||
if (sum > 1.01) {
|
if (sum > 1.01) {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import {
|
||||||
upsertParticipantEV,
|
upsertParticipantEV,
|
||||||
getAllParticipantEVsForSeason
|
getAllParticipantEVsForSeason
|
||||||
} from "~/models/participant-expected-value";
|
} from "~/models/participant-expected-value";
|
||||||
import type { ProbabilitySource } from "~/models/participant-expected-value";
|
|
||||||
|
|
||||||
export async function loader({ params }: Route.LoaderArgs) {
|
export async function loader({ params }: Route.LoaderArgs) {
|
||||||
const sportsSeason = await findSportsSeasonById(params.id);
|
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) {
|
export async function action({ request, params }: Route.ActionArgs) {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const participantId = formData.get("participantId");
|
|
||||||
const source = (formData.get("source") || "manual") as ProbabilitySource;
|
|
||||||
|
|
||||||
if (typeof participantId !== "string") {
|
const participantIdsRaw = formData.get("participantIds");
|
||||||
return { error: "Participant ID is required" };
|
if (typeof participantIdsRaw !== "string" || !participantIdsRaw) {
|
||||||
|
return { error: "No participant IDs provided" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get probability values (entered as percentages, convert to decimals)
|
const participantIds = participantIdsRaw.split(",").filter(Boolean);
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await upsertParticipantEV({
|
const results = await Promise.all(
|
||||||
participantId,
|
participantIds.map(async (participantId) => {
|
||||||
sportsSeasonId: params.id,
|
const probFirst = parseFloat(formData.get(`probFirst_${participantId}`) as string || "0") / 100;
|
||||||
probabilities: {
|
const probSecond = parseFloat(formData.get(`probSecond_${participantId}`) as string || "0") / 100;
|
||||||
probFirst,
|
const probThird = parseFloat(formData.get(`probThird_${participantId}`) as string || "0") / 100;
|
||||||
probSecond,
|
const probFourth = parseFloat(formData.get(`probFourth_${participantId}`) as string || "0") / 100;
|
||||||
probThird,
|
const probFifth = parseFloat(formData.get(`probFifth_${participantId}`) as string || "0") / 100;
|
||||||
probFourth,
|
const probSixth = parseFloat(formData.get(`probSixth_${participantId}`) as string || "0") / 100;
|
||||||
probFifth,
|
const probSeventh = parseFloat(formData.get(`probSeventh_${participantId}`) as string || "0") / 100;
|
||||||
probSixth,
|
const probEighth = parseFloat(formData.get(`probEighth_${participantId}`) as string || "0") / 100;
|
||||||
probSeventh,
|
|
||||||
probEighth,
|
|
||||||
},
|
|
||||||
scoringRules,
|
|
||||||
source,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return upsertParticipantEV({
|
||||||
success: true,
|
participantId,
|
||||||
participantId,
|
sportsSeasonId: params.id,
|
||||||
expectedValue: parseFloat(result.expectedValue),
|
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) {
|
} catch (error) {
|
||||||
console.error("Error saving probabilities:", error);
|
console.error("Error saving probabilities:", error);
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,15 @@ export { loader, action };
|
||||||
export default function ExpectedValuesPage({ loaderData, actionData }: Route.ComponentProps) {
|
export default function ExpectedValuesPage({ loaderData, actionData }: Route.ComponentProps) {
|
||||||
const { sportsSeason, participants, existingEVs } = loaderData;
|
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 (
|
return (
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<div className="container mx-auto p-6 space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|
@ -54,7 +63,7 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
|
||||||
)}
|
)}
|
||||||
{actionData?.success && (
|
{actionData?.success && (
|
||||||
<div className="p-4 bg-green-50 border border-green-200 rounded-md text-green-700">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -75,150 +84,148 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Table>
|
<Form method="post">
|
||||||
<TableHeader>
|
<input type="hidden" name="participantIds" value={participantIds} />
|
||||||
<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}`;
|
|
||||||
|
|
||||||
return (
|
<Table>
|
||||||
<TableRow key={participant.id}>
|
<TableHeader>
|
||||||
<TableCell className="font-medium">{participant.name}</TableCell>
|
<TableRow>
|
||||||
<TableCell>
|
<TableHead>Participant</TableHead>
|
||||||
<Input
|
<TableHead className="text-center">1st %</TableHead>
|
||||||
form={formId}
|
<TableHead className="text-center">2nd %</TableHead>
|
||||||
type="number"
|
<TableHead className="text-center">3rd %</TableHead>
|
||||||
name="probFirst"
|
<TableHead className="text-center">4th %</TableHead>
|
||||||
defaultValue={existingEV ? (parseFloat(existingEV.probFirst) * 100).toFixed(2) : "12.5"}
|
<TableHead className="text-center">5th %</TableHead>
|
||||||
step="0.01"
|
<TableHead className="text-center">6th %</TableHead>
|
||||||
min="0"
|
<TableHead className="text-center">7th %</TableHead>
|
||||||
max="100"
|
<TableHead className="text-center">8th %</TableHead>
|
||||||
className="w-20 text-center"
|
<TableHead className="text-center">EV</TableHead>
|
||||||
/>
|
</TableRow>
|
||||||
</TableCell>
|
</TableHeader>
|
||||||
<TableCell>
|
<TableBody>
|
||||||
<Input
|
{participants.map((participant: { id: string; name: string }) => {
|
||||||
form={formId}
|
const existingEV = existingEVs.get(participant.id);
|
||||||
type="number"
|
const id = participant.id;
|
||||||
name="probSecond"
|
|
||||||
defaultValue={existingEV ? (parseFloat(existingEV.probSecond) * 100).toFixed(2) : "12.5"}
|
return (
|
||||||
step="0.01"
|
<TableRow key={id}>
|
||||||
min="0"
|
<TableCell className="font-medium">{participant.name}</TableCell>
|
||||||
max="100"
|
<TableCell>
|
||||||
className="w-20 text-center"
|
<Input
|
||||||
/>
|
type="number"
|
||||||
</TableCell>
|
name={`probFirst_${id}`}
|
||||||
<TableCell>
|
defaultValue={existingEV ? (parseFloat(existingEV.probFirst) * 100).toFixed(2) : "0"}
|
||||||
<Input
|
step="0.01"
|
||||||
form={formId}
|
min="0"
|
||||||
type="number"
|
max="100"
|
||||||
name="probThird"
|
className="w-20 text-center"
|
||||||
defaultValue={existingEV ? (parseFloat(existingEV.probThird) * 100).toFixed(2) : "12.5"}
|
/>
|
||||||
step="0.01"
|
</TableCell>
|
||||||
min="0"
|
<TableCell>
|
||||||
max="100"
|
<Input
|
||||||
className="w-20 text-center"
|
type="number"
|
||||||
/>
|
name={`probSecond_${id}`}
|
||||||
</TableCell>
|
defaultValue={existingEV ? (parseFloat(existingEV.probSecond) * 100).toFixed(2) : "0"}
|
||||||
<TableCell>
|
step="0.01"
|
||||||
<Input
|
min="0"
|
||||||
form={formId}
|
max="100"
|
||||||
type="number"
|
className="w-20 text-center"
|
||||||
name="probFourth"
|
/>
|
||||||
defaultValue={existingEV ? (parseFloat(existingEV.probFourth) * 100).toFixed(2) : "12.5"}
|
</TableCell>
|
||||||
step="0.01"
|
<TableCell>
|
||||||
min="0"
|
<Input
|
||||||
max="100"
|
type="number"
|
||||||
className="w-20 text-center"
|
name={`probThird_${id}`}
|
||||||
/>
|
defaultValue={existingEV ? (parseFloat(existingEV.probThird) * 100).toFixed(2) : "0"}
|
||||||
</TableCell>
|
step="0.01"
|
||||||
<TableCell>
|
min="0"
|
||||||
<Input
|
max="100"
|
||||||
form={formId}
|
className="w-20 text-center"
|
||||||
type="number"
|
/>
|
||||||
name="probFifth"
|
</TableCell>
|
||||||
defaultValue={existingEV ? (parseFloat(existingEV.probFifth) * 100).toFixed(2) : "12.5"}
|
<TableCell>
|
||||||
step="0.01"
|
<Input
|
||||||
min="0"
|
type="number"
|
||||||
max="100"
|
name={`probFourth_${id}`}
|
||||||
className="w-20 text-center"
|
defaultValue={existingEV ? (parseFloat(existingEV.probFourth) * 100).toFixed(2) : "0"}
|
||||||
/>
|
step="0.01"
|
||||||
</TableCell>
|
min="0"
|
||||||
<TableCell>
|
max="100"
|
||||||
<Input
|
className="w-20 text-center"
|
||||||
form={formId}
|
/>
|
||||||
type="number"
|
</TableCell>
|
||||||
name="probSixth"
|
<TableCell>
|
||||||
defaultValue={existingEV ? (parseFloat(existingEV.probSixth) * 100).toFixed(2) : "12.5"}
|
<Input
|
||||||
step="0.01"
|
type="number"
|
||||||
min="0"
|
name={`probFifth_${id}`}
|
||||||
max="100"
|
defaultValue={existingEV ? (parseFloat(existingEV.probFifth) * 100).toFixed(2) : "0"}
|
||||||
className="w-20 text-center"
|
step="0.01"
|
||||||
/>
|
min="0"
|
||||||
</TableCell>
|
max="100"
|
||||||
<TableCell>
|
className="w-20 text-center"
|
||||||
<Input
|
/>
|
||||||
form={formId}
|
</TableCell>
|
||||||
type="number"
|
<TableCell>
|
||||||
name="probSeventh"
|
<Input
|
||||||
defaultValue={existingEV ? (parseFloat(existingEV.probSeventh) * 100).toFixed(2) : "12.5"}
|
type="number"
|
||||||
step="0.01"
|
name={`probSixth_${id}`}
|
||||||
min="0"
|
defaultValue={existingEV ? (parseFloat(existingEV.probSixth) * 100).toFixed(2) : "0"}
|
||||||
max="100"
|
step="0.01"
|
||||||
className="w-20 text-center"
|
min="0"
|
||||||
/>
|
max="100"
|
||||||
</TableCell>
|
className="w-20 text-center"
|
||||||
<TableCell>
|
/>
|
||||||
<Input
|
</TableCell>
|
||||||
form={formId}
|
<TableCell>
|
||||||
type="number"
|
<Input
|
||||||
name="probEighth"
|
type="number"
|
||||||
defaultValue={existingEV ? (parseFloat(existingEV.probEighth) * 100).toFixed(2) : "12.5"}
|
name={`probSeventh_${id}`}
|
||||||
step="0.01"
|
defaultValue={existingEV ? (parseFloat(existingEV.probSeventh) * 100).toFixed(2) : "0"}
|
||||||
min="0"
|
step="0.01"
|
||||||
max="100"
|
min="0"
|
||||||
className="w-20 text-center"
|
max="100"
|
||||||
/>
|
className="w-20 text-center"
|
||||||
</TableCell>
|
/>
|
||||||
<TableCell className="text-center font-semibold">
|
</TableCell>
|
||||||
{existingEV ? parseFloat(existingEV.expectedValue).toFixed(2) : "-"}
|
<TableCell>
|
||||||
</TableCell>
|
<Input
|
||||||
<TableCell>
|
type="number"
|
||||||
<Form method="post" id={formId}>
|
name={`probEighth_${id}`}
|
||||||
<input type="hidden" name="participantId" value={participant.id} />
|
defaultValue={existingEV ? (parseFloat(existingEV.probEighth) * 100).toFixed(2) : "0"}
|
||||||
<input type="hidden" name="source" value="manual" />
|
step="0.01"
|
||||||
<Button type="submit" size="sm">
|
min="0"
|
||||||
<Save className="h-4 w-4 mr-1" />
|
max="100"
|
||||||
Save
|
className="w-20 text-center"
|
||||||
</Button>
|
/>
|
||||||
</Form>
|
</TableCell>
|
||||||
</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>
|
</TableRow>
|
||||||
);
|
)}
|
||||||
})}
|
</TableBody>
|
||||||
</TableBody>
|
</Table>
|
||||||
</Table>
|
|
||||||
|
|
||||||
{participants.length === 0 && (
|
{participants.length === 0 ? (
|
||||||
<p className="text-center text-gray-500 py-8">
|
<p className="text-center text-gray-500 py-8">
|
||||||
No participants found. Please add participants to this sports season first.
|
No participants found. Please add participants to this sports season first.
|
||||||
</p>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue