PJ 1 day ago
committed by GitHub
parent
commit
5e424fb0f9
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 206
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.calendar.spec.ts
  2. 38
      apps/api/src/app/portfolio/get-calendar-performance.dto.spec.ts
  3. 25
      apps/api/src/app/portfolio/get-calendar-performance.dto.ts
  4. 102
      apps/api/src/app/portfolio/portfolio-calendar.controller.spec.ts
  5. 148
      apps/api/src/app/portfolio/portfolio-calendar.integration.spec.ts
  6. 28
      apps/api/src/app/portfolio/portfolio.controller.ts
  7. 146
      apps/api/src/app/portfolio/portfolio.service.ts
  8. 30
      apps/client/src/app/components/home-overview/home-overview.component.ts
  9. 12
      apps/client/src/app/components/home-overview/home-overview.html
  10. 9
      apps/client/src/app/components/home-overview/home-overview.scss
  11. 171
      apps/client/src/app/components/portfolio-pnl-calendar/portfolio-pnl-calendar.component.html
  12. 129
      apps/client/src/app/components/portfolio-pnl-calendar/portfolio-pnl-calendar.component.scss
  13. 352
      apps/client/src/app/components/portfolio-pnl-calendar/portfolio-pnl-calendar.component.ts
  14. 412
      apps/client/src/app/components/portfolio-pnl-calendar/portfolio-pnl-calendar.helper.spec.ts
  15. 220
      apps/client/src/app/components/portfolio-pnl-calendar/portfolio-pnl-calendar.helper.ts
  16. 11
      libs/ui/src/lib/services/data.service.ts

206
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);
});
});

38
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);
});
});

25
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[];
}

102
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'
});
});
});

148
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();
});
});

28
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)

146
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,

30
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<AssetProfileIdentifier[]>([]);
protected readonly hasImpersonationId = signal(false);
protected readonly historicalDataItems = signal<LineChartItem[] | null>(null);
protected readonly pnlCalendarChart = signal<HistoricalDataItem[] | null>(
null
);
protected readonly holdings = signal<PortfolioPosition[] | undefined>(
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([]);
}
});
}
}
}

12
apps/client/src/app/components/home-overview/home-overview.html

@ -1,5 +1,5 @@
<div
class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative px-xl-3"
class="align-items-center container d-flex flex-column justify-content-center overview p-0 position-relative px-xl-3"
>
@if (
!hasImpersonationId() &&
@ -116,5 +116,15 @@
</div>
}
</div>
@if (user()?.settings?.viewMode !== 'ZEN') {
<div class="pnl-calendar-container w-100">
<gf-portfolio-pnl-calendar
[chart]="pnlCalendarChart()"
[currency]="baseCurrency()"
[locale]="locale()"
[showDetails]="showDetails()"
/>
</div>
}
}
</div>

9
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;
}

171
apps/client/src/app/components/portfolio-pnl-calendar/portfolio-pnl-calendar.component.html

@ -0,0 +1,171 @@
<mat-card appearance="outlined" class="pnl-calendar">
<mat-card-header>
<mat-card-title i18n>Profit and loss calendar</mat-card-title>
</mat-card-header>
<mat-card-content>
<div class="calendar-toolbar">
<div
aria-label="Calendar view"
class="calendar-tabs"
i18n-aria-label
role="group"
>
<button
i18n
mat-button
type="button"
[attr.aria-pressed]="view() === 'daily'"
(click)="setView('daily')"
>
Daily
</button>
<button
i18n
mat-button
type="button"
[attr.aria-pressed]="view() === 'weekly'"
(click)="setView('weekly')"
>
Weekly
</button>
<button
i18n
mat-button
type="button"
[attr.aria-pressed]="view() === 'monthly'"
(click)="setView('monthly')"
>
Monthly
</button>
<button
i18n
mat-button
type="button"
[attr.aria-pressed]="view() === 'last30'"
(click)="setView('last30')"
>
Last 30 days
</button>
</div>
@if (showDetails()) {
<div
aria-label="Calendar unit"
class="calendar-tabs"
i18n-aria-label
role="group"
>
<button
mat-button
type="button"
[attr.aria-pressed]="effectiveUnit() === 'currency'"
(click)="setUnit('currency')"
>
{{ currency() }}
</button>
<button
mat-button
type="button"
[attr.aria-pressed]="effectiveUnit() === 'percentage'"
(click)="setUnit('percentage')"
>
%
</button>
</div>
}
</div>
<div class="calendar-navigation">
@if (view() !== 'last30') {
<button
aria-label="Previous period"
i18n-aria-label
mat-button
type="button"
(click)="navigate(-1)"
>
‹
</button>
}
<strong aria-live="polite">{{ title() }}</strong>
@if (view() !== 'last30') {
<button
aria-label="Next period"
i18n-aria-label
mat-button
type="button"
(click)="navigate(1)"
>
›
</button>
}
</div>
@if (chart() === null) {
<p class="calendar-status" i18n>Loading calendar…</p>
} @else if (days().length === 0) {
<p class="calendar-status" i18n>
No historical performance data available.
</p>
} @else if (view() === 'daily' || view() === 'last30') {
<div class="calendar-grid daily-grid">
@for (weekday of weekdays(); track $index) {
<div class="weekday">{{ weekday }}</div>
}
@for (cell of cells(); track cell.startDate) {
<div
class="calendar-cell"
[class.negative]="
!cell.outsideMonth &&
getValue(cell) !== undefined &&
(getValue(cell) ?? 0) < 0
"
[class.outside-month]="cell.outsideMonth"
[class.positive]="
!cell.outsideMonth &&
getValue(cell) !== undefined &&
(getValue(cell) ?? 0) > 0
"
>
<span class="cell-label">{{ cell.label }}</span>
<span class="cell-value">{{
cell.outsideMonth ? '—' : formatValue(getValue(cell))
}}</span>
</div>
}
</div>
} @else {
<div
class="calendar-grid"
[class.monthly-grid]="view() === 'monthly'"
[class.weekly-grid]="view() === 'weekly'"
>
@for (cell of cells(); track cell.startDate) {
<div
class="calendar-cell"
[class.negative]="
getValue(cell) !== undefined && (getValue(cell) ?? 0) < 0
"
[class.positive]="
getValue(cell) !== undefined && (getValue(cell) ?? 0) > 0
"
>
<span class="cell-label">{{ cell.label }}</span>
<span class="cell-value">{{ formatValue(getValue(cell)) }}</span>
</div>
}
</div>
}
<div class="calendar-total">
<span i18n>Period change</span>
<strong
[class.negative-text]="(periodTotal() ?? 0) < 0"
[class.positive-text]="(periodTotal() ?? 0) > 0"
>{{ formatValue(periodTotal()) }}</strong
>
</div>
@if (effectiveUnit() === 'percentage') {
<p class="calendar-note" i18n>
Period return calculated using Ghostfolio ROAI (return on average
investment). Unavailable percentages are shown as a dash.
</p>
}
</mat-card-content>
</mat-card>

129
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;
}
}

352
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<HistoricalDataItem[] | null>(null);
public readonly currency = input.required<string>();
public readonly locale = input.required<string>();
public readonly showDetails = input(false);
protected readonly view = signal<CalendarView>('daily');
protected readonly unit = signal<CalendarUnit>('currency');
protected readonly referenceDate = signal(new Date());
protected readonly effectiveUnit = computed<CalendarUnit>(() => {
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<CalendarCell[]>(() => {
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<Map<string, number> | 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);
}
}

412
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();
});
});

220
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;
}

11
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,

Loading…
Cancel
Save