Browse Source

Bugfix/date ranges in time zones other than UTC (#8099)

* Fix start date of date ranges for instances in time zones with negative UTC offset

* Update changelog
main
Thomas Kaul 11 hours ago
committed by GitHub
parent
commit
8fa4f6a673
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 9
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  3. 44
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts
  4. 8
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  5. 10
      apps/api/src/app/portfolio/portfolio.service.ts
  6. 24
      libs/common/src/lib/calculation-helper.spec.ts
  7. 20
      libs/common/src/lib/calculation-helper.ts

1
CHANGELOG.md

@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Fixed the _Copy AI prompt to clipboard_ actions on the analysis page in impersonation mode to be based on the impersonated user (experimental) - Fixed the _Copy AI prompt to clipboard_ actions on the analysis page in impersonation mode to be based on the impersonated user (experimental)
- Fixed the start date of the date ranges for instances in time zones with a negative UTC offset
## 3.83.0 - 2026-10-10 ## 3.83.0 - 2026-10-10

9
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts

@ -64,7 +64,6 @@ import {
format, format,
isAfter, isAfter,
isBefore, isBefore,
isEqual,
isFuture, isFuture,
isPast, isPast,
isWithinInterval, isWithinInterval,
@ -1418,8 +1417,8 @@ export abstract class PortfolioCalculator {
let netPerformanceAtStartDate: number; let netPerformanceAtStartDate: number;
let netPerformanceWithCurrencyEffectAtStartDate: number; let netPerformanceWithCurrencyEffectAtStartDate: number;
const endDate = this.toChartDate(end); const endDate = resetHours(end);
const startDate = this.toChartDate(start); const startDate = resetHours(start);
for (const historicalDataItem of historicalData) { for (const historicalDataItem of historicalData) {
const date = resetHours(parseDate(historicalDataItem.date)); const date = resetHours(parseDate(historicalDataItem.date));
@ -1914,8 +1913,4 @@ export abstract class PortfolioCalculator {
await this.initialize(attempt + 1); await this.initialize(attempt + 1);
} }
} }
private toChartDate(aDate: Date) {
return isEqual(aDate, endOfDay(aDate)) ? resetHours(aDate) : aDate;
}
} }

44
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts

@ -190,6 +190,10 @@ describe('PortfolioCalculator', () => {
}); });
describe('get performance', () => { describe('get performance', () => {
afterEach(() => {
jest.useRealTimers();
});
it('with a calendar year date range', async () => { it('with a calendar year date range', async () => {
const snapshot = await portfolioCalculator.getSnapshot(); const snapshot = await portfolioCalculator.getSnapshot();
@ -226,5 +230,45 @@ describe('PortfolioCalculator', () => {
}) })
).toEqual(['2016-12-31', '2017-12-31']); ).toEqual(['2016-12-31', '2017-12-31']);
}); });
it('with a year to date date range', async () => {
const snapshot = await portfolioCalculator.getSnapshot();
snapshot.historicalData = [
getHistoricalDataItem({
date: '2016-12-30',
investment: 100,
netPerformance: 0
}),
getHistoricalDataItem({
date: '2016-12-31',
investment: 100,
netPerformance: 10
}),
getHistoricalDataItem({
date: '2017-01-01',
investment: 100,
netPerformance: 20
})
];
// 1 January 2017, 10:00 in New York
jest.useFakeTimers().setSystemTime(new Date('2017-01-01T15:00:00.000Z'));
const { endDate, startDate } = getIntervalFromDateRange({
dateRange: 'ytd'
});
const { chart } = await portfolioCalculator.getPerformance({
end: endDate,
start: startDate
});
expect(
chart.map(({ date }) => {
return date;
})
).toEqual(['2016-12-31', '2017-01-01']);
});
}); });
}); });

8
apps/api/src/app/portfolio/portfolio.service.spec.ts

