From c2ec8100851edf65bf57768b3efc1b5dac30ff2f Mon Sep 17 00:00:00 2001 From: Alan Garber Date: Tue, 24 Feb 2026 18:13:33 -0500 Subject: [PATCH] Add date-range filtering to portfolio performance tool The agent can now answer period-specific performance questions (YTD, MTD, 1Y, etc.) instead of always returning all-time data. Uses historical MarketData prices to compute start-of-period valuations and calculates period gain adjusted for cash flows. Co-Authored-By: Claude Opus 4.6 --- apps/api/src/app/endpoints/ai/ai.service.ts | 1 + .../ai/tools/portfolio-performance.tool.ts | 463 ++++++++++++++---- 2 files changed, 371 insertions(+), 93 deletions(-) diff --git a/apps/api/src/app/endpoints/ai/ai.service.ts b/apps/api/src/app/endpoints/ai/ai.service.ts index 92f0c0f7a..3980b64c7 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.ts @@ -37,6 +37,7 @@ const AGENT_SYSTEM_PROMPT = [ '4. You are a READ-ONLY assistant. You cannot execute trades, modify portfolios, or make changes to accounts.', '5. If asked to perform actions like buying, selling, or transferring assets, politely decline and explain you can only provide information.', '6. Include appropriate financial disclaimers when providing analytical or forward-looking commentary.', + '7. When the user asks about performance for a specific time period, pass the appropriate dateRange parameter: "ytd" for this year, "1y" for past year, "5y" for 5 years, "mtd" for this month, "wtd" for this week, "1d" for today. Use "max" for all-time or when no specific period is mentioned.', '', 'DISCLAIMER: This is an AI assistant providing informational responses based on portfolio data.', 'This is not financial advice. Always consult with a qualified financial advisor before making investment decisions.' diff --git a/apps/api/src/app/endpoints/ai/tools/portfolio-performance.tool.ts b/apps/api/src/app/endpoints/ai/tools/portfolio-performance.tool.ts index 80ecece45..9d91e695c 100644 --- a/apps/api/src/app/endpoints/ai/tools/portfolio-performance.tool.ts +++ b/apps/api/src/app/endpoints/ai/tools/portfolio-performance.tool.ts @@ -1,5 +1,6 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { tool } from 'ai'; import { z } from 'zod'; @@ -11,9 +12,17 @@ export function getPortfolioPerformanceTool(deps: { }) { return tool({ description: - "Get the user's portfolio performance including total return, net performance percentage, and current net worth", - parameters: z.object({}), - execute: async () => { + "Get the user's portfolio performance including total return, net performance percentage, and current net worth. Supports date-range filtering: 1d (today), wtd (week-to-date), mtd (month-to-date), ytd (year-to-date), 1y (1 year), 5y (5 years), max (all-time).", + parameters: z.object({ + dateRange: z + .enum(["1d", "mtd", "wtd", "ytd", "1y", "5y", "max"]) + .optional() + .default("max") + .describe( + 'Time period: "ytd" for year-to-date, "1y" for last year, "5y" for 5 years, "mtd" for month-to-date, "wtd" for week-to-date, "1d" for today, "max" for all-time' + ) + }), + execute: async ({ dateRange }) => { // Get all orders for this user with their symbol profiles const orders = await deps.prismaService.order.findMany({ where: { userId: deps.userId }, @@ -21,108 +30,376 @@ export function getPortfolioPerformanceTool(deps: { type: true, quantity: true, unitPrice: true, + date: true, symbolProfileId: true } }); // Get all symbol profiles referenced by orders - const profileIds = [...new Set(orders.map(o => o.symbolProfileId))]; + const profileIds = [...new Set(orders.map((o) => o.symbolProfileId))]; const profiles = await deps.prismaService.symbolProfile.findMany({ where: { id: { in: profileIds } }, - select: { id: true, symbol: true, dataSource: true, name: true, currency: true } - }); - const profileMap = Object.fromEntries(profiles.map(p => [p.id, p])); - - // Compute cost basis from BUY orders and subtract SELL proceeds - const positionMap: Record = {}; - - for (const order of orders) { - const profile = profileMap[order.symbolProfileId]; - if (!profile) continue; - const sym = profile.symbol; - if (!positionMap[sym]) { - positionMap[sym] = { - quantity: 0, - totalCost: 0, - symbol: sym, - dataSource: profile.dataSource, - name: profile.name - }; - } - if (order.type === 'BUY') { - positionMap[sym].quantity += order.quantity; - positionMap[sym].totalCost += order.quantity * order.unitPrice; - } else if (order.type === 'SELL') { - positionMap[sym].quantity -= order.quantity; - positionMap[sym].totalCost -= order.quantity * order.unitPrice; + select: { + id: true, + symbol: true, + dataSource: true, + name: true, + currency: true } - } + }); + const profileMap = Object.fromEntries(profiles.map((p) => [p.id, p])); - // Filter to positions with quantity > 0 - const activePositions = Object.values(positionMap).filter(p => p.quantity > 0); - - if (activePositions.length === 0) { - return { - performance: { - currentNetWorth: 0, - totalInvestment: 0, - netPerformance: 0, - netPerformancePercentage: '0.00%' - }, - holdings: [] - }; + if (dateRange === "max") { + return computeAllTimePerformance(deps, orders, profileMap); } - // Get current market prices - const items = activePositions.map(p => ({ - dataSource: p.dataSource, - symbol: p.symbol - })); - - const quotes = await deps.dataProviderService.getQuotes({ items }); - - let totalInvestment = 0; - let currentValue = 0; - const holdingPerformance = []; - - for (const pos of activePositions) { - const quote = quotes[pos.symbol]; - const currentPrice = quote?.marketPrice ?? 0; - const posValue = pos.quantity * currentPrice; - const posCost = pos.totalCost; - const posGain = posValue - posCost; - const posGainPct = posCost > 0 ? (posGain / posCost) * 100 : 0; - - totalInvestment += posCost; - currentValue += posValue; - - holdingPerformance.push({ - symbol: pos.symbol, - name: pos.name, - quantity: pos.quantity, - avgCostPerShare: +(posCost / pos.quantity).toFixed(2), - currentPrice: +currentPrice.toFixed(2), - investedValue: +posCost.toFixed(2), - currentValue: +posValue.toFixed(2), - gain: +posGain.toFixed(2), - gainPercentage: `${posGainPct.toFixed(2)}%` - }); - } + return computePeriodPerformance(deps, orders, profileMap, dateRange); + } + }); +} + +function computeAllTimePerformance( + deps: { dataProviderService: DataProviderService }, + orders: { + type: string; + quantity: number; + unitPrice: number; + date: Date; + symbolProfileId: string; + }[], + profileMap: Record< + string, + { + id: string; + symbol: string; + dataSource: any; + name: string; + currency: string; + } + > +) { + return computePerformanceFromOrders(deps, orders, profileMap); +} + +async function computePerformanceFromOrders( + deps: { dataProviderService: DataProviderService }, + orders: { + type: string; + quantity: number; + unitPrice: number; + symbolProfileId: string; + }[], + profileMap: Record< + string, + { + id: string; + symbol: string; + dataSource: any; + name: string; + currency: string; + } + > +) { + const positionMap: Record< + string, + { + quantity: number; + totalCost: number; + symbol: string; + dataSource: any; + name: string; + } + > = {}; - const netPerformance = currentValue - totalInvestment; - const netPerformancePercentage = totalInvestment > 0 - ? (netPerformance / totalInvestment) * 100 - : 0; - - return { - performance: { - currentNetWorth: +currentValue.toFixed(2), - totalInvestment: +totalInvestment.toFixed(2), - netPerformance: +netPerformance.toFixed(2), - netPerformancePercentage: `${netPerformancePercentage.toFixed(2)}%` - }, - holdings: holdingPerformance + for (const order of orders) { + const profile = profileMap[order.symbolProfileId]; + if (!profile) continue; + const sym = profile.symbol; + if (!positionMap[sym]) { + positionMap[sym] = { + quantity: 0, + totalCost: 0, + symbol: sym, + dataSource: profile.dataSource, + name: profile.name }; } - }); + if (order.type === "BUY") { + positionMap[sym].quantity += order.quantity; + positionMap[sym].totalCost += order.quantity * order.unitPrice; + } else if (order.type === "SELL") { + positionMap[sym].quantity -= order.quantity; + positionMap[sym].totalCost -= order.quantity * order.unitPrice; + } + } + + const activePositions = Object.values(positionMap).filter( + (p) => p.quantity > 0 + ); + + if (activePositions.length === 0) { + return { + performance: { + currentNetWorth: 0, + totalInvestment: 0, + netPerformance: 0, + netPerformancePercentage: "0.00%" + }, + holdings: [] + }; + } + + const items = activePositions.map((p) => ({ + dataSource: p.dataSource, + symbol: p.symbol + })); + + const quotes = await deps.dataProviderService.getQuotes({ items }); + + let totalInvestment = 0; + let currentValue = 0; + const holdingPerformance = []; + + for (const pos of activePositions) { + const quote = quotes[pos.symbol]; + const currentPrice = quote?.marketPrice ?? 0; + const posValue = pos.quantity * currentPrice; + const posCost = pos.totalCost; + const posGain = posValue - posCost; + const posGainPct = posCost > 0 ? (posGain / posCost) * 100 : 0; + + totalInvestment += posCost; + currentValue += posValue; + + holdingPerformance.push({ + symbol: pos.symbol, + name: pos.name, + quantity: pos.quantity, + avgCostPerShare: +(posCost / pos.quantity).toFixed(2), + currentPrice: +currentPrice.toFixed(2), + investedValue: +posCost.toFixed(2), + currentValue: +posValue.toFixed(2), + gain: +posGain.toFixed(2), + gainPercentage: `${posGainPct.toFixed(2)}%` + }); + } + + const netPerformance = currentValue - totalInvestment; + const netPerformancePercentage = + totalInvestment > 0 ? (netPerformance / totalInvestment) * 100 : 0; + + return { + performance: { + currentNetWorth: +currentValue.toFixed(2), + totalInvestment: +totalInvestment.toFixed(2), + netPerformance: +netPerformance.toFixed(2), + netPerformancePercentage: `${netPerformancePercentage.toFixed(2)}%` + }, + holdings: holdingPerformance + }; +} + +async function computePeriodPerformance( + deps: { + dataProviderService: DataProviderService; + prismaService: PrismaService; + }, + orders: { + type: string; + quantity: number; + unitPrice: number; + date: Date; + symbolProfileId: string; + }[], + profileMap: Record< + string, + { + id: string; + symbol: string; + dataSource: any; + name: string; + currency: string; + } + >, + dateRange: "1d" | "mtd" | "wtd" | "ytd" | "1y" | "5y" +) { + const { startDate, endDate } = getIntervalFromDateRange(dateRange); + const warnings: string[] = []; + + // Partition orders into before-period and during-period + const ordersBeforePeriod = orders.filter( + (o) => new Date(o.date) < startDate + ); + const ordersDuringPeriod = orders.filter( + (o) => new Date(o.date) >= startDate && new Date(o.date) <= endDate + ); + + // Build positions at start of period from orders before period + const startPositions: Record< + string, + { quantity: number; costBasis: number; symbol: string; dataSource: any; name: string } + > = {}; + + for (const order of ordersBeforePeriod) { + const profile = profileMap[order.symbolProfileId]; + if (!profile) continue; + const sym = profile.symbol; + if (!startPositions[sym]) { + startPositions[sym] = { + quantity: 0, + costBasis: 0, + symbol: sym, + dataSource: profile.dataSource, + name: profile.name + }; + } + if (order.type === "BUY") { + startPositions[sym].quantity += order.quantity; + startPositions[sym].costBasis += order.quantity * order.unitPrice; + } else if (order.type === "SELL") { + startPositions[sym].quantity -= order.quantity; + startPositions[sym].costBasis -= order.quantity * order.unitPrice; + } + } + + // Look up historical prices on/before startDate for each starting position + let startValue = 0; + const activeStartPositions = Object.values(startPositions).filter( + (p) => p.quantity > 0 + ); + + for (const pos of activeStartPositions) { + const historicalPrice = await deps.prismaService.marketData.findFirst({ + where: { + symbol: pos.symbol, + dataSource: pos.dataSource, + date: { lte: startDate } + }, + orderBy: { date: "desc" }, + select: { marketPrice: true } + }); + + if (historicalPrice) { + startValue += + pos.quantity * (historicalPrice.marketPrice as unknown as number); + } else { + // Fall back to cost basis + startValue += pos.costBasis; + warnings.push( + `No historical price found for ${pos.symbol} at period start; using cost basis as fallback.` + ); + } + } + + // Apply orders during period to get end positions and track net cash flows + const endPositions: Record< + string, + { quantity: number; symbol: string; dataSource: any; name: string } + > = {}; + + // Start with positions from before the period + for (const pos of activeStartPositions) { + endPositions[pos.symbol] = { + quantity: pos.quantity, + symbol: pos.symbol, + dataSource: pos.dataSource, + name: pos.name + }; + } + + let netCashFlow = 0; // positive = money added (buys), negative = money removed (sells) + + for (const order of ordersDuringPeriod) { + const profile = profileMap[order.symbolProfileId]; + if (!profile) continue; + const sym = profile.symbol; + if (!endPositions[sym]) { + endPositions[sym] = { + quantity: 0, + symbol: sym, + dataSource: profile.dataSource, + name: profile.name + }; + } + if (order.type === "BUY") { + endPositions[sym].quantity += order.quantity; + netCashFlow += order.quantity * order.unitPrice; + } else if (order.type === "SELL") { + endPositions[sym].quantity -= order.quantity; + netCashFlow -= order.quantity * order.unitPrice; + } + } + + // Get current prices for end positions + const activeEndPositions = Object.values(endPositions).filter( + (p) => p.quantity > 0 + ); + + if (activeEndPositions.length === 0 && activeStartPositions.length === 0) { + return { + dateRange, + periodStart: startDate.toISOString().split("T")[0], + periodEnd: endDate.toISOString().split("T")[0], + performance: { + startValue: 0, + endValue: 0, + netCashFlow: 0, + periodGain: 0, + periodGainPercentage: "0.00%" + }, + holdings: [] + }; + } + + let endValue = 0; + const holdingPerformance = []; + + if (activeEndPositions.length > 0) { + const items = activeEndPositions.map((p) => ({ + dataSource: p.dataSource, + symbol: p.symbol + })); + const quotes = await deps.dataProviderService.getQuotes({ items }); + + for (const pos of activeEndPositions) { + const quote = quotes[pos.symbol]; + const currentPrice = quote?.marketPrice ?? 0; + const posValue = pos.quantity * currentPrice; + endValue += posValue; + + holdingPerformance.push({ + symbol: pos.symbol, + name: pos.name, + quantity: pos.quantity, + currentPrice: +currentPrice.toFixed(2), + currentValue: +posValue.toFixed(2) + }); + } + } + + // Period gain = endValue - startValue - netCashFlow + const periodGain = endValue - startValue - netCashFlow; + const denominator = startValue > 0 ? startValue : Math.abs(netCashFlow); + const periodGainPercentage = + denominator > 0 ? (periodGain / denominator) * 100 : 0; + + const result: Record = { + dateRange, + periodStart: startDate.toISOString().split("T")[0], + periodEnd: endDate.toISOString().split("T")[0], + performance: { + startValue: +startValue.toFixed(2), + endValue: +endValue.toFixed(2), + netCashFlow: +netCashFlow.toFixed(2), + periodGain: +periodGain.toFixed(2), + periodGainPercentage: `${periodGainPercentage.toFixed(2)}%` + }, + holdings: holdingPerformance + }; + + if (warnings.length > 0) { + result.warnings = warnings; + } + + return result; }