Browse Source

Merge 041a854c65 into eae2c20dfe

pull/6223/merge
John Costa 2 days ago
committed by GitHub
parent
commit
1879f0dac9
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 65
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts
  2. 57
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts
  3. 57
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  4. 125
      apps/api/src/app/portfolio/portfolio-chart.helper.spec.ts
  5. 42
      apps/api/src/app/portfolio/portfolio-chart.helper.ts
  6. 2
      apps/api/src/app/portfolio/portfolio.controller.ts
  7. 9
      apps/api/src/app/portfolio/portfolio.service.ts

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

@ -7,6 +7,7 @@ import {
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { getChartByYear } from '@ghostfolio/api/app/portfolio/portfolio-chart.helper';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -88,6 +89,10 @@ describe('PortfolioCalculator', () => {
);
});
afterEach(() => {
jest.useRealTimers();
});
describe('get current positions', () => {
it.only('with BTCUSD buy (in USD)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime());
@ -257,5 +262,65 @@ describe('PortfolioCalculator', () => {
{ date: '2022-01-01', investment: 0 }
]);
});
it.only('with BTCUSD buy - performance grouped by year', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime());
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
feeInBaseCurrency: 4.46,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: 44558.42
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: exportResponse.user.settings.currency,
userId: userDummyData.id
});
await portfolioCalculator.computeSnapshot();
const { chart } = await portfolioCalculator.getPerformance({
end: parseDate('2022-01-14'),
start: parseDate('2021-12-11')
});
const chartByYear = getChartByYear(chart);
// Chart spans from 2021-12-11 to 2022-01-14, covering two years
expect(chartByYear).toHaveLength(2);
// First year (2021) - should have the last data point from 2021-12-31
expect(chartByYear[0].date).toEqual('2021-01-01');
expect(chartByYear[0]).toMatchObject(
expect.objectContaining({
date: '2021-01-01',
totalInvestmentValueWithCurrencyEffect: 44558.42
})
);
// Second year (2022) - should have the last data point from 2022-01-14
expect(chartByYear[1].date).toEqual('2022-01-01');
expect(chartByYear[1]).toMatchObject(
expect.objectContaining({
date: '2022-01-01',
netPerformance: -1463.18,
totalInvestmentValueWithCurrencyEffect: 44558.42
})
);
});
});
});

57
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts

@ -6,6 +6,7 @@ import {
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { getChartByYear } from '@ghostfolio/api/app/portfolio/portfolio-chart.helper';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -90,6 +91,10 @@ describe('PortfolioCalculator', () => {
);
});
afterEach(() => {
jest.useRealTimers();
});
describe('get current positions', () => {
it.only('with GOOGL buy', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime());
@ -229,5 +234,57 @@ describe('PortfolioCalculator', () => {
{ date: '2023-01-01', investment: 82.329056 }
]);
});
it.only('with GOOGL buy - performance grouped by year', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2023-01-03'),
feeInAssetProfileCurrency: 1,
feeInBaseCurrency: 0.9238,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Alphabet Inc.',
symbol: 'GOOGL'
},
type: 'BUY',
unitPriceInAssetProfileCurrency: 89.12
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
await portfolioCalculator.computeSnapshot();
const { chart } = await portfolioCalculator.getPerformance({
end: parseDate('2023-07-10'),
start: parseDate('2023-01-03')
});
const chartByYear = getChartByYear(chart);
// All data is within 2023, so should only have one year entry
expect(chartByYear).toHaveLength(1);
// Should have the last data point of 2023 (2023-07-10) with normalized date
expect(chartByYear[0].date).toEqual('2023-01-01');
expect(chartByYear[0]).toMatchObject(
expect.objectContaining({
date: '2023-01-01',
netPerformance: new Big('26.33').mul(0.8854).toNumber(),
totalInvestmentValueWithCurrencyEffect: 82.329056
})
);
});
});
});

57
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts

