Browse Source

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 <noreply@anthropic.com>
pull/6456/head
Alan Garber 1 month ago
parent
commit
563f7a9857
  1. 91
      apps/api/src/app/endpoints/ai/tools/portfolio-performance.tool.ts

91
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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { subDays } from 'date-fns';
import { tool } from 'ai'; import { tool } from 'ai';
import { z } from 'zod'; import { z } from 'zod';
@ -58,30 +59,7 @@ export function getPortfolioPerformanceTool(deps: {
}); });
} }
function computeAllTimePerformance( async 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 }, deps: { dataProviderService: DataProviderService },
orders: { orders: {
type: string; type: string;
@ -224,7 +202,6 @@ async function computePeriodPerformance(
dateRange: "1d" | "mtd" | "wtd" | "ytd" | "1y" | "5y" dateRange: "1d" | "mtd" | "wtd" | "ytd" | "1y" | "5y"
) { ) {
const { startDate, endDate } = getIntervalFromDateRange(dateRange); const { startDate, endDate } = getIntervalFromDateRange(dateRange);
const warnings: string[] = [];
// Partition orders into before-period and during-period // Partition orders into before-period and during-period
const ordersBeforePeriod = orders.filter( const ordersBeforePeriod = orders.filter(
@ -237,7 +214,13 @@ async function computePeriodPerformance(
// Build positions at start of period from orders before period // Build positions at start of period from orders before period
const startPositions: Record< const startPositions: Record<
string, 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) { 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( const activeStartPositions = Object.values(startPositions).filter(
(p) => p.quantity > 0 (p) => p.quantity > 0
); );
for (const pos of activeStartPositions) { // Fetch historical prices from the data provider (Yahoo Finance) for the
const historicalPrice = await deps.prismaService.marketData.findFirst({ // period start date. Use a 7-day window to account for weekends/holidays.
where: { const startPriceMap: Record<string, number> = {};
symbol: pos.symbol,
dataSource: pos.dataSource, if (activeStartPositions.length > 0) {
date: { lte: startDate } const items = activeStartPositions.map((p) => ({
}, dataSource: p.dataSource,
orderBy: { date: "desc" }, symbol: p.symbol
select: { marketPrice: true } }));
const historicalData = await deps.dataProviderService.getHistoricalRaw({
assetProfileIdentifiers: items,
from: subDays(startDate, 7),
to: startDate
}); });
if (historicalPrice) { for (const pos of activeStartPositions) {
startValue += const symbolData = historicalData[pos.symbol];
pos.quantity * (historicalPrice.marketPrice as unknown as number); 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 { } else {
// Fall back to cost basis
startValue += pos.costBasis; startValue += pos.costBasis;
warnings.push( warnings.push(
`No historical price found for ${pos.symbol} at period start; using cost basis as fallback.` `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 } { quantity: number; symbol: string; dataSource: any; name: string }
> = {}; > = {};
// Start with positions from before the period
for (const pos of activeStartPositions) { for (const pos of activeStartPositions) {
endPositions[pos.symbol] = { endPositions[pos.symbol] = {
quantity: pos.quantity, 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) { for (const order of ordersDuringPeriod) {
const profile = profileMap[order.symbolProfileId]; const profile = profileMap[order.symbolProfileId];
@ -377,7 +381,6 @@ async function computePeriodPerformance(
} }
} }
// Period gain = endValue - startValue - netCashFlow
const periodGain = endValue - startValue - netCashFlow; const periodGain = endValue - startValue - netCashFlow;
const denominator = startValue > 0 ? startValue : Math.abs(netCashFlow); const denominator = startValue > 0 ? startValue : Math.abs(netCashFlow);
const periodGainPercentage = const periodGainPercentage =

Loading…
Cancel
Save