Centralize futures odds onto the simulator page and fix bulk import (#117)
Futures odds entry was split across two unrelated places: a standalone /futures-odds page (which auto-ran the simulation on save and surfaced a "Simulator is not ready" run failure as if the save itself had failed) and the Bulk Simulator Inputs CSV importer on the simulator setup page. Consolidate everything onto the Bulk Simulator Inputs card: - batchUpsertParticipantSimulatorInputs now COALESCE-wraps every conflict update column, so a partial paste (e.g. odds-only) updates just the columns it provides instead of nulling out previously stored Elo/rating/etc. - The bulk importer accepts sportsbook-style paste (one team per line ending in American odds) when no CSV header is present, reusing the existing fuzzy matcher, and auto-runs the simulation on save. A not-ready run is reported as a successful save plus the readiness gap, never as a failed save. - Retire the standalone /futures-odds page (now redirects to the simulator setup page) and drop the redundant Futures links. - Remove the now-dead futures-only model paths (batchSaveSourceOdds, clearSourceOddsForParticipants, batchSaveFuturesOddsForSimulator, batchSaveParticipantSimulatorSourceOdds); the EV-table bridge is preserved by batchUpsertParticipantSimulatorInputs. - Repoint the test to the consolidated path and assert the non-destructive COALESCE behaviour. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BHnoQ7myY3PzNdTnd7iGNE Co-authored-by: Claude <noreply@anthropic.com> Reviewed-on: #117
This commit is contained in:
parent
3273cf1bcb
commit
8efa4aab9e
7 changed files with 150 additions and 618 deletions
|
|
@ -6,7 +6,7 @@ vi.mock("~/database/context", () => ({
|
|||
}));
|
||||
|
||||
import { database } from "~/database/context";
|
||||
import { batchSaveFuturesOddsForSimulator } from "../simulator";
|
||||
import { batchUpsertParticipantSimulatorInputs } from "../simulator";
|
||||
|
||||
type SetPayload = Record<string, unknown>;
|
||||
|
||||
|
|
@ -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<unknown>) => cb(tx)),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
conflictSetCalls.length = 0;
|
||||
(database as ReturnType<typeof vi.fn>).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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
|
|
@ -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`,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -271,11 +271,6 @@ export default function AdminSimulators({ loaderData }: Route.ComponentProps) {
|
|||
Setup
|
||||
</Link>
|
||||
</Button>
|
||||
{sim.supportsFuturesOdds && (
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={`/admin/sports-seasons/${sim.sportsSeasonId}/futures-odds`}>Futures</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={`/admin/sports-seasons/${sim.sportsSeasonId}/expected-values`}>EVs</Link>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -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<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;
|
||||
});
|
||||
|
||||
// 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<string>();
|
||||
|
||||
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 (
|
||||
<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>
|
||||
|
||||
{/* Bulk Import */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Bulk Import</CardTitle>
|
||||
<CardDescription>
|
||||
Paste odds from FanDuel, DraftKings, OddsChecker, or any site. Each line should end with
|
||||
American odds (e.g. <code>Kansas City Chiefs +450</code>). Team names are fuzzy-matched to
|
||||
participants.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Textarea
|
||||
placeholder={`Paste odds here, one team per line:\nKansas City Chiefs +450\nSan Francisco 49ers +600\nBaltimore Ravens +700`}
|
||||
value={bulkText}
|
||||
onChange={e => { setBulkText(e.target.value); setParseResults(null); }}
|
||||
rows={8}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button type="button" variant="outline" onClick={parseBulkText} disabled={!bulkText.trim()}>
|
||||
Parse Odds
|
||||
</Button>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={clearExisting}
|
||||
onChange={e => setClearExisting(e.target.checked)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
Clear existing odds not in this import
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{parseResults && (
|
||||
<div className="space-y-3">
|
||||
{parseResults.matched.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-emerald-400 mb-2">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Matched ({parseResults.matched.length})
|
||||
</div>
|
||||
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/10 divide-y divide-emerald-500/20 text-sm">
|
||||
{parseResults.matched.map(m => (
|
||||
<div key={m.participantId} className="flex justify-between px-3 py-1.5">
|
||||
<span className="text-muted-foreground">{m.inputName}</span>
|
||||
<span className="font-medium">
|
||||
{m.name} → {m.odds > 0 ? '+' : ''}{m.odds}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parseResults.unmatched.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-amber-700 mb-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
Not matched ({parseResults.unmatched.length}) — enter manually below
|
||||
</div>
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 divide-y divide-amber-100 text-sm">
|
||||
{parseResults.unmatched.map((u) => (
|
||||
<div key={u.inputName} className="flex justify-between px-3 py-1.5">
|
||||
<span>{u.inputName}</span>
|
||||
<span className="font-medium">{u.odds > 0 ? '+' : ''}{u.odds}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parseResults.matched.length === 0 && parseResults.unmatched.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No odds found. Make sure each line ends with a value like +450 or -200.</p>
|
||||
)}
|
||||
|
||||
{parseResults.matched.length > 0 && (
|
||||
<Button type="button" onClick={applyMatches}>
|
||||
Apply {parseResults.matched.length} matched odds to form
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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.
|
||||
Enter American odds, then run the ICM simulation to compute and save probability distributions.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form method="post" className="space-y-4">
|
||||
<input type="hidden" name="clearExisting" value={clearExisting ? "1" : ""} />
|
||||
<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>
|
||||
|
||||
{actionData && !actionData.success && actionData.message && (
|
||||
<div className="text-sm text-destructive">{actionData.message}</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{isSubmitting ? 'Running...' : 'Run Simulation'}
|
||||
</Button>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
return redirect(`/admin/sports-seasons/${params.id}/simulator`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,6 +110,39 @@ function findParticipantId(name: string, participants: Array<{ id: string; name:
|
|||
);
|
||||
}
|
||||
|
||||
const ODDS_LINE_PATTERN = /^(.+?)\s+([+-]\d{2,6})\s*$/;
|
||||
|
||||
function parseOddsLines(
|
||||
lines: string[],
|
||||
sportsSeasonId: string,
|
||||
participants: Array<{ id: string; name: string }>
|
||||
): { inputs: UpsertParticipantSimulatorInput[]; unmatched: string[] } {
|
||||
const inputs: UpsertParticipantSimulatorInput[] = [];
|
||||
const unmatched: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const line of lines) {
|
||||
const match = ODDS_LINE_PATTERN.exec(line);
|
||||
if (!match) continue;
|
||||
|
||||
const name = match[1].trim();
|
||||
const sourceOdds = Number(match[2]);
|
||||
if (!Number.isFinite(sourceOdds)) continue;
|
||||
|
||||
const participantId = findParticipantId(name, participants);
|
||||
if (!participantId) {
|
||||
unmatched.push(name);
|
||||
continue;
|
||||
}
|
||||
if (seen.has(participantId)) continue;
|
||||
seen.add(participantId);
|
||||
|
||||
inputs.push({ participantId, sportsSeasonId, sourceOdds });
|
||||
}
|
||||
|
||||
return { inputs, unmatched };
|
||||
}
|
||||
|
||||
function parseInputCsv(
|
||||
text: string,
|
||||
sportsSeasonId: string,
|
||||
|
|
@ -122,7 +155,10 @@ function parseInputCsv(
|
|||
const indexes = new Map(header.map((value, index) => [value, index]));
|
||||
const nameIndex = indexes.get("name");
|
||||
if (nameIndex === undefined) {
|
||||
throw new Error("Bulk input CSV must include a `name` column.");
|
||||
// No CSV header — treat the paste as sportsbook futures odds, one team per
|
||||
// line ending in American odds (e.g. `Kansas City Chiefs +450`). This is the
|
||||
// friendly bulk-futures path; team names are fuzzy-matched to participants.
|
||||
return parseOddsLines(lines, sportsSeasonId, participants);
|
||||
}
|
||||
|
||||
const inputs: UpsertParticipantSimulatorInput[] = [];
|
||||
|
|
@ -241,7 +277,26 @@ export async function action({ request, params }: Route.ActionArgs): Promise<Act
|
|||
const suffix = parsed.unmatched.length > 0
|
||||
? ` ${parsed.unmatched.length} row(s) were unmatched: ${parsed.unmatched.join(", ")}.`
|
||||
: "";
|
||||
return { success: true, message: `Saved ${parsed.inputs.length} simulator input row(s).${suffix}` };
|
||||
const saved = `Saved ${parsed.inputs.length} simulator input row(s).${suffix}`;
|
||||
|
||||
// Auto-run the simulation so saved inputs immediately drive the standings.
|
||||
// The save itself already succeeded, so a run that never started (e.g.
|
||||
// participants missing required inputs) is reported as success with the
|
||||
// readiness gap — never as a failed save.
|
||||
try {
|
||||
await runSportsSeasonSimulation(sportsSeasonId);
|
||||
return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`);
|
||||
} catch (runError) {
|
||||
const reason = runError instanceof Error ? runError.message : "could not run.";
|
||||
// Distinguish "saved but never ran" (readiness/already-running) from
|
||||
// "started running and failed mid-run": the runner only flips the season
|
||||
// to status 'failed' once the simulation itself throws.
|
||||
const season = await findSportsSeasonById(sportsSeasonId);
|
||||
if (season?.simulationStatus === "failed") {
|
||||
return { success: false, message: `${saved} The simulation failed: ${reason}` };
|
||||
}
|
||||
return { success: true, message: `${saved} Simulation not run yet: ${reason}` };
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to save simulator inputs." };
|
||||
}
|
||||
|
|
@ -334,8 +389,7 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
|||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{setupSections.includes("futuresOdds") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/futures-odds`}>Futures Odds</Link></Button>}
|
||||
{setupSections.includes("eloRatings") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/elo-ratings`}>Elo Ratings</Link></Button>}
|
||||
{setupSections.includes("eloRatings") &&<Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/elo-ratings`}>Elo Ratings</Link></Button>}
|
||||
{setupSections.includes("surfaceElo") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/surface-elo`}>Surface Elo</Link></Button>}
|
||||
{setupSections.includes("golfSkills") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/golf-skills`}>Golf Skills</Link></Button>}
|
||||
{setupSections.includes("regularStandings") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/regular-standings`}>Regular Standings</Link></Button>}
|
||||
|
|
@ -484,8 +538,12 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
|||
<CardHeader>
|
||||
<CardTitle>Bulk Simulator Inputs</CardTitle>
|
||||
<CardDescription>
|
||||
Paste CSV with a header row. Supported columns: name, sourceElo, sourceOdds, worldRanking, rating,
|
||||
projectedWins, projectedTablePoints, seed, region. Values must not contain commas.
|
||||
Two ways to paste, then the simulation re-runs automatically on save:
|
||||
<strong> CSV</strong> with a header row — supported columns: name, sourceElo, sourceOdds,
|
||||
worldRanking, rating, projectedWins, projectedTablePoints, seed, region (values must not
|
||||
contain commas); or <strong>futures odds</strong>, one team per line ending in American
|
||||
odds (e.g. <code>Kansas City Chiefs +450</code>). Team names are fuzzy-matched to
|
||||
participants. A partial paste only updates the columns it includes — other inputs are kept.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
|
@ -495,7 +553,7 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
|||
name="bulkInputs"
|
||||
rows={8}
|
||||
className="font-mono text-sm"
|
||||
placeholder={`name,sourceElo,sourceOdds,worldRanking,rating\n${inputRows[0]?.participant.name ?? "Team Name"},1500,+2500,12,28.5`}
|
||||
placeholder={`name,sourceElo,sourceOdds,worldRanking,rating\n${inputRows[0]?.participant.name ?? "Team Name"},1500,+2500,12,28.5\n\n— or paste futures odds only —\n${inputRows[0]?.participant.name ?? "Team Name"} +2500`}
|
||||
/>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -675,14 +675,6 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
|
|||
<Calculator className="mr-2 h-4 w-4" />
|
||||
Simulator Setup
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/futures-odds`)}
|
||||
>
|
||||
<Calculator className="mr-2 h-4 w-4" />
|
||||
Futures Odds
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue