Browse Source

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

1
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.'

313
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,20 +30,86 @@ 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 }
select: {
id: true,
symbol: true,
dataSource: true,
name: true,
currency: true
}
});
const profileMap = Object.fromEntries(profiles.map((p) => [p.id, p]));
if (dateRange === "max") {
return computeAllTimePerformance(deps, orders, profileMap);
}
return computePeriodPerformance(deps, orders, profileMap, dateRange);
}
});
const profileMap = Object.fromEntries(profiles.map(p => [p.id, p]));
}
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);
}
// Compute cost basis from BUY orders and subtract SELL proceeds
const positionMap: Record<string, { quantity: number; totalCost: number; symbol: string; dataSource: any; name: string }> = {};
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;
}
> = {};
for (const order of orders) {
const profile = profileMap[order.symbolProfileId];
@ -49,17 +124,18 @@ export function getPortfolioPerformanceTool(deps: {
name: profile.name
};
}
if (order.type === 'BUY') {
if (order.type === "BUY") {
positionMap[sym].quantity += order.quantity;
positionMap[sym].totalCost += order.quantity * order.unitPrice;
} else if (order.type === 'SELL') {
} else if (order.type === "SELL") {
positionMap[sym].quantity -= order.quantity;
positionMap[sym].totalCost -= order.quantity * order.unitPrice;
}
}
// Filter to positions with quantity > 0
const activePositions = Object.values(positionMap).filter(p => p.quantity > 0);
const activePositions = Object.values(positionMap).filter(
(p) => p.quantity > 0
);
if (activePositions.length === 0) {
return {
@ -67,14 +143,13 @@ export function getPortfolioPerformanceTool(deps: {
currentNetWorth: 0,
totalInvestment: 0,
netPerformance: 0,
netPerformancePercentage: '0.00%'
netPerformancePercentage: "0.00%"
},
holdings: []
};
}
// Get current market prices
const items = activePositions.map(p => ({
const items = activePositions.map((p) => ({
dataSource: p.dataSource,
symbol: p.symbol
}));
@ -110,9 +185,8 @@ export function getPortfolioPerformanceTool(deps: {
}
const netPerformance = currentValue - totalInvestment;
const netPerformancePercentage = totalInvestment > 0
? (netPerformance / totalInvestment) * 100
: 0;
const netPerformancePercentage =
totalInvestment > 0 ? (netPerformance / totalInvestment) * 100 : 0;
return {
performance: {
@ -123,6 +197,209 @@ export function getPortfolioPerformanceTool(deps: {
},
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<string, any> = {
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;
}

Loading…
Cancel
Save