feat: add syncVorpForSeason and wire into EV update flow
Adds syncVorpForSeason to recalculate and persist VORP for all participants in a season after any EV change. Wires it into upsertParticipantEV, batchUpsertParticipantEVs, and recalculateAllEVsForSeason so VORP stays current whenever EVs are updated. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
22ec6381b8
commit
3c740c50bf
2 changed files with 162 additions and 2 deletions
|
|
@ -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";
|
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.
|
* 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", () => {
|
describe("participant-expected-value model", () => {
|
||||||
const _defaultScoring: ScoringRules = {
|
const _defaultScoring: ScoringRules = {
|
||||||
pointsFor1st: 100,
|
pointsFor1st: 100,
|
||||||
|
|
@ -126,4 +154,92 @@ describe("participant-expected-value model", () => {
|
||||||
expect(true).toBe(true);
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { database } from "~/database/context";
|
||||||
import { participantExpectedValues, participants } from "~/database/schema";
|
import { participantExpectedValues, participants } from "~/database/schema";
|
||||||
import { eq, and, count, sql } from "drizzle-orm";
|
import { eq, and, count, sql } from "drizzle-orm";
|
||||||
import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator";
|
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";
|
export type ProbabilitySource = "manual" | "futures_odds" | "elo_simulation" | "performance_model";
|
||||||
|
|
||||||
|
|
@ -49,6 +49,40 @@ export interface UpdateProbabilityInput {
|
||||||
source?: ProbabilitySource;
|
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<void> {
|
||||||
|
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
|
* Create or update participant probabilities and calculate EV
|
||||||
*
|
*
|
||||||
|
|
@ -145,6 +179,9 @@ export async function upsertParticipantEV(
|
||||||
.set({ expectedValue: expectedValue.toString(), updatedAt: now })
|
.set({ expectedValue: expectedValue.toString(), updatedAt: now })
|
||||||
.where(eq(participants.id, participantId));
|
.where(eq(participants.id, participantId));
|
||||||
|
|
||||||
|
// Recalculate VORP for all participants in this season (replacement level may have shifted)
|
||||||
|
await syncVorpForSeason(sportsSeasonId);
|
||||||
|
|
||||||
return result;
|
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;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -478,5 +519,8 @@ export async function recalculateAllEVsForSeason(
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Sync VORP once after all EVs are updated
|
||||||
|
await syncVorpForSeason(sportsSeasonId);
|
||||||
|
|
||||||
return allEVs.length;
|
return allEVs.length;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue