Browse Source

Fix invested capital of ROI portfolio calculator and guard performance calculation type

pull/7993/head
Thomas Kaul 6 hours ago
parent
commit
ae80a38ce8
  1. 2
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  2. 237
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-sell-and-buy-on-same-day.spec.ts
  3. 86
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  4. 357
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-cash.spec.ts
  5. 84
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts
  6. 1
      apps/api/src/app/user/user.controller.ts
  7. 3
      apps/client/src/app/components/user-account-settings/user-account-settings.html

2
apps/api/src/app/endpoints/mcp/mcp.controller.ts

@ -94,7 +94,7 @@ export class GhostfolioMcpController {
', '
)}. If the access reads the monetary values, these columns are given in addition: ${PortfolioTableService.getPerformanceTableValueColumnNames().join(
', '
)}. The value in base currency is the value at the end of the date range. The asset performance excludes the effect of the exchange rates, the currency performance is that effect, and the net performance is the sum of both in the base currency of the user. Each performance is the return on average investment (ROAI) and includes the dividends (total return). The accounts and the activities which are excluded from analysis are not part of the performance. The parameters limit the performance to the holdings of the accounts, of the asset classes or of the asset profile.`,
)}. The value in base currency is the value at the end of the date range. The asset performance excludes the effect of the exchange rates, the currency performance is that effect, and the net performance is the sum of both in the base currency of the user. Each performance uses the performance calculation type of the user (default: return on average investment, ROAI) and includes the dividends (total return). The accounts and the activities which are excluded from analysis are not part of the performance. The parameters limit the performance to the holdings of the accounts, of the asset classes or of the asset profile.`,
name: 'get-performance',
parameters: GET_PERFORMANCE_PARAMETERS
})

237
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-sell-and-buy-on-same-day.spec.ts

@ -0,0 +1,237 @@
import {
activityDummyData,
assetProfileDummyData,
getPerformanceByDateRange,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
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 { 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';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator (ROI)', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
describe('get current positions', () => {
it.only('with BALN.SW sell and buy on the same day', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
// The second holding is BALN.SW of another data source, so that both
// holdings have the same market prices
const activities: Activity[] = [
{
...activityDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
date: new Date('2021-11-22'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
type: 'BUY',
unitPriceInAssetProfileCurrency: 142.9
},
{
...activityDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.65,
feeInBaseCurrency: 1.65,
quantity: 2,
type: 'SELL',
unitPriceInAssetProfileCurrency: 136.6
},
{
...activityDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'CHF',
dataSource: 'EOD_HISTORICAL_DATA',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 2,
type: 'BUY',
unitPriceInAssetProfileCurrency: 136.6
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROI,
currency: 'CHF',
usePortfolioSnapshotCache: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const performanceByDateRange = await getPerformanceByDateRange({
portfolioCalculator,
dateRanges: ['1d', 'max']
});
/**
* The invested capital of each holding is the sum of its buy amounts:
* 2 * 136.6 CHF = 273.2 CHF and 2 * 142.9 CHF = 285.8 CHF
*/
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('297.8'),
errors: [],
hasErrors: false,
positions: [
{
averageInvestment: new Big('273.2'),
averageInvestmentWithCurrencyEffect: new Big('273.2'),
dataSource: 'EOD_HISTORICAL_DATA',
investment: new Big('273.2'),
netPerformance: new Big('24.6'),
netPerformancePercentage: new Big('0.09004392386530014641'),
quantity: new Big('2'),
symbol: 'BALN.SW',
valueInBaseCurrency: new Big('297.8')
},
{
averageInvestment: new Big('285.8'),
averageInvestmentWithCurrencyEffect: new Big('285.8'),
dataSource: 'YAHOO',
investment: new Big('0'),
netPerformance: new Big('-15.8'),
netPerformancePercentage: new Big('-0.0552834149755073478'),
quantity: new Big('0'),
symbol: 'BALN.SW',
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('3.2'),
totalInvestment: new Big('273.2'),
totalInvestmentWithCurrencyEffect: new Big('273.2')
});
/**
* The sell and the buy on 2021-11-30 move the money from one holding to
* the other. Thus the invested capital of the portfolio stays 285.8 CHF
* and is not 285.8 CHF + 273.2 CHF = 559 CHF.
*
* Net performance: -15.8 CHF + 24.6 CHF = 8.8 CHF
* Net performance in percentage: 8.8 CHF / 285.8 CHF ≈ 3.08 %
*/
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: 8.8,
netPerformanceInPercentage: 0.03079076277116865,
netPerformanceInPercentageWithCurrencyEffect: 0.03079076277116865,
netPerformanceWithCurrencyEffect: 8.8,
totalInvestment: 273.2,
totalInvestmentValueWithCurrencyEffect: 273.2
})
);
/**
* 1d: 2 * (148.9 CHF - 143.9 CHF) = 10 CHF / 287.8 CHF ≈ 3.47 %
* max: the same invested capital as the chart above
*/
expect(performanceByDateRange).toMatchObject({
'1d': {
date: '2021-12-18',
netPerformance: 10,
netPerformanceInPercentage: 0.03474635163307853,
netPerformanceInPercentageWithCurrencyEffect: 0.03474635163307853,
netPerformanceWithCurrencyEffect: 10,
valueWithCurrencyEffect: 297.8
},
max: {
date: '2021-12-18',
netPerformance: 8.8,
netPerformanceInPercentage: 0.03079076277116865,
netPerformanceInPercentageWithCurrencyEffect: 0.03079076277116865,
netPerformanceWithCurrencyEffect: 8.8,
valueWithCurrencyEffect: 297.8
}
});
});
});
});

86
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts

@ -13,6 +13,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
@ -112,7 +113,7 @@ describe('PortfolioCalculator (ROI)', () => {
const performanceByDateRange = await getPerformanceByDateRange({
portfolioCalculator,
dateRanges: ['1d', 'max', 'ytd']
dateRanges: ['1d', '2017', 'max', 'ytd']
});
const investments = portfolioCalculator.getInvestments();
@ -162,11 +163,13 @@ describe('PortfolioCalculator (ROI)', () => {
netPerformancePercentage: new Big('42.40043067128546016291'),
netPerformancePercentageWithCurrencyEffectMap: {
'1d': new Big('-0.04016229506406263535'),
'2017': new Big('43.08868315035799522673'),
max: new Big('41.62112555619955206456'),
ytd: new Big('-0.04016229506406263535')
},
netPerformanceWithCurrencyEffectMap: {
'1d': new Big('-556.443324'),
'2017': new Big('27081.23736'),
max: new Big('26516.208701400000064086'),
ytd: new Big('-556.443324')
},
@ -258,6 +261,15 @@ describe('PortfolioCalculator (ROI)', () => {
totalInvestmentValueWithCurrencyEffect: 318.54266729999995,
valueWithCurrencyEffect: 13298.425356
},
'2017': {
date: '2017-12-31',
netPerformance: 26957.033439,
netPerformanceInPercentage: 44.04821002386635,
netPerformanceInPercentageWithCurrencyEffect: 43.088683150357994,
netPerformanceWithCurrencyEffect: 27081.23736,
totalInvestmentValueWithCurrencyEffect: 318.54266729999995,
valueWithCurrencyEffect: 13854.86868
},
max: {
date: '2018-01-01',
netPerformance: 26458.9121202,
@ -277,6 +289,78 @@ describe('PortfolioCalculator (ROI)', () => {
valueWithCurrencyEffect: 13298.425356
}
});
const { endDate, startDate } = getIntervalFromDateRange({
dateRange: '2017'
});
const { chart } = await portfolioCalculator.getPerformance({
end: endDate,
start: startDate
});
expect(chart[0].date).toBe('2016-12-31');
});
it.only('with BTCUSD buy and sell partially and fewer chart items', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2018-01-01').getTime());
const environmentConfigurationService = new ConfigurationService();
// Fewer chart items give fewer chart dates, which must not change the
// invested capital. The range 2017 starts with a gross performance and
// ends with the sell.
jest.spyOn(configurationService, 'get').mockImplementation((key) => {
return key === 'MAX_CHART_ITEMS'
? 50
: environmentConfigurationService.get(key);
});
const { activities, userCurrency } = loadActivitiesFromExportFile(
'btcusd-buy-and-sell-partially.json'
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROI,
currency: userCurrency,
usePortfolioSnapshotCache: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const performanceByDateRange = await getPerformanceByDateRange({
portfolioCalculator,
dateRanges: ['2017', 'max']
});
expect(
portfolioSnapshot.positions[0]
.netPerformancePercentageWithCurrencyEffectMap
).toMatchObject({
'2017': new Big('43.08868315035799522673'),
max: new Big('41.62112555619955206456')
});
// Other chart dates give another rounding of the floating point numbers
expect(
performanceByDateRange['2017'].netPerformanceInPercentage
).toBeCloseTo(44.04821002386635, 10);
expect(
performanceByDateRange['2017']
.netPerformanceInPercentageWithCurrencyEffect
).toBeCloseTo(43.088683150357994, 10);
expect(performanceByDateRange.max.netPerformanceInPercentage).toBeCloseTo(
42.400430671285456,
10
);
expect(
performanceByDateRange.max.netPerformanceInPercentageWithCurrencyEffect
).toBeCloseTo(41.62112555619955, 10);
});
});
});

