diff --git a/app/models/__tests__/futures-odds-simulator.test.ts b/app/models/__tests__/futures-odds-simulator.test.ts index aed3c1f..ba6604e 100644 --- a/app/models/__tests__/futures-odds-simulator.test.ts +++ b/app/models/__tests__/futures-odds-simulator.test.ts @@ -6,7 +6,7 @@ vi.mock("~/database/context", () => ({ })); import { database } from "~/database/context"; -import { batchSaveFuturesOddsForSimulator } from "../simulator"; +import { batchUpsertParticipantSimulatorInputs } from "../simulator"; type SetPayload = Record; @@ -17,36 +17,71 @@ const onConflictDoUpdate = vi.fn((arg: { set: SetPayload }) => { return Promise.resolve(undefined); }); -const mockDb = { +const tx = { insert: vi.fn(() => ({ values: vi.fn(() => ({ onConflictDoUpdate })), })), }; +const mockDb = { + transaction: vi.fn((cb: (t: typeof tx) => Promise) => cb(tx)), +}; + beforeEach(() => { vi.clearAllMocks(); conflictSetCalls.length = 0; (database as ReturnType).mockReturnValue(mockDb); }); -describe("batchSaveFuturesOddsForSimulator", () => { - it("persists odds without destroying a stored Elo/rating", async () => { - await batchSaveFuturesOddsForSimulator([ +// Recursively flatten a Drizzle `sql` template into its static text so we can +// assert how a conflict-update column is built (e.g. wrapped in COALESCE). +function sqlToText(value: unknown): string { + if (value === null || value === undefined) return ""; + const chunks = (value as { queryChunks?: unknown[] }).queryChunks; + if (Array.isArray(chunks)) return chunks.map(sqlToText).join(""); + const stringValue = (value as { value?: unknown }).value; + if (Array.isArray(stringValue)) return stringValue.join(""); + if (typeof stringValue === "string") return stringValue; + return ""; +} + +describe("batchUpsertParticipantSimulatorInputs", () => { + it("updates only the columns it is given, preserving the rest (non-destructive)", async () => { + await batchUpsertParticipantSimulatorInputs([ + // Odds-only import (e.g. from the bulk futures paste): no Elo/rating supplied. { participantId: "team-1", sportsSeasonId: "season-1", sourceOdds: 550 }, ]); - // The upsert writes the odds and leaves Elo/rating untouched — whether the - // odds override them is now decided by the configurable source priority, - // not by nulling stored values here. - expect(mockDb.insert).toHaveBeenCalledTimes(1); - expect(conflictSetCalls).toHaveLength(1); - expect(conflictSetCalls[0]).toHaveProperty("sourceOdds"); - expect(conflictSetCalls[0]).not.toHaveProperty("sourceElo"); - expect(conflictSetCalls[0]).not.toHaveProperty("rating"); + // Runs in a transaction, writing the simulator-inputs row first then the + // legacy EV bridge row. + expect(mockDb.transaction).toHaveBeenCalledTimes(1); + expect(conflictSetCalls.length).toBeGreaterThanOrEqual(1); + + const simulatorSet = conflictSetCalls[0]; + // Every input column participates in the conflict update so partial pastes + // can target any field... + for (const column of ["sourceOdds", "sourceElo", "worldRanking", "rating", "seed", "region"]) { + expect(simulatorSet).toHaveProperty(column); + } + // ...but each is COALESCE-wrapped, so a null incoming value keeps the stored + // one instead of clobbering it. A bare `excluded.*` here would be the bug. + const sourceEloSql = sqlToText(simulatorSet.sourceElo).toLowerCase(); + expect(sourceEloSql).toContain("coalesce"); + const ratingSql = sqlToText(simulatorSet.rating).toLowerCase(); + expect(ratingSql).toContain("coalesce"); + + // Metadata must drop the per-column method flag whenever that column gets a + // fresh direct value, so a stale "generated" flag can't hide a newly entered + // Elo/rating. Blindly preserving metadata (e.g. COALESCE) would be the bug. + const metadataSql = sqlToText(simulatorSet.metadata).toLowerCase(); + expect(metadataSql).toContain("sourceelomethod"); + expect(metadataSql).toContain("ratingmethod"); + expect(metadataSql).toContain("excluded.source_elo"); + expect(metadataSql).toContain("excluded.rating"); }); it("is a no-op when given no inputs", async () => { - await batchSaveFuturesOddsForSimulator([]); - expect(mockDb.insert).not.toHaveBeenCalled(); + await batchUpsertParticipantSimulatorInputs([]); + expect(mockDb.transaction).not.toHaveBeenCalled(); }); }); diff --git a/app/models/participant-expected-value.ts b/app/models/participant-expected-value.ts index 132fcce..5034690 100644 --- a/app/models/participant-expected-value.ts +++ b/app/models/participant-expected-value.ts @@ -6,11 +6,11 @@ */ import { database } from "~/database/context"; -import { seasonParticipantExpectedValues, seasonParticipants, seasonParticipantSimulatorInputs } from "~/database/schema"; -import { eq, and, count, inArray, sql } from "drizzle-orm"; +import { seasonParticipantExpectedValues, seasonParticipants } from "~/database/schema"; +import { eq, and, count, sql } from "drizzle-orm"; import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator"; import { calculateEV, normalizeProbabilities, calculateReplacementLevel, calculateVORP } from "~/services/ev-calculator"; -import { batchSaveParticipantSimulatorSourceOdds, batchUpsertParticipantSimulatorInputs } from "~/models/simulator"; +import { batchUpsertParticipantSimulatorInputs } from "~/models/simulator"; export type ProbabilitySource = "manual" | "futures_odds" | "elo_simulation" | "performance_model"; @@ -366,100 +366,6 @@ export async function batchUpsertParticipantEVs( return results; } -/** - * Save American odds for a batch of seasonParticipants without touching probabilities or EV. - * Used by the futures-odds admin page to persist odds before running the full simulation. - */ -export async function batchSaveSourceOdds( - inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }> -): Promise { - const db = database(); - - await db.transaction(async (tx) => { - const now = new Date(); - - for (const { participantId, sportsSeasonId, sourceOdds } of inputs) { - const existing = await tx - .select({ id: seasonParticipantExpectedValues.id }) - .from(seasonParticipantExpectedValues) - .where( - and( - eq(seasonParticipantExpectedValues.participantId, participantId), - eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId) - ) - ) - .limit(1); - - if (existing.length > 0) { - await tx - .update(seasonParticipantExpectedValues) - // Persist the odds and label the source as odds-driven for display. - // We no longer null a stored Elo here: how much these odds move it is - // decided at run time by the season's blend weight - // (SimulatorInputPolicy.oddsWeight), and prepareSimulatorInputsForRun - // overwrites the resolved Elo before the simulator reads it. - .set({ sourceOdds, source: "futures_odds", updatedAt: now }) - .where(eq(seasonParticipantExpectedValues.id, existing[0].id)); - } else { - // Insert a stub record — probabilities/EV will be filled in by the simulator - await tx.insert(seasonParticipantExpectedValues).values({ - participantId, - sportsSeasonId, - probFirst: "0", - probSecond: "0", - probThird: "0", - probFourth: "0", - probFifth: "0", - probSixth: "0", - probSeventh: "0", - probEighth: "0", - expectedValue: "0", - source: "futures_odds", - sourceOdds, - calculatedAt: now, - updatedAt: now, - }); - } - } - }); - - await batchSaveParticipantSimulatorSourceOdds(inputs); -} - -export async function clearSourceOddsForParticipants( - sportsSeasonId: string, - participantIds: string[] -): Promise { - if (participantIds.length === 0) return; - const db = database(); - const now = new Date(); - await db.transaction(async (tx) => { - await tx - .update(seasonParticipantExpectedValues) - .set({ sourceOdds: null, updatedAt: now }) - .where( - and( - eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId), - inArray(seasonParticipantExpectedValues.participantId, participantIds) - ) - ); - await tx - .update(seasonParticipantSimulatorInputs) - .set({ - sourceOdds: null, - rating: null, - metadata: sql`coalesce(${seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`, - updatedAt: now, - }) - .where( - and( - eq(seasonParticipantSimulatorInputs.sportsSeasonId, sportsSeasonId), - inArray(seasonParticipantSimulatorInputs.participantId, participantIds) - ) - ); - }); -} - /** * Persist raw Elo ratings (and optional world rankings) for a batch of seasonParticipants. * Used by Elo-based simulators like snooker_bracket and darts_bracket. diff --git a/app/models/simulator.ts b/app/models/simulator.ts index 3f35f87..b41469f 100644 --- a/app/models/simulator.ts +++ b/app/models/simulator.ts @@ -307,93 +307,6 @@ export async function getParticipantSimulatorInputs( }); } -function generatedRatingMethodSql() { - return sql`${schema.seasonParticipantSimulatorInputs.metadata}->>'ratingMethod' in ('sourceOdds', 'fallbackRating', 'averageKnown', 'worstKnownMinus')`; -} - -export async function batchSaveParticipantSimulatorSourceOdds( - inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }> -): Promise { - if (inputs.length === 0) return; - const db = database(); - const now = new Date(); - const seasonIds = [...new Set(inputs.map((input) => input.sportsSeasonId))]; - - await db.transaction(async (tx) => { - await tx - .update(schema.seasonParticipantSimulatorInputs) - .set({ - rating: null, - metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`, - updatedAt: now, - }) - .where( - and( - inArray(schema.seasonParticipantSimulatorInputs.sportsSeasonId, seasonIds), - generatedRatingMethodSql() - ) - ); - - await tx - .insert(schema.seasonParticipantSimulatorInputs) - .values( - inputs.map((input) => ({ - participantId: input.participantId, - sportsSeasonId: input.sportsSeasonId, - sourceOdds: input.sourceOdds, - createdAt: now, - updatedAt: now, - })) - ) - .onConflictDoUpdate({ - target: [ - schema.seasonParticipantSimulatorInputs.participantId, - schema.seasonParticipantSimulatorInputs.sportsSeasonId, - ], - set: { - sourceOdds: sql`excluded.source_odds`, - rating: sql`case when ${generatedRatingMethodSql()} then null else ${schema.seasonParticipantSimulatorInputs.rating} end`, - metadata: sql`case when ${generatedRatingMethodSql()} then coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod' else ${schema.seasonParticipantSimulatorInputs.metadata} end`, - updatedAt: now, - }, - }); - }); -} - -export async function batchSaveFuturesOddsForSimulator( - inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }> -): Promise { - if (inputs.length === 0) return; - const db = database(); - const now = new Date(); - - // Persist the odds non-destructively. How much these odds move a stored - // Elo/rating is now governed by the season's blend weight (see resolveSourceElos - // / SimulatorInputPolicy.oddsWeight), so we no longer null out manually entered - // Elo here — that policy decides at run time. - await db - .insert(schema.seasonParticipantSimulatorInputs) - .values( - inputs.map((input) => ({ - participantId: input.participantId, - sportsSeasonId: input.sportsSeasonId, - sourceOdds: input.sourceOdds, - createdAt: now, - updatedAt: now, - })) - ) - .onConflictDoUpdate({ - target: [ - schema.seasonParticipantSimulatorInputs.participantId, - schema.seasonParticipantSimulatorInputs.sportsSeasonId, - ], - set: { - sourceOdds: sql`excluded.source_odds`, - updatedAt: now, - }, - }); -} - export async function batchUpsertParticipantSimulatorInputs( inputs: UpsertParticipantSimulatorInput[] ): Promise { @@ -426,16 +339,34 @@ export async function batchUpsertParticipantSimulatorInputs( schema.seasonParticipantSimulatorInputs.participantId, schema.seasonParticipantSimulatorInputs.sportsSeasonId, ], + // Non-destructive: only overwrite a column when the incoming value is + // non-null. This lets a partial import (e.g. odds-only) update just the + // columns it provides without clobbering previously stored inputs like a + // manually entered Elo or rating. Mirrors the COALESCE bridge used for the + // legacy EV table below. set: { - sourceOdds: sql`excluded.source_odds`, - sourceElo: sql`excluded.source_elo`, - worldRanking: sql`excluded.world_ranking`, - rating: sql`excluded.rating`, - projectedWins: sql`excluded.projected_wins`, - projectedTablePoints: sql`excluded.projected_table_points`, - seed: sql`excluded.seed`, - region: sql`excluded.region`, - metadata: sql`excluded.metadata`, + sourceOdds: sql`COALESCE(excluded.source_odds, ${schema.seasonParticipantSimulatorInputs.sourceOdds})`, + sourceElo: sql`COALESCE(excluded.source_elo, ${schema.seasonParticipantSimulatorInputs.sourceElo})`, + worldRanking: sql`COALESCE(excluded.world_ranking, ${schema.seasonParticipantSimulatorInputs.worldRanking})`, + rating: sql`COALESCE(excluded.rating, ${schema.seasonParticipantSimulatorInputs.rating})`, + projectedWins: sql`COALESCE(excluded.projected_wins, ${schema.seasonParticipantSimulatorInputs.projectedWins})`, + projectedTablePoints: sql`COALESCE(excluded.projected_table_points, ${schema.seasonParticipantSimulatorInputs.projectedTablePoints})`, + seed: sql`COALESCE(excluded.seed, ${schema.seasonParticipantSimulatorInputs.seed})`, + region: sql`COALESCE(excluded.region, ${schema.seasonParticipantSimulatorInputs.region})`, + // Metadata carries the method flags (sourceEloMethod/ratingMethod) that + // tell readers whether the stored Elo/rating is generated vs. a trusted + // direct value. When a caller supplies explicit metadata, use it as-is + // (prepareSimulatorInputsForRun and the projection importer set the + // correct flags). Otherwise preserve existing metadata, but drop the + // method flag for any column receiving a fresh direct value — otherwise a + // stale "generated" flag would cause that newly-entered Elo/rating to be + // filtered out as derived (see getParticipantSimulatorInputs). + metadata: sql`CASE + WHEN excluded.metadata IS NOT NULL THEN excluded.metadata + ELSE COALESCE(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) + - (CASE WHEN excluded.source_elo IS NOT NULL THEN 'sourceEloMethod' ELSE '' END) + - (CASE WHEN excluded.rating IS NOT NULL THEN 'ratingMethod' ELSE '' END) + END`, updatedAt: sql`excluded.updated_at`, }, }); diff --git a/app/routes/admin.simulators.tsx b/app/routes/admin.simulators.tsx index c1d50ac..2bb6b40 100644 --- a/app/routes/admin.simulators.tsx +++ b/app/routes/admin.simulators.tsx @@ -271,11 +271,6 @@ export default function AdminSimulators({ loaderData }: Route.ComponentProps) { Setup - {sim.supportsFuturesOdds && ( - - )} diff --git a/app/routes/admin.sports-seasons.$id.futures-odds.tsx b/app/routes/admin.sports-seasons.$id.futures-odds.tsx index e1ed62b..f133f5e 100644 --- a/app/routes/admin.sports-seasons.$id.futures-odds.tsx +++ b/app/routes/admin.sports-seasons.$id.futures-odds.tsx @@ -1,394 +1,9 @@ -import { Form, redirect, useLoaderData, useActionData, useNavigation } from 'react-router'; +import { redirect } from 'react-router'; import type { Route } from './+types/admin.sports-seasons.$id.futures-odds'; -import { logger } from '~/lib/logger'; -import { findSportsSeasonById } from '~/models/sports-season'; -import { findParticipantsBySportsSeasonId } from '~/models/season-participant'; -import { getAllParticipantEVsForSeason, batchSaveSourceOdds, clearSourceOddsForParticipants } from '~/models/participant-expected-value'; -import { batchSaveFuturesOddsForSimulator } from '~/models/simulator'; -import { Button } from '~/components/ui/button'; -import { Input } from '~/components/ui/input'; -import { Label } from '~/components/ui/label'; -import { Textarea } from '~/components/ui/textarea'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '~/components/ui/card'; -import { useState } from 'react'; -import { Loader2, Info, CheckCircle2, AlertCircle } from 'lucide-react'; -import { runSportsSeasonSimulation } from '~/services/simulations/runner'; - -export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { - return [{ title: `Futures Odds — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }]; -} - +// Futures odds entry has been consolidated into the Bulk Simulator Inputs card on +// the simulator setup page (paste sportsbook lines like `Chiefs +450` or a CSV with +// a sourceOdds column). This route now just redirects old bookmarks/links there. 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); - - // Show any saved odds regardless of what simulation ran afterwards - const existingOdds = new Map( - existingEVs - .filter(ev => ev.sourceOdds !== null) - .map(ev => [ev.participantId, ev.sourceOdds]) - ); - - return { - sportsSeason, - participants, - existingOdds, - }; -} - -interface ActionData { - success?: boolean; - message?: string; -} - -export async function action({ request, params }: Route.ActionArgs) { - const sportsSeasonId = params.id; - const formData = await request.formData(); - - 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' }; - } - - if (!sportsSeason.sport?.simulatorType) { - return { success: false, message: 'This sport has no simulator type configured. Set one on the sport in the admin panel.' }; - } - - if (sportsSeason.simulationStatus === 'running') { - return { success: false, message: 'A simulation is already running. Please wait.' }; - } - - const shouldClearExisting = formData.get('clearExisting') === '1'; - - try { - const oddsInputs = futuresOdds.map(({ participantId, odds }) => ({ participantId, sportsSeasonId, sourceOdds: odds })); - - // Save to legacy table (EV-page display), then clear all ratings and save - // sourceOdds to the simulator inputs table so odds always drive the run. - // Sequential: batchSaveFuturesOddsForSimulator must win on the inputs table. - await batchSaveSourceOdds(oddsInputs); - await batchSaveFuturesOddsForSimulator(oddsInputs); - - if (shouldClearExisting) { - const keptIds = new Set(futuresOdds.map(f => f.participantId)); - const clearedIds = participants.filter(p => !keptIds.has(p.id)).map(p => p.id); - await clearSourceOddsForParticipants(sportsSeasonId, clearedIds); - } - await runSportsSeasonSimulation(sportsSeasonId); - } catch (error) { - logger.error('Error running simulation:', error); - return { - success: false, - message: error instanceof Error ? error.message : 'Simulation failed', - }; - } - - return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`); -} - -function normalizeName(name: string): string { - return name.toLowerCase().replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, ' ').trim(); -} - -export default function AdminSportsSeasonFuturesOdds() { - const { sportsSeason, participants, existingOdds } = useLoaderData(); - const actionData = useActionData(); - const navigation = useNavigation(); - - // Initialize odds values from existing data - const [oddsValues, setOddsValues] = useState>(() => { - const initial: Record = {}; - participants.forEach(p => { - const existingOdd = existingOdds.get(p.id); - if (existingOdd !== undefined && existingOdd !== null) { - initial[p.id] = existingOdd.toString(); - } - }); - return initial; - }); - - // Bulk import state - const [clearExisting, setClearExisting] = useState(false); - const [bulkText, setBulkText] = useState(''); - const [parseResults, setParseResults] = useState<{ - matched: Array<{ participantId: string; name: string; odds: number; inputName: string }>; - unmatched: Array<{ inputName: string; odds: number }>; - } | null>(null); - - function findParticipantMatch(inputName: string) { - const normalizedInput = normalizeName(inputName); - const normalized = participants.map(p => ({ p, n: normalizeName(p.name) })); - - const exact = normalized.find(({ n }) => n === normalizedInput); - if (exact) return exact.p; - - const contains = normalized.find(({ n }) => n.includes(normalizedInput) || normalizedInput.includes(n)); - if (contains) return contains.p; - - const inputWords = normalizedInput.split(' ').filter(w => w.length > 2); - const overlap = normalized.find(({ n }) => { - const pWords = n.split(' ').filter(w => w.length > 2); - const shared = inputWords.filter(w => pWords.includes(w)); - return shared.length > 0 && shared.length >= Math.min(inputWords.length, pWords.length) * 0.5; - }); - - return overlap?.p ?? null; - } - - function parseBulkText() { - const lines = bulkText.split('\n'); - const oddsPattern = /^(.+?)\s+([+-]\d{2,6})\s*$/; - - const matched: Array<{ participantId: string; name: string; odds: number; inputName: string }> = []; - const unmatched: Array<{ inputName: string; odds: number }> = []; - const seenParticipants = new Set(); - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || trimmed.length < 3) continue; - - const match = oddsPattern.exec(trimmed); - if (!match) continue; - - const inputName = match[1].trim(); - const odds = parseInt(match[2], 10); - if (isNaN(odds)) continue; - - const participant = findParticipantMatch(inputName); - if (participant && !seenParticipants.has(participant.id)) { - seenParticipants.add(participant.id); - matched.push({ participantId: participant.id, name: participant.name, odds, inputName }); - } else if (!participant) { - unmatched.push({ inputName, odds }); - } - } - - setParseResults({ matched, unmatched }); - } - - function applyMatches() { - if (!parseResults) return; - const newOdds = clearExisting ? {} : { ...oddsValues }; - for (const m of parseResults.matched) { - newOdds[m.participantId] = m.odds.toString(); - } - setOddsValues(newOdds); - setParseResults(null); - setBulkText(''); - setClearExisting(false); - } - - const isSubmitting = navigation.state === 'submitting'; - - return ( -
-
-

Futures Odds Entry

-

- {sportsSeason.sport.name} - {sportsSeason.name} -

-
- - {/* Bulk Import */} - - - Bulk Import - - Paste odds from FanDuel, DraftKings, OddsChecker, or any site. Each line should end with - American odds (e.g. Kansas City Chiefs +450). Team names are fuzzy-matched to - participants. - - - -