mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
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