357
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-cash.spec.ts

@ -0,0 +1,357 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import {
activityDummyData,
assetProfileDummyData,
getPerformanceByDateRange,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
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 { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PortfolioSnapshotHolding } from '@ghostfolio/common/models';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { DataSource } from '@prisma/client';
import { Big } from 'big.js';
import { eachDayOfInterval, isSameDay } from 'date-fns';
import { randomUUID } from 'node:crypto';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return ExchangeRateDataServiceMock;
})
};
}
);
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator (ROI)', () => {
let accountBalanceService: AccountBalanceService;
let accountService: AccountService;
let activitiesService: ActivitiesService;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
accountBalanceService = new AccountBalanceService(
null,
exchangeRateDataService,
null
);
accountService = new AccountService(
accountBalanceService,
null,
exchangeRateDataService,
null,
null
);
redisCacheService = new RedisCacheService(null, configurationService);
dataProviderService = new DataProviderService(
configurationService,
null,
null,
null,
null,
redisCacheService
);
currentRateService = new CurrentRateService(
null,
dataProviderService,
null
);
activitiesService = new ActivitiesService(
accountBalanceService,
accountService,
{
getSplitsByUserId: jest.fn().mockResolvedValue([])
} as unknown as AssetProfileSplitService,
null,
null,
dataProviderService,
null,
exchangeRateDataService,
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
describe('Cash Performance', () => {
it('should count a buy with cash in a foreign currency only once in the invested capital', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2025-01-01').getTime());
const accountId = randomUUID();
// The account balance goes back to 0 USD with the buy of MSFT
jest
.spyOn(accountBalanceService, 'getAccountBalances')
.mockResolvedValue({
balances: [
{
accountId,
date: parseDate('2023-12-31'),
id: randomUUID(),
value: 1000,
valueInBaseCurrency: 850
},
{
accountId,
date: parseDate('2024-12-31'),
id: randomUUID(),
value: 0,
valueInBaseCurrency: 0
}
]
});
jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({
accounts: [
{
balance: 0,
comment: null,
createdAt: parseDate('2023-12-31'),
currency: 'USD',
id: accountId,
name: 'USD',
platformId: null,
updatedAt: parseDate('2023-12-31'),
userId: userDummyData.id
}
],
balanceInBaseCurrency: 0
});
jest
.spyOn(dataProviderService, 'getDataSourceForExchangeRates')
.mockReturnValue(DataSource.YAHOO);
jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({
activities: [
{
...activityDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
date: parseDate('2024-12-31'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 10,
type: 'BUY',
unitPriceInAssetProfileCurrency: 100
}
],
count: 1
});
const { activities } =
await activitiesService.getActivitiesForPortfolioCalculator({
userCurrency: 'CHF',
userId: userDummyData.id,
withCash: true
});
// The cash symbol has no market data. The holding is quoted at 100 USD
// and at 110 USD on the last day.
jest
.spyOn(currentRateService, 'getValues')
.mockImplementation(({ dataGatheringItems, dateQuery }) => {
const values = [];
for (const date of eachDayOfInterval({
end: dateQuery.lt,
start: dateQuery.gte
})) {
for (const { dataSource, symbol } of dataGatheringItems) {
if (symbol === 'MSFT') {
values.push({
date,
dataSource,
symbol,
marketPrice: isSameDay(parseDate('2025-01-01'), date)
? 110
: 100
});
}
}
}
return Promise.resolve({
values,
dataProviderInfos: [],
errors: []
});
});
const accountBalanceItems =
await accountBalanceService.getAccountBalanceItems({
userCurrency: 'CHF',
userId: userDummyData.id
});
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
accountBalanceItems,
activities,
calculationType: PerformanceCalculationType.ROI,
currency: 'CHF',
usePortfolioSnapshotCache: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const performanceByDateRange = await getPerformanceByDateRange({
portfolioCalculator,
dateRanges: ['max']
});
/**
* The invested capital of each holding is the sum of its buy amounts:
* 1000 USD * 0.91 = 910 CHF (850 CHF with currency effect) for the cash
* and 10 * 100 USD * 0.91 = 910 CHF (900 CHF with currency effect) for
* MSFT
*/
expect(
portfolioSnapshot.positions.find(({ symbol }) => {
return symbol === 'USD';
})
).toMatchObject<Partial<PortfolioSnapshotHolding>>({
averageInvestment: new Big(910),
averageInvestmentWithCurrencyEffect: new Big(850),
grossPerformanceWithCurrencyEffect: new Big(50),
investment: new Big(0),
quantity: new Big(0)
});
expect(
portfolioSnapshot.positions.find(({ symbol }) => {
return symbol === 'MSFT';
})
).toMatchObject<Partial<PortfolioSnapshotHolding>>({
averageInvestment: new Big(910),
averageInvestmentWithCurrencyEffect: new Big(900),
grossPerformanceWithCurrencyEffect: new Big(101),
investment: new Big(910),
quantity: new Big(10)
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big(1001),
hasErrors: false,
totalInvestment: new Big(910),
totalInvestmentWithCurrencyEffect: new Big(900)
});
/**
* The buy of MSFT with the cash moves the money from one holding to the
* other. Thus the invested capital of the portfolio is not the sum of
* the invested capital of the holdings.
*
* Invested capital: 1000 USD * 0.91 = 910 CHF
* Invested capital with currency effect: 1000 USD * 0.85 = 850 CHF plus
* the currency gain of 50 CHF which goes into the buy = 900 CHF
* Net performance: 10 * (110 USD - 100 USD) * 0.91 = 91 CHF
* Net performance in percentage: 91 CHF / 910 CHF = 10 %
* Net performance with currency effect: 50 CHF + 101 CHF = 151 CHF
* Net performance in percentage with currency effect:
* 151 CHF / 900 CHF ≈ 16.78 %
*/
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
date: '2025-01-01',
netPerformance: 91,
netPerformanceInPercentage: 0.1,
netPerformanceInPercentageWithCurrencyEffect: 0.16777777777777778,
netPerformanceWithCurrencyEffect: 151,
totalInvestment: 910,
totalInvestmentValueWithCurrencyEffect: 900
})
);
expect(performanceByDateRange.max).toMatchObject({
date: '2025-01-01',
netPerformance: 91,
netPerformanceInPercentage: 0.1,
netPerformanceInPercentageWithCurrencyEffect: 0.16777777777777778,
netPerformanceWithCurrencyEffect: 151
});
});
});
});

