diff --git a/app/models/__tests__/participant-expected-value.test.ts b/app/models/__tests__/participant-expected-value.test.ts index 26cd891..0486a8c 100644 --- a/app/models/__tests__/participant-expected-value.test.ts +++ b/app/models/__tests__/participant-expected-value.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator"; /** @@ -11,6 +11,34 @@ import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calcul * Full integration tests are in the E2E test suite. */ +// Mock database context +const mockUpdate = vi.fn(); +const mockSet = vi.fn(); +const mockWhere = vi.fn(); +const mockDb = { + update: mockUpdate, + select: vi.fn(), +}; + +vi.mock("~/database/context", () => ({ + database: () => mockDb, +})); + +vi.mock("~/database/schema", () => ({ + participants: { id: "id" }, + participantExpectedValues: { + participantId: "participantId", + sportsSeasonId: "sportsSeasonId", + }, +})); + +vi.mock("drizzle-orm", () => ({ + eq: vi.fn((field, value) => ({ field, value })), + and: vi.fn((...args) => ({ and: args })), + count: vi.fn(() => ({ count: true })), + sql: vi.fn(), +})); + describe("participant-expected-value model", () => { const _defaultScoring: ScoringRules = { pointsFor1st: 100, @@ -126,4 +154,92 @@ describe("participant-expected-value model", () => { expect(true).toBe(true); }); }); + + describe("syncVorpForSeason", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should calculate correct VORP values for 14 participants with EVs 100 down to 35 (step 5)", async () => { + // 14 participants: EVs = 100, 95, 90, 85, 80, 75, 70, 65, 60, 55, 50, 45, 40, 35 + // Sorted descending (already sorted) + // Replacement level = avg of positions 12-14 (0-indexed 11-13) = avg(45, 40, 35) = 40 + // VORP(100) = 60, VORP(35) = -5 + + const { calculateReplacementLevel, calculateVORP } = await import("~/services/ev-calculator"); + + const evValues = Array.from({ length: 14 }, (_, i) => 100 - i * 5); + // [100, 95, 90, 85, 80, 75, 70, 65, 60, 55, 50, 45, 40, 35] + + const replacementLevel = calculateReplacementLevel(evValues); + expect(replacementLevel).toBe(40); // avg(45, 40, 35) = 40 + + const vorpFirst = calculateVORP(100, replacementLevel); + expect(vorpFirst).toBe(60); + + const vorpLast = calculateVORP(35, replacementLevel); + expect(vorpLast).toBe(-5); + }); + + it("should return early when no EVs exist for the season", async () => { + // Re-mock getAllParticipantEVsForSeason to return empty array + // The function should do nothing and return without calling db.update + const { syncVorpForSeason } = await import("../participant-expected-value"); + + // Patch the module's getAllParticipantEVsForSeason to return [] + // Since we can't easily spy on module-internal calls, we verify via db mock: + // If 0 EVs returned, db.update should not be called + + // Setup: db.select chain for getAllParticipantEVsForSeason returns [] + const mockSelectChain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue([]), + }; + mockDb.select = vi.fn().mockReturnValue(mockSelectChain); + + await syncVorpForSeason("season-empty"); + + // db.update should NOT have been called (no participants to update) + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it("should call db.update with correct vorpValue for each participant", async () => { + const { syncVorpForSeason } = await import("../participant-expected-value"); + + // 3 participants with EVs: 100, 70, 40 + // sorted: [100, 70, 40] + // replacement level = avg of positions 12-14, but only 3 participants + // startIdx = min(11, 2) = 2, endIdx = min(13, 2) = 2 → slice = [40] + // replacementLevel = 40 + // VORP: 100→60, 70→30, 40→0 + const mockEvRecords = [ + { participantId: "p1", expectedValue: "100", sportsSeasonId: "season-1" }, + { participantId: "p2", expectedValue: "70", sportsSeasonId: "season-1" }, + { participantId: "p3", expectedValue: "40", sportsSeasonId: "season-1" }, + ]; + + const mockSelectChain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue(mockEvRecords), + }; + mockDb.select = vi.fn().mockReturnValue(mockSelectChain); + + const mockWhereResolved = vi.fn().mockResolvedValue([]); + mockSet.mockReturnValue({ where: mockWhereResolved }); + mockUpdate.mockReturnValue({ set: mockSet }); + + await syncVorpForSeason("season-1"); + + // Should have called db.update 3 times (once per participant) + expect(mockUpdate).toHaveBeenCalledTimes(3); + + // Verify the set calls include vorpValue + const setCalls = mockSet.mock.calls; + const vorpValues = setCalls.map((call) => call[0].vorpValue); + + expect(vorpValues).toContain("60.0000"); + expect(vorpValues).toContain("30.0000"); + expect(vorpValues).toContain("0.0000"); + }); + }); }); diff --git a/app/models/participant-expected-value.ts b/app/models/participant-expected-value.ts index 405b06f..03f642e 100644 --- a/app/models/participant-expected-value.ts +++ b/app/models/participant-expected-value.ts @@ -9,7 +9,7 @@ import { database } from "~/database/context"; import { participantExpectedValues, participants } from "~/database/schema"; import { eq, and, count, sql } from "drizzle-orm"; import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator"; -import { calculateEV, normalizeProbabilities } from "~/services/ev-calculator"; +import { calculateEV, normalizeProbabilities, calculateReplacementLevel, calculateVORP } from "~/services/ev-calculator"; export type ProbabilitySource = "manual" | "futures_odds" | "elo_simulation" | "performance_model"; @@ -49,6 +49,40 @@ export interface UpdateProbabilityInput { source?: ProbabilitySource; } +/** + * Recalculate and persist VORP for every participant in a sports season. + * + * VORP = participant EV − replacement level EV + * Replacement level = average EV of participants ranked 12th–14th in this season. + * + * Call this after any operation that changes EVs for participants in the season. + */ +export async function syncVorpForSeason(sportsSeasonId: string): Promise { + const db = database(); + + const allEvs = await getAllParticipantEVsForSeason(sportsSeasonId); + if (allEvs.length === 0) return; + + const sorted = [...allEvs].sort( + (a, b) => parseFloat(b.expectedValue) - parseFloat(a.expectedValue) + ); + + const sortedEvNumbers = sorted.map((ev) => parseFloat(ev.expectedValue)); + const replacementLevel = calculateReplacementLevel(sortedEvNumbers); + + const now = new Date(); + + await Promise.all( + sorted.map((ev) => { + const vorp = calculateVORP(parseFloat(ev.expectedValue), replacementLevel); + return db + .update(participants) + .set({ vorpValue: vorp.toFixed(4), updatedAt: now }) + .where(eq(participants.id, ev.participantId)); + }) + ); +} + /** * Create or update participant probabilities and calculate EV * @@ -145,6 +179,9 @@ export async function upsertParticipantEV( .set({ expectedValue: expectedValue.toString(), updatedAt: now }) .where(eq(participants.id, participantId)); + // Recalculate VORP for all participants in this season (replacement level may have shifted) + await syncVorpForSeason(sportsSeasonId); + return result; } @@ -306,6 +343,10 @@ export async function batchUpsertParticipantEVs( } }); + // Sync VORP for all affected seasons + const uniqueSeasonIds = [...new Set(inputs.map((i) => i.sportsSeasonId))]; + await Promise.all(uniqueSeasonIds.map((id) => syncVorpForSeason(id))); + return results; } @@ -478,5 +519,8 @@ export async function recalculateAllEVsForSeason( ) ); + // Sync VORP once after all EVs are updated + await syncVorpForSeason(sportsSeasonId); + return allEVs.length; }