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 { 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<string, number> = {};
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 =

Loading…
Cancel
Save