From 9e50d193a871e3e88d3b23c4b3eba72ac5cf83a6 Mon Sep 17 00:00:00 2001 From: Steve Freeman Date: Fri, 11 Sep 2026 12:37:42 -0400 Subject: [PATCH 1/6] feat(fantasy): use FantasyCalc player values for trade quantification Integrate the FantasyCalc /values/current API to give the AI trade analysis a consensus market value per player, scaled to each league's format (dynasty/keeper vs redraft, QB count, PPR). Values are attached to FantasyPlayer records across rosters, waiver candidates, and pending trade/waiver views, and cached per format for 6 hours. Failures are non-fatal and logged. Also tightens the ChatGPT trade prompt to explicitly weigh give/receive market value totals (keeping them within ~10-20% with a slight edge to the user) and require the rationale to state the value comparison, instead of relying on qualitative judgment alone. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/backend/src/fantasy/fantasy.model.ts | 16 +++ .../src/fantasy/fantasy.service.spec.ts | 122 +++++++++++++++++ .../backend/src/fantasy/fantasy.service.ts | 127 +++++++++++++++--- packages/frontend/src/app.model.ts | 2 + 4 files changed, 249 insertions(+), 18 deletions(-) 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..aaf311a6 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, @@ -117,6 +118,8 @@ type FantasyServiceInternals = { remainingBudget: number, ) => WaiverBidGuidance; weightedMedian: (samples: Array<{ pricePerPoint: number; weight: number }>) => number | null; + resolveLeagueFormat: (league: SleeperLeague) => { isDynasty: boolean; numQbs: number; ppr: number }; + getFantasyCalcValues: (league: SleeperLeague) => Promise>; }; describe('FantasyService', () => { @@ -921,6 +924,8 @@ describe('FantasyService', () => { team: 'BUF', injuryStatus: null, fantasyPositions: [position], + marketValue: null, + positionRank: null, projectedPoints, }); const players = [ @@ -970,6 +975,8 @@ describe('FantasyService', () => { team: 'BUF', injuryStatus: null, fantasyPositions: ['QB'], + marketValue: null, + positionRank: null, }, { id: 'unknown', @@ -978,6 +985,8 @@ describe('FantasyService', () => { team: null, injuryStatus: null, fantasyPositions: [], + marketValue: null, + positionRank: null, }, { id: 'edge', @@ -986,6 +995,8 @@ describe('FantasyService', () => { team: 'DAL', injuryStatus: null, fantasyPositions: ['DL'], + marketValue: null, + positionRank: null, }, { id: 'swing', @@ -994,6 +1005,8 @@ describe('FantasyService', () => { team: 'MIA', injuryStatus: null, fantasyPositions: ['RB', 'WR'], + marketValue: null, + positionRank: null, }, ], }, @@ -1018,6 +1031,8 @@ describe('FantasyService', () => { team: 'DAL', injuryStatus: null, fantasyPositions: ['QB', 'WR'], + marketValue: null, + positionRank: null, }, { id: 'qb-only', @@ -1026,6 +1041,8 @@ describe('FantasyService', () => { team: 'BUF', injuryStatus: null, fantasyPositions: ['QB'], + marketValue: null, + positionRank: null, }, ], }, @@ -1045,6 +1062,8 @@ describe('FantasyService', () => { team: 'WAS', injuryStatus: null, fantasyPositions: ['WR'], + marketValue: null, + positionRank: null, }, { id: 'jacksonville-player', @@ -1053,6 +1072,8 @@ describe('FantasyService', () => { team: 'JAC', injuryStatus: null, fantasyPositions: ['RB'], + marketValue: null, + positionRank: null, }, ]; @@ -1125,6 +1146,8 @@ describe('FantasyService', () => { team: 'BUF', injuryStatus: null, fantasyPositions: ['RB'], + marketValue: null, + positionRank: null, }; const guidance = await internals.getWaiverBidGuidance( { @@ -1181,6 +1204,8 @@ describe('FantasyService', () => { team: 'BUF', injuryStatus: null, fantasyPositions: [position], + marketValue: null, + positionRank: null, }); expect(internals.weightedMedian([])).toBeNull(); @@ -1230,6 +1255,8 @@ describe('FantasyService', () => { team: 'BUF', injuryStatus: null, fantasyPositions: ['RB'], + marketValue: null, + positionRank: null, }, ], }; @@ -1312,4 +1339,99 @@ 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 }); + }); + + 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'], + }; + (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 }, + ], + }); + + 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 } }), + ); + 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(); + }); + + it('returns an empty map when the FantasyCalc request fails', 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'], + }; + (Axios.get as Mock).mockRejectedValueOnce(new Error('network error')); + + await expect(internals.getFantasyCalcValues(league)).resolves.toEqual(new Map()); + }); }); diff --git a/packages/backend/src/fantasy/fantasy.service.ts b/packages/backend/src/fantasy/fantasy.service.ts index 68d8fded..bbb12fe1 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,11 @@ 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 WAIVER_HISTORY_SEASONS = 3; const NFL_REGULAR_SEASON_WEEKS = 18; const SLEEPER_ID_PATTERN = /^\d{1,32}$/; @@ -91,6 +94,16 @@ interface RosterNeed { deficit: number; } +interface FantasyCalcApiEntry { + player: { sleeperId?: string | null }; + value: number; + redraftValue: number; + overallRank: number; + positionRank: number; + trend30Day: number; + maybeTradeFrequency?: number | null; +} + const isOutputMessage = (item: ResponseOutputItem): item is ResponseOutputMessage => item.type === 'message'; const isOutputText = (item: ResponseOutputMessage['content'][number]): item is ResponseOutputText => item.type === 'output_text'; @@ -124,6 +137,7 @@ export class FantasyService { private playerRequest: Promise> | null = null; private waiverMarketCache = new Map(); private projectionCache = new Map(); + private fantasyCalcCache = new Map }>(); private analysisCache = new Map(); private readonly openAi: OpenAIClientLike; private readonly serviceLogger = logger.child({ module: 'FantasyService' }); @@ -180,7 +194,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, marketValues] = await Promise.all([ this.get(`/league/${leagueId}/rosters`), this.get(`/league/${leagueId}/users`), Promise.all( @@ -195,6 +209,7 @@ export class FantasyService { }, timeout: 10000, }).then((response) => response.data), + this.getFantasyCalcValues(league), ]); const transactions = Array.from( new Map(transactionGroups.flat().map((transaction) => [transaction.transaction_id, transaction])).values(), @@ -206,7 +221,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 +248,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); @@ -326,8 +341,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 +378,59 @@ 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 numQbs = Math.max(1, rosterPositions.filter((slot) => slot === 'QB' || slot === 'SUPER_FLEX').length); + const ppr = league.scoring_settings?.rec ?? 0.5; + // 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 }; + } + + /** + * 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 cacheKey = `${isDynasty}:${numQbs}:${ppr}`; + const cached = this.fantasyCalcCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.values; + } + try { + const response = await Axios.get(FANTASYCALC_API_URL, { + params: { isDynasty, numQbs, numTeams: league.total_rosters || 12, ppr }, + timeout: 10000, + }); + 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: isDynasty ? entry.value : entry.redraftValue, + overallRank: entry.overallRank, + positionRank: entry.positionRank, + trend30Day: entry.trend30Day, + tradeFrequency: entry.maybeTradeFrequency ?? null, + }, + ]), + ); + this.fantasyCalcCache.set(cacheKey, { expiresAt: Date.now() + FANTASYCALC_CACHE_MS, values }); + return values; + } catch (error) { + logError(this.serviceLogger, 'Failed to load FantasyCalc player values', error, { isDynasty, numQbs, ppr }); + return new Map(); + } + } + private getNflState(): Promise { return this.get('/state/nfl'); } @@ -656,8 +724,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 +742,8 @@ export class FantasyService { : player?.position ? [player.position] : [], + marketValue: marketValue?.value ?? null, + positionRank: marketValue?.positionRank ?? null, }; } @@ -921,11 +996,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 +1010,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 +1029,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 +1047,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 +1057,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, }; }); @@ -1138,12 +1217,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 +1239,22 @@ 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 (e.g. a rookie ' + + 'or deep bench piece); in that 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 redraftValue-equivalent scaling already applied) 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 { From d20be0a6f2ec497bb28f8b136020fb4b32e4aa9e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:55:00 +0000 Subject: [PATCH 2/6] fix(fantasy): address review feedback --- .../src/fantasy/fantasy.service.spec.ts | 139 +++++++++++++++++- .../backend/src/fantasy/fantasy.service.ts | 22 ++- 2 files changed, 149 insertions(+), 12 deletions(-) diff --git a/packages/backend/src/fantasy/fantasy.service.spec.ts b/packages/backend/src/fantasy/fantasy.service.spec.ts index aaf311a6..1d12ee9d 100644 --- a/packages/backend/src/fantasy/fantasy.service.spec.ts +++ b/packages/backend/src/fantasy/fantasy.service.spec.ts @@ -259,6 +259,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: [ @@ -364,14 +402,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: 4000, positionRank: 12 }), + expect.objectContaining({ id: 'p4', marketValue: 5800, 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: 5000, + positionRank: 9, + }); + expect(result?.pendingTrades[0]?.sides[1]?.players[0]).toMatchObject({ + name: 'Alex Receiver', + marketValue: 4000, + positionRank: 12, + }); expect(result?.gamesToWatch[0]).toMatchObject({ awayTeam: 'Buffalo Bills', homeTeam: 'New York Jets', @@ -382,15 +434,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: 1800, positionRank: 34 }, + drop: { id: 'p1', name: 'Alex Receiver', marketValue: 4000, 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: 1800, positionRank: 34 }, + drop: { id: 'p1', name: 'Alex Receiver', marketValue: 4000, positionRank: 12 }, bid: 14, }); expect(result?.teamHealth).toEqual({ @@ -408,6 +460,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: 4000, positionRank: 12 }), + expect.objectContaining({ id: 'p4', marketValue: 5800, positionRank: 5 }), + ]), + }), + ]), + ); + expect(payload.waiverCandidates).toEqual( + expect.arrayContaining([expect.objectContaining({ id: 'p3', marketValue: 1800, positionRank: 34 })]), + ); }); it('normalizes week-zero matchup context before generating AI analysis', async () => { @@ -1382,6 +1448,7 @@ describe('FantasyService', () => { settings: { type: 2 }, roster_positions: ['QB', 'RB'], }; + const twelveTeamLeague: SleeperLeague = { ...league, league_id: '1000', total_rosters: 12 }; (Axios.get as Mock).mockResolvedValueOnce({ data: [ { @@ -1396,6 +1463,19 @@ describe('FantasyService', () => { { 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); @@ -1417,9 +1497,18 @@ describe('FantasyService', () => { (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 } }), + ); + expect(twelveTeamValues.get('p1')?.value).toBe(9100); }); - it('returns an empty map when the FantasyCalc request fails', async () => { + it('returns an empty map and briefly negative-caches FantasyCalc failures', 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', @@ -1431,7 +1520,43 @@ describe('FantasyService', () => { 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, + }, + ], + }); - await expect(internals.getFantasyCalcValues(league)).resolves.toEqual(new Map()); + try { + await expect(internals.getFantasyCalcValues(league)).resolves.toEqual(new Map()); + await expect(internals.getFantasyCalcValues(league)).resolves.toEqual(new Map()); + expect(Axios.get).toHaveBeenCalledTimes(1); + + 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); + } finally { + vi.useRealTimers(); + } }); }); diff --git a/packages/backend/src/fantasy/fantasy.service.ts b/packages/backend/src/fantasy/fantasy.service.ts index bbb12fe1..c4ac571c 100644 --- a/packages/backend/src/fantasy/fantasy.service.ts +++ b/packages/backend/src/fantasy/fantasy.service.ts @@ -46,6 +46,7 @@ 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 WAIVER_HISTORY_SEASONS = 3; const NFL_REGULAR_SEASON_WEEKS = 18; const SLEEPER_ID_PATTERN = /^\d{1,32}$/; @@ -396,14 +397,15 @@ export class FantasyService { */ private async getFantasyCalcValues(league: SleeperLeague): Promise> { const { isDynasty, numQbs, ppr } = this.resolveLeagueFormat(league); - const cacheKey = `${isDynasty}:${numQbs}:${ppr}`; + const numTeams = league.total_rosters || 12; + const cacheKey = `${isDynasty}:${numQbs}:${numTeams}:${ppr}`; const cached = this.fantasyCalcCache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { return cached.values; } try { const response = await Axios.get(FANTASYCALC_API_URL, { - params: { isDynasty, numQbs, numTeams: league.total_rosters || 12, ppr }, + params: { isDynasty, numQbs, numTeams, ppr }, timeout: 10000, }); const values = new Map( @@ -426,8 +428,18 @@ export class FantasyService { this.fantasyCalcCache.set(cacheKey, { expiresAt: Date.now() + FANTASYCALC_CACHE_MS, values }); return values; } catch (error) { - logError(this.serviceLogger, 'Failed to load FantasyCalc player values', error, { isDynasty, numQbs, ppr }); - return new Map(); + logError(this.serviceLogger, 'Failed to load FantasyCalc player values', error, { + isDynasty, + numQbs, + numTeams, + ppr, + }); + const emptyValues = new Map(); + this.fantasyCalcCache.set(cacheKey, { + expiresAt: Date.now() + FANTASYCALC_FAILURE_CACHE_MS, + values: emptyValues, + }); + return emptyValues; } } @@ -1249,7 +1261,7 @@ export class FantasyService { '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 redraftValue-equivalent scaling already applied) so the target ' + + "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 ' + From 9d479d5f2dc994417ccf67f6bea4f431d6e5d532 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:10:21 +0000 Subject: [PATCH 3/6] fix(fantasy): address review caching and fairness feedback --- .../src/fantasy/fantasy.service.spec.ts | 236 +++++++++++++++++- .../backend/src/fantasy/fantasy.service.ts | 126 +++++++--- 2 files changed, 319 insertions(+), 43 deletions(-) diff --git a/packages/backend/src/fantasy/fantasy.service.spec.ts b/packages/backend/src/fantasy/fantasy.service.spec.ts index 1d12ee9d..fb0de7e6 100644 --- a/packages/backend/src/fantasy/fantasy.service.spec.ts +++ b/packages/backend/src/fantasy/fantasy.service.spec.ts @@ -120,6 +120,12 @@ type FantasyServiceInternals = { weightedMedian: (samples: Array<{ pricePerPoint: number; weight: number }>) => number | null; resolveLeagueFormat: (league: SleeperLeague) => { isDynasty: boolean; numQbs: number; ppr: number }; getFantasyCalcValues: (league: SleeperLeague) => Promise>; + buildTradeSuggestions: ( + analysis: AITradeAnalysis, + ownRoster: FantasyTeam, + teams: FantasyTeam[], + leagueId: string, + ) => unknown[]; }; describe('FantasyService', () => { @@ -583,6 +589,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({ @@ -718,6 +727,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({ @@ -846,6 +858,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({ @@ -942,6 +957,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: { @@ -1434,6 +1452,20 @@ describe('FantasyService', () => { 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 () => { @@ -1481,7 +1513,7 @@ describe('FantasyService', () => { expect(Axios.get).toHaveBeenCalledWith( 'https://api.fantasycalc.com/values/current', - expect.objectContaining({ params: { isDynasty: true, numQbs: 1, numTeams: 10, ppr: 0.5 } }), + expect.objectContaining({ params: { isDynasty: true, numQbs: 1, numTeams: 10, ppr: 0.5 }, timeout: 2000 }), ); expect(values.get('p1')).toEqual({ sleeperId: 'p1', @@ -1501,11 +1533,51 @@ describe('FantasyService', () => { 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 } }), + 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: FantasyCalcApiEntry[] }) => 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(8000); + }); + it('returns an empty map and briefly negative-caches FantasyCalc failures', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-09-11T12:00:00.000Z')); @@ -1559,4 +1631,164 @@ describe('FantasyService', () => { vi.useRealTimers(); } }); + + it('clears cached AI analysis when FantasyCalc values refresh', 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); + (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)).resolves.toBe(firstAnalysis); + await internals.getFantasyCalcValues(league); + await expect(internals.getTradeAnalysis('key', false, generate)).resolves.toBe(refreshedAnalysis); + expect(generate).toHaveBeenCalledTimes(2); + }); + + it('filters AI trade suggestions that are unfair or missing market values', () => { + 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, + }, + ], + }; + + 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 make this unverifiable.', + }, + ], + waiverSuggestions: [], + lineupSummary: 'Use the projected starters.', + }, + ownRoster, + [ownRoster, target], + '999', + ); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toMatchObject({ + targetRosterId: 2, + targetOwnerName: 'Bob', + give: [expect.objectContaining({ id: 'give-fair', marketValue: 4800 })], + receive: [expect.objectContaining({ id: 'receive-fair', marketValue: 5000 })], + }); + }); }); diff --git a/packages/backend/src/fantasy/fantasy.service.ts b/packages/backend/src/fantasy/fantasy.service.ts index c4ac571c..082f7205 100644 --- a/packages/backend/src/fantasy/fantasy.service.ts +++ b/packages/backend/src/fantasy/fantasy.service.ts @@ -47,6 +47,7 @@ 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 WAIVER_HISTORY_SEASONS = 3; const NFL_REGULAR_SEASON_WEEKS = 18; const SLEEPER_ID_PATTERN = /^\d{1,32}$/; @@ -139,6 +140,7 @@ export class FantasyService { private waiverMarketCache = new Map(); private projectionCache = new Map(); private fantasyCalcCache = new Map }>(); + private fantasyCalcRequests = new Map>>(); private analysisCache = new Map(); private readonly openAi: OpenAIClientLike; private readonly serviceLogger = logger.child({ module: 'FantasyService' }); @@ -381,8 +383,10 @@ export class FantasyService { private resolveLeagueFormat(league: SleeperLeague): { isDynasty: boolean; numQbs: number; ppr: number } { const rosterPositions = league.roster_positions ?? []; - const numQbs = Math.max(1, rosterPositions.filter((slot) => slot === 'QB' || slot === 'SUPER_FLEX').length); - const ppr = league.scoring_settings?.rec ?? 0.5; + 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; @@ -403,44 +407,56 @@ export class FantasyService { if (cached && cached.expiresAt > Date.now()) { return cached.values; } - try { - const response = await Axios.get(FANTASYCALC_API_URL, { - params: { isDynasty, numQbs, numTeams, ppr }, - timeout: 10000, - }); - 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: isDynasty ? entry.value : entry.redraftValue, - overallRank: entry.overallRank, - positionRank: entry.positionRank, - trend30Day: entry.trend30Day, - tradeFrequency: entry.maybeTradeFrequency ?? null, - }, - ]), - ); - this.fantasyCalcCache.set(cacheKey, { expiresAt: Date.now() + FANTASYCALC_CACHE_MS, values }); - return values; - } catch (error) { - logError(this.serviceLogger, 'Failed to load FantasyCalc player values', error, { - isDynasty, - numQbs, - numTeams, - ppr, - }); - const emptyValues = new Map(); - this.fantasyCalcCache.set(cacheKey, { - expiresAt: Date.now() + FANTASYCALC_FAILURE_CACHE_MS, - values: emptyValues, - }); - return emptyValues; + 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: isDynasty ? entry.value : entry.redraftValue, + overallRank: entry.overallRank, + positionRank: entry.positionRank, + trend30Day: entry.trend30Day, + tradeFrequency: entry.maybeTradeFrequency ?? null, + }, + ]), + ); + this.fantasyCalcCache.set(cacheKey, { expiresAt: Date.now() + FANTASYCALC_CACHE_MS, values }); + this.analysisCache.clear(); + return values; + }) + .catch((error) => { + logError(this.serviceLogger, 'Failed to load FantasyCalc player values', error, { + isDynasty, + numQbs, + numTeams, + ppr, + }); + const emptyValues = new Map(); + this.fantasyCalcCache.set(cacheKey, { + expiresAt: Date.now() + FANTASYCALC_FAILURE_CACHE_MS, + values: emptyValues, + }); + this.analysisCache.clear(); + return emptyValues; + }) + .finally(() => { + this.fantasyCalcRequests.delete(cacheKey); + }); + this.fantasyCalcRequests.set(cacheKey, request); + return request; } private getNflState(): Promise { @@ -1106,6 +1122,22 @@ export class FantasyService { }); return []; } + const giveTotal = this.totalMarketValue(give); + const receiveTotal = this.totalMarketValue(receive); + if ( + giveTotal === null || + receiveTotal === null || + receiveTotal <= 0 || + 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, @@ -1119,6 +1151,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, @@ -1253,8 +1296,9 @@ export class FantasyService { 'teamHealth must assess the user roster relative to the supplied league with an integer percentage from 0 to 100 and a concise summary. ' + "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 (e.g. a rookie ' + - 'or deep bench piece); in that case fall back to position, roster needs, and matchup context instead of value. ' + + '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 ' + From 0a53fc1d54c35cad6f2a666440b78c19ef5efa7d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:44:54 +0000 Subject: [PATCH 4/6] fix(fantasy): address remaining review feedback --- .../src/fantasy/fantasy.service.spec.ts | 106 ++++++++++++++++-- .../backend/src/fantasy/fantasy.service.ts | 50 +++++++-- 2 files changed, 136 insertions(+), 20 deletions(-) diff --git a/packages/backend/src/fantasy/fantasy.service.spec.ts b/packages/backend/src/fantasy/fantasy.service.spec.ts index fb0de7e6..cf456a47 100644 --- a/packages/backend/src/fantasy/fantasy.service.spec.ts +++ b/packages/backend/src/fantasy/fantasy.service.spec.ts @@ -66,11 +66,22 @@ 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; buildLineupRecommendation: ( week: number, @@ -119,6 +130,7 @@ type FantasyServiceInternals = { ) => 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, @@ -1549,7 +1561,7 @@ describe('FantasyService', () => { total_rosters: 12, roster_positions: ['QB', 'RB'], }; - let resolveRequest: ((value: { data: FantasyCalcApiEntry[] }) => void) | undefined; + let resolveRequest: ((value: { data: FantasyCalcResponseEntry[] }) => void) | undefined; (Axios.get as Mock).mockImplementationOnce( () => new Promise((resolve) => { @@ -1578,7 +1590,7 @@ describe('FantasyService', () => { expect(firstValues.get('p1')?.value).toBe(8000); }); - it('returns an empty map and briefly negative-caches FantasyCalc failures', async () => { + 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; @@ -1606,9 +1618,11 @@ describe('FantasyService', () => { }); 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( @@ -1627,12 +1641,13 @@ describe('FantasyService', () => { ]), ); expect(Axios.get).toHaveBeenCalledTimes(2); + expect(internals.getFantasyCalcAnalysisVersion(league)).toBe(1); } finally { vi.useRealTimers(); } }); - it('clears cached AI analysis when FantasyCalc values refresh', async () => { + 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', @@ -1655,6 +1670,7 @@ describe('FantasyService', () => { 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: [ { @@ -1668,13 +1684,15 @@ describe('FantasyService', () => { ], }); - await expect(internals.getTradeAnalysis('key', false, generate)).resolves.toBe(firstAnalysis); + await expect(internals.getTradeAnalysis('key', false, generate, initialVersion)).resolves.toBe(firstAnalysis); await internals.getFantasyCalcValues(league); - await expect(internals.getTradeAnalysis('key', false, generate)).resolves.toBe(refreshedAnalysis); + 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 AI trade suggestions that are unfair or missing market values', () => { + it('filters only unfair AI trade suggestions when market values are available', () => { const internals = service as unknown as FantasyServiceInternals; const ownRoster: FantasyTeam = { rosterId: 1, @@ -1772,7 +1790,7 @@ describe('FantasyService', () => { targetRosterId: 2, givePlayerIds: ['give-null'], receivePlayerIds: ['receive-null'], - rationale: 'Missing values make this unverifiable.', + rationale: 'Missing values should fall back to the qualitative recommendation.', }, ], waiverSuggestions: [], @@ -1783,12 +1801,84 @@ describe('FantasyService', () => { '999', ); - expect(suggestions).toHaveLength(1); + 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 082f7205..b6791d4d 100644 --- a/packages/backend/src/fantasy/fantasy.service.ts +++ b/packages/backend/src/fantasy/fantasy.service.ts @@ -140,6 +140,7 @@ export class FantasyService { private waiverMarketCache = new Map(); private projectionCache = new Map(); private fantasyCalcCache = new Map }>(); + private fantasyCalcAnalysisVersions = new Map(); private fantasyCalcRequests = new Map>>(); private analysisCache = new Map(); private readonly openAi: OpenAIClientLike; @@ -329,6 +330,7 @@ export class FantasyService { [currentProjections, ...upcomingMatchupProjections], ); }, + this.getFantasyCalcAnalysisVersion(league), ); } catch (error) { logError(this.serviceLogger, 'Failed to generate fantasy trade analysis', error, { @@ -393,6 +395,16 @@ export class FantasyService { 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 @@ -402,7 +414,7 @@ export class FantasyService { private async getFantasyCalcValues(league: SleeperLeague): Promise> { const { isDynasty, numQbs, ppr } = this.resolveLeagueFormat(league); const numTeams = league.total_rosters || 12; - const cacheKey = `${isDynasty}:${numQbs}:${numTeams}:${ppr}`; + const cacheKey = this.getFantasyCalcCacheKey(league); const cached = this.fantasyCalcCache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { return cached.values; @@ -434,7 +446,7 @@ export class FantasyService { ]), ); this.fantasyCalcCache.set(cacheKey, { expiresAt: Date.now() + FANTASYCALC_CACHE_MS, values }); - this.analysisCache.clear(); + this.fantasyCalcAnalysisVersions.set(cacheKey, this.getFantasyCalcAnalysisVersion(league) + 1); return values; }) .catch((error) => { @@ -449,7 +461,6 @@ export class FantasyService { expiresAt: Date.now() + FANTASYCALC_FAILURE_CACHE_MS, values: emptyValues, }); - this.analysisCache.clear(); return emptyValues; }) .finally(() => { @@ -501,14 +512,16 @@ export class FantasyService { key: string, refresh: boolean, generate: () => Promise, + marketValueVersion = 0, ): Promise { - const cached = this.analysisCache.get(key); + const versionedKey = `${key}:${marketValueVersion}`; + const cached = this.analysisCache.get(versionedKey); if (!refresh && cached && cached.expiresAt > Date.now()) { return cached.analysis; } const analysis = await generate(); - this.analysisCache.set(key, { + this.analysisCache.set(versionedKey, { expiresAt: Date.now() + AI_ANALYSIS_CACHE_MS, analysis, }); @@ -1110,14 +1123,28 @@ 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 []; @@ -1125,11 +1152,10 @@ export class FantasyService { const giveTotal = this.totalMarketValue(give); const receiveTotal = this.totalMarketValue(receive); if ( - giveTotal === null || - receiveTotal === null || - receiveTotal <= 0 || - giveTotal < receiveTotal * 0.8 || - giveTotal > receiveTotal + giveTotal !== null && + receiveTotal !== null && + receiveTotal > 0 && + (giveTotal < receiveTotal * 0.8 || giveTotal > receiveTotal) ) { this.serviceLogger.warn('Ignoring AI suggestion with unverifiable or unrealistic market values', { targetRosterId: suggestion.targetRosterId, From 3fca9458ec835c62d9b4adcafc0602edb2d70319 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:00:05 +0000 Subject: [PATCH 5/6] fix fantasycalc overview blocking and cache/value handling --- .../src/fantasy/fantasy.service.spec.ts | 24 +++++----- .../backend/src/fantasy/fantasy.service.ts | 45 ++++++++++++++++--- 2 files changed, 50 insertions(+), 19 deletions(-) diff --git a/packages/backend/src/fantasy/fantasy.service.spec.ts b/packages/backend/src/fantasy/fantasy.service.spec.ts index cf456a47..3e633de5 100644 --- a/packages/backend/src/fantasy/fantasy.service.spec.ts +++ b/packages/backend/src/fantasy/fantasy.service.spec.ts @@ -424,8 +424,8 @@ describe('FantasyService', () => { expect(result?.league.name).toBe('Friends League'); expect(result?.roster.players).toEqual([ - expect.objectContaining({ id: 'p1', marketValue: 4000, positionRank: 12 }), - expect.objectContaining({ id: 'p4', marketValue: 5800, positionRank: 5 }), + expect.objectContaining({ id: 'p1', marketValue: 4500, positionRank: 12 }), + expect.objectContaining({ id: 'p4', marketValue: 6100, positionRank: 5 }), ]); expect(result?.pendingTrades[0]).toMatchObject({ transactionId: 'trade-1', @@ -434,12 +434,12 @@ describe('FantasyService', () => { }); expect(result?.pendingTrades[0]?.sides[0]?.players[0]).toMatchObject({ name: 'Blake Runner', - marketValue: 5000, + marketValue: 5200, positionRank: 9, }); expect(result?.pendingTrades[0]?.sides[1]?.players[0]).toMatchObject({ name: 'Alex Receiver', - marketValue: 4000, + marketValue: 4500, positionRank: 12, }); expect(result?.gamesToWatch[0]).toMatchObject({ @@ -452,15 +452,15 @@ describe('FantasyService', () => { sleeperUrl: 'https://sleeper.com/leagues/999', }); expect(result?.waiverSuggestions[0]).toMatchObject({ - add: { id: 'p3', name: 'Casey Waiver', marketValue: 1800, positionRank: 34 }, - drop: { id: 'p1', name: 'Alex Receiver', marketValue: 4000, positionRank: 12 }, + 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', marketValue: 1800, positionRank: 34 }, - drop: { id: 'p1', name: 'Alex Receiver', marketValue: 4000, positionRank: 12 }, + 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({ @@ -483,14 +483,14 @@ describe('FantasyService', () => { expect.objectContaining({ rosterId: 1, players: expect.arrayContaining([ - expect.objectContaining({ id: 'p1', marketValue: 4000, positionRank: 12 }), - expect.objectContaining({ id: 'p4', marketValue: 5800, positionRank: 5 }), + 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: 1800, positionRank: 34 })]), + expect.arrayContaining([expect.objectContaining({ id: 'p3', marketValue: 2100, positionRank: 34 })]), ); }); @@ -1587,7 +1587,7 @@ describe('FantasyService', () => { expect(Axios.get).toHaveBeenCalledTimes(1); expect(firstValues).toBe(secondValues); - expect(firstValues.get('p1')?.value).toBe(8000); + expect(firstValues.get('p1')?.value).toBe(9000); }); it('returns an empty map and briefly negative-caches FantasyCalc failures without bumping the analysis version', async () => { diff --git a/packages/backend/src/fantasy/fantasy.service.ts b/packages/backend/src/fantasy/fantasy.service.ts index b6791d4d..b52fa2ee 100644 --- a/packages/backend/src/fantasy/fantasy.service.ts +++ b/packages/backend/src/fantasy/fantasy.service.ts @@ -48,6 +48,7 @@ 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}$/; @@ -142,7 +143,10 @@ export class FantasyService { private fantasyCalcCache = new Map }>(); private fantasyCalcAnalysisVersions = new Map(); private fantasyCalcRequests = new Map>>(); - private analysisCache = new Map(); + private analysisCache = new Map< + string, + { expiresAt: number; analysis: AITradeAnalysis; marketValueVersion: number } + >(); private readonly openAi: OpenAIClientLike; private readonly serviceLogger = logger.child({ module: 'FantasyService' }); @@ -213,7 +217,7 @@ export class FantasyService { }, timeout: 10000, }).then((response) => response.data), - this.getFantasyCalcValues(league), + this.getFantasyCalcValuesForOverview(league), ]); const transactions = Array.from( new Map(transactionGroups.flat().map((transaction) => [transaction.transaction_id, transaction])).values(), @@ -437,7 +441,7 @@ export class FantasyService { entry.player.sleeperId, { sleeperId: entry.player.sleeperId, - value: isDynasty ? entry.value : entry.redraftValue, + value: entry.value, overallRank: entry.overallRank, positionRank: entry.positionRank, trend30Day: entry.trend30Day, @@ -470,6 +474,33 @@ export class FantasyService { 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(cached.values); + } + + const fallbackValues = cached?.values ?? new Map(); + const refreshPromise = this.getFantasyCalcValues(league); + if (cached) { + return Promise.resolve(fallbackValues); + } + + return new Promise((resolve) => { + const timeout = setTimeout(() => resolve(fallbackValues), FANTASYCALC_OVERVIEW_WAIT_MS); + refreshPromise + .then((values) => { + clearTimeout(timeout); + resolve(values); + }) + .catch(() => { + clearTimeout(timeout); + resolve(fallbackValues); + }); + }); + } + private getNflState(): Promise { return this.get('/state/nfl'); } @@ -514,16 +545,16 @@ export class FantasyService { generate: () => Promise, marketValueVersion = 0, ): Promise { - const versionedKey = `${key}:${marketValueVersion}`; - const cached = this.analysisCache.get(versionedKey); - if (!refresh && cached && cached.expiresAt > Date.now()) { + const cached = this.analysisCache.get(key); + if (!refresh && cached && cached.expiresAt > Date.now() && cached.marketValueVersion === marketValueVersion) { return cached.analysis; } const analysis = await generate(); - this.analysisCache.set(versionedKey, { + this.analysisCache.set(key, { expiresAt: Date.now() + AI_ANALYSIS_CACHE_MS, analysis, + marketValueVersion, }); return analysis; } From 9205a57298b908fb1b81e12a387b1ef1c04bf3ea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:19:43 +0000 Subject: [PATCH 6/6] Fix latest fantasy review feedback --- .../src/fantasy/fantasy.service.spec.ts | 145 ++++++++++++++++++ .../backend/src/fantasy/fantasy.service.ts | 52 ++++--- 2 files changed, 176 insertions(+), 21 deletions(-) diff --git a/packages/backend/src/fantasy/fantasy.service.spec.ts b/packages/backend/src/fantasy/fantasy.service.spec.ts index 3e633de5..b904e742 100644 --- a/packages/backend/src/fantasy/fantasy.service.spec.ts +++ b/packages/backend/src/fantasy/fantasy.service.spec.ts @@ -83,6 +83,9 @@ type FantasyServiceInternals = { generate: () => Promise, marketValueVersion?: number, ) => Promise; + getFantasyCalcValuesForOverview: ( + league: SleeperLeague, + ) => Promise<{ values: Map; version: number }>; buildLineupRecommendation: ( week: number, league: SleeperLeague, @@ -1647,6 +1650,132 @@ describe('FantasyService', () => { } }); + 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 = { @@ -1766,6 +1895,16 @@ describe('FantasyService', () => { marketValue: null, positionRank: null, }, + { + id: 'receive-zero', + name: 'Receive Zero', + position: 'RB', + team: 'NYJ', + injuryStatus: null, + fantasyPositions: ['RB'], + marketValue: 0, + positionRank: 99, + }, ], }; @@ -1792,6 +1931,12 @@ describe('FantasyService', () => { 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.', diff --git a/packages/backend/src/fantasy/fantasy.service.ts b/packages/backend/src/fantasy/fantasy.service.ts index b52fa2ee..34aad527 100644 --- a/packages/backend/src/fantasy/fantasy.service.ts +++ b/packages/backend/src/fantasy/fantasy.service.ts @@ -107,6 +107,15 @@ interface FantasyCalcApiEntry { 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'; @@ -140,7 +149,7 @@ export class FantasyService { private playerRequest: Promise> | null = null; private waiverMarketCache = new Map(); private projectionCache = new Map(); - private fantasyCalcCache = new Map }>(); + private fantasyCalcCache = new Map(); private fantasyCalcAnalysisVersions = new Map(); private fantasyCalcRequests = new Map>>(); private analysisCache = new Map< @@ -202,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, marketValues] = 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( @@ -219,6 +228,7 @@ export class FantasyService { }).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(), ); @@ -334,7 +344,7 @@ export class FantasyService { [currentProjections, ...upcomingMatchupProjections], ); }, - this.getFantasyCalcAnalysisVersion(league), + marketValueSnapshot.version, ); } catch (error) { logError(this.serviceLogger, 'Failed to generate fantasy trade analysis', error, { @@ -449,8 +459,9 @@ export class FantasyService { }, ]), ); - this.fantasyCalcCache.set(cacheKey, { expiresAt: Date.now() + FANTASYCALC_CACHE_MS, values }); - this.fantasyCalcAnalysisVersions.set(cacheKey, this.getFantasyCalcAnalysisVersion(league) + 1); + 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) => { @@ -460,12 +471,14 @@ export class FantasyService { numTeams, ppr, }); - const emptyValues = new Map(); + 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: emptyValues, + values: fallbackValues, + version, }); - return emptyValues; + return fallbackValues; }) .finally(() => { this.fantasyCalcRequests.delete(cacheKey); @@ -474,29 +487,31 @@ export class FantasyService { return request; } - private getFantasyCalcValuesForOverview(league: SleeperLeague): Promise> { + 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(cached.values); + return Promise.resolve({ values: cached.values, version: cached.version }); } - const fallbackValues = cached?.values ?? new Map(); + 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(fallbackValues); + return Promise.resolve(fallbackSnapshot); } return new Promise((resolve) => { - const timeout = setTimeout(() => resolve(fallbackValues), FANTASYCALC_OVERVIEW_WAIT_MS); + const timeout = setTimeout(() => resolve(fallbackSnapshot), FANTASYCALC_OVERVIEW_WAIT_MS); refreshPromise .then((values) => { clearTimeout(timeout); - resolve(values); + resolve({ values, version: this.getFantasyCalcAnalysisVersion(league) }); }) .catch(() => { clearTimeout(timeout); - resolve(fallbackValues); + resolve(fallbackSnapshot); }); }); } @@ -1182,12 +1197,7 @@ export class FantasyService { } const giveTotal = this.totalMarketValue(give); const receiveTotal = this.totalMarketValue(receive); - if ( - giveTotal !== null && - receiveTotal !== null && - receiveTotal > 0 && - (giveTotal < receiveTotal * 0.8 || giveTotal > receiveTotal) - ) { + 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,