2026-03-09 15:34:31 -07:00
|
|
|
/**
|
|
|
|
|
* Admin: Run EV Simulation for a Sports Season
|
|
|
|
|
*
|
|
|
|
|
* Action-only route. POSTing here:
|
|
|
|
|
* 1. Guards against concurrent runs via simulationStatus
|
|
|
|
|
* 2. Runs the appropriate simulator based on the sport's simulatorType
|
|
|
|
|
* 3. Persists updated EVs to participantExpectedValues (transactional)
|
2026-03-20 14:39:05 -07:00
|
|
|
* 4. Refreshes cached projectedPoints in teamStandings for all affected fantasy seasons
|
|
|
|
|
* 5. Takes an EV snapshot from simulation output (upsert for today's date)
|
|
|
|
|
* 6. Redirects back to the sports season admin page
|
2026-03-09 15:34:31 -07:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { redirect } from "react-router";
|
|
|
|
|
import type { Route } from "./+types/admin.sports-seasons.$id.simulate";
|
|
|
|
|
import { findSportsSeasonById, updateSportsSeason } from "~/models/sports-season";
|
|
|
|
|
import { batchUpsertParticipantEVs } from "~/models/participant-expected-value";
|
|
|
|
|
import { batchUpsertParticipantEvSnapshots } from "~/models/ev-snapshot";
|
2026-05-01 16:17:23 +00:00
|
|
|
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
2026-03-09 15:34:31 -07:00
|
|
|
import { getSimulator, type SimulatorType } from "~/services/simulations/registry";
|
Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator (#242)
* Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator, fixes #127
- New `groupStageMatches` table for recording group play results (W/D/L, scores, matchday, schedule)
- `computeGroupStandings()` model function: pts → GD → GF → name tiebreaker ordering
- `GroupStageStandings` component showing all 12 groups with standings table and manager column
- Admin bracket UI: group match score entry, per-group standings, "Recalculate Floors" action
- `WorldCupSimulator`: 50k Monte Carlo covering group stage + best-8 3rd-place + knockout + 3rd place game
- Fuzzy name matching for national team Elo lookup (exact → substring → word-overlap), warns on miss
- Partial group completion: completed matches replayed with real scores, remaining matches simulated
- Elo priority: admin-entered sourceElo > futures odds converted to Elo > hardcoded national team ratings
- `fifa_48` bracket template: added Third Place Game round with `loserFeedsInto` on Semifinals
- Scoring rules: distinct 3rd/4th place for `fifa_48` (not averaged), QF losers share 5th–8th equally
- Floor scoring: SF participants guaranteed 4th (provisional), finalized after 3rd place game
- `recalculate-floors` admin action deletes and replays all results from scratch (fixes stale guard bug)
- Unique index on `(tournamentGroupId, participant1Id, participant2Id)` to prevent duplicate pairings
- Batch `findMatchesByGroupIds()` replacing N sequential queries in the sport season loader
- League home mini-standings now shows `actualPoints` (includes floor) instead of `totalPoints` only
- Elo ratings admin page supports World Cup (same bulk-import flow as snooker)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors: update GroupStandingData type to use findMatchesByGroupIds
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Increase Node heap to 4GB for unit tests in CI to prevent OOM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix OOM in CI: make WorldCupSimulator simulation count configurable for tests
Tests now pass numSimulations=500 instead of the production default of 50,000.
Six simulator tests × 50k iterations each was exhausting the 4GB heap on GitHub
Actions runners. Also reduce simGroupMatch stat tests from 50k to 5k iterations.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:27:47 -07:00
|
|
|
import { calculateEV } from "~/services/ev-calculator";
|
|
|
|
|
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
2026-03-20 14:39:05 -07:00
|
|
|
import { recalculateStandings } from "~/models/scoring-calculator";
|
|
|
|
|
import { database } from "~/database/context";
|
|
|
|
|
import * as schema from "~/database/schema";
|
|
|
|
|
import { eq } from "drizzle-orm";
|
2026-03-09 15:34:31 -07:00
|
|
|
|
|
|
|
|
export async function action({ params }: Route.ActionArgs) {
|
|
|
|
|
const sportsSeasonId = params.id;
|
|
|
|
|
|
|
|
|
|
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
|
|
|
|
|
if (!sportsSeason) {
|
|
|
|
|
throw new Response("Sports season not found", { status: 404 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!sportsSeason.sport?.simulatorType) {
|
|
|
|
|
throw new Response(
|
|
|
|
|
"This sport has no simulator type configured. Set a simulator type on the sport in the admin panel before running a simulation.",
|
|
|
|
|
{ status: 400 }
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Guard against concurrent simulation runs
|
|
|
|
|
if (sportsSeason.simulationStatus === "running") {
|
|
|
|
|
throw new Response(
|
|
|
|
|
"A simulation is already running for this sports season. Please wait for it to complete.",
|
|
|
|
|
{ status: 409 }
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mark as running
|
|
|
|
|
await updateSportsSeason(sportsSeasonId, { simulationStatus: "running" });
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const simulator = getSimulator(sportsSeason.sport.simulatorType as SimulatorType);
|
|
|
|
|
|
|
|
|
|
const results = await simulator.simulate(sportsSeasonId);
|
|
|
|
|
|
|
|
|
|
if (results.length === 0) {
|
|
|
|
|
throw new Error("Simulation returned no results. Check that participants have EV data.");
|
|
|
|
|
}
|
|
|
|
|
|
Add NCAAM and NCAAW bracket Monte Carlo simulators (#150)
- NCAAM: KenPom AEM logistic formula (1/(1+exp(-diff/7.5))), data through 2025-26 March 15
- NCAAW: Barttorvik Barthag Log5 formula (A*(1-B)/(A*(1-B)+B*(1-A))), same bracket structure
- Both simulators: 50,000-iteration Monte Carlo, First Four simulation, honors completed matches
- Track E8+ placements only: champion, finalist, FF losers (3rd/4th), E8 losers (5th–8th)
- Add bracket configuration validation: null R64 slots must exactly match First Four mapping
- Fix DEFAULT_SCORING_RULES to 100/70/45/45/20/20/20/20 (3rd/4th=45, 5th–8th=20)
- Align scoring constants across simulate route, expected-values display, and server action
- Zero out EVs for non-bracket participants on every simulation run (prevents EV inflation)
- Add EV total invariant warning (expected ~340) on expected-values admin page
- 98 unit tests across NCAAM, NCAAW, and UCL simulators — all passing
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 23:31:47 -07:00
|
|
|
// Build a complete set of EV inputs covering ALL season participants.
|
|
|
|
|
// Participants not included in simulation results (e.g., teams in the season
|
|
|
|
|
// but not in the bracket) get zeroed out so stale EVs from prior runs don't
|
|
|
|
|
// inflate the total. Without this, extra participants with old non-zero EVs
|
|
|
|
|
// would cause the total EV to exceed the expected sum of scoring values (340).
|
|
|
|
|
const allSeasonParticipants = await findParticipantsBySportsSeasonId(sportsSeasonId);
|
|
|
|
|
const simulatedIds = new Set(results.map((r) => r.participantId));
|
|
|
|
|
const ZERO_PROBS = {
|
|
|
|
|
probFirst: 0, probSecond: 0, probThird: 0, probFourth: 0,
|
|
|
|
|
probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0,
|
|
|
|
|
};
|
|
|
|
|
const evInputs = [
|
|
|
|
|
...results.map((r) => ({
|
2026-03-09 15:34:31 -07:00
|
|
|
participantId: r.participantId,
|
|
|
|
|
sportsSeasonId,
|
|
|
|
|
probabilities: r.probabilities,
|
|
|
|
|
scoringRules: DEFAULT_SCORING_RULES,
|
Add NCAAM and NCAAW bracket Monte Carlo simulators (#150)
- NCAAM: KenPom AEM logistic formula (1/(1+exp(-diff/7.5))), data through 2025-26 March 15
- NCAAW: Barttorvik Barthag Log5 formula (A*(1-B)/(A*(1-B)+B*(1-A))), same bracket structure
- Both simulators: 50,000-iteration Monte Carlo, First Four simulation, honors completed matches
- Track E8+ placements only: champion, finalist, FF losers (3rd/4th), E8 losers (5th–8th)
- Add bracket configuration validation: null R64 slots must exactly match First Four mapping
- Fix DEFAULT_SCORING_RULES to 100/70/45/45/20/20/20/20 (3rd/4th=45, 5th–8th=20)
- Align scoring constants across simulate route, expected-values display, and server action
- Zero out EVs for non-bracket participants on every simulation run (prevents EV inflation)
- Add EV total invariant warning (expected ~340) on expected-values admin page
- 98 unit tests across NCAAM, NCAAW, and UCL simulators — all passing
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 23:31:47 -07:00
|
|
|
source: "elo_simulation" as const,
|
|
|
|
|
})),
|
|
|
|
|
...allSeasonParticipants
|
|
|
|
|
.filter((p) => !simulatedIds.has(p.id))
|
|
|
|
|
.map((p) => ({
|
|
|
|
|
participantId: p.id,
|
|
|
|
|
sportsSeasonId,
|
|
|
|
|
probabilities: ZERO_PROBS,
|
|
|
|
|
scoringRules: DEFAULT_SCORING_RULES,
|
|
|
|
|
source: "elo_simulation" as const,
|
|
|
|
|
})),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// Persist updated EVs (transactional)
|
|
|
|
|
await batchUpsertParticipantEVs(evInputs);
|
2026-03-09 15:34:31 -07:00
|
|
|
|
2026-03-20 14:39:05 -07:00
|
|
|
// Refresh cached projectedPoints in teamStandings for all affected fantasy seasons
|
|
|
|
|
const seasonSports = await database().query.seasonSports.findMany({
|
|
|
|
|
where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId),
|
|
|
|
|
});
|
|
|
|
|
await Promise.all(seasonSports.map(({ seasonId }) => recalculateStandings(seasonId)));
|
|
|
|
|
|
2026-03-09 15:34:31 -07:00
|
|
|
// Take EV snapshot from simulation output (not re-read from DB)
|
|
|
|
|
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
|
|
|
|
await batchUpsertParticipantEvSnapshots(
|
|
|
|
|
results.map((r) => ({
|
|
|
|
|
participantId: r.participantId,
|
|
|
|
|
sportsSeasonId,
|
|
|
|
|
snapshotDate: today,
|
|
|
|
|
probFirst: r.probabilities.probFirst,
|
|
|
|
|
probSecond: r.probabilities.probSecond,
|
|
|
|
|
probThird: r.probabilities.probThird,
|
|
|
|
|
probFourth: r.probabilities.probFourth,
|
|
|
|
|
probFifth: r.probabilities.probFifth,
|
|
|
|
|
probSixth: r.probabilities.probSixth,
|
|
|
|
|
probSeventh: r.probabilities.probSeventh,
|
|
|
|
|
probEighth: r.probabilities.probEighth,
|
|
|
|
|
calculatedEV: calculateEV(r.probabilities, DEFAULT_SCORING_RULES),
|
|
|
|
|
source: r.source,
|
|
|
|
|
}))
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Mark as idle
|
|
|
|
|
await updateSportsSeason(sportsSeasonId, { simulationStatus: "idle" });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// Mark as failed so the UI shows the error state
|
|
|
|
|
await updateSportsSeason(sportsSeasonId, { simulationStatus: "failed" });
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`);
|
|
|
|
|
}
|