From 563f7a98577a44dccc99eaba5bdcf7cb1e5a50d2 Mon Sep 17 00:00:00 2001 From: Alan Garber Date: Tue, 24 Feb 2026 18:49:21 -0500 Subject: [PATCH] Fix period performance using getHistoricalRaw for start prices The period performance tool was falling back to cost basis when the local MarketData table lacked historical prices at the period start, making YTD numbers identical to all-time. Now uses DataProviderService.getHistoricalRaw() to fetch prices directly from Yahoo Finance with a 7-day lookback window, ensuring accurate start-of-period valuations regardless of local DB state. Co-Authored-By: Claude Opus 4.6 --- .../ai/tools/portfolio-performance.tool.ts | 91 ++++++++++--------- 1 file changed, 47 insertions(+), 44 deletions(-) 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 9d91e695c..c7abf1c14 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 @@ -2,6 +2,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; +import { subDays } from 'date-fns'; import { tool } from 'ai'; import { z } from 'zod'; @@ -58,30 +59,7 @@ export function getPortfolioPerformanceTool(deps: { }); } -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( +async function computeAllTimePerformance( deps: { dataProviderService: DataProviderService }, orders: { type: string; @@ -224,7 +202,6 @@ async function computePeriodPerformance( 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( @@ -237,7 +214,13 @@ async function computePeriodPerformance( // Build positions at start of period from orders before period const startPositions: Record< string, - { quantity: number; costBasis: number; symbol: string; dataSource: any; name: string } + { + quantity: number; + costBasis: number; + symbol: string; + dataSource: any; + name: string; + } > = {}; for (const order of ordersBeforePeriod) { @@ -262,28 +245,50 @@ async function computePeriodPerformance( } } - // 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 } + // Fetch historical prices from the data provider (Yahoo Finance) for the + // period start date. Use a 7-day window to account for weekends/holidays. + const startPriceMap: Record = {}; + + if (activeStartPositions.length > 0) { + const items = activeStartPositions.map((p) => ({ + dataSource: p.dataSource, + symbol: p.symbol + })); + + const historicalData = await deps.dataProviderService.getHistoricalRaw({ + assetProfileIdentifiers: items, + from: subDays(startDate, 7), + to: startDate }); - if (historicalPrice) { - startValue += - pos.quantity * (historicalPrice.marketPrice as unknown as number); + for (const pos of activeStartPositions) { + const symbolData = historicalData[pos.symbol]; + if (symbolData) { + // Get the most recent price in the window (closest to startDate) + const dates = Object.keys(symbolData).sort(); + const latestDate = dates[dates.length - 1]; + if (latestDate && symbolData[latestDate]?.marketPrice != null) { + startPriceMap[pos.symbol] = Number( + symbolData[latestDate].marketPrice + ); + } + } + } + } + + // Compute startValue using historical prices, with cost-basis fallback + let startValue = 0; + const warnings: string[] = []; + + for (const pos of activeStartPositions) { + const historicalPrice = startPriceMap[pos.symbol]; + if (historicalPrice != null) { + startValue += pos.quantity * historicalPrice; } 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.` @@ -297,7 +302,6 @@ async function computePeriodPerformance( { 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, @@ -307,7 +311,7 @@ async function computePeriodPerformance( }; } - let netCashFlow = 0; // positive = money added (buys), negative = money removed (sells) + let netCashFlow = 0; for (const order of ordersDuringPeriod) { const profile = profileMap[order.symbolProfileId]; @@ -377,7 +381,6 @@ async function computePeriodPerformance( } } - // Period gain = endValue - startValue - netCashFlow const periodGain = endValue - startValue - netCashFlow; const denominator = startValue > 0 ? startValue : Math.abs(netCashFlow); const periodGainPercentage =