Browse Source

Merge b5667b26c9 into 57c4f5cb22

pull/7825/merge
玉升 1 day ago
committed by GitHub
parent
commit
01bba38fa2
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 6
      apps/api/src/app/portfolio/get-performance.dto.ts
  3. 2
      apps/api/src/app/portfolio/portfolio.controller.ts
  4. 131
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  5. 39
      apps/api/src/app/portfolio/portfolio.service.ts

1
CHANGELOG.md

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added support for grouping portfolio performance by year
- Introduced a DTO for the query parameters of the asset profiles endpoint - Introduced a DTO for the query parameters of the asset profiles endpoint
- Introduced a DTO for the query parameters of the symbol lookup endpoints - Introduced a DTO for the query parameters of the symbol lookup endpoints

6
apps/api/src/app/portfolio/get-performance.dto.ts

@ -1,9 +1,13 @@
import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto'; import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { IsBoolean } from 'class-validator'; import { IsBoolean, IsIn, IsOptional } from 'class-validator';
export class GetPerformanceDto extends DateRangeFilterDto { export class GetPerformanceDto extends DateRangeFilterDto {
@IsIn(['year'])
@IsOptional()
groupBy?: 'year';
@IsBoolean() @IsBoolean()
@Transform(({ value }: TransformFnParams) => { @Transform(({ value }: TransformFnParams) => {
return value === 'true'; return value === 'true';

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

@ -494,6 +494,7 @@ export class PortfolioController {
accounts, accounts,
assetClasses, assetClasses,
dataSource, dataSource,
groupBy,
range, range,
symbol, symbol,
tags, tags,
@ -510,6 +511,7 @@ export class PortfolioController {
const performanceInformation = await this.portfolioService.getPerformance({ const performanceInformation = await this.portfolioService.getPerformance({
filters, filters,
groupBy,
userId, userId,
withExcludedAccounts, withExcludedAccounts,
dateRange: range dateRange: range

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

@ -1,3 +1,4 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
@ -25,6 +26,7 @@ import { randomUUID } from 'node:crypto';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
describe('PortfolioService', () => { describe('PortfolioService', () => {
let accountBalanceService: AccountBalanceService;
let accountService: AccountService; let accountService: AccountService;
let activitiesService: ActivitiesService; let activitiesService: ActivitiesService;
let configurationService: ConfigurationService; let configurationService: ConfigurationService;
@ -54,6 +56,12 @@ describe('PortfolioService', () => {
null null
); );
accountBalanceService = new AccountBalanceService(
null,
exchangeRateDataService,
null
);
accountService = new AccountService( accountService = new AccountService(
null, null,
null, null,
@ -100,7 +108,7 @@ describe('PortfolioService', () => {
); );
portfolioService = new PortfolioService( portfolioService = new PortfolioService(
null, accountBalanceService,
accountService, accountService,
activitiesService, activitiesService,
null, null,
@ -490,6 +498,127 @@ describe('PortfolioService', () => {
}); });
}); });
describe('getPerformance', () => {
const getPerformance = jest.fn();
const portfolioCalculator = {
getPerformance,
getSnapshot: jest.fn()
} as unknown as PortfolioCalculator;
beforeEach(() => {
jest
.spyOn(accountBalanceService, 'getAccountBalanceItems')
.mockResolvedValue([]);
getPerformance.mockReset();
jest.spyOn(userService, 'user').mockResolvedValue({
id: userDummyData.id,
settings: {
settings: {
baseCurrency: 'USD'
}
}
} as unknown as Awaited<ReturnType<typeof userService.user>>);
jest
.spyOn(activitiesService, 'getActivitiesForPortfolioCalculator')
.mockResolvedValue({ activities: [{}], count: 1 } as never);
jest
.spyOn(portfolioCalculatorFactory, 'createCalculator')
.mockReturnValue(portfolioCalculator);
portfolioCalculator.getSnapshot = jest.fn().mockResolvedValue({
errors: [],
hasErrors: false,
historicalData: [{ date: '2024-01-01' }]
});
});
afterEach(() => {
jest.useRealTimers();
});
it('returns one chart entry per interval year, including years without chart data, when grouping by year', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2025-06-15'));
let performanceCallCount = 0;
getPerformance.mockImplementation(
({ end }: { end: Date; start: Date }) => {
performanceCallCount++;
if (performanceCallCount === 1) {
return Promise.resolve({
chart: [
{
date: '2025-06-15',
netPerformance: 10000,
netPerformanceInPercentage: 1,
netPerformanceInPercentageWithCurrencyEffect: 1.1,
netPerformanceWithCurrencyEffect: 11000,
netWorth: 30000,
totalInvestment: 20000,
totalInvestmentValueWithCurrencyEffect: 21000,
valueWithCurrencyEffect: 31000
}
]
});
}
const year = end.getFullYear();
if (year === 2022) {
return Promise.resolve({ chart: [] });
}
return Promise.resolve({
chart: [
{ date: `${year}-01-01`, netPerformance: year - 1 },
{
date: `${year}-12-31`,
netPerformance: year,
netWorth: year * 10
}
]
});
}
);
const result = await portfolioService.getPerformance({
dateRange: '5y',
groupBy: 'year',
userId: userDummyData.id
});
expect(result.chart).toEqual(
[2020, 2021, 2022, 2023, 2024, 2025].map((year) =>
year === 2022
? { date: `${year}-01-01` }
: {
date: `${year}-01-01`,
netPerformance: year,
netWorth: year * 10
}
)
);
expect(result.performance).toEqual({
currentNetWorth: 30000,
currentValueInBaseCurrency: 31000,
netPerformance: 10000,
netPerformancePercentage: 1,
netPerformancePercentageWithCurrencyEffect: 1.1,
netPerformanceWithCurrencyEffect: 11000,
totalInvestment: 20000,
totalInvestmentValueWithCurrencyEffect: 21000
});
expect(getPerformance).toHaveBeenCalledTimes(7);
expect(
getPerformance.mock.calls.slice(1).map(([{ end }]) => {
return end.getFullYear();
})
).toEqual([2020, 2021, 2022, 2023, 2024, 2025]);
});
});
describe('getSummary', () => { describe('getSummary', () => {
const getSummary = (args: object) => { const getSummary = (args: object) => {
return ( return (

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

@ -92,11 +92,15 @@ import {
import { Big } from 'big.js'; import { Big } from 'big.js';
import { import {
differenceInDays, differenceInDays,
eachYearOfInterval,
endOfYear,
format, format,
isAfter, isAfter,
isBefore, isBefore,
isSameMonth, isSameMonth,
isSameYear, isSameYear,
max,
min,
parseISO, parseISO,
set set
} from 'date-fns'; } from 'date-fns';
@ -1135,10 +1139,12 @@ export class PortfolioService {
public async getPerformance({ public async getPerformance({
dateRange = DEFAULT_DATE_RANGE, dateRange = DEFAULT_DATE_RANGE,
filters, filters,
groupBy,
userId userId
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[]; filters?: Filter[];
groupBy?: 'year';
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}): Promise<PortfolioPerformanceResponse> { }): Promise<PortfolioPerformanceResponse> {
@ -1190,11 +1196,40 @@ export class PortfolioService {
const { endDate, startDate } = getIntervalFromDateRange({ dateRange }); const { endDate, startDate } = getIntervalFromDateRange({ dateRange });
const { chart } = await portfolioCalculator.getPerformance({ const { chart: fullChart } = await portfolioCalculator.getPerformance({
end: endDate, end: endDate,
start: startDate start: startDate
}); });
let chart = fullChart;
if (groupBy === 'year') {
chart = [];
for (const year of eachYearOfInterval({
end: endDate,
start: startDate
})) {
const intervalEnd = min([endDate, endOfYear(year)]);
const intervalStart = max([startDate, year]);
if (!isBefore(intervalStart, intervalEnd)) {
continue;
}
const { chart: chartForYear } =
await portfolioCalculator.getPerformance({
end: intervalEnd,
start: intervalStart
});
chart.push({
...(chartForYear.at(-1) ?? {}),
date: format(year, DATE_FORMAT)
});
}
}
const { const {
netPerformance, netPerformance,
netPerformanceInPercentage, netPerformanceInPercentage,
@ -1204,7 +1239,7 @@ export class PortfolioService {
totalInvestment, totalInvestment,
totalInvestmentValueWithCurrencyEffect, totalInvestmentValueWithCurrencyEffect,
valueWithCurrencyEffect valueWithCurrencyEffect
} = chart?.at(-1) ?? { } = fullChart?.at(-1) ?? {
netPerformance: 0, netPerformance: 0,
netPerformanceInPercentage: 0, netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0, netPerformanceInPercentageWithCurrencyEffect: 0,

Loading…
Cancel
Save