From 9feacd6ad484cc0c7e220795df8f535d4b309315 Mon Sep 17 00:00:00 2001 From: piggei Date: Tue, 13 Oct 2026 18:32:45 +0200 Subject: [PATCH] feat(portfolio): add PnL calendar to home overview --- .../portfolio-calculator.calendar.spec.ts | 206 +++++++++ .../get-calendar-performance.dto.spec.ts | 38 ++ .../portfolio/get-calendar-performance.dto.ts | 25 ++ .../portfolio-calendar.controller.spec.ts | 102 +++++ .../portfolio-calendar.integration.spec.ts | 148 +++++++ .../src/app/portfolio/portfolio.controller.ts | 28 ++ .../src/app/portfolio/portfolio.service.ts | 146 ++++++- .../home-overview/home-overview.component.ts | 30 ++ .../home-overview/home-overview.html | 12 +- .../home-overview/home-overview.scss | 9 + .../portfolio-pnl-calendar.component.html | 171 ++++++++ .../portfolio-pnl-calendar.component.scss | 129 ++++++ .../portfolio-pnl-calendar.component.ts | 352 +++++++++++++++ .../portfolio-pnl-calendar.helper.spec.ts | 412 ++++++++++++++++++ .../portfolio-pnl-calendar.helper.ts | 220 ++++++++++ libs/ui/src/lib/services/data.service.ts | 11 + 16 files changed, 2037 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.calendar.spec.ts create mode 100644 apps/api/src/app/portfolio/get-calendar-performance.dto.spec.ts create mode 100644 apps/api/src/app/portfolio/get-calendar-performance.dto.ts create mode 100644 apps/api/src/app/portfolio/portfolio-calendar.controller.spec.ts create mode 100644 apps/api/src/app/portfolio/portfolio-calendar.integration.spec.ts create mode 100644 apps/client/src/app/components/portfolio-pnl-calendar/portfolio-pnl-calendar.component.html create mode 100644 apps/client/src/app/components/portfolio-pnl-calendar/portfolio-pnl-calendar.component.scss create mode 100644 apps/client/src/app/components/portfolio-pnl-calendar/portfolio-pnl-calendar.component.ts create mode 100644 apps/client/src/app/components/portfolio-pnl-calendar/portfolio-pnl-calendar.helper.spec.ts create mode 100644 apps/client/src/app/components/portfolio-pnl-calendar/portfolio-pnl-calendar.helper.ts diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.calendar.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.calendar.spec.ts new file mode 100644 index 0000000000..211eb975fe --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.calendar.spec.ts @@ -0,0 +1,206 @@ +import { HistoricalDataItem } from '@ghostfolio/common/interfaces'; + +import { RoaiPortfolioCalculator } from './portfolio-calculator'; + +describe('ROAI period return for PnL calendar', () => { + const calculate = (historicalDataItems: HistoricalDataItem[]) => { + // Use the real ROAI implementation without initializing market-data services. + const calculator = Object.create( + RoaiPortfolioCalculator.prototype + ) as RoaiPortfolioCalculator; + const method = calculator as unknown as { + calculatePerformancePercentagesForDateRange: (args: { + historicalDataItems: HistoricalDataItem[]; + }) => Record< + string, + { netPerformanceInPercentageWithCurrencyEffect: number } + >; + }; + return method.calculatePerformancePercentagesForDateRange({ + historicalDataItems + }); + }; + + it('returns 10 percent with no contributions', () => { + const result = calculate([ + { + date: '2026-01-01', + value: 1000, + valueWithCurrencyEffect: 1000, + totalInvestment: 1000, + totalInvestmentValueWithCurrencyEffect: 1000, + netPerformance: 0, + netPerformanceWithCurrencyEffect: 0, + dividendInBaseCurrency: 0 + }, + { + date: '2026-01-02', + value: 1100, + valueWithCurrencyEffect: 1100, + totalInvestment: 1000, + totalInvestmentValueWithCurrencyEffect: 1000, + netPerformance: 100, + netPerformanceWithCurrencyEffect: 100, + dividendInBaseCurrency: 0 + } + ]); + expect( + result['2026-01-02'].netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.1); + }); + + it('accounts for a contribution in the ROAI denominator', () => { + const result = calculate([ + { + date: '2026-01-01', + value: 1000, + valueWithCurrencyEffect: 1000, + totalInvestment: 1000, + totalInvestmentValueWithCurrencyEffect: 1000, + netPerformance: 0, + netPerformanceWithCurrencyEffect: 0, + dividendInBaseCurrency: 0 + }, + { + date: '2026-01-02', + value: 2100, + valueWithCurrencyEffect: 2100, + totalInvestment: 2000, + totalInvestmentValueWithCurrencyEffect: 2000, + netPerformance: 100, + netPerformanceWithCurrencyEffect: 100, + dividendInBaseCurrency: 0 + } + ]); + // 100 / average(1000, 2000) = 6.666...% rather than 10%. + expect( + result['2026-01-02'].netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(1 / 15); + }); + + it('accounts for a withdrawal in the ROAI denominator', () => { + const result = calculate([ + { + date: '2026-01-01', + value: 1000, + valueWithCurrencyEffect: 1000, + totalInvestment: 1000, + totalInvestmentValueWithCurrencyEffect: 1000, + netPerformance: 0, + netPerformanceWithCurrencyEffect: 0, + dividendInBaseCurrency: 0 + }, + { + date: '2026-01-02', + value: 550, + valueWithCurrencyEffect: 550, + totalInvestment: 500, + totalInvestmentValueWithCurrencyEffect: 500, + netPerformance: 50, + netPerformanceWithCurrencyEffect: 50, + dividendInBaseCurrency: 0 + } + ]); + // 50 / average(1000, 500) = 6.666...% rather than 5%. + expect( + result['2026-01-02'].netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(1 / 15); + }); + + it('calculates a negative return without external flows', () => { + const result = calculate([ + { + date: '2026-03-01', + value: 1000, + valueWithCurrencyEffect: 1000, + totalInvestment: 1000, + totalInvestmentValueWithCurrencyEffect: 1000, + netPerformance: 0, + netPerformanceWithCurrencyEffect: 0, + dividendInBaseCurrency: 0 + }, + { + date: '2026-03-02', + value: 900, + valueWithCurrencyEffect: 900, + totalInvestment: 1000, + totalInvestmentValueWithCurrencyEffect: 1000, + netPerformance: -100, + netPerformanceWithCurrencyEffect: -100, + dividendInBaseCurrency: 0 + } + ]); + expect( + result['2026-03-02'].netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(-0.1); + }); + + it('computes a multi-day period from the complete ROAI investment history', () => { + const result = calculate([ + { + date: '2026-01-31', + value: 1000, + valueWithCurrencyEffect: 1000, + totalInvestment: 1000, + totalInvestmentValueWithCurrencyEffect: 1000, + netPerformance: 0, + netPerformanceWithCurrencyEffect: 0, + dividendInBaseCurrency: 0 + }, + { + date: '2026-02-01', + value: 1100, + valueWithCurrencyEffect: 1100, + totalInvestment: 1000, + totalInvestmentValueWithCurrencyEffect: 1000, + netPerformance: 100, + netPerformanceWithCurrencyEffect: 100, + dividendInBaseCurrency: 0 + }, + { + date: '2026-02-02', + value: 2200, + valueWithCurrencyEffect: 2200, + totalInvestment: 2000, + totalInvestmentValueWithCurrencyEffect: 2000, + netPerformance: 200, + netPerformanceWithCurrencyEffect: 200, + dividendInBaseCurrency: 0 + } + ]); + // Average capital = (1000 + 1000 + 2000) / 3; performance = 200. + expect( + result['2026-02-02'].netPerformanceInPercentageWithCurrencyEffect + ).toBeCloseTo(0.15); + }); + + it('does not produce a non-finite percentage for zero capital', () => { + const result = calculate([ + { + date: '2026-04-01', + value: 0, + valueWithCurrencyEffect: 0, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0, + netPerformance: 0, + netPerformanceWithCurrencyEffect: 0, + dividendInBaseCurrency: 0 + }, + { + date: '2026-04-02', + value: 0, + valueWithCurrencyEffect: 0, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0, + netPerformance: 0, + netPerformanceWithCurrencyEffect: 0, + dividendInBaseCurrency: 0 + } + ]); + expect( + Number.isFinite( + result['2026-04-02'].netPerformanceInPercentageWithCurrencyEffect + ) + ).toBe(true); + }); +}); diff --git a/apps/api/src/app/portfolio/get-calendar-performance.dto.spec.ts b/apps/api/src/app/portfolio/get-calendar-performance.dto.spec.ts new file mode 100644 index 0000000000..e4c7639b23 --- /dev/null +++ b/apps/api/src/app/portfolio/get-calendar-performance.dto.spec.ts @@ -0,0 +1,38 @@ +import { plainToInstance } from 'class-transformer'; +import { validateSync } from 'class-validator'; +import 'reflect-metadata'; + +import { GetCalendarPerformanceDto } from './get-calendar-performance.dto'; + +describe('Calendar performance request validation', () => { + const validate = (periods: unknown) => + validateSync(plainToInstance(GetCalendarPerformanceDto, { periods })); + + it('accepts periods across month and year boundaries', () => { + expect( + validate([ + { startDate: '2025-12-29', endDate: '2026-01-04' }, + { startDate: '2026-02-01', endDate: '2026-02-28' } + ]) + ).toHaveLength(0); + }); + + it('rejects a missing or empty batch', () => { + expect(validate(undefined).length).toBeGreaterThan(0); + expect(validate([]).length).toBeGreaterThan(0); + }); + + it('rejects more than 55 periods', () => { + const period = { startDate: '2026-01-01', endDate: '2026-01-02' }; + expect( + validate(Array.from({ length: 56 }, () => period)).length + ).toBeGreaterThan(0); + }); + + it('rejects malformed dates and nested objects', () => { + expect( + validate([{ startDate: 'invalid', endDate: '2026-01-02' }]).length + ).toBeGreaterThan(0); + expect(validate([null]).length).toBeGreaterThan(0); + }); +}); diff --git a/apps/api/src/app/portfolio/get-calendar-performance.dto.ts b/apps/api/src/app/portfolio/get-calendar-performance.dto.ts new file mode 100644 index 0000000000..309c91f094 --- /dev/null +++ b/apps/api/src/app/portfolio/get-calendar-performance.dto.ts @@ -0,0 +1,25 @@ +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + Matches, + ValidateNested +} from 'class-validator'; + +export class CalendarPerformancePeriodDto { + @Matches(/^\d{4}-\d{2}-\d{2}$/) + startDate: string; + + @Matches(/^\d{4}-\d{2}-\d{2}$/) + endDate: string; +} + +export class GetCalendarPerformanceDto { + @ArrayMaxSize(55) + @ArrayMinSize(1) + @IsArray() + @Type(() => CalendarPerformancePeriodDto) + @ValidateNested({ each: true }) + periods: CalendarPerformancePeriodDto[]; +} diff --git a/apps/api/src/app/portfolio/portfolio-calendar.controller.spec.ts b/apps/api/src/app/portfolio/portfolio-calendar.controller.spec.ts new file mode 100644 index 0000000000..28c9a13f20 --- /dev/null +++ b/apps/api/src/app/portfolio/portfolio-calendar.controller.spec.ts @@ -0,0 +1,102 @@ +import { isRestrictedView } from '@ghostfolio/common/permissions'; +import { scopes } from '@ghostfolio/common/scopes'; + +import 'reflect-metadata'; + +import { PortfolioController } from './portfolio.controller'; + +// Keep the actual permission helpers, but control restricted-view policy +// explicitly in this controller unit test. +jest.mock('@ghostfolio/common/permissions', () => ({ + ...jest.requireActual('@ghostfolio/common/permissions'), + isRestrictedView: jest.fn(() => false) +})); + +describe('Portfolio calendar controller access restrictions', () => { + const periods = [{ startDate: '2026-01-02', endDate: '2026-01-03' }]; + const getCalendarPerformancePercentages = jest.fn(async () => [ + { ...periods[0], percentage: 3.25 } + ]); + + const setup = (viewMode: string) => + Object.assign(Object.create(PortfolioController.prototype), { + request: { + user: { + settings: { settings: { viewMode } } + } + }, + portfolioService: { getCalendarPerformancePercentages } + }) as PortfolioController; + + beforeEach(() => { + getCalendarPerformancePercentages.mockClear(); + jest.mocked(isRestrictedView).mockReturnValue(false); + }); + + it('does not disclose percentages without portfolio value scope', async () => { + const controller = setup('DEFAULT'); + expect( + await controller.getCalendarPerformance( + { + isActive: false, + scopes: [], + userId: 'synthetic-user', + userSettings: {} + }, + { periods } + ) + ).toEqual(periods); + expect(getCalendarPerformancePercentages).not.toHaveBeenCalled(); + }); + + it('does not disclose percentages in Zen Mode', async () => { + const controller = setup('ZEN'); + expect( + await controller.getCalendarPerformance( + { + isActive: false, + scopes: [scopes.portfolioReadValues], + userId: 'synthetic-user', + userSettings: {} + }, + { periods } + ) + ).toEqual(periods); + expect(getCalendarPerformancePercentages).not.toHaveBeenCalled(); + }); + it('does not disclose percentages in a restricted view despite value scope', async () => { + jest.mocked(isRestrictedView).mockReturnValue(true); + const controller = setup('DEFAULT'); + expect( + await controller.getCalendarPerformance( + { + isActive: false, + scopes: [scopes.portfolioReadValues], + userId: 'synthetic-user', + userSettings: {} + }, + { periods } + ) + ).toEqual(periods); + expect(getCalendarPerformancePercentages).not.toHaveBeenCalled(); + }); + + it('delegates to the service when reading values is permitted', async () => { + const controller = setup('DEFAULT'); + expect( + await controller.getCalendarPerformance( + { + isActive: false, + scopes: [scopes.portfolioReadValues], + userId: 'synthetic-user', + userSettings: {} + }, + { periods } + ) + ).toEqual([{ ...periods[0], percentage: 3.25 }]); + expect(getCalendarPerformancePercentages).toHaveBeenCalledWith({ + periods, + userId: 'synthetic-user' + }); + }); +}); diff --git a/apps/api/src/app/portfolio/portfolio-calendar.integration.spec.ts b/apps/api/src/app/portfolio/portfolio-calendar.integration.spec.ts new file mode 100644 index 0000000000..d6cbe92953 --- /dev/null +++ b/apps/api/src/app/portfolio/portfolio-calendar.integration.spec.ts @@ -0,0 +1,148 @@ +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { BadRequestException } from '@nestjs/common'; + +import { PortfolioService } from './portfolio.service'; + +describe('Portfolio calendar performance service integration', () => { + const userId = 'synthetic-user'; + const period = (startDate: string, endDate = startDate) => ({ + startDate, + endDate + }); + + const setup = ({ + dates = ['2026-01-01', '2026-01-02', '2026-01-03'], + method = PerformanceCalculationType.ROAI, + hasErrors = false + }: { + dates?: string[]; + method?: PerformanceCalculationType; + hasErrors?: boolean; + } = {}) => { + const getPerformance = jest.fn( + async ({ start, end }: { start: Date; end: Date }) => ({ + chart: [ + { date: start.toISOString().slice(0, 10) }, + { + date: end.toISOString().slice(0, 10), + netPerformanceInPercentageWithCurrencyEffect: 0.125 + } + ] + }) + ); + const getSnapshot = jest.fn(async () => ({ + hasErrors, + historicalData: dates.map((date) => ({ date })) + })); + const createCalculator = jest.fn(() => ({ getPerformance, getSnapshot })); + const getAccountBalanceItems = jest.fn(async () => []); + const getActivitiesForPortfolioCalculator = jest.fn(async () => ({ + activities: [{ type: 'BUY' }] + })); + const getUser = jest.fn(async () => ({ + settings: { + settings: { baseCurrency: 'EUR', performanceCalculationType: method } + } + })); + // Exercise the actual service implementation with synthetic dependency results. + // No database, market-data provider, HTTP session or private portfolio is needed. + const service = Object.assign(Object.create(PortfolioService.prototype), { + accountBalanceService: { getAccountBalanceItems }, + activitiesService: { getActivitiesForPortfolioCalculator }, + calculatorFactory: { createCalculator }, + userService: { user: getUser } + }) as PortfolioService; + + // The configured methodology and currency are normally resolved from user settings. + Object.assign(service, { + getUserPerformanceCalculationType: () => method, + getUserCurrency: () => 'EUR' + }); + + return { + service, + createCalculator, + getPerformance, + getSnapshot, + getAccountBalanceItems, + getActivitiesForPortfolioCalculator + }; + }; + + it('reuses one calculator and calculates each period from the previous calendar day', async () => { + const { service, createCalculator, getPerformance, getSnapshot } = setup(); + const results = await service.getCalendarPerformancePercentages({ + userId, + periods: [period('2026-01-02'), period('2026-01-03')] + }); + + expect(results).toEqual([ + { ...period('2026-01-02'), percentage: 12.5 }, + { ...period('2026-01-03'), percentage: 12.5 } + ]); + expect(createCalculator).toHaveBeenCalledTimes(1); + expect(getSnapshot).toHaveBeenCalledTimes(1); + expect(getPerformance).toHaveBeenCalledTimes(2); + expect( + getPerformance.mock.calls.map(([dates]) => [ + dates.start.toISOString().slice(0, 10), + dates.end.toISOString().slice(0, 10) + ]) + ).toEqual([ + ['2026-01-01', '2026-01-02'], + ['2026-01-02', '2026-01-03'] + ]); + }); + + it('does not calculate a percentage if the baseline or endpoint is missing', async () => { + const { service, getPerformance } = setup({ dates: ['2026-01-02'] }); + expect( + await service.getCalendarPerformancePercentages({ + userId, + periods: [period('2026-01-02'), period('2026-01-03')] + }) + ).toEqual([period('2026-01-02'), period('2026-01-03')]); + expect(getPerformance).not.toHaveBeenCalled(); + }); + + it('does not return synthetic percentages when the snapshot contains errors', async () => { + const { service, getPerformance } = setup({ hasErrors: true }); + expect( + await service.getCalendarPerformancePercentages({ + userId, + periods: [period('2026-01-02')] + }) + ).toEqual([period('2026-01-02')]); + expect(getPerformance).not.toHaveBeenCalled(); + }); + + it('avoids initializing a calculator for an unsupported methodology', async () => { + const { service, createCalculator } = setup({ + method: PerformanceCalculationType.TWR + }); + expect( + await service.getCalendarPerformancePercentages({ + userId, + periods: [period('2026-01-02')] + }) + ).toEqual([period('2026-01-02')]); + expect(createCalculator).not.toHaveBeenCalled(); + }); + + it('rejects inverted, future, malformed, or excessive batches before fetching data', async () => { + const { service, getAccountBalanceItems } = setup(); + const invalid = [ + [period('2026-01-03', '2026-01-02')], + [period('2026-13-01')], + [period('2099-01-02')], + Array.from({ length: 56 }, () => period('2026-01-02')) + ]; + for (const periods of invalid) { + await expect( + service.getCalendarPerformancePercentages({ userId, periods }) + ).rejects.toBeInstanceOf(BadRequestException); + } + expect(getAccountBalanceItems).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 72468f1d6e..aafa0304d8 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -38,9 +38,12 @@ import { Body, Controller, Get, + HttpCode, HttpException, + HttpStatus, Inject, Param, + Post, Put, Query, UseInterceptors, @@ -51,6 +54,7 @@ import { DataSource } from '@prisma/client'; import { Big } from 'big.js'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; +import { GetCalendarPerformanceDto } from './get-calendar-performance.dto'; import { GetDetailsDto } from './get-details.dto'; import { GetDividendsDto } from './get-dividends.dto'; import { GetHoldingsDto } from './get-holdings.dto'; @@ -482,6 +486,30 @@ export class PortfolioController { return { investments, savingsRate, streaks }; } + @Post('performance/calendar') + @HttpCode(HttpStatus.OK) + @RequiresScope(scopes.portfolioRead) + @Version('2') + public async getCalendarPerformance( + @Impersonation() + { scopes: impersonationScopes, userId }: ImpersonationContext, + @Body() { periods }: GetCalendarPerformanceDto + ): Promise<{ startDate: string; endDate: string; percentage?: number }[]> { + // Do not disclose return information through the calendar in restricted views. + if ( + !hasScope(impersonationScopes, scopes.portfolioReadValues) || + isRestrictedView(this.request.user) || + this.request.user.settings.settings.viewMode === 'ZEN' + ) { + return periods.map(({ startDate, endDate }) => ({ startDate, endDate })); + } + + return this.portfolioService.getCalendarPerformancePercentages({ + periods, + userId + }); + } + @Get('performance') @RequiresScope(scopes.portfolioRead) @UseInterceptors(PerformanceLoggingInterceptor) diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index ebd05805f5..78282297b0 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -80,7 +80,12 @@ import { import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { utc } from '@date-fns/utc'; -import { Inject, Injectable, Logger } from '@nestjs/common'; +import { + BadRequestException, + Inject, + Injectable, + Logger +} from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { Type as ActivityType, @@ -110,6 +115,7 @@ import { groupBy, uniq } from 'lodash-es'; import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory'; import { CurrentRateService } from './current-rate.service'; +import { CalendarPerformancePeriodDto } from './get-calendar-performance.dto'; import { RulesService } from './rules.service'; const Fuse = require('fuse.js'); @@ -1196,6 +1202,144 @@ export class PortfolioService { }; } + /** Calculate period returns using the same calculator as portfolio/performance. + * Each interval includes the previous calendar day as its baseline. + * Unsupported methodologies and incomplete history yield no percentage. + */ + public async getCalendarPerformancePercentages({ + periods, + userId + }: { + periods: CalendarPerformancePeriodDto[]; + userId: string; + }): Promise<{ startDate: string; endDate: string; percentage?: number }[]> { + if ( + !Array.isArray(periods) || + periods.length === 0 || + periods.length > 55 + ) { + throw new BadRequestException('Invalid calendar periods'); + } + // Browser-local "today" may be one calendar day ahead of UTC near + // midnight. Allow that single-day rollover; the calculator still + // returns no result when the requested closing snapshot is absent. + const latestAllowedDate = new Date(Date.now() + 86400000) + .toISOString() + .slice(0, 10); + const parseKey = (key: string) => { + const date = new Date(`${key}T00:00:00.000Z`); + if ( + !Number.isFinite(date.getTime()) || + date.toISOString().slice(0, 10) !== key + ) { + throw new BadRequestException('Invalid calendar date'); + } + return date; + }; + + for (const { startDate, endDate } of periods) { + const start = parseKey(startDate); + const end = parseKey(endDate); + if ( + start > end || + (end.getTime() - start.getTime()) / 86400000 > 366 || + endDate > latestAllowedDate + ) { + throw new BadRequestException('Invalid calendar period'); + } + } + + const earliest = periods.reduce( + (date, period) => (period.startDate < date ? period.startDate : date), + periods[0].startDate + ); + const latest = periods.reduce( + (date, period) => (period.endDate > date ? period.endDate : date), + periods[0].endDate + ); + if ( + (parseKey(latest).getTime() - parseKey(earliest).getTime()) / 86400000 > + 366 + ) { + throw new BadRequestException('Calendar request exceeds one year'); + } + + const unavailable = () => + periods.map(({ startDate, endDate }) => ({ startDate, endDate })); + const user = await this.userService.user({ id: userId }); + // The other calculation classes in Ghostfolio 3.85.0 are placeholders. + if ( + this.getUserPerformanceCalculationType(user) !== + PerformanceCalculationType.ROAI + ) { + return unavailable(); + } + + const userCurrency = this.getUserCurrency(user); + const [accountBalanceItems, { activities }] = await Promise.all([ + this.accountBalanceService.getAccountBalanceItems({ + userId, + userCurrency + }), + this.activitiesService.getActivitiesForPortfolioCalculator({ + userId, + userCurrency + }) + ]); + if (accountBalanceItems.length === 0 && activities.length === 0) { + return unavailable(); + } + + const calculator = this.calculatorFactory.createCalculator({ + accountBalanceItems, + activities, + userId, + calculationType: PerformanceCalculationType.ROAI, + currency: userCurrency, + subscriptionType: user.subscription?.type + }); + const snapshot = await calculator.getSnapshot(); + if (snapshot.hasErrors) { + return unavailable(); + } + const availableDates = new Set( + snapshot.historicalData.map(({ date }) => date) + ); + const results: { + startDate: string; + endDate: string; + percentage?: number; + }[] = []; + + for (const { startDate, endDate } of periods) { + const baselineDate = new Date(`${startDate}T00:00:00.000Z`); + baselineDate.setUTCDate(baselineDate.getUTCDate() - 1); + const baselineKey = baselineDate.toISOString().slice(0, 10); + if (!availableDates.has(baselineKey) || !availableDates.has(endDate)) { + results.push({ startDate, endDate }); + continue; + } + + const { chart } = await calculator.getPerformance({ + start: parseKey(baselineKey), + end: parseKey(endDate) + }); + const last = chart.at(-1); + const value = last?.netPerformanceInPercentageWithCurrencyEffect; + if ( + chart.length >= 2 && + last?.date === endDate && + typeof value === 'number' && + Number.isFinite(value) + ) { + results.push({ startDate, endDate, percentage: value * 100 }); + } else { + results.push({ startDate, endDate }); + } + } + return results; + } + public async getPerformance({ dateRange = DEFAULT_DATE_RANGE, filters, diff --git a/apps/client/src/app/components/home-overview/home-overview.component.ts b/apps/client/src/app/components/home-overview/home-overview.component.ts index c7b880efce..57628dcca7 100644 --- a/apps/client/src/app/components/home-overview/home-overview.component.ts +++ b/apps/client/src/app/components/home-overview/home-overview.component.ts @@ -1,4 +1,5 @@ import { GfPortfolioPerformanceComponent } from '@ghostfolio/client/components/portfolio-performance/portfolio-performance.component'; +import { GfPortfolioPnlCalendarComponent } from '@ghostfolio/client/components/portfolio-pnl-calendar/portfolio-pnl-calendar.component'; import { LayoutService } from '@ghostfolio/client/core/layout.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; @@ -11,6 +12,7 @@ import { import { isCashPosition } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, + HistoricalDataItem, LineChartItem, PortfolioPerformance, PortfolioPosition, @@ -43,6 +45,7 @@ import { DeviceDetectorService } from 'ngx-device-detector'; imports: [ GfHoldingsTableComponent, GfLineChartComponent, + GfPortfolioPnlCalendarComponent, GfPortfolioPerformanceComponent, MatButtonModule, MatCardModule, @@ -56,11 +59,18 @@ export class GfHomeOverviewComponent implements OnInit { protected readonly errors = signal([]); protected readonly hasImpersonationId = signal(false); protected readonly historicalDataItems = signal(null); + protected readonly pnlCalendarChart = signal( + null + ); protected readonly holdings = signal( undefined ); protected readonly isLoadingPerformance = signal(true); + protected readonly baseCurrency = computed(() => { + return this.user()?.settings?.baseCurrency ?? DEFAULT_CURRENCY; + }); + protected readonly locale = computed(() => { return this.user()?.settings?.locale ?? DEFAULT_LOCALE; }); @@ -163,6 +173,7 @@ export class GfHomeOverviewComponent implements OnInit { const userSettings = this.user()?.settings; this.historicalDataItems.set(null); + this.pnlCalendarChart.set(null); this.isLoadingPerformance.set(true); if (this.showExperimentalHoldings()) { @@ -198,6 +209,11 @@ export class GfHomeOverviewComponent implements OnInit { this.errors.set(errors ?? []); this.performance.set(performance); + // Reuse the overview request whenever it already covers all history. + if ((userSettings?.dateRange ?? DEFAULT_DATE_RANGE) === 'max') { + this.pnlCalendarChart.set(chart ?? []); + } + this.historicalDataItems.set( chart?.map( ({ date, netPerformanceInPercentageWithCurrencyEffect }) => { @@ -221,5 +237,19 @@ export class GfHomeOverviewComponent implements OnInit { this.isLoadingPerformance.set(false); }); + + if ((userSettings?.dateRange ?? DEFAULT_DATE_RANGE) !== 'max') { + this.dataService + .fetchPortfolioPerformance({ range: 'max' }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: ({ chart }) => { + this.pnlCalendarChart.set(chart ?? []); + }, + error: () => { + this.pnlCalendarChart.set([]); + } + }); + } } } diff --git a/apps/client/src/app/components/home-overview/home-overview.html b/apps/client/src/app/components/home-overview/home-overview.html index f8e696595c..0112cfc617 100644 --- a/apps/client/src/app/components/home-overview/home-overview.html +++ b/apps/client/src/app/components/home-overview/home-overview.html @@ -1,5 +1,5 @@
@if ( !hasImpersonationId() && @@ -116,5 +116,15 @@
} + @if (user()?.settings?.viewMode !== 'ZEN') { +
+ +
+ } } diff --git a/apps/client/src/app/components/home-overview/home-overview.scss b/apps/client/src/app/components/home-overview/home-overview.scss index 6d0484296f..f80e8f9523 100644 --- a/apps/client/src/app/components/home-overview/home-overview.scss +++ b/apps/client/src/app/components/home-overview/home-overview.scss @@ -2,6 +2,10 @@ display: block; height: 100%; + .overview { + min-height: 100%; + } + .chart-container { aspect-ratio: 16 / 9; height: auto; @@ -44,6 +48,11 @@ } } + .pnl-calendar-container { + margin-top: 2rem; + max-width: 72rem; + } + .introduction { max-width: 50rem; } diff --git a/apps/client/src/app/components/portfolio-pnl-calendar/portfolio-pnl-calendar.component.html b/apps/client/src/app/components/portfolio-pnl-calendar/portfolio-pnl-calendar.component.html new file mode 100644 index 0000000000..529722c9bb --- /dev/null +++ b/apps/client/src/app/components/portfolio-pnl-calendar/portfolio-pnl-calendar.component.html @@ -0,0 +1,171 @@ + + + Profit and loss calendar + + +
+
+ + + + +
+ @if (showDetails()) { +
+ + +
+ } +
+
+ @if (view() !== 'last30') { + + } + {{ title() }} + @if (view() !== 'last30') { + + } +
+ @if (chart() === null) { +

Loading calendar…

+ } @else if (days().length === 0) { +

+ No historical performance data available. +

+ } @else if (view() === 'daily' || view() === 'last30') { +
+ @for (weekday of weekdays(); track $index) { +
{{ weekday }}
+ } + @for (cell of cells(); track cell.startDate) { +
+ {{ cell.label }} + {{ + cell.outsideMonth ? '—' : formatValue(getValue(cell)) + }} +
+ } +
+ } @else { +
+ @for (cell of cells(); track cell.startDate) { +
+ {{ cell.label }} + {{ formatValue(getValue(cell)) }} +
+ } +
+ } +
+ Period change + {{ formatValue(periodTotal()) }} +
+ @if (effectiveUnit() === 'percentage') { +

+ Period return calculated using Ghostfolio ROAI (return on average + investment). Unavailable percentages are shown as a dash. +

+ } +
+
diff --git a/apps/client/src/app/components/portfolio-pnl-calendar/portfolio-pnl-calendar.component.scss b/apps/client/src/app/components/portfolio-pnl-calendar/portfolio-pnl-calendar.component.scss new file mode 100644 index 0000000000..d029f30c2a --- /dev/null +++ b/apps/client/src/app/components/portfolio-pnl-calendar/portfolio-pnl-calendar.component.scss @@ -0,0 +1,129 @@ +:host { + display: block; + width: 100%; +} + +.pnl-calendar { + mat-card-content { + padding-top: 1rem; + } +} + +.calendar-toolbar, +.calendar-navigation, +.calendar-total { + align-items: center; + display: flex; + justify-content: space-between; +} + +.calendar-toolbar { + flex-wrap: wrap; + gap: 0.5rem; +} + +.calendar-tabs { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + + button[aria-pressed='true'] { + background-color: var(--mat-sys-secondary-container); + color: var(--mat-sys-on-secondary-container); + } +} + +.calendar-navigation { + gap: 1rem; + margin: 0.75rem 0; +} + +.calendar-grid { + display: grid; + gap: 0.3rem; +} + +.daily-grid { + grid-template-columns: repeat(7, minmax(0, 1fr)); +} + +.weekly-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.monthly-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.weekday { + font-size: 0.75rem; + text-align: center; +} + +.calendar-cell { + background: var(--mat-sys-surface-container-low); + border-radius: 0.4rem; + display: flex; + flex-direction: column; + gap: 0.25rem; + min-height: 4.25rem; + min-width: 0; + padding: 0.5rem; + text-align: center; + + &.positive { + background: rgba(29, 132, 75, 0.3); + } + + &.negative { + background: rgba(194, 45, 45, 0.29); + } + + &.outside-month { + opacity: 0.35; + } +} + +.cell-label { + font-size: 0.75rem; +} + +.cell-value { + font-size: 0.8rem; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} + +.calendar-total { + margin-top: 1rem; +} + +.calendar-status { + margin: 1.5rem 0; + text-align: center; +} + +.positive-text { + color: var(--mat-sys-primary); +} + +.negative-text { + color: var(--mat-sys-error); +} + +.calendar-note { + font-size: 0.75rem; + margin-bottom: 0; + opacity: 0.75; +} + +@media (max-width: 600px) { + .calendar-cell { + min-height: 3.5rem; + padding: 0.2rem; + } + + .cell-value { + font-size: 0.65rem; + } +} diff --git a/apps/client/src/app/components/portfolio-pnl-calendar/portfolio-pnl-calendar.component.ts b/apps/client/src/app/components/portfolio-pnl-calendar/portfolio-pnl-calendar.component.ts new file mode 100644 index 0000000000..38d7c82a62 --- /dev/null +++ b/apps/client/src/app/components/portfolio-pnl-calendar/portfolio-pnl-calendar.component.ts @@ -0,0 +1,352 @@ +import type { HistoricalDataItem } from '@ghostfolio/common/interfaces'; +import { DataService } from '@ghostfolio/ui/services'; + +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + signal +} from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { catchError, of, switchMap } from 'rxjs'; + +import { + addCalendarDays, + aggregatePnlCalendarDays, + aggregatePnlCalendarWeekInMonth, + getLocalTodayKey, + getMonday, + getPnlCalendarDays, + type PnlCalendarPeriod +} from './portfolio-pnl-calendar.helper'; + +type CalendarView = 'daily' | 'weekly' | 'monthly' | 'last30'; +type CalendarUnit = 'currency' | 'percentage'; + +interface CalendarCell extends PnlCalendarPeriod { + label: string; + outsideMonth?: boolean; +} + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatButtonModule, MatCardModule], + selector: 'gf-portfolio-pnl-calendar', + styleUrls: ['./portfolio-pnl-calendar.component.scss'], + templateUrl: './portfolio-pnl-calendar.component.html' +}) +export class GfPortfolioPnlCalendarComponent { + public readonly chart = input(null); + public readonly currency = input.required(); + public readonly locale = input.required(); + public readonly showDetails = input(false); + + protected readonly view = signal('daily'); + protected readonly unit = signal('currency'); + protected readonly referenceDate = signal(new Date()); + + protected readonly effectiveUnit = computed(() => { + return this.showDetails() ? this.unit() : 'percentage'; + }); + + protected readonly days = computed(() => { + return getPnlCalendarDays(this.chart() ?? []); + }); + protected readonly firstHistoricalDate = computed(() => { + return this.chart() + ?.map(({ date }) => date) + .filter((date) => /^\d{4}-\d{2}-\d{2}$/.test(date)) + .sort()[0]; + }); + protected readonly weekdays = computed(() => { + const formatter = new Intl.DateTimeFormat(this.locale(), { + weekday: 'short', + timeZone: 'UTC' + }); + + return Array.from({ length: 7 }, (_, index) => { + return formatter.format(new Date(Date.UTC(2024, 0, 1 + index))); + }); + }); + + protected readonly title = computed(() => { + const referenceDate = this.referenceDate(); + + if (this.view() === 'last30') { + const endDate = this.toDateKey( + referenceDate.getFullYear(), + referenceDate.getMonth(), + referenceDate.getDate() + ); + return `${this.formatShortDate(addCalendarDays(endDate, -29))} – ${this.formatShortDate(endDate)}`; + } + + return new Intl.DateTimeFormat(this.locale(), { + month: this.view() === 'monthly' ? undefined : 'long', + year: 'numeric' + }).format(referenceDate); + }); + + protected readonly cells = computed(() => { + const referenceDate = this.referenceDate(); + const year = referenceDate.getFullYear(); + const month = referenceDate.getMonth(); + const days = this.days(); + + if (this.view() === 'last30') { + const lastDate = this.toDateKey(year, month, referenceDate.getDate()); + const firstDate = addCalendarDays(lastDate, -29); + const rollingFirstMonday = getMonday(firstDate); + const rollingLastSunday = addCalendarDays(getMonday(lastDate), 6); + const numberOfDays = + Math.round( + (Date.parse(`${rollingLastSunday}T00:00:00Z`) - + Date.parse(`${rollingFirstMonday}T00:00:00Z`)) / + 86400000 + ) + 1; + + return Array.from({ length: numberOfDays }, (_, index) => { + const startDate = addCalendarDays(rollingFirstMonday, index); + return { + ...aggregatePnlCalendarDays({ days, endDate: startDate, startDate }), + label: `${Number(startDate.slice(8, 10))}`, + outsideMonth: startDate < firstDate || startDate > lastDate + }; + }); + } + + if (this.view() === 'monthly') { + return Array.from({ length: 12 }, (_, index) => { + const startDate = this.toDateKey(year, index, 1); + const endDate = addCalendarDays(this.toDateKey(year, index + 1, 1), -1); + + return { + ...aggregatePnlCalendarDays({ + days, + endDate, + startDate, + requireComplete: true, + firstHistoricalDate: this.firstHistoricalDate() + }), + label: new Intl.DateTimeFormat(this.locale(), { + month: 'short', + timeZone: 'UTC' + }).format(new Date(Date.UTC(year, index, 1))) + }; + }); + } + + const monthStart = this.toDateKey(year, month, 1); + const monthEnd = addCalendarDays(this.toDateKey(year, month + 1, 1), -1); + const firstMonday = getMonday(monthStart); + const lastSunday = addCalendarDays(getMonday(monthEnd), 6); + const weekCount = + ((Date.parse(`${lastSunday}T00:00:00Z`) - + Date.parse(`${firstMonday}T00:00:00Z`)) / + 86400000 + + 1) / + 7; + + if (this.view() === 'weekly') { + return Array.from({ length: weekCount }, (_, index) => { + const startDate = addCalendarDays(firstMonday, index * 7); + const endDate = addCalendarDays(startDate, 6); + + return { + ...aggregatePnlCalendarWeekInMonth({ + days, + monthEnd, + monthStart, + weekStart: startDate, + requireComplete: true, + firstHistoricalDate: this.firstHistoricalDate() + }), + label: `${this.formatShortDate(startDate)} – ${this.formatShortDate(endDate)}` + }; + }); + } + + return Array.from({ length: weekCount * 7 }, (_, index) => { + const startDate = addCalendarDays(firstMonday, index); + + return { + ...aggregatePnlCalendarDays({ + days, + endDate: startDate, + startDate + }), + label: `${Number(startDate.slice(8, 10))}`, + outsideMonth: startDate < monthStart || startDate > monthEnd + }; + }); + }); + + protected readonly periodTotal = computed(() => { + const date = this.referenceDate(); + const year = date.getFullYear(); + const month = date.getMonth(); + const isYear = this.view() === 'monthly'; + const isRolling = this.view() === 'last30'; + const endDate = isRolling + ? this.toDateKey(year, month, date.getDate()) + : addCalendarDays(this.toDateKey(year, isYear ? 12 : month + 1, 1), -1); + const startDate = isRolling + ? addCalendarDays(endDate, -29) + : this.toDateKey(year, isYear ? 0 : month, 1); + const period = aggregatePnlCalendarDays({ + days: this.days(), + endDate, + startDate, + requireComplete: true, + firstHistoricalDate: this.firstHistoricalDate() + }); + + return this.effectiveUnit() === 'currency' + ? period.amount + : this.getPeriodPercentage(startDate, endDate); + }); + + private readonly dataService = inject(DataService); + private readonly percentages = signal | null>(null); + private readonly percentageRequest = computed(() => { + if ( + this.chart() === null || + this.effectiveUnit() !== 'percentage' || + this.days().length === 0 + ) { + return null; + } + const date = this.referenceDate(); + const year = date.getFullYear(); + const month = date.getMonth(); + const rolling = this.view() === 'last30'; + const annual = this.view() === 'monthly'; + const endDate = rolling + ? this.toDateKey(year, month, date.getDate()) + : addCalendarDays(this.toDateKey(year, annual ? 12 : month + 1, 1), -1); + const startDate = rolling + ? addCalendarDays(endDate, -29) + : this.toDateKey(year, annual ? 0 : month, 1); + const today = getLocalTodayKey(); + const periods = this.cells() + .filter( + ({ outsideMonth, startDate: cellStartDate }) => + !outsideMonth && cellStartDate <= today + ) + .map(({ startDate: cellStartDate, endDate: cellEndDate }) => ({ + startDate: cellStartDate, + endDate: cellEndDate > today ? today : cellEndDate + })); + if (startDate <= today) { + periods.push({ startDate, endDate: endDate > today ? today : endDate }); + } + return periods; + }); + + public constructor() { + toObservable(this.percentageRequest) + .pipe( + switchMap((periods) => { + this.percentages.set(null); + return periods?.length + ? this.dataService + .fetchPortfolioCalendarPercentages(periods) + .pipe(catchError(() => of([]))) + : of([]); + }), + takeUntilDestroyed() + ) + .subscribe((results) => { + this.percentages.set( + new Map( + results.flatMap(({ startDate, endDate, percentage }) => + typeof percentage === 'number' && Number.isFinite(percentage) + ? [[`${startDate}|${endDate}`, percentage] as const] + : [] + ) + ) + ); + }); + } + + protected setView(view: CalendarView) { + if (view === 'last30') { + // Last 30 days always means the current rolling window, not a + // historical window inherited from monthly navigation. + this.referenceDate.set(new Date()); + } + this.view.set(view); + } + + protected setUnit(unit: CalendarUnit) { + this.unit.set(unit); + } + + protected navigate(direction: -1 | 1) { + if (this.view() === 'last30') { + return; + } + + const date = this.referenceDate(); + + this.referenceDate.set( + new Date( + date.getFullYear(), + date.getMonth() + (this.view() === 'monthly' ? 12 : 1) * direction, + 1 + ) + ); + } + + protected getValue(cell: PnlCalendarPeriod): number | undefined { + return this.effectiveUnit() === 'currency' + ? cell.amount + : this.getPeriodPercentage(cell.startDate, cell.endDate); + } + + protected formatValue(value: number | undefined): string { + if (value === undefined) { + return '—'; + } + + return ( + new Intl.NumberFormat(this.locale(), { + ...(this.effectiveUnit() === 'currency' + ? { currency: this.currency(), style: 'currency' as const } + : { style: 'decimal' as const }), + maximumFractionDigits: 2, + minimumFractionDigits: 2, + signDisplay: 'exceptZero' + }).format(value) + (this.effectiveUnit() === 'percentage' ? ' %' : '') + ); + } + + private getPeriodPercentage( + startDate: string, + endDate: string + ): number | undefined { + const today = getLocalTodayKey(); + if (startDate > today) { + return undefined; + } + return this.percentages()?.get( + `${startDate}|${endDate > today ? today : endDate}` + ); + } + + private formatShortDate(date: string): string { + return new Intl.DateTimeFormat(this.locale(), { + day: 'numeric', + month: 'short', + timeZone: 'UTC' + }).format(new Date(`${date}T00:00:00Z`)); + } + + private toDateKey(year: number, month: number, day: number): string { + return new Date(Date.UTC(year, month, day)).toISOString().slice(0, 10); + } +} diff --git a/apps/client/src/app/components/portfolio-pnl-calendar/portfolio-pnl-calendar.helper.spec.ts b/apps/client/src/app/components/portfolio-pnl-calendar/portfolio-pnl-calendar.helper.spec.ts new file mode 100644 index 0000000000..065e761e44 --- /dev/null +++ b/apps/client/src/app/components/portfolio-pnl-calendar/portfolio-pnl-calendar.helper.spec.ts @@ -0,0 +1,412 @@ +import { + addCalendarDays, + aggregatePnlCalendarDays, + aggregatePnlCalendarWeekInMonth, + getLocalTodayKey, + getMonday, + getPnlCalendarDays +} from './portfolio-pnl-calendar.helper'; + +describe('PnL calendar', () => { + it('calculates monetary PnL and changes in percentage points from cumulative values', () => { + const days = getPnlCalendarDays([ + { + date: '2026-01-01', + netPerformanceWithCurrencyEffect: 50, + netPerformanceInPercentageWithCurrencyEffect: 0.05 + }, + { + date: '2026-01-02', + netPerformanceWithCurrencyEffect: 65, + netPerformanceInPercentageWithCurrencyEffect: 0.055 + }, + { + date: '2026-01-03', + netPerformanceWithCurrencyEffect: 60, + netPerformanceInPercentageWithCurrencyEffect: 0.04 + } + ]); + + expect(days[0].amount).toBe(15); + expect(days[0].percentagePoints).toBeCloseTo(0.5); + expect(days[1].amount).toBe(-5); + expect(days[1].percentagePoints).toBeCloseTo(-1.5); + }); + + it('does not confuse capital deposits with profit', () => { + expect( + getPnlCalendarDays([ + { + date: '2026-03-10', + netPerformanceWithCurrencyEffect: 25, + valueWithCurrencyEffect: 1000 + }, + { + date: '2026-03-11', + netPerformanceWithCurrencyEffect: 25, + valueWithCurrencyEffect: 11000 + } + ])[0].amount + ).toBe(0); + }); + + it('does not invent values when performance fields are absent', () => { + expect( + getPnlCalendarDays([ + { date: '2026-01-01' }, + { date: '2026-01-02', netPerformanceWithCurrencyEffect: 10 }, + { + date: '2026-01-03', + netPerformanceWithCurrencyEffect: 15, + netPerformanceInPercentageWithCurrencyEffect: 0.02 + } + ]) + ).toEqual([{ amount: 5, date: '2026-01-03', percentagePoints: undefined }]); + }); + + it('aggregates only observed days and distinguishes missing from zero', () => { + const days = [ + { amount: 10, date: '2026-01-02', percentagePoints: 0.5 }, + { amount: -10, date: '2026-01-05', percentagePoints: -0.25 } + ]; + + expect( + aggregatePnlCalendarDays({ + days, + startDate: '2026-01-01', + endDate: '2026-01-04' + }) + ).toEqual({ + amount: 10, + startDate: '2026-01-01', + endDate: '2026-01-04', + percentagePoints: 0.5 + }); + + expect( + aggregatePnlCalendarDays({ + days, + startDate: '2026-01-02', + endDate: '2026-01-05' + }) + ).toEqual({ + amount: 0, + startDate: '2026-01-02', + endDate: '2026-01-05', + percentagePoints: 0.25 + }); + + expect( + aggregatePnlCalendarDays({ + days, + startDate: '2026-02-01', + endDate: '2026-02-28' + }).amount + ).toBeUndefined(); + }); + + it('clips weeks crossing month boundaries without changing the month total', () => { + const days = [ + { date: '2026-09-30', amount: 200, percentagePoints: 2 }, + { date: '2026-10-01', amount: 10, percentagePoints: 0.1 }, + { date: '2026-10-04', amount: -2, percentagePoints: -0.02 }, + { date: '2026-10-05', amount: 3, percentagePoints: 0.03 }, + { date: '2026-10-31', amount: 5, percentagePoints: 0.05 }, + { date: '2026-11-01', amount: 100, percentagePoints: 1 } + ]; + const weeks = Array.from({ length: 5 }, (_, index) => + aggregatePnlCalendarWeekInMonth({ + days, + monthStart: '2026-10-01', + monthEnd: '2026-10-31', + weekStart: addCalendarDays('2026-09-28', index * 7) + }) + ); + const october = aggregatePnlCalendarDays({ + days, + startDate: '2026-10-01', + endDate: '2026-10-31' + }); + + expect(weeks[0]).toEqual({ + amount: 8, + percentagePoints: 0.08, + startDate: '2026-10-01', + endDate: '2026-10-04' + }); + expect(weeks[4].amount).toBe(5); + expect(weeks.reduce((sum, week) => sum + (week.amount ?? 0), 0)).toBe( + october.amount + ); + }); + + it('never approximates a financial return from the opening portfolio value', () => { + const days = getPnlCalendarDays([ + { + date: '2026-01-01', + netPerformanceWithCurrencyEffect: 100, + valueWithCurrencyEffect: 1000 + }, + { + date: '2026-01-02', + netPerformanceWithCurrencyEffect: 120, + valueWithCurrencyEffect: 1200 + }, + { + date: '2026-01-03', + netPerformanceWithCurrencyEffect: 90, + valueWithCurrencyEffect: 1150 + } + ]); + const period = aggregatePnlCalendarDays({ + days, + startDate: '2026-01-02', + endDate: '2026-01-03' + }); + expect(period.amount).toBe(-10); + expect('percentage' in period).toBe(false); + }); + + it('preserves zero monetary PnL with a deposit', () => { + const days = getPnlCalendarDays([ + { + date: '2026-03-10', + netPerformanceWithCurrencyEffect: 25, + valueWithCurrencyEffect: 1000 + }, + { + date: '2026-03-11', + netPerformanceWithCurrencyEffect: 25, + valueWithCurrencyEffect: 11000 + } + ]); + expect( + aggregatePnlCalendarDays({ + days, + startDate: '2026-03-11', + endDate: '2026-03-11' + }).amount + ).toBe(0); + }); + + it('handles a rolling 30-day interval across years', () => { + expect(addCalendarDays('2026-01-13', -29)).toBe('2025-12-15'); + expect(addCalendarDays('2026-10-13', -29)).toBe('2026-09-14'); + }); + + it('handles Mondays, Sundays, leap days and calendar-year boundaries', () => { + expect(getMonday('2026-01-01')).toBe('2025-12-29'); + expect(getMonday('2026-01-04')).toBe('2025-12-29'); + expect(getMonday('2026-01-05')).toBe('2026-01-05'); + expect(addCalendarDays('2024-02-28', 1)).toBe('2024-02-29'); + expect(addCalendarDays('2026-01-01', -1)).toBe('2025-12-31'); + }); + + it('never attributes a multi-day cumulative change to a single day', () => { + const days = getPnlCalendarDays([ + { date: '2026-01-02', netPerformanceWithCurrencyEffect: 10 }, + { date: '2026-01-05', netPerformanceWithCurrencyEffect: 40 }, + { date: '2026-01-06', netPerformanceWithCurrencyEffect: 45 } + ]); + expect(days).toEqual([ + { amount: 5, date: '2026-01-06', percentagePoints: undefined } + ]); + }); + + it('accepts contiguous dates across a leap day and a year boundary', () => { + const days = getPnlCalendarDays([ + { date: '2024-02-28', netPerformanceWithCurrencyEffect: 1 }, + { date: '2024-02-29', netPerformanceWithCurrencyEffect: 3 }, + { date: '2024-03-01', netPerformanceWithCurrencyEffect: 6 }, + { date: '2024-12-31', netPerformanceWithCurrencyEffect: 20 }, + { date: '2025-01-01', netPerformanceWithCurrencyEffect: 24 } + ]); + expect(days.map(({ date, amount }) => ({ date, amount }))).toEqual([ + { date: '2024-02-29', amount: 2 }, + { date: '2024-03-01', amount: 3 }, + { date: '2025-01-01', amount: 4 } + ]); + }); + + it('hides incomplete weekly and monthly monetary totals, rather than reporting partial sums', () => { + const days = [ + { date: '2026-01-02', amount: 10 }, + { date: '2026-01-04', amount: 20 } + ]; + const result = aggregatePnlCalendarDays({ + days, + startDate: '2026-01-02', + endDate: '2026-01-04', + requireComplete: true, + asOfDate: '2026-01-04' + }); + expect(result.amount).toBeUndefined(); + // The legacy aggregate remains useful for explicitly partial sums. + expect( + aggregatePnlCalendarDays({ + days, + startDate: '2026-01-02', + endDate: '2026-01-04' + }).amount + ).toBe(30); + }); + + it('preserves a real zero and does not require dates in the future', () => { + const days = [ + { date: '2026-10-12', amount: 0 }, + { date: '2026-10-13', amount: 0 } + ]; + expect( + aggregatePnlCalendarDays({ + days, + startDate: '2026-10-12', + endDate: '2026-10-31', + requireComplete: true, + asOfDate: '2026-10-13' + }).amount + ).toBe(0); + expect( + aggregatePnlCalendarDays({ + days, + startDate: '2026-10-14', + endDate: '2026-10-31', + requireComplete: true, + asOfDate: '2026-10-13' + }).amount + ).toBeUndefined(); + }); + + it('treats absent or undefined monetary observations as incomplete', () => { + expect( + aggregatePnlCalendarDays({ + days: [ + { date: '2026-01-01', amount: 2 }, + { date: '2026-01-02', percentagePoints: 0.1 } + ], + startDate: '2026-01-01', + endDate: '2026-01-02', + requireComplete: true, + asOfDate: '2026-01-02' + }).amount + ).toBeUndefined(); + }); + + it('uses the local calendar day instead of a UTC date', () => { + const now = new Date(2026, 9, 13, 0, 15); + expect(getLocalTodayKey(now)).toBe('2026-10-13'); + }); + + it('does not mistake pre-inception dates for gaps, but rejects later missing days', () => { + const days = [ + { date: '2026-01-15', amount: 0 }, + { date: '2026-01-16', amount: 4 }, + { date: '2026-01-17', amount: -1 } + ]; + expect( + aggregatePnlCalendarDays({ + days, + startDate: '2026-01-01', + endDate: '2026-01-17', + requireComplete: true, + asOfDate: '2026-01-17', + firstHistoricalDate: '2026-01-15' + }).amount + ).toBe(3); + expect( + aggregatePnlCalendarDays({ + days: [...days, { date: '2026-01-19', amount: 2 }], + startDate: '2026-01-01', + endDate: '2026-01-19', + requireComplete: true, + asOfDate: '2026-01-19', + firstHistoricalDate: '2026-01-15' + }).amount + ).toBeUndefined(); + // A missing day at the start is still a gap when earlier history exists. + expect( + aggregatePnlCalendarDays({ + days: [{ date: '2025-12-31', amount: 1 }, ...days], + startDate: '2026-01-01', + endDate: '2026-01-17', + requireComplete: true, + asOfDate: '2026-01-17' + }).amount + ).toBeUndefined(); + }); + + it('does not report a complete total before any observed monetary day', () => { + expect( + aggregatePnlCalendarDays({ + days: [], + startDate: '2026-01-01', + endDate: '2026-01-31', + requireComplete: true, + asOfDate: '2026-01-31' + }).amount + ).toBeUndefined(); + }); + + it('does not infer inception from the first PnL point alone', () => { + expect( + aggregatePnlCalendarDays({ + days: [{ date: '2026-01-15', amount: 10 }], + startDate: '2026-01-01', + endDate: '2026-01-15', + requireComplete: true, + asOfDate: '2026-01-15' + }).amount + ).toBeUndefined(); + }); + + it('handles a synthetic first month with purchases on October 9 and 18', () => { + // Entirely artificial chart: the first activity occurs on October 9. + // The second purchase adds capital but does not generate profit itself. + const chart = [ + { + date: '2026-10-09', + netPerformanceWithCurrencyEffect: 0, + valueWithCurrencyEffect: 1000 + }, + ...Array.from({ length: 14 }, (_, index) => { + const day = index + 10; + const netPerformance = + day === 18 ? -40 : day === 23 ? -45 : -5 * (day - 9); + return { + date: `2026-10-${String(day).padStart(2, '0')}`, + netPerformanceWithCurrencyEffect: netPerformance, + valueWithCurrencyEffect: (day >= 18 ? 2000 : 1000) + netPerformance + }; + }) + ]; + const days = getPnlCalendarDays(chart); + const month = aggregatePnlCalendarDays({ + days, + startDate: '2026-10-01', + endDate: '2026-10-31', + requireComplete: true, + firstHistoricalDate: '2026-10-09', + asOfDate: '2026-10-23' + }); + + expect(days[0]).toMatchObject({ date: '2026-10-10', amount: -5 }); + expect(days.find(({ date }) => date === '2026-10-18')?.amount).toBe(0); + expect(month.amount).toBe(-45); + expect(days.some(({ date }) => date < '2026-10-09')).toBe(false); + }); + + it('rejects a missing day after synthetic inception, even with two purchases', () => { + const days = Array.from({ length: 14 }, (_, index) => ({ + date: `2026-10-${String(index + 10).padStart(2, '0')}`, + amount: index === 8 ? 0 : -2 + })); + const month = aggregatePnlCalendarDays({ + days: days.filter(({ date }) => date !== '2026-10-14'), + startDate: '2026-10-01', + endDate: '2026-10-31', + firstHistoricalDate: '2026-10-09', + requireComplete: true, + asOfDate: '2026-10-23' + }); + expect(month.amount).toBeUndefined(); + }); +}); diff --git a/apps/client/src/app/components/portfolio-pnl-calendar/portfolio-pnl-calendar.helper.ts b/apps/client/src/app/components/portfolio-pnl-calendar/portfolio-pnl-calendar.helper.ts new file mode 100644 index 0000000000..8b8552de9a --- /dev/null +++ b/apps/client/src/app/components/portfolio-pnl-calendar/portfolio-pnl-calendar.helper.ts @@ -0,0 +1,220 @@ +import type { HistoricalDataItem } from '@ghostfolio/common/interfaces'; + +export interface PnlCalendarDay { + date: string; + amount?: number; + percentagePoints?: number; +} + +export interface PnlCalendarPeriod { + amount?: number; + endDate: string; + percentagePoints?: number; + startDate: string; +} + +// Work on ISO calendar dates, not local instants: DST must not change the day. +export function addCalendarDays(date: string, days: number): string { + const [year, month, day] = date.split('-').map(Number); + const result = new Date(Date.UTC(year, month - 1, day + days)); + + return result.toISOString().slice(0, 10); +} + +/** Current local calendar date, independent of UTC rollover. */ +export function getLocalTodayKey(now: Date = new Date()): string { + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + + return `${year}-${month}-${day}`; +} + +export function getMonday(date: string): string { + const [year, month, day] = date.split('-').map(Number); + const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay(); + + return addCalendarDays(date, -((weekday + 6) % 7)); +} + +/** + * The portfolio performance chart contains cumulative values for its range. + * Their differences give the period's monetary PnL and the CHANGE in the + * cumulative return (percentage points), not a period's investment return. + * Do not replace the latter with a "daily return" label: ROAI and other + * non-time-weighted methods cannot be chained that way. + */ +export function getPnlCalendarDays( + chart: HistoricalDataItem[] +): PnlCalendarDay[] { + const items = [...chart] + .filter(({ date }) => /^\d{4}-\d{2}-\d{2}$/.test(date)) + .sort((a, b) => a.date.localeCompare(b.date)); + const days: PnlCalendarDay[] = []; + + for (let index = 1; index < items.length; index++) { + const current = items[index]; + const previous = items[index - 1]; + + // A multi-day delta is not a daily PnL: do not assign it to the last day. + if (current.date !== addCalendarDays(previous.date, 1)) { + continue; + } + + const amount = differenceOrUndefined( + current.netPerformanceWithCurrencyEffect, + previous.netPerformanceWithCurrencyEffect + ); + const percentagePoints = differenceOrUndefined( + current.netPerformanceInPercentageWithCurrencyEffect, + previous.netPerformanceInPercentageWithCurrencyEffect + ); + + if (amount !== undefined || percentagePoints !== undefined) { + days.push({ + amount, + date: current.date, + percentagePoints: + percentagePoints === undefined ? undefined : percentagePoints * 100 + }); + } + } + + return days; +} + +export function aggregatePnlCalendarDays({ + days, + endDate, + startDate, + requireComplete = false, + asOfDate = getLocalTodayKey(), + firstHistoricalDate +}: { + days: PnlCalendarDay[]; + endDate: string; + startDate: string; + requireComplete?: boolean; + asOfDate?: string; + firstHistoricalDate?: string; +}): PnlCalendarPeriod { + let amount: number | undefined; + let percentagePoints: number | undefined; + + for (const day of days) { + if (day.date < startDate || day.date > endDate) { + continue; + } + + if (day.amount !== undefined) { + amount = (amount ?? 0) + day.amount; + } + + if (day.percentagePoints !== undefined) { + percentagePoints = (percentagePoints ?? 0) + day.percentagePoints; + } + } + + // A partial monetary sum must not be presented as a full period result. + // Ignore future dates, but require one finite monetary observation for + // every elapsed calendar day in a complete-period aggregation. + if (requireComplete) { + const lastRequiredDate = endDate < asOfDate ? endDate : asOfDate; + const observed = new Set( + days + .filter( + ({ amount: value, date }) => + typeof value === 'number' && + Number.isFinite(value) && + date >= startDate && + date <= lastRequiredDate + ) + .map(({ date }) => date) + ); + // Before the first observable daily PnL there is no return to report. + // Do not mistake that pre-history for a gap inside the observed series. + // A missing observation AFTER the first day still invalidates the total. + const firstObservedDate = days + .filter( + ({ amount: value, date }) => + typeof value === 'number' && + Number.isFinite(value) && + date <= lastRequiredDate + ) + .map(({ date }) => date) + .sort()[0]; + // Only a known start of the underlying chart can justify trimming + // pre-history. The first monetary observation alone is not evidence + // of inception: it might follow an unobserved data gap. + const effectiveStart = + firstHistoricalDate && + firstHistoricalDate >= startDate && + firstObservedDate && + firstObservedDate > startDate + ? firstObservedDate + : startDate; + let isComplete = + Boolean(firstObservedDate) && effectiveStart <= lastRequiredDate; + for ( + let date = effectiveStart; + isComplete && date <= lastRequiredDate; + date = addCalendarDays(date, 1) + ) { + if (!observed.has(date)) { + isComplete = false; + } + } + if (!isComplete) { + amount = undefined; + } + } + + return { + amount, + endDate, + percentagePoints, + startDate + }; +} + +/** Aggregate only the portion of an ISO Monday-Sunday week in the selected month. */ +export function aggregatePnlCalendarWeekInMonth({ + days, + monthEnd, + monthStart, + weekStart, + requireComplete = false, + asOfDate, + firstHistoricalDate +}: { + days: PnlCalendarDay[]; + monthEnd: string; + monthStart: string; + weekStart: string; + requireComplete?: boolean; + asOfDate?: string; + firstHistoricalDate?: string; +}): PnlCalendarPeriod { + const weekEnd = addCalendarDays(weekStart, 6); + + return aggregatePnlCalendarDays({ + days, + startDate: weekStart < monthStart ? monthStart : weekStart, + endDate: weekEnd > monthEnd ? monthEnd : weekEnd, + requireComplete, + asOfDate, + firstHistoricalDate + }); +} + +function differenceOrUndefined( + current: number | undefined, + previous: number | undefined +): number | undefined { + return typeof current === 'number' && + Number.isFinite(current) && + typeof previous === 'number' && + Number.isFinite(previous) + ? current - previous + : undefined; +} diff --git a/libs/ui/src/lib/services/data.service.ts b/libs/ui/src/lib/services/data.service.ts index a74dabded0..a45cb427a0 100644 --- a/libs/ui/src/lib/services/data.service.ts +++ b/libs/ui/src/lib/services/data.service.ts @@ -727,6 +727,17 @@ export class DataService { ); } + public fetchPortfolioCalendarPercentages( + periods: { + startDate: string; + endDate: string; + }[] + ): Observable<{ startDate: string; endDate: string; percentage?: number }[]> { + return this.http.post< + { startDate: string; endDate: string; percentage?: number }[] + >('/api/v2/portfolio/performance/calendar', { periods }); + } + public fetchPortfolioPerformance({ filters, groupBy,