@ -7,6 +7,7 @@ import {
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { getChartByYear } from '@ghostfolio/api/app/portfolio/portfolio-chart.helper';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -91,6 +92,10 @@ describe('PortfolioCalculator', () => {
);
});
afterEach(() => {
jest.useRealTimers();
});
describe('get current positions', () => {
it.only('with NOVN.SW buy and sell', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
@ -260,5 +265,57 @@ describe('PortfolioCalculator', () => {
{ date: '2022-01-01', investment: 0 }
]);
});
it.only('with NOVN.SW buy and sell - performance grouped by year', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
feeInBaseCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: activity.unitPrice
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: exportResponse.user.settings.currency,
userId: userDummyData.id
});
await portfolioCalculator.computeSnapshot();
const { chart } = await portfolioCalculator.getPerformance({
end: parseDate('2022-04-11'),
start: parseDate('2022-03-06')
});
const chartByYear = getChartByYear(chart);
// All data is within 2022, so should only have one year entry
expect(chartByYear).toHaveLength(1);
// Should have the last data point of 2022 (2022-04-11) with normalized date
expect(chartByYear[0].date).toEqual('2022-01-01');
expect(chartByYear[0]).toMatchObject(
expect.objectContaining({
date: '2022-01-01',
netPerformance: 19.86,
netPerformanceInPercentage: 0.13100263852242744,
totalInvestmentValueWithCurrencyEffect: 0
})
);
});
});
});

125
apps/api/src/app/portfolio/portfolio-chart.helper.spec.ts

@ -0,0 +1,125 @@
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { getChartByYear } from './portfolio-chart.helper';
describe('portfolio-chart.helper', () => {
describe('getChartByYear', () => {
it('returns empty array for empty input', () => {
expect(getChartByYear([])).toEqual([]);
});
it('returns empty array for null input', () => {
expect(getChartByYear(null)).toEqual([]);
});
it('returns empty array for undefined input', () => {
expect(getChartByYear(undefined)).toEqual([]);
});
it('groups data by year with normalized dates (YYYY-01-01)', () => {
const chart: HistoricalDataItem[] = [
{ date: '2023-03-15', netPerformance: 100, netWorth: 1000 },
{ date: '2023-06-20', netPerformance: 150, netWorth: 1100 },
{ date: '2023-12-31', netPerformance: 200, netWorth: 1200 },
{ date: '2024-01-15', netPerformance: 180, netWorth: 1150 },
{ date: '2024-06-30', netPerformance: 250, netWorth: 1300 }
];
const result = getChartByYear(chart);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
date: '2023-01-01',
netPerformance: 200,
netWorth: 1200
});
expect(result[1]).toEqual({
date: '2024-01-01',
netPerformance: 250,
netWorth: 1300
});
});
it('uses last data point of each year as representative value', () => {
const chart: HistoricalDataItem[] = [
{ date: '2022-01-01', netPerformance: 10, netWorth: 100 },
{ date: '2022-02-01', netPerformance: 20, netWorth: 110 },
{ date: '2022-03-01', netPerformance: 30, netWorth: 120 }
];
const result = getChartByYear(chart);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
date: '2022-01-01',
netPerformance: 30,
netWorth: 120
});
});
it('handles single data point', () => {
const chart: HistoricalDataItem[] = [
{ date: '2025-07-15', netPerformance: 500, netWorth: 5000 }
];
const result = getChartByYear(chart);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
date: '2025-01-01',
netPerformance: 500,
netWorth: 5000
});
});
it('handles data spanning multiple years', () => {
const chart: HistoricalDataItem[] = [
{ date: '2020-06-01', netWorth: 100 },
{ date: '2021-06-01', netWorth: 200 },
{ date: '2022-06-01', netWorth: 300 },
{ date: '2023-06-01', netWorth: 400 },
{ date: '2024-06-01', netWorth: 500 }
];
const result = getChartByYear(chart);
expect(result).toHaveLength(5);
expect(result.map((r) => r.date)).toEqual([
'2020-01-01',
'2021-01-01',
'2022-01-01',
'2023-01-01',
'2024-01-01'
]);
expect(result.map((r) => r.netWorth)).toEqual([100, 200, 300, 400, 500]);
});
it('preserves all properties from the data point', () => {
const chart: HistoricalDataItem[] = [
{
date: '2023-12-31',
netPerformance: 100,
netPerformanceInPercentage: 0.1,
netPerformanceWithCurrencyEffect: 95,
netPerformanceInPercentageWithCurrencyEffect: 0.095,
netWorth: 1000,
totalInvestment: 900,
investmentValueWithCurrencyEffect: 850
}
];
const result = getChartByYear(chart);
expect(result[0]).toEqual({
date: '2023-01-01',
netPerformance: 100,
netPerformanceInPercentage: 0.1,
netPerformanceWithCurrencyEffect: 95,
netPerformanceInPercentageWithCurrencyEffect: 0.095,
netWorth: 1000,
totalInvestment: 900,
investmentValueWithCurrencyEffect: 850
});
});
});
});