84
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts

@ -32,12 +32,21 @@ import {
/**
* Return on Investment (ROI): the gain divided by the invested capital, without
* time weighting. The invested capital of a holding is the sum of its buy
* amounts. The invested capital of a date range is the value at the start of
* the range plus the buy amounts during the range.
* time weighting.
*
* The invested capital fills the average investment fields of the holding
* performance, which the base class sums per date.
* The invested capital of a holding is the sum of its buy amounts. It fills the
* average investment fields of the holding performance.
*
* The invested capital of the portfolio is the sum of the positive net
* investment changes from date to date. Thus money which moves from one holding
* to another on the same date counts one time only, for example a buy with cash
* in a foreign currency. The invested capital of a date range is the value at
* the start of the range plus the positive net investment changes during the
* range.
*
* A sell amount does not reduce the invested capital, so that a sold holding
* keeps its share of the gain. Therefore money which moves from one holding to
* another on different dates counts two times.
*/
export class RoiPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance(
@ -175,36 +184,69 @@ export class RoiPortfolioCalculator extends PortfolioCalculator {
}: {
accumulatedValuesByDate: { [date: string]: AccumulatedValues };
}): { [date: string]: PerformancePercentages } {
let investedCapital = new Big(0);
let investedCapitalWithCurrencyEffect = new Big(0);
let previousTotalInvestmentValue = new Big(0);
let previousTotalInvestmentValueWithCurrencyEffect = new Big(0);
const performancePercentagesByDate: {
[date: string]: PerformancePercentages;
} = {};
// The dates are in chronological order
for (const [
date,
{
totalAverageInvestmentValue: totalInvestedCapital,
totalAverageInvestmentValueWithCurrencyEffect:
totalInvestedCapitalWithCurrencyEffect,
totalDividendValueWithCurrencyEffect,
totalInvestmentValue,
totalInvestmentValueWithCurrencyEffect,
totalNetPerformanceValue,
totalNetPerformanceValueWithCurrencyEffect
}
] of Object.entries(accumulatedValuesByDate)) {
// A positive net investment change of the portfolio increases the
// invested capital. A negative change does not reduce it, so that a sold
// holding keeps its share of the gain.
const investmentChange = totalInvestmentValue.minus(
previousTotalInvestmentValue
);
if (investmentChange.gt(0)) {
investedCapital = investedCapital.plus(investmentChange);
}
const investmentChangeWithCurrencyEffect =
totalInvestmentValueWithCurrencyEffect.minus(
previousTotalInvestmentValueWithCurrencyEffect
);
if (investmentChangeWithCurrencyEffect.gt(0)) {
investedCapitalWithCurrencyEffect =
investedCapitalWithCurrencyEffect.plus(
investmentChangeWithCurrencyEffect
);
}
previousTotalInvestmentValue = totalInvestmentValue;
previousTotalInvestmentValueWithCurrencyEffect =
totalInvestmentValueWithCurrencyEffect;
performancePercentagesByDate[date] = {
dividendInPercentageWithCurrencyEffect:
totalInvestedCapitalWithCurrencyEffect.eq(0)
investedCapitalWithCurrencyEffect.eq(0)
? 0
: totalDividendValueWithCurrencyEffect
.div(totalInvestedCapitalWithCurrencyEffect)
.div(investedCapitalWithCurrencyEffect)
.toNumber(),
netPerformanceInPercentage: totalInvestedCapital.eq(0)
netPerformanceInPercentage: investedCapital.eq(0)
? 0
: totalNetPerformanceValue.div(totalInvestedCapital).toNumber(),
: totalNetPerformanceValue.div(investedCapital).toNumber(),
netPerformanceInPercentageWithCurrencyEffect:
totalInvestedCapitalWithCurrencyEffect.eq(0)
investedCapitalWithCurrencyEffect.eq(0)
? 0
: totalNetPerformanceValueWithCurrencyEffect
.div(totalInvestedCapitalWithCurrencyEffect)
.div(investedCapitalWithCurrencyEffect)
.toNumber()
};
}
@ -232,9 +274,9 @@ export class RoiPortfolioCalculator extends PortfolioCalculator {
investedCapitalWithCurrencyEffect =
historicalDataItem.valueWithCurrencyEffect;
} else {
// The buy amounts during the date range increase the invested capital.
// The sell amounts do not reduce it, so that a sold holding keeps its
// share of the gain.
// A positive net investment change of the portfolio increases the
// invested capital. A negative change does not reduce it, so that a
// sold holding keeps its share of the gain.
const investmentChange =
historicalDataItem.totalInvestment -
historicalDataItems[index - 1].totalInvestment;
@ -243,9 +285,13 @@ export class RoiPortfolioCalculator extends PortfolioCalculator {
investedCapital += investmentChange;
}
if (historicalDataItem.investmentValueWithCurrencyEffect > 0) {
const investmentChangeWithCurrencyEffect =
historicalDataItem.totalInvestmentValueWithCurrencyEffect -
historicalDataItems[index - 1].totalInvestmentValueWithCurrencyEffect;
if (investmentChangeWithCurrencyEffect > 0) {
investedCapitalWithCurrencyEffect +=
historicalDataItem.investmentValueWithCurrencyEffect;
investmentChangeWithCurrencyEffect;
}
}

1
apps/api/src/app/user/user.controller.ts

@ -242,6 +242,7 @@ export class UserController {
if (!userSettings.isExperimentalFeatures) {
delete userSettings.isGhostfolioAiEnabled;
delete userSettings.performanceCalculationType;
}
return this.userService.updateUserSetting({

3
apps/client/src/app/components/user-account-settings/user-account-settings.html

@ -39,6 +39,9 @@
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-select
name="performanceCalculationType"
[disabled]="
hasImpersonationId || !hasPermissionToUpdateUserSettings
"
[value]="user.settings.performanceCalculationType"
(selectionChange)="
onChangeUserSetting(

Loading…
Cancel
Save