mirror of https://github.com/ghostfolio/ghostfolio
16 changed files with 2037 additions and 2 deletions
@ -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); |
|||
}); |
|||
}); |
|||
@ -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); |
|||
}); |
|||
}); |
|||
@ -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[]; |
|||
} |
|||
@ -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' |
|||
}); |
|||
}); |
|||
}); |
|||
@ -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(); |
|||
}); |
|||
}); |
|||
@ -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> |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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(); |
|||
}); |
|||
}); |
|||
@ -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; |
|||
} |
|||
Loading…
Reference in new issue