42
apps/api/src/app/portfolio/portfolio-chart.helper.ts

@ -0,0 +1,42 @@
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { format, isSameYear, set } from 'date-fns';
/**
* Groups chart data by year, providing one entry per year.
* For each year, the last available data point is used as the representative value.
* Dates are normalized to YYYY-01-01 format.
*/
export function getChartByYear(
chart: HistoricalDataItem[]
): HistoricalDataItem[] {
if (!chart?.length) {
return [];
}
const chartByYear: HistoricalDataItem[] = [];
let currentDate: Date | null = null;
for (const dataPoint of chart) {
const date = parseDate(dataPoint.date);
if (!isSameYear(date, currentDate)) {
// New year: Push a new entry with normalized date (YYYY-01-01)
chartByYear.push({
...dataPoint,
date: format(set(date, { date: 1, month: 0 }), DATE_FORMAT)
});
currentDate = date;
} else {
// Same year: Update the last entry with latest data (keep normalized date)
const normalizedDate = chartByYear[chartByYear.length - 1].date;
chartByYear[chartByYear.length - 1] = {
...dataPoint,
date: normalizedDate
};
}
}
return chartByYear;
}

2
apps/api/src/app/portfolio/portfolio.controller.ts

@ -508,6 +508,7 @@ export class PortfolioController {
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('groupBy') groupBy?: Extract<GroupBy, 'year'>,
@Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@ -526,6 +527,7 @@ export class PortfolioController {
const performanceInformation = await this.portfolioService.getPerformance({
dateRange,
filters,
groupBy,
impersonationId,
withExcludedAccounts,
userId: this.request.user.id

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

@ -91,6 +91,7 @@ import {
import { PortfolioCalculator } from './calculator/portfolio-calculator';
import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory';
import { getChartByYear } from './portfolio-chart.helper';
import { RulesService } from './rules.service';
const Fuse = require('fuse.js');
@ -976,11 +977,13 @@ export class PortfolioService {
public async getPerformance({
dateRange = 'max',
filters,
groupBy,
impersonationId,
userId
}: {
dateRange?: DateRange;
filters?: Filter[];
groupBy?: Extract<GroupBy, 'year'>;
impersonationId: string;
userId: string;
withExcludedAccounts?: boolean;
@ -1033,11 +1036,13 @@ export class PortfolioService {
const { endDate, startDate } = getIntervalFromDateRange(dateRange);
const { chart } = await portfolioCalculator.getPerformance({
const { chart: rawChart } = await portfolioCalculator.getPerformance({
end: endDate,
start: startDate
});
const chart = groupBy === 'year' ? getChartByYear(rawChart) : rawChart;
const {
netPerformance,
netPerformanceInPercentage,
@ -1046,7 +1051,7 @@ export class PortfolioService {
netWorth,
totalInvestment,
valueWithCurrencyEffect
} = chart?.at(-1) ?? {
} = rawChart?.at(-1) ?? {
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,

Loading…
Cancel
Save