diff --git a/packages/backend/src/fantasy/fantasy.model.ts b/packages/backend/src/fantasy/fantasy.model.ts index 8e218dd2..d619e2bf 100644 --- a/packages/backend/src/fantasy/fantasy.model.ts +++ b/packages/backend/src/fantasy/fantasy.model.ts @@ -15,6 +15,8 @@ export interface SleeperLeague { total_rosters: number; settings?: { waiver_budget?: number; + /** Sleeper league format: 0 = redraft, 1 = keeper, 2 = dynasty. */ + type?: number; }; roster_positions?: string[]; scoring_settings?: Record; @@ -95,6 +97,20 @@ export interface FantasyPlayer { team: string | null; injuryStatus: string | null; fantasyPositions: string[]; + /** Consensus market value from FantasyCalc for the league's format (dynasty/redraft, QB count, PPR). Null if unranked. */ + marketValue: number | null; + /** FantasyCalc rank among players at the same position. Null if unranked. */ + positionRank: number | null; +} + +/** A single player valuation entry from the FantasyCalc `/values/current` API, keyed by Sleeper player ID. */ +export interface FantasyCalcPlayerValue { + sleeperId: string; + value: number; + overallRank: number; + positionRank: number; + trend30Day: number; + tradeFrequency: number | null; } export interface FantasyTeam { diff --git a/packages/backend/src/fantasy/fantasy.service.spec.ts b/packages/backend/src/fantasy/fantasy.service.spec.ts index ae20ccd5..b904e742 100644 --- a/packages/backend/src/fantasy/fantasy.service.spec.ts +++ b/packages/backend/src/fantasy/fantasy.service.spec.ts @@ -3,6 +3,7 @@ import { getRepository } from 'typeorm'; import type { OpenAIClientLike } from '../lib/resilientOpenAIClient'; import type { AITradeAnalysis, + FantasyCalcPlayerValue, FantasyPlayer, FantasyTeam, LineupRecommendation, @@ -65,12 +66,26 @@ const aiResponse = { ], }; +type FantasyCalcResponseEntry = { + player: { sleeperId?: string | null }; + value: number; + redraftValue: number; + overallRank: number; + positionRank: number; + trend30Day: number; + maybeTradeFrequency?: number | null; +}; + type FantasyServiceInternals = { getTradeAnalysis: ( key: string, refresh: boolean, generate: () => Promise, + marketValueVersion?: number, ) => Promise; + getFantasyCalcValuesForOverview: ( + league: SleeperLeague, + ) => Promise<{ values: Map; version: number }>; buildLineupRecommendation: ( week: number, league: SleeperLeague, @@ -117,6 +132,15 @@ type FantasyServiceInternals = { remainingBudget: number, ) => WaiverBidGuidance; weightedMedian: (samples: Array<{ pricePerPoint: number; weight: number }>) => number | null; + resolveLeagueFormat: (league: SleeperLeague) => { isDynasty: boolean; numQbs: number; ppr: number }; + getFantasyCalcAnalysisVersion: (league: SleeperLeague) => number; + getFantasyCalcValues: (league: SleeperLeague) => Promise>; + buildTradeSuggestions: ( + analysis: AITradeAnalysis, + ownRoster: FantasyTeam, + teams: FantasyTeam[], + leagueId: string, + ) => unknown[]; }; describe('FantasyService', () => { @@ -256,6 +280,44 @@ describe('FantasyService', () => { ], }); } + if (url === 'https://api.fantasycalc.com/values/current') { + return Promise.resolve({ + data: [ + { + player: { sleeperId: 'p1' }, + value: 4500, + redraftValue: 4000, + overallRank: 30, + positionRank: 12, + trend30Day: 3, + }, + { + player: { sleeperId: 'p2' }, + value: 5200, + redraftValue: 5000, + overallRank: 18, + positionRank: 9, + trend30Day: 8, + }, + { + player: { sleeperId: 'p3' }, + value: 2100, + redraftValue: 1800, + overallRank: 85, + positionRank: 34, + trend30Day: 12, + }, + { + player: { sleeperId: 'p4' }, + value: 6100, + redraftValue: 5800, + overallRank: 11, + positionRank: 5, + trend30Day: 15, + }, + ], + }); + } if (url.endsWith('/league/999/transactions/1')) { return Promise.resolve({ data: [ @@ -361,14 +423,28 @@ describe('FantasyService', () => { }); const result = await service.getOverview('U1', 'T1', '999'); + const payload = JSON.parse((create.mock.calls.at(-1) as [Record])[0].input); expect(result?.league.name).toBe('Friends League'); + expect(result?.roster.players).toEqual([ + expect.objectContaining({ id: 'p1', marketValue: 4500, positionRank: 12 }), + expect.objectContaining({ id: 'p4', marketValue: 6100, positionRank: 5 }), + ]); expect(result?.pendingTrades[0]).toMatchObject({ transactionId: 'trade-1', insight: 'The incoming receiver adds weekly upside.', recommendation: 'accept', }); - expect(result?.pendingTrades[0]?.sides[0]?.players[0]?.name).toBe('Blake Runner'); + expect(result?.pendingTrades[0]?.sides[0]?.players[0]).toMatchObject({ + name: 'Blake Runner', + marketValue: 5200, + positionRank: 9, + }); + expect(result?.pendingTrades[0]?.sides[1]?.players[0]).toMatchObject({ + name: 'Alex Receiver', + marketValue: 4500, + positionRank: 12, + }); expect(result?.gamesToWatch[0]).toMatchObject({ awayTeam: 'Buffalo Bills', homeTeam: 'New York Jets', @@ -379,15 +455,15 @@ describe('FantasyService', () => { sleeperUrl: 'https://sleeper.com/leagues/999', }); expect(result?.waiverSuggestions[0]).toMatchObject({ - add: { id: 'p3', name: 'Casey Waiver' }, - drop: { id: 'p1', name: 'Alex Receiver' }, + add: { id: 'p3', name: 'Casey Waiver', marketValue: 2100, positionRank: 34 }, + drop: { id: 'p1', name: 'Alex Receiver', marketValue: 4500, positionRank: 12 }, priority: 'high', recommendedBid: 5, }); expect(result?.pendingWaivers[0]).toMatchObject({ transactionId: 'waiver-1', - add: { id: 'p3', name: 'Casey Waiver' }, - drop: { id: 'p1', name: 'Alex Receiver' }, + add: { id: 'p3', name: 'Casey Waiver', marketValue: 2100, positionRank: 34 }, + drop: { id: 'p1', name: 'Alex Receiver', marketValue: 4500, positionRank: 12 }, bid: 14, }); expect(result?.teamHealth).toEqual({ @@ -405,6 +481,20 @@ describe('FantasyService', () => { sit: [{ id: 'p1' }], summary: 'Start Drew Runner to maximize your matchup ceiling this week.', }); + expect(payload.teams).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + rosterId: 1, + players: expect.arrayContaining([ + expect.objectContaining({ id: 'p1', marketValue: 4500, positionRank: 12 }), + expect.objectContaining({ id: 'p4', marketValue: 6100, positionRank: 5 }), + ]), + }), + ]), + ); + expect(payload.waiverCandidates).toEqual( + expect.arrayContaining([expect.objectContaining({ id: 'p3', marketValue: 2100, positionRank: 34 })]), + ); }); it('normalizes week-zero matchup context before generating AI analysis', async () => { @@ -514,6 +604,9 @@ describe('FantasyService', () => { }, }); } + if (url === 'https://api.fantasycalc.com/values/current') { + return Promise.resolve({ data: [] }); + } if (new URL(url).hostname === 'site.api.espn.com') { const week = config?.params?.week ?? 0; return Promise.resolve({ @@ -649,6 +742,9 @@ describe('FantasyService', () => { }, }); } + if (url === 'https://api.fantasycalc.com/values/current') { + return Promise.resolve({ data: [] }); + } if (new URL(url).hostname === 'site.api.espn.com') { const week = config?.params?.week ?? 0; return Promise.resolve({ @@ -777,6 +873,9 @@ describe('FantasyService', () => { }, }); } + if (url === 'https://api.fantasycalc.com/values/current') { + return Promise.resolve({ data: [] }); + } if (new URL(url).hostname === 'site.api.espn.com') { const week = config?.params?.week ?? 0; return Promise.resolve({ @@ -873,6 +972,9 @@ describe('FantasyService', () => { }, }); } + if (url === 'https://api.fantasycalc.com/values/current') { + return Promise.resolve({ data: [] }); + } if (new URL(url).hostname === 'site.api.espn.com') { return Promise.resolve({ data: { @@ -921,6 +1023,8 @@ describe('FantasyService', () => { team: 'BUF', injuryStatus: null, fantasyPositions: [position], + marketValue: null, + positionRank: null, projectedPoints, }); const players = [ @@ -970,6 +1074,8 @@ describe('FantasyService', () => { team: 'BUF', injuryStatus: null, fantasyPositions: ['QB'], + marketValue: null, + positionRank: null, }, { id: 'unknown', @@ -978,6 +1084,8 @@ describe('FantasyService', () => { team: null, injuryStatus: null, fantasyPositions: [], + marketValue: null, + positionRank: null, }, { id: 'edge', @@ -986,6 +1094,8 @@ describe('FantasyService', () => { team: 'DAL', injuryStatus: null, fantasyPositions: ['DL'], + marketValue: null, + positionRank: null, }, { id: 'swing', @@ -994,6 +1104,8 @@ describe('FantasyService', () => { team: 'MIA', injuryStatus: null, fantasyPositions: ['RB', 'WR'], + marketValue: null, + positionRank: null, }, ], }, @@ -1018,6 +1130,8 @@ describe('FantasyService', () => { team: 'DAL', injuryStatus: null, fantasyPositions: ['QB', 'WR'], + marketValue: null, + positionRank: null, }, { id: 'qb-only', @@ -1026,6 +1140,8 @@ describe('FantasyService', () => { team: 'BUF', injuryStatus: null, fantasyPositions: ['QB'], + marketValue: null, + positionRank: null, }, ], }, @@ -1045,6 +1161,8 @@ describe('FantasyService', () => { team: 'WAS', injuryStatus: null, fantasyPositions: ['WR'], + marketValue: null, + positionRank: null, }, { id: 'jacksonville-player', @@ -1053,6 +1171,8 @@ describe('FantasyService', () => { team: 'JAC', injuryStatus: null, fantasyPositions: ['RB'], + marketValue: null, + positionRank: null, }, ]; @@ -1125,6 +1245,8 @@ describe('FantasyService', () => { team: 'BUF', injuryStatus: null, fantasyPositions: ['RB'], + marketValue: null, + positionRank: null, }; const guidance = await internals.getWaiverBidGuidance( { @@ -1181,6 +1303,8 @@ describe('FantasyService', () => { team: 'BUF', injuryStatus: null, fantasyPositions: [position], + marketValue: null, + positionRank: null, }); expect(internals.weightedMedian([])).toBeNull(); @@ -1230,6 +1354,8 @@ describe('FantasyService', () => { team: 'BUF', injuryStatus: null, fantasyPositions: ['RB'], + marketValue: null, + positionRank: null, }, ], }; @@ -1312,4 +1438,592 @@ describe('FantasyService', () => { ), ).toThrow(/no usable weekly projections for the opponent/i); }); + + it('resolves league format for FantasyCalc from Sleeper league settings', () => { + const internals = service as unknown as FantasyServiceInternals; + + expect( + internals.resolveLeagueFormat({ + league_id: '1', + name: 'Redraft 1QB Half PPR', + season: '2026', + status: 'in_season', + avatar: null, + total_rosters: 12, + roster_positions: ['QB', 'RB', 'WR'], + }), + ).toEqual({ isDynasty: false, numQbs: 1, ppr: 0.5 }); + + expect( + internals.resolveLeagueFormat({ + league_id: '2', + name: 'Dynasty Superflex Full PPR', + season: '2026', + status: 'in_season', + avatar: null, + total_rosters: 12, + settings: { type: 2 }, + roster_positions: ['QB', 'SUPER_FLEX', 'RB'], + scoring_settings: { rec: 1 }, + }), + ).toEqual({ isDynasty: true, numQbs: 2, ppr: 1 }); + + expect( + internals.resolveLeagueFormat({ + league_id: '3', + name: 'Keeper 2QB Quarter PPR', + season: '2026', + status: 'in_season', + avatar: null, + total_rosters: 12, + settings: { type: 1 }, + roster_positions: ['QB', 'QB', 'SUPER_FLEX', 'RB'], + scoring_settings: { rec: 0.25 }, + }), + ).toEqual({ isDynasty: true, numQbs: 2, ppr: 0.5 }); + }); + + it('fetches and caches FantasyCalc player values scaled to the league format', async () => { + 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: 10, + settings: { type: 2 }, + roster_positions: ['QB', 'RB'], + }; + const twelveTeamLeague: SleeperLeague = { ...league, league_id: '1000', total_rosters: 12 }; + (Axios.get as Mock).mockResolvedValueOnce({ + data: [ + { + player: { sleeperId: 'p1' }, + value: 9000, + redraftValue: 8000, + overallRank: 1, + positionRank: 1, + trend30Day: 10, + maybeTradeFrequency: 0.05, + }, + { player: { sleeperId: null }, value: 1, redraftValue: 1, overallRank: 999, positionRank: 99, trend30Day: 0 }, + ], + }); + (Axios.get as Mock).mockResolvedValueOnce({ + data: [ + { + player: { sleeperId: 'p1' }, + value: 9100, + redraftValue: 8100, + overallRank: 2, + positionRank: 2, + trend30Day: 11, + maybeTradeFrequency: 0.04, + }, + ], + }); + + const values = await internals.getFantasyCalcValues(league); + + expect(Axios.get).toHaveBeenCalledWith( + 'https://api.fantasycalc.com/values/current', + expect.objectContaining({ params: { isDynasty: true, numQbs: 1, numTeams: 10, ppr: 0.5 }, timeout: 2000 }), + ); + expect(values.get('p1')).toEqual({ + sleeperId: 'p1', + value: 9000, + overallRank: 1, + positionRank: 1, + trend30Day: 10, + tradeFrequency: 0.05, + }); + expect(values.has('null')).toBe(false); + expect(values.size).toBe(1); + + (Axios.get as Mock).mockClear(); + await internals.getFantasyCalcValues(league); + expect(Axios.get).not.toHaveBeenCalled(); + + const twelveTeamValues = await internals.getFantasyCalcValues(twelveTeamLeague); + expect(Axios.get).toHaveBeenCalledWith( + 'https://api.fantasycalc.com/values/current', + expect.objectContaining({ params: { isDynasty: true, numQbs: 1, numTeams: 12, ppr: 0.5 }, timeout: 2000 }), + ); + expect(twelveTeamValues.get('p1')?.value).toBe(9100); + }); + + it('deduplicates concurrent FantasyCalc requests for the same cache key', async () => { + 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: 12, + roster_positions: ['QB', 'RB'], + }; + let resolveRequest: ((value: { data: FantasyCalcResponseEntry[] }) => void) | undefined; + (Axios.get as Mock).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRequest = resolve; + }), + ); + + const first = internals.getFantasyCalcValues(league); + const second = internals.getFantasyCalcValues(league); + resolveRequest?.({ + data: [ + { + player: { sleeperId: 'p1' }, + value: 9000, + redraftValue: 8000, + overallRank: 1, + positionRank: 1, + trend30Day: 10, + }, + ], + }); + const [firstValues, secondValues] = await Promise.all([first, second]); + + expect(Axios.get).toHaveBeenCalledTimes(1); + expect(firstValues).toBe(secondValues); + expect(firstValues.get('p1')?.value).toBe(9000); + }); + + it('returns an empty map and briefly negative-caches FantasyCalc failures without bumping the analysis version', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-11T12:00:00.000Z')); + const internals = service as unknown as FantasyServiceInternals; + const league: SleeperLeague = { + league_id: '998', + name: 'Redraft League', + season: '2026', + status: 'in_season', + avatar: null, + total_rosters: 12, + roster_positions: ['QB'], + }; + (Axios.get as Mock).mockRejectedValueOnce(new Error('network error')); + (Axios.get as Mock).mockResolvedValueOnce({ + data: [ + { + player: { sleeperId: 'p2' }, + value: 1234, + redraftValue: 1234, + overallRank: 50, + positionRank: 20, + trend30Day: 1, + }, + ], + }); + + try { + expect(internals.getFantasyCalcAnalysisVersion(league)).toBe(0); + await expect(internals.getFantasyCalcValues(league)).resolves.toEqual(new Map()); + await expect(internals.getFantasyCalcValues(league)).resolves.toEqual(new Map()); + expect(Axios.get).toHaveBeenCalledTimes(1); + expect(internals.getFantasyCalcAnalysisVersion(league)).toBe(0); + + vi.advanceTimersByTime(5 * 60 * 1000 + 1); + await expect(internals.getFantasyCalcValues(league)).resolves.toEqual( + new Map([ + [ + 'p2', + { + sleeperId: 'p2', + value: 1234, + overallRank: 50, + positionRank: 20, + trend30Day: 1, + tradeFrequency: null, + }, + ], + ]), + ); + expect(Axios.get).toHaveBeenCalledTimes(2); + expect(internals.getFantasyCalcAnalysisVersion(league)).toBe(1); + } finally { + vi.useRealTimers(); + } + }); + + it('preserves stale FantasyCalc values during failure backoff', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-11T12:00:00.000Z')); + const internals = service as unknown as FantasyServiceInternals; + const serviceState = service as unknown as { + fantasyCalcCache: Map< + string, + { expiresAt: number; values: Map; version: number } + >; + fantasyCalcAnalysisVersions: Map; + }; + const league: SleeperLeague = { + league_id: '998', + name: 'Redraft League', + season: '2026', + status: 'in_season', + avatar: null, + total_rosters: 12, + roster_positions: ['QB'], + }; + const staleValues = new Map([ + [ + 'p1', + { + sleeperId: 'p1', + value: 900, + overallRank: 100, + positionRank: 40, + trend30Day: -1, + tradeFrequency: null, + }, + ], + ]); + const cacheKey = 'false:1:12:0.5'; + serviceState.fantasyCalcAnalysisVersions.set(cacheKey, 1); + serviceState.fantasyCalcCache.set(cacheKey, { + expiresAt: Date.now() - 1, + values: staleValues, + version: 1, + }); + (Axios.get as Mock).mockRejectedValueOnce(new Error('network error')); + + try { + await expect(internals.getFantasyCalcValues(league)).resolves.toBe(staleValues); + await expect(internals.getFantasyCalcValues(league)).resolves.toBe(staleValues); + expect(Axios.get).toHaveBeenCalledTimes(1); + expect(internals.getFantasyCalcAnalysisVersion(league)).toBe(1); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps the overview FantasyCalc version aligned with the fallback values snapshot', async () => { + const internals = service as unknown as FantasyServiceInternals; + const serviceState = service as unknown as { + fantasyCalcCache: Map< + string, + { expiresAt: number; values: Map; version: number } + >; + fantasyCalcAnalysisVersions: Map; + }; + const league: SleeperLeague = { + league_id: '998', + name: 'Redraft League', + season: '2026', + status: 'in_season', + avatar: null, + total_rosters: 12, + roster_positions: ['QB'], + }; + const staleValues = new Map([ + [ + 'p1', + { + sleeperId: 'p1', + value: 900, + overallRank: 100, + positionRank: 40, + trend30Day: -1, + tradeFrequency: null, + }, + ], + ]); + const cacheKey = 'false:1:12:0.5'; + serviceState.fantasyCalcAnalysisVersions.set(cacheKey, 1); + serviceState.fantasyCalcCache.set(cacheKey, { + expiresAt: Date.now() - 1, + values: staleValues, + version: 1, + }); + (Axios.get as Mock).mockResolvedValueOnce({ + data: [ + { + player: { sleeperId: 'p2' }, + value: 1234, + redraftValue: 1234, + overallRank: 50, + positionRank: 20, + trend30Day: 1, + }, + ], + }); + + const snapshot = await internals.getFantasyCalcValuesForOverview(league); + + expect(snapshot.values).toBe(staleValues); + expect(snapshot.version).toBe(1); + await expect(internals.getFantasyCalcValues(league)).resolves.toEqual( + new Map([ + [ + 'p2', + { + sleeperId: 'p2', + value: 1234, + overallRank: 50, + positionRank: 20, + trend30Day: 1, + tradeFrequency: null, + }, + ], + ]), + ); + expect(internals.getFantasyCalcAnalysisVersion(league)).toBe(2); + expect(Axios.get).toHaveBeenCalledTimes(1); + }); + + it('refreshes cached AI analysis only after FantasyCalc values successfully refresh for that format', async () => { + const internals = service as unknown as FantasyServiceInternals; + const league: SleeperLeague = { + league_id: '998', + name: 'Redraft League', + season: '2026', + status: 'in_season', + avatar: null, + total_rosters: 12, + roster_positions: ['QB'], + }; + const firstAnalysis: AITradeAnalysis = { + teamHealth: { percentage: 80, summary: 'Healthy roster.' }, + tradeInsights: [], + suggestions: [], + waiverSuggestions: [], + lineupSummary: 'Use the projected starters.', + }; + const refreshedAnalysis: AITradeAnalysis = { + ...firstAnalysis, + teamHealth: { percentage: 70, summary: 'Values were refreshed.' }, + }; + const generate = vi.fn().mockResolvedValueOnce(firstAnalysis).mockResolvedValueOnce(refreshedAnalysis); + const initialVersion = internals.getFantasyCalcAnalysisVersion(league); + (Axios.get as Mock).mockResolvedValueOnce({ + data: [ + { + player: { sleeperId: 'p2' }, + value: 1234, + redraftValue: 1234, + overallRank: 50, + positionRank: 20, + trend30Day: 1, + }, + ], + }); + + await expect(internals.getTradeAnalysis('key', false, generate, initialVersion)).resolves.toBe(firstAnalysis); + await internals.getFantasyCalcValues(league); + const refreshedVersion = internals.getFantasyCalcAnalysisVersion(league); + expect(refreshedVersion).toBe(initialVersion + 1); + await expect(internals.getTradeAnalysis('key', false, generate, refreshedVersion)).resolves.toBe(refreshedAnalysis); + expect(generate).toHaveBeenCalledTimes(2); + }); + + it('filters only unfair AI trade suggestions when market values are available', () => { + const internals = service as unknown as FantasyServiceInternals; + const ownRoster: FantasyTeam = { + rosterId: 1, + ownerName: 'Alice', + starters: [], + players: [ + { + id: 'give-fair', + name: 'Give Fair', + position: 'WR', + team: 'BUF', + injuryStatus: null, + fantasyPositions: ['WR'], + marketValue: 4800, + positionRank: 12, + }, + { + id: 'give-cheap', + name: 'Give Cheap', + position: 'WR', + team: 'BUF', + injuryStatus: null, + fantasyPositions: ['WR'], + marketValue: 3000, + positionRank: 20, + }, + { + id: 'give-null', + name: 'Give Null', + position: 'WR', + team: 'BUF', + injuryStatus: null, + fantasyPositions: ['WR'], + marketValue: null, + positionRank: null, + }, + ], + }; + const target: FantasyTeam = { + rosterId: 2, + ownerName: 'Bob', + starters: [], + players: [ + { + id: 'receive-fair', + name: 'Receive Fair', + position: 'RB', + team: 'NYJ', + injuryStatus: null, + fantasyPositions: ['RB'], + marketValue: 5000, + positionRank: 10, + }, + { + id: 'receive-expensive', + name: 'Receive Expensive', + position: 'RB', + team: 'NYJ', + injuryStatus: null, + fantasyPositions: ['RB'], + marketValue: 5000, + positionRank: 10, + }, + { + id: 'receive-null', + name: 'Receive Null', + position: 'RB', + team: 'NYJ', + injuryStatus: null, + fantasyPositions: ['RB'], + marketValue: null, + positionRank: null, + }, + { + id: 'receive-zero', + name: 'Receive Zero', + position: 'RB', + team: 'NYJ', + injuryStatus: null, + fantasyPositions: ['RB'], + marketValue: 0, + positionRank: 99, + }, + ], + }; + + const suggestions = internals.buildTradeSuggestions( + { + teamHealth: { percentage: 80, summary: 'Healthy roster.' }, + tradeInsights: [], + suggestions: [ + { + targetRosterId: 2, + givePlayerIds: ['give-fair'], + receivePlayerIds: ['receive-fair'], + rationale: 'Roughly even value, slight edge to you.', + }, + { + targetRosterId: 2, + givePlayerIds: ['give-cheap'], + receivePlayerIds: ['receive-expensive'], + rationale: 'This is too one-sided.', + }, + { + targetRosterId: 2, + givePlayerIds: ['give-null'], + receivePlayerIds: ['receive-null'], + rationale: 'Missing values should fall back to the qualitative recommendation.', + }, + { + targetRosterId: 2, + givePlayerIds: ['give-cheap'], + receivePlayerIds: ['receive-zero'], + rationale: 'Zero-value returns should still be rejected when the outgoing value is higher.', + }, + ], + waiverSuggestions: [], + lineupSummary: 'Use the projected starters.', + }, + ownRoster, + [ownRoster, target], + '999', + ); + + expect(suggestions).toHaveLength(2); + expect(suggestions[0]).toMatchObject({ + targetRosterId: 2, + targetOwnerName: 'Bob', + give: [expect.objectContaining({ id: 'give-fair', marketValue: 4800 })], + receive: [expect.objectContaining({ id: 'receive-fair', marketValue: 5000 })], + }); + expect(suggestions[1]).toMatchObject({ + targetRosterId: 2, + targetOwnerName: 'Bob', + give: [expect.objectContaining({ id: 'give-null', marketValue: null })], + receive: [expect.objectContaining({ id: 'receive-null', marketValue: null })], + }); + }); + + it('rejects AI trade suggestions with duplicate or unknown player ids', () => { + const internals = service as unknown as FantasyServiceInternals; + const ownRoster: FantasyTeam = { + rosterId: 1, + ownerName: 'Alice', + starters: [], + players: [ + { + id: 'give-fair', + name: 'Give Fair', + position: 'WR', + team: 'BUF', + injuryStatus: null, + fantasyPositions: ['WR'], + marketValue: 4800, + positionRank: 12, + }, + ], + }; + const target: FantasyTeam = { + rosterId: 2, + ownerName: 'Bob', + starters: [], + players: [ + { + id: 'receive-fair', + name: 'Receive Fair', + position: 'RB', + team: 'NYJ', + injuryStatus: null, + fantasyPositions: ['RB'], + marketValue: 5000, + positionRank: 10, + }, + ], + }; + + const suggestions = internals.buildTradeSuggestions( + { + teamHealth: { percentage: 80, summary: 'Healthy roster.' }, + tradeInsights: [], + suggestions: [ + { + targetRosterId: 2, + givePlayerIds: ['give-fair', 'give-fair'], + receivePlayerIds: ['receive-fair'], + rationale: 'Duplicate outgoing players should be rejected.', + }, + { + targetRosterId: 2, + givePlayerIds: ['give-fair'], + receivePlayerIds: ['receive-fair', 'missing-player'], + rationale: 'Unknown players should be rejected.', + }, + ], + waiverSuggestions: [], + lineupSummary: 'Use the projected starters.', + }, + ownRoster, + [ownRoster, target], + '999', + ); + + expect(suggestions).toEqual([]); + }); }); diff --git a/packages/backend/src/fantasy/fantasy.service.ts b/packages/backend/src/fantasy/fantasy.service.ts index 68d8fded..34aad527 100644 --- a/packages/backend/src/fantasy/fantasy.service.ts +++ b/packages/backend/src/fantasy/fantasy.service.ts @@ -17,6 +17,7 @@ import type { FantasyLandingResponse, LineupRecommendation, FantasyOverview, + FantasyCalcPlayerValue, FantasyPlayer, FantasyTeam, GameToWatch, @@ -40,9 +41,14 @@ import type { 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 FANTASYCALC_API_URL = 'https://api.fantasycalc.com/values/current'; const PLAYER_CACHE_MS = 24 * 60 * 60 * 1000; const AI_ANALYSIS_CACHE_MS = 24 * 60 * 60 * 1000; const WAIVER_MARKET_CACHE_MS = 6 * 60 * 60 * 1000; +const FANTASYCALC_CACHE_MS = 6 * 60 * 60 * 1000; +const FANTASYCALC_FAILURE_CACHE_MS = 5 * 60 * 1000; +const FANTASYCALC_TIMEOUT_MS = 2000; +const FANTASYCALC_OVERVIEW_WAIT_MS = 150; const WAIVER_HISTORY_SEASONS = 3; const NFL_REGULAR_SEASON_WEEKS = 18; const SLEEPER_ID_PATTERN = /^\d{1,32}$/; @@ -91,6 +97,25 @@ interface RosterNeed { deficit: number; } +interface FantasyCalcApiEntry { + player: { sleeperId?: string | null }; + value: number; + redraftValue: number; + overallRank: number; + positionRank: number; + trend30Day: number; + maybeTradeFrequency?: number | null; +} + +interface FantasyCalcSnapshot { + values: Map; + version: number; +} + +interface FantasyCalcCacheEntry extends FantasyCalcSnapshot { + expiresAt: number; +} + const isOutputMessage = (item: ResponseOutputItem): item is ResponseOutputMessage => item.type === 'message'; const isOutputText = (item: ResponseOutputMessage['content'][number]): item is ResponseOutputText => item.type === 'output_text'; @@ -124,7 +149,13 @@ export class FantasyService { private playerRequest: Promise> | null = null; private waiverMarketCache = new Map(); private projectionCache = new Map(); - private analysisCache = new Map(); + private fantasyCalcCache = new Map(); + private fantasyCalcAnalysisVersions = new Map(); + private fantasyCalcRequests = new Map>>(); + private analysisCache = new Map< + string, + { expiresAt: number; analysis: AITradeAnalysis; marketValueVersion: number } + >(); private readonly openAi: OpenAIClientLike; private readonly serviceLogger = logger.child({ module: 'FantasyService' }); @@ -180,7 +211,7 @@ export class FantasyService { const currentWeek = Math.max(state.week, 1); const transactionRounds = state.week > 1 ? [state.week, state.week - 1] : [currentWeek]; - const [rosters, users, transactionGroups, players, scoreboard] = await Promise.all([ + const [rosters, users, transactionGroups, players, scoreboard, marketValueSnapshot] = await Promise.all([ this.get(`/league/${leagueId}/rosters`), this.get(`/league/${leagueId}/users`), Promise.all( @@ -195,7 +226,9 @@ export class FantasyService { }, timeout: 10000, }).then((response) => response.data), + this.getFantasyCalcValuesForOverview(league), ]); + const marketValues = marketValueSnapshot.values; const transactions = Array.from( new Map(transactionGroups.flat().map((transaction) => [transaction.transaction_id, transaction])).values(), ); @@ -206,7 +239,7 @@ export class FantasyService { return null; } - const teams = rosters.map((roster) => this.toFantasyTeam(roster, ownerNames, players)); + const teams = rosters.map((roster) => this.toFantasyTeam(roster, ownerNames, players, marketValues)); const roster = teams.find((team) => team.rosterId === ownRoster.roster_id); if (!roster) { return null; @@ -233,7 +266,7 @@ export class FantasyService { (left?.search_rank ?? Number.MAX_SAFE_INTEGER) - (right?.search_rank ?? Number.MAX_SAFE_INTEGER), ) .slice(0, 120) - .map(([id]) => this.toPlayer(id, players)); + .map(([id]) => this.toPlayer(id, players, marketValues)); const waiverBudget = Math.max(0, league.settings?.waiver_budget ?? 100); const waiverBudgetUsed = Math.max(0, ownRoster.settings?.waiver_budget_used ?? 0); const remainingWaiverBudget = Math.max(0, waiverBudget - waiverBudgetUsed); @@ -311,6 +344,7 @@ export class FantasyService { [currentProjections, ...upcomingMatchupProjections], ); }, + marketValueSnapshot.version, ); } catch (error) { logError(this.serviceLogger, 'Failed to generate fantasy trade analysis', error, { @@ -326,8 +360,8 @@ export class FantasyService { }; aiStatus = 'unavailable'; } - const pendingTrades = this.buildPendingTrades(pendingTransactions, ownerNames, players, analysis); - const pendingWaivers = this.buildPendingWaivers(pendingWaiverTransactions, roster.rosterId, players); + const pendingTrades = this.buildPendingTrades(pendingTransactions, ownerNames, players, analysis, marketValues); + const pendingWaivers = this.buildPendingWaivers(pendingWaiverTransactions, roster.rosterId, players, marketValues); const tradeSuggestions = this.buildTradeSuggestions(analysis, roster, teams, leagueId); const waiverSuggestions = this.buildWaiverSuggestions( analysis, @@ -363,6 +397,125 @@ export class FantasyService { return Axios.get(`${SLEEPER_API_URL}${path}`, { timeout: 10000 }).then((response) => response.data); } + private resolveLeagueFormat(league: SleeperLeague): { isDynasty: boolean; numQbs: number; ppr: number } { + const rosterPositions = league.roster_positions ?? []; + const requestedQbs = Math.max(1, rosterPositions.filter((slot) => slot === 'QB' || slot === 'SUPER_FLEX').length); + const numQbs = requestedQbs >= 2 ? 2 : 1; + const requestedPpr = league.scoring_settings?.rec; + const ppr = requestedPpr === undefined ? 0.5 : requestedPpr >= 0.75 ? 1 : requestedPpr >= 0.25 ? 0.5 : 0; + // Sleeper league format: 0 = redraft, 1 = keeper, 2 = dynasty. Keeper leagues value long-term assets + // similarly to dynasty, so both are treated as dynasty for valuation purposes. + const isDynasty = (league.settings?.type ?? 0) >= 1; + return { isDynasty, numQbs, ppr }; + } + + private getFantasyCalcCacheKey(league: SleeperLeague): string { + const { isDynasty, numQbs, ppr } = this.resolveLeagueFormat(league); + const numTeams = league.total_rosters || 12; + return `${isDynasty}:${numQbs}:${numTeams}:${ppr}`; + } + + private getFantasyCalcAnalysisVersion(league: SleeperLeague): number { + return this.fantasyCalcAnalysisVersions.get(this.getFantasyCalcCacheKey(league)) ?? 0; + } + + /** + * Fetches consensus player trade values from the FantasyCalc API (https://fantasycalc.com/api-docs), + * matched to the league's format (dynasty/redraft, QB count, PPR). Values are keyed by Sleeper player ID + * so they can be merged directly onto `FantasyPlayer` records. Failures are non-fatal: trade/waiver + * recommendations still work without market values, just with less precise fairness signal. + */ + private async getFantasyCalcValues(league: SleeperLeague): Promise> { + const { isDynasty, numQbs, ppr } = this.resolveLeagueFormat(league); + const numTeams = league.total_rosters || 12; + const cacheKey = this.getFantasyCalcCacheKey(league); + const cached = this.fantasyCalcCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.values; + } + const inFlight = this.fantasyCalcRequests.get(cacheKey); + if (inFlight) { + return inFlight; + } + const request = Axios.get(FANTASYCALC_API_URL, { + params: { isDynasty, numQbs, numTeams, ppr }, + timeout: FANTASYCALC_TIMEOUT_MS, + }) + .then((response) => { + const values = new Map( + response.data + .filter((entry): entry is FantasyCalcApiEntry & { player: { sleeperId: string } } => + Boolean(entry.player.sleeperId), + ) + .map((entry) => [ + entry.player.sleeperId, + { + sleeperId: entry.player.sleeperId, + value: entry.value, + overallRank: entry.overallRank, + positionRank: entry.positionRank, + trend30Day: entry.trend30Day, + tradeFrequency: entry.maybeTradeFrequency ?? null, + }, + ]), + ); + const version = this.getFantasyCalcAnalysisVersion(league) + 1; + this.fantasyCalcCache.set(cacheKey, { expiresAt: Date.now() + FANTASYCALC_CACHE_MS, values, version }); + this.fantasyCalcAnalysisVersions.set(cacheKey, version); + return values; + }) + .catch((error) => { + logError(this.serviceLogger, 'Failed to load FantasyCalc player values', error, { + isDynasty, + numQbs, + numTeams, + ppr, + }); + const fallbackValues = cached?.values ?? new Map(); + const version = cached?.version ?? this.getFantasyCalcAnalysisVersion(league); + this.fantasyCalcCache.set(cacheKey, { + expiresAt: Date.now() + FANTASYCALC_FAILURE_CACHE_MS, + values: fallbackValues, + version, + }); + return fallbackValues; + }) + .finally(() => { + this.fantasyCalcRequests.delete(cacheKey); + }); + this.fantasyCalcRequests.set(cacheKey, request); + return request; + } + + private getFantasyCalcValuesForOverview(league: SleeperLeague): Promise { + const cacheKey = this.getFantasyCalcCacheKey(league); + const cached = this.fantasyCalcCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return Promise.resolve({ values: cached.values, version: cached.version }); + } + + const fallbackSnapshot: FantasyCalcSnapshot = cached + ? { values: cached.values, version: cached.version } + : { values: new Map(), version: this.getFantasyCalcAnalysisVersion(league) }; + const refreshPromise = this.getFantasyCalcValues(league); + if (cached) { + return Promise.resolve(fallbackSnapshot); + } + + return new Promise((resolve) => { + const timeout = setTimeout(() => resolve(fallbackSnapshot), FANTASYCALC_OVERVIEW_WAIT_MS); + refreshPromise + .then((values) => { + clearTimeout(timeout); + resolve({ values, version: this.getFantasyCalcAnalysisVersion(league) }); + }) + .catch(() => { + clearTimeout(timeout); + resolve(fallbackSnapshot); + }); + }); + } + private getNflState(): Promise { return this.get('/state/nfl'); } @@ -405,9 +558,10 @@ export class FantasyService { key: string, refresh: boolean, generate: () => Promise, + marketValueVersion = 0, ): Promise { const cached = this.analysisCache.get(key); - if (!refresh && cached && cached.expiresAt > Date.now()) { + if (!refresh && cached && cached.expiresAt > Date.now() && cached.marketValueVersion === marketValueVersion) { return cached.analysis; } @@ -415,6 +569,7 @@ export class FantasyService { this.analysisCache.set(key, { expiresAt: Date.now() + AI_ANALYSIS_CACHE_MS, analysis, + marketValueVersion, }); return analysis; } @@ -656,8 +811,13 @@ export class FantasyService { ); } - private toPlayer(id: string, players: Record): FantasyPlayer { + private toPlayer( + id: string, + players: Record, + values?: Map, + ): FantasyPlayer { const player = players[id]; + const marketValue = values?.get(id); return { id, name: playerName(player, id), @@ -669,6 +829,8 @@ export class FantasyService { : player?.position ? [player.position] : [], + marketValue: marketValue?.value ?? null, + positionRank: marketValue?.positionRank ?? null, }; } @@ -921,11 +1083,12 @@ export class FantasyService { roster: SleeperRoster, ownerNames: Map, players: Record, + values?: Map, ): FantasyTeam { return { rosterId: roster.roster_id, ownerName: ownerNames.get(roster.roster_id) ?? `Roster ${roster.roster_id}`, - players: (roster.players ?? []).map((id) => this.toPlayer(id, players)), + players: (roster.players ?? []).map((id) => this.toPlayer(id, players, values)), starters: roster.starters ?? [], }; } @@ -934,13 +1097,14 @@ export class FantasyService { transaction: SleeperTransaction, ownerNames: Map, players: Record, + values?: Map, ): TradeSide[] { return transaction.roster_ids.map((rosterId) => ({ rosterId, ownerName: ownerNames.get(rosterId) ?? `Roster ${rosterId}`, players: Object.entries(transaction.adds ?? {}) .filter(([, destinationRosterId]) => destinationRosterId === rosterId) - .map(([playerId]) => this.toPlayer(playerId, players)), + .map(([playerId]) => this.toPlayer(playerId, players, values)), draftPicks: (transaction.draft_picks ?? []) .filter((pick) => pick.owner_id === rosterId) .map((pick) => `${pick.season} round ${pick.round}`), @@ -952,13 +1116,14 @@ export class FantasyService { ownerNames: Map, players: Record, analysis: AITradeAnalysis, + values?: Map, ): PendingTrade[] { return transactions.map((transaction) => { const insight = analysis.tradeInsights.find((item) => item.transactionId === transaction.transaction_id); return { transactionId: transaction.transaction_id, createdAt: new Date(transaction.created).toISOString(), - sides: this.buildTradeSides(transaction, ownerNames, players), + sides: this.buildTradeSides(transaction, ownerNames, players, values), insight: insight?.insight ?? 'AI insight is temporarily unavailable for this trade.', recommendation: insight?.recommendation ?? 'negotiate', }; @@ -969,6 +1134,7 @@ export class FantasyService { transactions: SleeperTransaction[], rosterId: number, players: Record, + values?: Map, ): PendingWaiver[] { return transactions.map((transaction) => { const addPlayerId = Object.entries(transaction.adds ?? {}).find( @@ -978,8 +1144,8 @@ export class FantasyService { return { transactionId: transaction.transaction_id, createdAt: new Date(transaction.created).toISOString(), - add: addPlayerId ? this.toPlayer(addPlayerId, players) : null, - drop: dropPlayerId ? this.toPlayer(dropPlayerId, players) : null, + add: addPlayerId ? this.toPlayer(addPlayerId, players, values) : null, + drop: dropPlayerId ? this.toPlayer(dropPlayerId, players, values) : null, bid: Number.isFinite(transaction.settings?.waiver_bid) ? (transaction.settings?.waiver_bid ?? null) : null, }; }); @@ -1003,18 +1169,42 @@ export class FantasyService { return []; } const targetPlayers = new Map(target.players.map((player) => [player.id, player])); + if ( + new Set(suggestion.givePlayerIds).size !== suggestion.givePlayerIds.length || + new Set(suggestion.receivePlayerIds).size !== suggestion.receivePlayerIds.length + ) { + this.serviceLogger.warn('Ignoring AI suggestion with duplicate player ids', { + targetRosterId: suggestion.targetRosterId, + }); + return []; + } const give = suggestion.givePlayerIds .map((id) => ownPlayers.get(id)) .filter((item): item is FantasyPlayer => !!item); const receive = suggestion.receivePlayerIds .map((id) => targetPlayers.get(id)) .filter((item): item is FantasyPlayer => !!item); - if (!give.length || !receive.length) { - this.serviceLogger.warn('Ignoring AI suggestion with players outside the proposed rosters', { + if ( + !give.length || + !receive.length || + give.length !== suggestion.givePlayerIds.length || + receive.length !== suggestion.receivePlayerIds.length + ) { + this.serviceLogger.warn('Ignoring AI suggestion with unknown players outside the proposed rosters', { targetRosterId: suggestion.targetRosterId, }); return []; } + const giveTotal = this.totalMarketValue(give); + const receiveTotal = this.totalMarketValue(receive); + if (giveTotal !== null && receiveTotal !== null && (giveTotal < receiveTotal * 0.8 || giveTotal > receiveTotal)) { + this.serviceLogger.warn('Ignoring AI suggestion with unverifiable or unrealistic market values', { + targetRosterId: suggestion.targetRosterId, + giveTotal, + receiveTotal, + }); + return []; + } return [ { targetRosterId: target.rosterId, @@ -1028,6 +1218,17 @@ export class FantasyService { }); } + private totalMarketValue(players: FantasyPlayer[]): number | null { + let total = 0; + for (const player of players) { + if (player.marketValue === null) { + return null; + } + total += player.marketValue; + } + return total; + } + private buildWaiverSuggestions( analysis: AITradeAnalysis, ownRoster: FantasyTeam, @@ -1138,12 +1339,14 @@ export class FantasyService { const compactTeams = teams.map((team) => ({ rosterId: team.rosterId, ownerName: team.ownerName, - players: team.players.map(({ id, name, position, team: nflTeam, injuryStatus }) => ({ + players: team.players.map(({ id, name, position, team: nflTeam, injuryStatus, marketValue, positionRank }) => ({ id, name, position, team: nflTeam, injuryStatus, + marketValue, + positionRank, })), needs: rosterNeeds.get(team.rosterId) ?? [], })); @@ -1158,12 +1361,23 @@ export class FantasyService { instructions: 'You are a fantasy football analyst. Return only valid JSON with keys teamHealth, tradeInsights, suggestions, waiverSuggestions, and lineupSummary. ' + 'teamHealth must assess the user roster relative to the supplied league with an integer percentage from 0 to 100 and a concise summary. ' + - 'tradeInsights must include exactly one item per pending transaction with transactionId, a concise insight, ' + - 'and recommendation of accept, decline, or negotiate. suggestions must contain up to 3 realistic options ' + - 'with targetRosterId, givePlayerIds, receivePlayerIds, and rationale. Make each proposed trade fair enough that ' + - 'the target manager is likely to accept, but favor a small, subtle value edge for the user. Consider the current ' + - 'week, upcoming schedule, roster needs for both teams, positional scarcity, and whether the timing makes the offer ' + - 'more or less appealing. waiverSuggestions must contain up to 3 ' + + "Each player object includes a marketValue (a FantasyCalc consensus trade value already scaled to this league's " + + 'format, dynasty vs redraft, QB count, and PPR - higher is more valuable) and a positionRank (rank among players ' + + 'at the same position, 1 is best). A null marketValue means the player is unranked by FantasyCalc or the market-value ' + + 'feed was unavailable for this request; in either case fall back to position, roster needs, and matchup context instead ' + + 'of value. ' + + 'tradeInsights must include exactly one item per pending transaction with transactionId, a concise insight that ' + + "explicitly weighs the total marketValue given versus received for the user's side alongside roster needs and bye/injury " + + 'risk, and recommendation of accept, decline, or negotiate; recommend decline or negotiate if the user gives up ' + + 'materially more marketValue than they receive without a clear positional-need justification. ' + + 'suggestions must contain up to 3 realistic options with targetRosterId, givePlayerIds, receivePlayerIds, and rationale. ' + + "Sum the marketValue of givePlayerIds and receivePlayerIds for both sides of each suggestion: the two sides' totals " + + "must be within roughly 10-20% of each other (using the league-format scaling already applied to each player's marketValue) so the target " + + "manager is realistically likely to accept, while keeping a small, subtle edge in the user's favor - never propose a " + + "trade where the user's outgoing marketValue total is more than about 20% below what they receive. State the " + + 'approximate value comparison in the rationale (e.g. "roughly even value, slight edge to you") in addition to explaining ' + + 'the roster-needs and timing rationale. Consider the current week, upcoming schedule, roster needs for both teams, ' + + 'positional scarcity, and whether the timing makes the offer more or less appealing. 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. ' + 'Prioritize the user roster gaps first, then compare current and upcoming matchups for the candidate and the dropped ' + diff --git a/packages/frontend/src/app.model.ts b/packages/frontend/src/app.model.ts index 5280a4ec..a8b4d532 100644 --- a/packages/frontend/src/app.model.ts +++ b/packages/frontend/src/app.model.ts @@ -132,6 +132,8 @@ export interface FantasyPlayer { team: string | null; injuryStatus: string | null; fantasyPositions: string[]; + marketValue: number | null; + positionRank: number | null; } export interface ProjectedFantasyPlayer extends FantasyPlayer {