@ -16,7 +16,7 @@ import {
TAG_ID_EXCLUDE_FROM_ANALYSIS, TAG_ID_EXCLUDE_FROM_ANALYSIS,
UNKNOWN_KEY UNKNOWN_KEY
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { parseDate, resetHours } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { import {
Activity, Activity,
AssetProfileIdentifier, AssetProfileIdentifier,
@ -1045,7 +1045,7 @@ describe('PortfolioService', () => {
expect(getPerformanceOfCalculator).toHaveBeenCalledTimes(3); expect(getPerformanceOfCalculator).toHaveBeenCalledTimes(3);
expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(2, { expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(2, {
end: endOfDay(parseDate('2024-12-31')), end: endOfDay(parseDate('2024-12-31')),
start: resetHours(parseDate('2024-06-15')) start: parseDate('2024-06-15')
}); });
expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(3, { expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(3, {
end: endOfDay(parseDate('2025-06-15')), end: endOfDay(parseDate('2025-06-15')),
@ -1099,11 +1099,11 @@ describe('PortfolioService', () => {
expect(getPerformanceOfCalculator).toHaveBeenCalledTimes(3); expect(getPerformanceOfCalculator).toHaveBeenCalledTimes(3);
expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(1, { expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(1, {
end: endOfDay(parseDate('2026-01-01')), end: endOfDay(parseDate('2026-01-01')),
start: resetHours(parseDate('2025-01-01')) start: parseDate('2025-01-01')
}); });
expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(2, { expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(2, {
end: endOfDay(parseDate('2025-12-31')), end: endOfDay(parseDate('2025-12-31')),
start: resetHours(parseDate('2025-01-01')) start: parseDate('2025-01-01')
}); });
expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(3, { expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(3, {
end: endOfDay(parseDate('2026-01-01')), end: endOfDay(parseDate('2026-01-01')),

10
apps/api/src/app/portfolio/portfolio.service.ts

@ -47,6 +47,7 @@ import {
isAccountExcluded, isAccountExcluded,
isDraftActivity, isDraftActivity,
parseDate, parseDate,
resetHours,
resolveUserSettings resolveUserSettings
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
@ -559,8 +560,13 @@ export class PortfolioService {
const { historicalData } = await portfolioCalculator.getSnapshot(); const { historicalData } = await portfolioCalculator.getSnapshot();
const items = historicalData.filter(({ date }) => { const items = historicalData.filter((historicalDataItem) => {
return !isBefore(date, startDate) && !isAfter(date, endDate); const date = resetHours(parseDate(historicalDataItem.date));
return (
!isBefore(date, resetHours(startDate)) &&
!isAfter(date, resetHours(endDate))
);
}); });
let investments: InvestmentItem[]; let investments: InvestmentItem[];

24
libs/common/src/lib/calculation-helper.spec.ts

@ -6,9 +6,14 @@ import {
getIntervalFromDateRange getIntervalFromDateRange
} from './calculation-helper'; } from './calculation-helper';
import { DATE_FORMAT } from './helper'; import { DATE_FORMAT } from './helper';
import { DateRange } from './types';
describe('CalculationHelper', () => { describe('CalculationHelper', () => {
describe('interval from date range', () => { describe('interval from date range', () => {
afterEach(() => {
jest.useRealTimers();
});
it('Get interval of a calendar year', async () => { it('Get interval of a calendar year', async () => {
const { endDate, startDate } = getIntervalFromDateRange({ const { endDate, startDate } = getIntervalFromDateRange({
dateRange: '2024' dateRange: '2024'
@ -23,6 +28,25 @@ describe('CalculationHelper', () => {
// part of the interval // part of the interval
expect(startDate.getTime()).toEqual(new Date(2024, 0, 1).getTime() - 1); expect(startDate.getTime()).toEqual(new Date(2024, 0, 1).getTime() - 1);
}); });
it('Get interval of a date range relative to today', () => {
jest.useFakeTimers().setSystemTime(new Date(2024, 9, 10, 22, 0));
expect(
(['1d', 'mtd', 'wtd', 'ytd', '1y', '5y'] as DateRange[]).map(
(dateRange) => {
return getIntervalFromDateRange({ dateRange }).startDate;
}
)
).toEqual([
new Date(2024, 9, 9),
new Date(2024, 8, 30),
new Date(2024, 9, 6),
new Date(2023, 11, 31),
new Date(2023, 9, 10),
new Date(2019, 9, 10)
]);
});
}); });
describe('annualized performance percentage', () => { describe('annualized performance percentage', () => {

20
libs/common/src/lib/calculation-helper.ts

@ -3,6 +3,7 @@ import {
endOfDay, endOfDay,
endOfYear, endOfYear,
max, max,
startOfDay,
startOfMonth, startOfMonth,
startOfWeek, startOfWeek,
startOfYear, startOfYear,
@ -12,7 +13,6 @@ import {
} from 'date-fns'; } from 'date-fns';
import { isFinite, isNumber } from 'lodash-es'; import { isFinite, isNumber } from 'lodash-es';
import { resetHours } from './helper';
import { DateRange } from './types'; import { DateRange } from './types';
export function getAnnualizedPerformancePercent({ export function getAnnualizedPerformancePercent({
@ -48,31 +48,25 @@ export function getIntervalFromDateRange(params: {
switch (dateRange) { switch (dateRange) {
case '1d': case '1d':
startDate = max([startDate, subDays(resetHours(new Date()), 1)]); startDate = max([startDate, subDays(startOfDay(new Date()), 1)]);
break; break;
case 'mtd': case 'mtd':
startDate = max([ startDate = max([startDate, subDays(startOfMonth(new Date()), 1)]);
startDate,
subDays(startOfMonth(resetHours(new Date())), 1)
]);
break; break;
case 'wtd': case 'wtd':
startDate = max([ startDate = max([
startDate, startDate,
subDays(startOfWeek(resetHours(new Date()), { weekStartsOn: 1 }), 1) subDays(startOfWeek(new Date(), { weekStartsOn: 1 }), 1)
]); ]);
break; break;
case 'ytd': case 'ytd':
startDate = max([ startDate = max([startDate, subDays(startOfYear(new Date()), 1)]);
startDate,
subDays(startOfYear(resetHours(new Date())), 1)
]);
break; break;
case '1y': case '1y':
startDate = max([startDate, subYears(resetHours(new Date()), 1)]); startDate = max([startDate, subYears(startOfDay(new Date()), 1)]);
break; break;
case '5y': case '5y':
startDate = max([startDate, subYears(resetHours(new Date()), 5)]); startDate = max([startDate, subYears(startOfDay(new Date()), 5)]);
break; break;
case 'max': case 'max':
break; break;

Loading…
Cancel
Save