2025-11-17 22:19:46 -08:00
|
|
|
/**
|
|
|
|
|
* Model for Participant Expected Values
|
|
|
|
|
*
|
|
|
|
|
* Manages probability distributions and calculated EVs for participants
|
|
|
|
|
* in sports seasons.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { database } from "~/database/context";
|
2026-02-19 11:26:40 -08:00
|
|
|
import { participantExpectedValues, participants } from "~/database/schema";
|
Add Zod validation and expand export/import for EV and results data (#14)
* Include participant EV/futures data in data sync export/import
The export now includes the full participantExpectedValues records
(placement probabilities, source, sourceOdds) alongside participants.
On import, EV records are upserted in merge mode and created fresh in
replace mode (cascade handles cleanup). Backward-compatible with older
exports that lack the participantExpectedValues field.
https://claude.ai/code/session_01TVzuuDR7jPtmihaXTrsX7B
* Address all data-sync code review findings
- Wrap importSportsDataFromJSON in a db.transaction() so any mid-import
failure rolls back cleanly instead of leaving partial data
- Add zod validation of imported JSON before any DB writes, giving a
clear error on malformed/incompatible files
- Fix N+1 queries: build participantIdMap during participant import so
the EV and results sections resolve IDs from memory, not extra queries
- Fix merge mode silently skipping existing participants — now updates
shortName, externalId, and expectedValue
- Add participantResults to export/import (was deleted in replace mode
but never exported, so results were permanently lost)
- Restore calculatedAt timestamp on EV import instead of defaulting to
now(); stored as ISO string in the export for portability
- Document that onDelete:cascade covers participantExpectedValues and
participantResults when participants is deleted; remove the now-
redundant explicit participantResults delete from replace mode
- Bump export version to 1.1; old v1.0 files still import cleanly since
participantExpectedValues, participantResults, and calculatedAt are
all optional in the zod schema
- Collapse all six DB fetches in exportSportsDataToJSON into one
Promise.all for parallel execution
- Add countAllParticipants() and countAllParticipantEVs() model funcs
- Show Participants and EV Records counts on the dashboard stat cards
- Replace raw DOM form creation/submit in handleImport with useFetcher,
giving proper loading state, no full-page reload, and type-safe data
- Remove dead export intent handler from the action function
https://claude.ai/code/session_01TVzuuDR7jPtmihaXTrsX7B
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 20:25:25 -08:00
|
|
|
import { eq, and, count } from "drizzle-orm";
|
2025-11-17 22:19:46 -08:00
|
|
|
import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator";
|
2026-02-19 15:57:05 -08:00
|
|
|
import { calculateEV, normalizeProbabilities } from "~/services/ev-calculator";
|
2025-11-17 22:19:46 -08:00
|
|
|
|
|
|
|
|
export type ProbabilitySource = "manual" | "futures_odds" | "elo_simulation" | "performance_model";
|
|
|
|
|
|
|
|
|
|
export interface ParticipantEV {
|
|
|
|
|
id: string;
|
|
|
|
|
participantId: string;
|
|
|
|
|
sportsSeasonId: string;
|
|
|
|
|
probFirst: string;
|
|
|
|
|
probSecond: string;
|
|
|
|
|
probThird: string;
|
|
|
|
|
probFourth: string;
|
|
|
|
|
probFifth: string;
|
|
|
|
|
probSixth: string;
|
|
|
|
|
probSeventh: string;
|
|
|
|
|
probEighth: string;
|
|
|
|
|
expectedValue: string;
|
|
|
|
|
source: ProbabilitySource | null;
|
|
|
|
|
sourceOdds: number | null;
|
|
|
|
|
calculatedAt: Date;
|
|
|
|
|
updatedAt: Date;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface CreateProbabilityInput {
|
|
|
|
|
participantId: string;
|
|
|
|
|
sportsSeasonId: string;
|
|
|
|
|
probabilities: ProbabilityDistribution;
|
|
|
|
|
scoringRules: ScoringRules;
|
|
|
|
|
source?: ProbabilitySource;
|
|
|
|
|
sourceOdds?: number; // American odds if source is futures_odds
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface UpdateProbabilityInput {
|
|
|
|
|
probabilities: ProbabilityDistribution;
|
|
|
|
|
scoringRules: ScoringRules;
|
|
|
|
|
source?: ProbabilitySource;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create or update participant probabilities and calculate EV
|
|
|
|
|
*
|
|
|
|
|
* @param input - Probabilities, scoring rules, and metadata
|
|
|
|
|
* @returns Created/updated participant EV record
|
2025-11-21 22:05:50 -08:00
|
|
|
* @throws Error if probabilities don't sum to 1.0 (within tolerance)
|
2025-11-17 22:19:46 -08:00
|
|
|
*/
|
|
|
|
|
export async function upsertParticipantEV(
|
|
|
|
|
input: CreateProbabilityInput
|
|
|
|
|
): Promise<ParticipantEV> {
|
|
|
|
|
const { participantId, sportsSeasonId, probabilities, scoringRules, source = "manual", sourceOdds } = input;
|
|
|
|
|
|
2025-11-21 22:05:50 -08:00
|
|
|
// Always validate probabilities don't exceed 1.0
|
|
|
|
|
const sum = Object.values(probabilities).reduce((a, b) => a + b, 0);
|
|
|
|
|
if (sum > 1.01) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Probabilities cannot sum to more than 1.0. Current sum: ${sum.toFixed(4)} (${(sum * 100).toFixed(1)}%)`
|
2025-11-17 22:19:46 -08:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate EV
|
|
|
|
|
const expectedValue = calculateEV(probabilities, scoringRules);
|
|
|
|
|
|
|
|
|
|
const db = database();
|
|
|
|
|
|
|
|
|
|
// Check if record exists
|
|
|
|
|
const existing = await db
|
|
|
|
|
.select()
|
|
|
|
|
.from(participantExpectedValues)
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(participantExpectedValues.participantId, participantId),
|
|
|
|
|
eq(participantExpectedValues.sportsSeasonId, sportsSeasonId)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
.limit(1);
|
|
|
|
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
2026-02-19 11:26:40 -08:00
|
|
|
let result: ParticipantEV;
|
|
|
|
|
|
2025-11-17 22:19:46 -08:00
|
|
|
if (existing.length > 0) {
|
|
|
|
|
// Update existing
|
|
|
|
|
const updated = await db
|
|
|
|
|
.update(participantExpectedValues)
|
|
|
|
|
.set({
|
|
|
|
|
probFirst: probabilities.probFirst.toString(),
|
|
|
|
|
probSecond: probabilities.probSecond.toString(),
|
|
|
|
|
probThird: probabilities.probThird.toString(),
|
|
|
|
|
probFourth: probabilities.probFourth.toString(),
|
|
|
|
|
probFifth: probabilities.probFifth.toString(),
|
|
|
|
|
probSixth: probabilities.probSixth.toString(),
|
|
|
|
|
probSeventh: probabilities.probSeventh.toString(),
|
|
|
|
|
probEighth: probabilities.probEighth.toString(),
|
|
|
|
|
expectedValue: expectedValue.toString(),
|
|
|
|
|
source,
|
|
|
|
|
sourceOdds: sourceOdds ?? null,
|
|
|
|
|
calculatedAt: now,
|
|
|
|
|
updatedAt: now,
|
|
|
|
|
})
|
|
|
|
|
.where(eq(participantExpectedValues.id, existing[0].id))
|
|
|
|
|
.returning();
|
|
|
|
|
|
2026-02-19 11:26:40 -08:00
|
|
|
result = updated[0];
|
2025-11-17 22:19:46 -08:00
|
|
|
} else {
|
|
|
|
|
// Create new
|
|
|
|
|
const created = await db
|
|
|
|
|
.insert(participantExpectedValues)
|
|
|
|
|
.values({
|
|
|
|
|
participantId,
|
|
|
|
|
sportsSeasonId,
|
|
|
|
|
probFirst: probabilities.probFirst.toString(),
|
|
|
|
|
probSecond: probabilities.probSecond.toString(),
|
|
|
|
|
probThird: probabilities.probThird.toString(),
|
|
|
|
|
probFourth: probabilities.probFourth.toString(),
|
|
|
|
|
probFifth: probabilities.probFifth.toString(),
|
|
|
|
|
probSixth: probabilities.probSixth.toString(),
|
|
|
|
|
probSeventh: probabilities.probSeventh.toString(),
|
|
|
|
|
probEighth: probabilities.probEighth.toString(),
|
|
|
|
|
expectedValue: expectedValue.toString(),
|
|
|
|
|
source,
|
|
|
|
|
sourceOdds: sourceOdds ?? null,
|
|
|
|
|
calculatedAt: now,
|
|
|
|
|
updatedAt: now,
|
|
|
|
|
})
|
|
|
|
|
.returning();
|
|
|
|
|
|
2026-02-19 11:26:40 -08:00
|
|
|
result = created[0];
|
2025-11-17 22:19:46 -08:00
|
|
|
}
|
2026-02-19 11:26:40 -08:00
|
|
|
|
|
|
|
|
// Sync calculated EV to participants table for draft room ranking
|
|
|
|
|
await db
|
|
|
|
|
.update(participants)
|
|
|
|
|
.set({ expectedValue: expectedValue.toString(), updatedAt: now })
|
|
|
|
|
.where(eq(participants.id, participantId));
|
|
|
|
|
|
|
|
|
|
return result;
|
2025-11-17 22:19:46 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create or update with auto-normalization
|
|
|
|
|
* Automatically normalizes probabilities if they don't sum to 100%
|
|
|
|
|
*/
|
|
|
|
|
export async function upsertParticipantEVWithNormalization(
|
|
|
|
|
input: CreateProbabilityInput
|
|
|
|
|
): Promise<ParticipantEV> {
|
|
|
|
|
const normalized = normalizeProbabilities(input.probabilities);
|
|
|
|
|
|
|
|
|
|
return upsertParticipantEV({
|
|
|
|
|
...input,
|
|
|
|
|
probabilities: normalized,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
Add Zod validation and expand export/import for EV and results data (#14)
* Include participant EV/futures data in data sync export/import
The export now includes the full participantExpectedValues records
(placement probabilities, source, sourceOdds) alongside participants.
On import, EV records are upserted in merge mode and created fresh in
replace mode (cascade handles cleanup). Backward-compatible with older
exports that lack the participantExpectedValues field.
https://claude.ai/code/session_01TVzuuDR7jPtmihaXTrsX7B
* Address all data-sync code review findings
- Wrap importSportsDataFromJSON in a db.transaction() so any mid-import
failure rolls back cleanly instead of leaving partial data
- Add zod validation of imported JSON before any DB writes, giving a
clear error on malformed/incompatible files
- Fix N+1 queries: build participantIdMap during participant import so
the EV and results sections resolve IDs from memory, not extra queries
- Fix merge mode silently skipping existing participants — now updates
shortName, externalId, and expectedValue
- Add participantResults to export/import (was deleted in replace mode
but never exported, so results were permanently lost)
- Restore calculatedAt timestamp on EV import instead of defaulting to
now(); stored as ISO string in the export for portability
- Document that onDelete:cascade covers participantExpectedValues and
participantResults when participants is deleted; remove the now-
redundant explicit participantResults delete from replace mode
- Bump export version to 1.1; old v1.0 files still import cleanly since
participantExpectedValues, participantResults, and calculatedAt are
all optional in the zod schema
- Collapse all six DB fetches in exportSportsDataToJSON into one
Promise.all for parallel execution
- Add countAllParticipants() and countAllParticipantEVs() model funcs
- Show Participants and EV Records counts on the dashboard stat cards
- Replace raw DOM form creation/submit in handleImport with useFetcher,
giving proper loading state, no full-page reload, and type-safe data
- Remove dead export intent handler from the action function
https://claude.ai/code/session_01TVzuuDR7jPtmihaXTrsX7B
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 20:25:25 -08:00
|
|
|
export async function countAllParticipantEVs(): Promise<number> {
|
|
|
|
|
const db = database();
|
|
|
|
|
const result = await db.select({ value: count() }).from(participantExpectedValues);
|
|
|
|
|
return result[0].value;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-17 22:19:46 -08:00
|
|
|
/**
|
|
|
|
|
* Get participant EV for a specific sports season
|
|
|
|
|
*/
|
|
|
|
|
export async function getParticipantEV(
|
|
|
|
|
participantId: string,
|
|
|
|
|
sportsSeasonId: string
|
|
|
|
|
): Promise<ParticipantEV | null> {
|
|
|
|
|
const db = database();
|
|
|
|
|
const result = await db
|
|
|
|
|
.select()
|
|
|
|
|
.from(participantExpectedValues)
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(participantExpectedValues.participantId, participantId),
|
|
|
|
|
eq(participantExpectedValues.sportsSeasonId, sportsSeasonId)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
.limit(1);
|
|
|
|
|
|
|
|
|
|
return result[0] || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get all participant EVs for a sports season
|
|
|
|
|
*/
|
|
|
|
|
export async function getAllParticipantEVsForSeason(
|
|
|
|
|
sportsSeasonId: string
|
|
|
|
|
): Promise<ParticipantEV[]> {
|
|
|
|
|
const db = database();
|
|
|
|
|
return db
|
|
|
|
|
.select()
|
|
|
|
|
.from(participantExpectedValues)
|
|
|
|
|
.where(eq(participantExpectedValues.sportsSeasonId, sportsSeasonId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Delete participant EV
|
|
|
|
|
*/
|
|
|
|
|
export async function deleteParticipantEV(
|
|
|
|
|
participantId: string,
|
|
|
|
|
sportsSeasonId: string
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const db = database();
|
|
|
|
|
await db
|
|
|
|
|
.delete(participantExpectedValues)
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(participantExpectedValues.participantId, participantId),
|
|
|
|
|
eq(participantExpectedValues.sportsSeasonId, sportsSeasonId)
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-02-19 11:26:40 -08:00
|
|
|
|
|
|
|
|
// Reset participant EV to 0
|
|
|
|
|
await db
|
|
|
|
|
.update(participants)
|
|
|
|
|
.set({ expectedValue: "0", updatedAt: new Date() })
|
|
|
|
|
.where(eq(participants.id, participantId));
|
2025-11-17 22:19:46 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Batch upsert multiple participant EVs
|
|
|
|
|
* Useful for updating all participants after generating probabilities
|
|
|
|
|
*/
|
|
|
|
|
export async function batchUpsertParticipantEVs(
|
|
|
|
|
inputs: CreateProbabilityInput[]
|
|
|
|
|
): Promise<ParticipantEV[]> {
|
|
|
|
|
const results: ParticipantEV[] = [];
|
|
|
|
|
|
|
|
|
|
// Process in batches to avoid overwhelming the database
|
|
|
|
|
const batchSize = 50;
|
|
|
|
|
for (let i = 0; i < inputs.length; i += batchSize) {
|
|
|
|
|
const batch = inputs.slice(i, i + batchSize);
|
|
|
|
|
const batchResults = await Promise.all(
|
|
|
|
|
batch.map((input) => upsertParticipantEV(input))
|
|
|
|
|
);
|
|
|
|
|
results.push(...batchResults);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return results;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Convert database record to ProbabilityDistribution
|
|
|
|
|
*/
|
|
|
|
|
export function toProbabilityDistribution(ev: ParticipantEV): ProbabilityDistribution {
|
|
|
|
|
return {
|
|
|
|
|
probFirst: parseFloat(ev.probFirst),
|
|
|
|
|
probSecond: parseFloat(ev.probSecond),
|
|
|
|
|
probThird: parseFloat(ev.probThird),
|
|
|
|
|
probFourth: parseFloat(ev.probFourth),
|
|
|
|
|
probFifth: parseFloat(ev.probFifth),
|
|
|
|
|
probSixth: parseFloat(ev.probSixth),
|
|
|
|
|
probSeventh: parseFloat(ev.probSeventh),
|
|
|
|
|
probEighth: parseFloat(ev.probEighth),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Recalculate EV for a participant with new scoring rules
|
|
|
|
|
* Keeps probabilities the same, only updates EV based on new scoring
|
|
|
|
|
*/
|
|
|
|
|
export async function recalculateEV(
|
|
|
|
|
participantId: string,
|
|
|
|
|
sportsSeasonId: string,
|
|
|
|
|
newScoringRules: ScoringRules
|
|
|
|
|
): Promise<ParticipantEV | null> {
|
|
|
|
|
const existing = await getParticipantEV(participantId, sportsSeasonId);
|
|
|
|
|
|
|
|
|
|
if (!existing) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const probabilities = toProbabilityDistribution(existing);
|
|
|
|
|
const newEV = calculateEV(probabilities, newScoringRules);
|
|
|
|
|
|
|
|
|
|
const db = database();
|
2026-02-19 11:26:40 -08:00
|
|
|
const now = new Date();
|
2025-11-17 22:19:46 -08:00
|
|
|
const updated = await db
|
|
|
|
|
.update(participantExpectedValues)
|
|
|
|
|
.set({
|
|
|
|
|
expectedValue: newEV.toString(),
|
2026-02-19 11:26:40 -08:00
|
|
|
calculatedAt: now,
|
|
|
|
|
updatedAt: now,
|
2025-11-17 22:19:46 -08:00
|
|
|
})
|
|
|
|
|
.where(eq(participantExpectedValues.id, existing.id))
|
|
|
|
|
.returning();
|
|
|
|
|
|
2026-02-19 11:26:40 -08:00
|
|
|
// Sync recalculated EV to participants table
|
|
|
|
|
await db
|
|
|
|
|
.update(participants)
|
|
|
|
|
.set({ expectedValue: newEV.toString(), updatedAt: now })
|
|
|
|
|
.where(eq(participants.id, participantId));
|
|
|
|
|
|
2025-11-17 22:19:46 -08:00
|
|
|
return updated[0];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Recalculate EVs for all participants in a sports season
|
|
|
|
|
* Used when scoring rules change
|
|
|
|
|
*/
|
|
|
|
|
export async function recalculateAllEVsForSeason(
|
|
|
|
|
sportsSeasonId: string,
|
|
|
|
|
newScoringRules: ScoringRules
|
|
|
|
|
): Promise<number> {
|
|
|
|
|
const allEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
|
|
|
|
|
|
|
|
|
|
await Promise.all(
|
|
|
|
|
allEVs.map((ev) =>
|
|
|
|
|
recalculateEV(ev.participantId, sportsSeasonId, newScoringRules)
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return allEVs.length;
|
|
|
|
|
}
|