From e20e7767e7befdce8e8c416e54638d7e531b6c13 Mon Sep 17 00:00:00 2001 From: Steve Freeman Date: Wed, 9 Sep 2026 13:27:44 -0400 Subject: [PATCH] Ground waiver bids in league history Use recency-weighted historical FAAB spend per projected point from linked Sleeper seasons to produce deterministic, budget-aware waiver recommendations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/backend/src/fantasy/fantasy.model.ts | 6 + .../src/fantasy/fantasy.service.spec.ts | 174 +++++++++++- .../backend/src/fantasy/fantasy.service.ts | 251 +++++++++++++++++- 3 files changed, 428 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/fantasy/fantasy.model.ts b/packages/backend/src/fantasy/fantasy.model.ts index 8ff3245b..8e218dd2 100644 --- a/packages/backend/src/fantasy/fantasy.model.ts +++ b/packages/backend/src/fantasy/fantasy.model.ts @@ -7,6 +7,7 @@ export interface SleeperUser { export interface SleeperLeague { league_id: string; + previous_league_id?: string | null; name: string; season: string; status: string; @@ -82,6 +83,11 @@ export interface SleeperProjection { stats?: Record; } +export interface WaiverBidGuidance { + sampleSize: number; + suggestedBids: Record; +} + export interface FantasyPlayer { id: string; name: string; diff --git a/packages/backend/src/fantasy/fantasy.service.spec.ts b/packages/backend/src/fantasy/fantasy.service.spec.ts index 453b4fa2..75b94eb2 100644 --- a/packages/backend/src/fantasy/fantasy.service.spec.ts +++ b/packages/backend/src/fantasy/fantasy.service.spec.ts @@ -7,7 +7,10 @@ import type { LineupRecommendation, SleeperLeague, SleeperMatchup, + SleeperPlayer, SleeperProjection, + SleeperTransaction, + WaiverBidGuidance, } from './fantasy.model'; import { FantasyService } from './fantasy.service'; @@ -81,6 +84,24 @@ type FantasyServiceInternals = { stats: Record | undefined, scoringSettings: Record | undefined, ) => number | null; + getWaiverBidGuidance: ( + league: SleeperLeague, + state: { season: string; week: number; season_type: 'pre' | 'regular' | 'post' }, + currentRounds: number[], + currentTransactionGroups: SleeperTransaction[][], + currentProjections: SleeperProjection[], + candidates: FantasyPlayer[], + players: Record, + remainingBudget: number, + ) => Promise; + buildWaiverBidGuidance: ( + samples: Array<{ pricePerPoint: number; position: string | null; weight: number }>, + league: SleeperLeague, + currentProjections: SleeperProjection[], + candidates: FantasyPlayer[], + remainingBudget: number, + ) => WaiverBidGuidance; + weightedMedian: (samples: Array<{ pricePerPoint: number; weight: number }>) => number | null; }; describe('FantasyService', () => { @@ -195,6 +216,16 @@ describe('FantasyService', () => { settings: { waiver_bid: 14 }, created: 1788883300000, }, + { + transaction_id: 'completed-waiver', + type: 'waiver', + status: 'complete', + roster_ids: [1], + adds: { p4: 1 }, + drops: null, + settings: { waiver_bid: 5 }, + created: 1788883100000, + }, ], }); } @@ -288,7 +319,7 @@ describe('FantasyService', () => { add: { id: 'p3', name: 'Casey Waiver' }, drop: { id: 'p1', name: 'Alex Receiver' }, priority: 'high', - recommendedBid: 17, + recommendedBid: 5, }); expect(result?.pendingWaivers[0]).toMatchObject({ transactionId: 'waiver-1', @@ -462,6 +493,147 @@ describe('FantasyService', () => { expect(internals.projectedPoints({}, undefined)).toBeNull(); }); + it('prices waiver bids from prior-league dollars per projected point', async () => { + const internals = service as unknown as FantasyServiceInternals; + (Axios.get as Mock).mockImplementation((url: string) => { + if (url.endsWith('/league/888')) { + return Promise.resolve({ + data: { + league_id: '888', + previous_league_id: null, + name: 'Friends League', + season: '2025', + status: 'complete', + avatar: null, + total_rosters: 2, + settings: { waiver_budget: 200 }, + scoring_settings: { pts_ppr: 1 }, + }, + }); + } + if (url.endsWith('/league/888/transactions/1')) { + return Promise.resolve({ + data: [ + { + transaction_id: 'historical-waiver', + type: 'waiver', + status: 'complete', + roster_ids: [1], + adds: { historical: 1 }, + drops: null, + settings: { waiver_bid: 40 }, + created: 1756857600000, + }, + ], + }); + } + if (url.includes('/league/888/transactions/')) return Promise.resolve({ data: [] }); + if (url.includes('/projections/nfl/2025/1')) { + return Promise.resolve({ + data: [{ player_id: 'historical', stats: { pts_ppr: 10 } }], + }); + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const candidate: FantasyPlayer = { + id: 'candidate', + name: 'Current Candidate', + position: 'RB', + team: 'BUF', + injuryStatus: null, + fantasyPositions: ['RB'], + }; + const guidance = await internals.getWaiverBidGuidance( + { + league_id: '999', + previous_league_id: '888', + name: 'Friends League', + season: '2026', + status: 'in_season', + avatar: null, + total_rosters: 2, + settings: { waiver_budget: 100 }, + scoring_settings: { pts_ppr: 1 }, + }, + { season: '2026', week: 1, season_type: 'regular' }, + [1], + [[]], + [{ player_id: 'candidate', stats: { pts_ppr: 15 } }], + [candidate], + { + historical: { + player_id: 'historical', + first_name: 'Past', + last_name: 'Player', + position: 'RB', + team: null, + injury_status: null, + }, + }, + 100, + ); + + expect(guidance).toEqual({ + sampleSize: 1, + suggestedBids: { candidate: 30 }, + }); + }); + + it('handles sparse, position-specific, and budget-limited waiver markets', () => { + const internals = service as unknown as FantasyServiceInternals; + const league: SleeperLeague = { + league_id: '999', + name: 'Friends League', + season: '2026', + status: 'in_season', + avatar: null, + total_rosters: 2, + settings: { waiver_budget: 100 }, + scoring_settings: { pts_ppr: 1 }, + }; + const candidate = (id: string, position: string): FantasyPlayer => ({ + id, + name: id, + position, + team: 'BUF', + injuryStatus: null, + fantasyPositions: [position], + }); + + expect(internals.weightedMedian([])).toBeNull(); + expect( + internals.weightedMedian([ + { pricePerPoint: 0.1, weight: 1 }, + { pricePerPoint: 0.2, weight: 2 }, + ]), + ).toBe(0.2); + expect(internals.buildWaiverBidGuidance([], league, [], [candidate('rb', 'RB')], 50)).toEqual({ + sampleSize: 0, + suggestedBids: {}, + }); + expect( + internals.buildWaiverBidGuidance( + Array.from({ length: 5 }, () => ({ pricePerPoint: 0.02, position: 'RB', weight: 1 })).concat({ + pricePerPoint: 0.5, + position: 'WR', + weight: 1, + }), + league, + [ + { player_id: 'rb', stats: { pts_ppr: 20 } }, + { player_id: 'wr', stats: { pts_ppr: 0 } }, + { player_id: 'missing', stats: {} }, + ], + [candidate('rb', 'RB'), candidate('wr', 'WR'), candidate('missing', 'TE')], + 25, + ), + ).toEqual({ + sampleSize: 6, + suggestedBids: { rb: 25 }, + }); + }); + it('rejects incomplete lineup inputs and supports a matchup without an opponent', () => { const internals = service as unknown as FantasyServiceInternals; const ownRoster: FantasyTeam = { diff --git a/packages/backend/src/fantasy/fantasy.service.ts b/packages/backend/src/fantasy/fantasy.service.ts index d6ea79ff..de55ce75 100644 --- a/packages/backend/src/fantasy/fantasy.service.ts +++ b/packages/backend/src/fantasy/fantasy.service.ts @@ -33,6 +33,7 @@ import type { TeamHealth, TradeSide, TradeSuggestion, + WaiverBidGuidance, WaiverSuggestion, } from './fantasy.model'; @@ -40,6 +41,9 @@ const SLEEPER_API_URL = 'https://api.sleeper.app/v1'; const SLEEPER_PROJECTIONS_URL = 'https://api.sleeper.com/projections/nfl'; const ESPN_SCOREBOARD_URL = 'https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard'; const PLAYER_CACHE_MS = 24 * 60 * 60 * 1000; +const WAIVER_MARKET_CACHE_MS = 6 * 60 * 60 * 1000; +const WAIVER_HISTORY_SEASONS = 3; +const NFL_REGULAR_SEASON_WEEKS = 18; const SLEEPER_ID_PATTERN = /^\d{1,32}$/; interface NflState { @@ -48,6 +52,12 @@ interface NflState { season_type: 'pre' | 'regular' | 'post'; } +interface WaiverMarketSample { + pricePerPoint: number; + position: string | null; + weight: number; +} + interface EspnTeam { abbreviation?: string; displayName?: string; @@ -103,6 +113,8 @@ export class FantasyValidationError extends Error { export class FantasyService { private playerCache: { expiresAt: number; players: Record } | null = null; private playerRequest: Promise> | null = null; + private waiverMarketCache = new Map(); + private projectionCache = new Map(); private readonly openAi: OpenAIClientLike; private readonly serviceLogger = logger.child({ module: 'FantasyService' }); @@ -211,12 +223,14 @@ export class FantasyService { const waiverBudgetUsed = Math.max(0, ownRoster.settings?.waiver_budget_used ?? 0); const remainingWaiverBudget = Math.max(0, waiverBudget - waiverBudgetUsed); let lineupRecommendation: LineupRecommendation | null = null; + let currentProjections: SleeperProjection[] = []; let lineupStatus: FantasyOverview['lineupStatus']; try { const [matchups, projections] = await Promise.all([ this.get(`/league/${leagueId}/matchups/${Math.max(state.week, 1)}`), this.getProjections(state), ]); + currentProjections = projections; lineupRecommendation = this.buildLineupRecommendation( Math.max(state.week, 1), league, @@ -234,6 +248,21 @@ export class FantasyService { }); lineupStatus = 'unavailable'; } + let waiverBidGuidance: WaiverBidGuidance = { sampleSize: 0, suggestedBids: {} }; + try { + waiverBidGuidance = await this.getWaiverBidGuidance( + league, + state, + transactionRounds, + transactionGroups, + currentProjections, + waiverCandidates, + players, + remainingWaiverBudget, + ); + } catch (error) { + logError(this.serviceLogger, 'Failed to build historical waiver bid guidance', error, { leagueId }); + } let analysis: AITradeAnalysis; let aiStatus: FantasyOverview['aiStatus'] = 'ready'; try { @@ -245,6 +274,7 @@ export class FantasyService { waiverCandidates, remainingWaiverBudget, lineupRecommendation, + waiverBidGuidance, ); } catch (error) { logError(this.serviceLogger, 'Failed to generate fantasy trade analysis', error, { @@ -263,7 +293,13 @@ export class FantasyService { const pendingTrades = this.buildPendingTrades(pendingTransactions, ownerNames, players, analysis); const pendingWaivers = this.buildPendingWaivers(pendingWaiverTransactions, roster.rosterId, players); const tradeSuggestions = this.buildTradeSuggestions(analysis, roster, teams, leagueId); - const waiverSuggestions = this.buildWaiverSuggestions(analysis, roster, waiverCandidates, leagueId); + const waiverSuggestions = this.buildWaiverSuggestions( + analysis, + roster, + waiverCandidates, + leagueId, + waiverBidGuidance, + ); return { league, @@ -318,6 +354,212 @@ export class FantasyService { ).then((response) => response.data); } + private getSeasonProjections(season: string, week: number): Promise { + const key = `${season}:${week}`; + const cached = this.projectionCache.get(key); + if (cached && cached.expiresAt > Date.now()) return Promise.resolve(cached.projections); + return Axios.get(`${SLEEPER_PROJECTIONS_URL}/${encodeURIComponent(season)}/${week}`, { + params: { season_type: 'regular' }, + timeout: 10000, + }).then((response) => { + this.projectionCache.set(key, { + expiresAt: Date.now() + WAIVER_MARKET_CACHE_MS, + projections: response.data, + }); + return response.data; + }); + } + + private async getWaiverBidGuidance( + league: SleeperLeague, + state: NflState, + currentRounds: number[], + currentTransactionGroups: SleeperTransaction[][], + currentProjections: SleeperProjection[], + candidates: FantasyPlayer[], + players: Record, + remainingBudget: number, + ): Promise { + const cacheKey = `${league.league_id}:${state.season}:${state.week}`; + const cached = this.waiverMarketCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return this.buildWaiverBidGuidance(cached.samples, league, currentProjections, candidates, remainingBudget); + } + + type SeasonTransactions = { + league: SleeperLeague; + age: number; + rounds: Array<{ week: number; transactions: SleeperTransaction[] }>; + }; + const suppliedCurrentRounds = new Map( + currentRounds.map((week, index) => [week, currentTransactionGroups[index] ?? []]), + ); + const allCurrentRounds = Array.from({ length: Math.max(state.week, 1) }, (_, index) => index + 1); + const missingCurrentRounds = allCurrentRounds.filter((week) => !suppliedCurrentRounds.has(week)); + const missingCurrentResults = await Promise.allSettled( + missingCurrentRounds.map((week) => + this.get(`/league/${league.league_id}/transactions/${week}`), + ), + ); + missingCurrentRounds.forEach((week, index) => { + const result = missingCurrentResults[index]; + suppliedCurrentRounds.set(week, result.status === 'fulfilled' ? result.value : []); + }); + const failedCurrentRounds = missingCurrentResults.filter((result) => result.status === 'rejected').length; + if (failedCurrentRounds) { + this.serviceLogger.warn('Some current Sleeper transaction rounds were unavailable for waiver pricing', { + leagueId: league.league_id, + failedRounds: failedCurrentRounds, + }); + } + const seasons: SeasonTransactions[] = [ + { + league, + age: 0, + rounds: allCurrentRounds.map((week) => ({ + week, + transactions: suppliedCurrentRounds.get(week) ?? [], + })), + }, + ]; + let previousLeagueId = league.previous_league_id; + for (let age = 1; age < WAIVER_HISTORY_SEASONS && previousLeagueId; age += 1) { + let historicalLeague: SleeperLeague; + try { + historicalLeague = await this.get(`/league/${previousLeagueId}`); + } catch (error) { + logError(this.serviceLogger, 'Failed to load a previous Sleeper league for waiver pricing', error, { + leagueId: previousLeagueId, + }); + break; + } + const rounds = Array.from({ length: NFL_REGULAR_SEASON_WEEKS }, (_, index) => index + 1); + const transactionResults = await Promise.allSettled( + rounds.map((week) => + this.get(`/league/${historicalLeague.league_id}/transactions/${week}`), + ), + ); + const failedRounds = transactionResults.filter((result) => result.status === 'rejected').length; + if (failedRounds) { + this.serviceLogger.warn('Some historical Sleeper transaction rounds were unavailable', { + leagueId: historicalLeague.league_id, + failedRounds, + }); + } + seasons.push({ + league: historicalLeague, + age, + rounds: rounds.map((week, index) => ({ + week, + transactions: transactionResults[index]?.status === 'fulfilled' ? transactionResults[index].value : [], + })), + }); + previousLeagueId = historicalLeague.previous_league_id; + } + + const samples: WaiverMarketSample[] = []; + for (const season of seasons) { + const paidWaivers = season.rounds + .map(({ week, transactions }) => ({ + week, + transactions: transactions.filter( + (transaction) => + transaction.type === 'waiver' && + transaction.status === 'complete' && + Number.isFinite(transaction.settings?.waiver_bid) && + (transaction.settings?.waiver_bid ?? 0) > 0 && + Object.keys(transaction.adds ?? {}).length > 0, + ), + })) + .filter(({ transactions }) => transactions.length); + const projectionsByWeek = new Map(); + const projectionResults = await Promise.allSettled( + paidWaivers.map(async ({ week }) => { + if (season.age === 0 && season.league.season === state.season && week === Math.max(state.week, 1)) { + return currentProjections; + } + return this.getSeasonProjections(season.league.season, week); + }), + ); + projectionResults.forEach((result, index) => { + if (result.status === 'fulfilled') projectionsByWeek.set(paidWaivers[index]!.week, result.value); + }); + const failedWeeks = projectionResults.filter((result) => result.status === 'rejected').length; + if (failedWeeks) { + this.serviceLogger.warn('Some historical Sleeper projections were unavailable for waiver pricing', { + leagueId: season.league.league_id, + failedWeeks, + }); + } + const seasonBudget = Math.max(1, season.league.settings?.waiver_budget ?? 100); + for (const { week, transactions } of paidWaivers) { + const projections = new Map( + (projectionsByWeek.get(week) ?? []).map((projection) => [projection.player_id, projection]), + ); + for (const transaction of transactions) { + const bidShare = (transaction.settings?.waiver_bid ?? 0) / Object.keys(transaction.adds ?? {}).length; + for (const playerId of Object.keys(transaction.adds ?? {})) { + const points = this.projectedPoints( + projections.get(playerId)?.stats, + season.league.scoring_settings ?? league.scoring_settings, + ); + if (points !== null && points > 0) { + samples.push({ + pricePerPoint: bidShare / seasonBudget / points, + position: players[playerId]?.position ?? null, + weight: 1 / (season.age + 1), + }); + } + } + } + } + } + + this.waiverMarketCache.set(cacheKey, { + expiresAt: Date.now() + WAIVER_MARKET_CACHE_MS, + samples, + }); + return this.buildWaiverBidGuidance(samples, league, currentProjections, candidates, remainingBudget); + } + + private buildWaiverBidGuidance( + samples: WaiverMarketSample[], + league: SleeperLeague, + currentProjections: SleeperProjection[], + candidates: FantasyPlayer[], + remainingBudget: number, + ): WaiverBidGuidance { + const allRate = this.weightedMedian(samples); + const suggestedBids = + allRate === null + ? {} + : Object.fromEntries( + candidates.flatMap((candidate) => { + const projection = currentProjections.find((item) => item.player_id === candidate.id); + const points = this.projectedPoints(projection?.stats, league.scoring_settings); + if (points === null || points <= 0) return []; + const positionSamples = samples.filter((sample) => sample.position === candidate.position); + const rate = this.weightedMedian(positionSamples.length >= 5 ? positionSamples : samples) ?? allRate; + const budget = Math.max(1, league.settings?.waiver_budget ?? 100); + const marketBid = Math.max(1, Math.round(rate * points * budget)); + return [[candidate.id, Math.min(remainingBudget, marketBid)]]; + }), + ); + return { sampleSize: samples.length, suggestedBids }; + } + + private weightedMedian(samples: Array<{ pricePerPoint: number; weight: number }>): number | null { + if (!samples.length) return null; + const ordered = [...samples].sort((left, right) => left.pricePerPoint - right.pricePerPoint); + const midpoint = ordered.reduce((total, sample) => total + sample.weight, 0) / 2; + let weight = 0; + for (const sample of ordered) { + weight += sample.weight; + if (weight >= midpoint) return sample.pricePerPoint; + } + return ordered[ordered.length - 1]?.pricePerPoint ?? null; + } + private async getPlayers(): Promise> { if (this.playerCache && this.playerCache.expiresAt > Date.now()) { return this.playerCache.players; @@ -645,6 +887,7 @@ export class FantasyService { ownRoster: FantasyTeam, waiverCandidates: FantasyPlayer[], leagueId: string, + bidGuidance: WaiverBidGuidance, ): WaiverSuggestion[] { const rosterPlayers = new Map(ownRoster.players.map((player) => [player.id, player])); const availablePlayers = new Map(waiverCandidates.map((player) => [player.id, player])); @@ -665,7 +908,7 @@ export class FantasyService { drop, rationale: suggestion.rationale, priority: suggestion.priority, - recommendedBid: suggestion.recommendedBid, + recommendedBid: bidGuidance.suggestedBids[add.id] ?? suggestion.recommendedBid, sleeperUrl: `https://sleeper.com/leagues/${leagueId}`, }, ]; @@ -731,6 +974,7 @@ export class FantasyService { waiverCandidates: FantasyPlayer[], remainingWaiverBudget: number, lineupRecommendation: LineupRecommendation | null, + waiverBidGuidance: WaiverBidGuidance, ): Promise { const compactTeams = teams.map((team) => ({ rosterId: team.rosterId, @@ -754,6 +998,8 @@ export class FantasyService { 'with targetRosterId, givePlayerIds, receivePlayerIds, and rationale. waiverSuggestions must contain up to 3 ' + 'add/drop proposals using only the supplied waiver candidate and user roster IDs, with rationale and a high, ' + 'medium, or low priority, plus an integer recommendedBid in dollars that does not exceed remainingWaiverBudget. ' + + 'When historicalBidGuidance contains a suggested bid for a player, use that exact amount; it is calculated from ' + + 'the league history using recency-weighted dollars per projected point and is authoritative. ' + 'Waivers process Wednesday and Sunday. lineupSummary must briefly explain the supplied deterministic weekly lineup recommendation, ' + 'including its overall potential versus the opponent; do not change or invent player IDs or projections. Use only supplied IDs and rosters.', input: JSON.stringify({ @@ -763,6 +1009,7 @@ export class FantasyService { pendingTransactions, waiverCandidates, remainingWaiverBudget, + historicalBidGuidance: waiverBidGuidance, lineupRecommendation, }), text: {