diff --git a/app/services/simulations/auto-racing-simulator.ts b/app/services/simulations/auto-racing-simulator.ts index 64072ee..8120702 100644 --- a/app/services/simulations/auto-racing-simulator.ts +++ b/app/services/simulations/auto-racing-simulator.ts @@ -117,13 +117,19 @@ export class AutoRacingSimulator implements Simulator { seasonResults.map((r) => [r.participant.id, parseFloat(r.currentPoints ?? "0")]) ); - // 3. Count remaining races: incomplete scoring events, excluding schedule_event entries + // 3. Count remaining and completed races (exclude schedule_event entries) const allEvents = await db.query.scoringEvents.findMany({ where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), }); const remainingRaces = allEvents.filter( (e) => !e.isComplete && e.eventType !== "schedule_event" ).length; + const completedRaces = allEvents.filter( + (e) => e.isComplete && e.eventType !== "schedule_event" + ).length; + // 0.0 = pre-season, 1.0 = all races done + const totalRaces = completedRaces + remainingRaces; + const seasonProgress = totalRaces > 0 ? completedRaces / totalRaces : 0; // 4. Load EV data for championship win probabilities const evs = await getAllParticipantEVsForSeason(sportsSeasonId); @@ -160,7 +166,28 @@ export class AutoRacingSimulator implements Simulator { } } - // 7. Accumulate finish counts across simulations + // 7. Build blended probability weights that combine futures-odds strength + // with standings-based strength, weighted by season progress. + // - Pre-season (seasonProgress=0): pure futures odds + // - Mid/late season: standings dominate, reducing the distortion from + // championship futures (which penalize 2nd-place drivers whose odds of + // *winning* the title are weak, even though they'll likely finish top 3) + const totalCurrentPoints = [...currentPointsMap.values()].reduce((a, b) => a + b, 0); + const blendedProbs = new Map(); + for (const id of ids) { + const oddsW = baseProbs.get(id) ?? fallbackProb; + let standingsW: number; + if (totalCurrentPoints > 0) { + const pts = currentPointsMap.get(id) ?? 0; + // Drivers with 0 pts (new entry, early DNF) fall back to odds strength + standingsW = pts > 0 ? pts / totalCurrentPoints : oddsW; + } else { + standingsW = oddsW; + } + blendedProbs.set(id, (1 - seasonProgress) * oddsW + seasonProgress * standingsW); + } + + // Accumulate finish counts across simulations // rankCounts[id][0..7] = number of times driver finished 1st..8th const rankCounts = new Map(); for (const id of ids) { @@ -180,14 +207,17 @@ export class AutoRacingSimulator implements Simulator { } } else { // In-season: simulate remaining races from current standings. + // Volatility shrinks as the season progresses — late-season standings are + // much more predictive than early-season odds. + const effectiveVolatility = PARTICIPANT_VOLATILITY * (1 - seasonProgress * 0.7); for (let sim = 0; sim < NUM_SIMULATIONS; sim++) { - // 7a. Season-long performance multiplier per driver + // 7a. Season-long performance multiplier per driver (uses blended strength) const seasonWeights = new Map(); for (const id of ids) { - const base = baseProbs.get(id) ?? fallbackProb; + const base = blendedProbs.get(id) ?? fallbackProb; const mult = Math.max( 0.05, - 1 - PARTICIPANT_VOLATILITY + Math.random() * PARTICIPANT_VOLATILITY * 2 + 1 - effectiveVolatility + Math.random() * effectiveVolatility * 2 ); seasonWeights.set(id, base * mult); }