Thomas Kaul 2 days ago
committed by GitHub
parent
commit
117e6270e5
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      CHANGELOG.md
  2. 185
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy-and-buy.spec.ts
  3. 222
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy-and-sell.spec.ts
  4. 276
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy.spec.ts
  5. 282
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  6. 202
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-msft-buy-with-dividend.spec.ts
  7. 575
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts
  8. 27
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
  9. 4
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  10. 27
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  11. 5
      libs/common/src/lib/dtos/update-user-setting.dto.ts

6
CHANGELOG.md

@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Added
- Added the _Return on Investment_ (ROI) performance calculation type (experimental)
## 3.85.0 - 2026-10-12 ## 3.85.0 - 2026-10-12
### Added ### Added

185
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy-and-buy.spec.ts

@ -0,0 +1,185 @@
import {
loadActivitiesFromExportFile,
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 { 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 buy and buy', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const { activities, userCurrency } = loadActivitiesFromExportFile(
'baln-buy-and-buy.json'
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROI,
currency: userCurrency,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('595.6'),
errors: [],
hasErrors: false,
positions: [
{
activitiesCount: 2,
averageInvestment: new Big('559'),
averageInvestmentWithCurrencyEffect: new Big('559'),
averagePrice: new Big('139.75'),
currency: 'CHF',
dataSource: 'YAHOO',
dateOfFirstActivity: '2021-11-22',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'),
feeInBaseCurrency: new Big('3.2'),
grossPerformance: new Big('36.6'),
grossPerformancePercentage: new Big('0.0654740608228980322'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.0654740608228980322'
),
grossPerformanceWithCurrencyEffect: new Big('36.6'),
investment: new Big('559'),
investmentWithCurrencyEffect: new Big('559'),
netPerformance: new Big('33.4'),
netPerformancePercentage: new Big('0.05974955277280858676'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.05974955277280858676')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('33.4')
},
marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9,
quantity: new Big('4'),
symbol: 'BALN.SW',
tags: [],
valueInBaseCurrency: new Big('595.6')
}
],
totalFeesWithCurrencyEffect: new Big('3.2'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('559'),
totalInvestmentWithCurrencyEffect: new Big('559'),
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: 33.4,
netPerformanceInPercentage: 0.059749552772808585,
netPerformanceInPercentageWithCurrencyEffect: 0.059749552772808585,
netPerformanceWithCurrencyEffect: 33.4,
totalInvestment: 559,
totalInvestmentValueWithCurrencyEffect: 559
})
);
expect(investments).toEqual([
{ date: '2021-11-22', investment: new Big('285.8') },
{ date: '2021-11-30', investment: new Big('559') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: 559 },
{ date: '2021-12-01', investment: 0 }
]);
expect(investmentsByYear).toEqual([
{ date: '2021-01-01', investment: 559 }
]);
});
});
});

222
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy-and-sell.spec.ts

@ -0,0 +1,222 @@
import {
getPerformanceByDateRange,
loadActivitiesFromExportFile,
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 { 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 buy and sell', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const { activities, userCurrency } = loadActivitiesFromExportFile(
'baln-buy-and-sell.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: ['1d', 'max', 'ytd']
});
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
hasErrors: false,
positions: [
{
activitiesCount: 2,
averageInvestment: new Big('285.8'),
averageInvestmentWithCurrencyEffect: new Big('285.8'),
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
dateOfFirstActivity: '2021-11-22',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'),
feeInBaseCurrency: new Big('3.2'),
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.0440867739678096571'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'-0.0440867739678096571'
),
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('-15.8'),
netPerformancePercentage: new Big('-0.0552834149755073478'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('-0.0552834149755073478')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('-15.8')
},
marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9,
quantity: new Big('0'),
symbol: 'BALN.SW',
tags: [],
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('3.2'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: -15.8,
netPerformanceInPercentage: -0.05528341497550734703,
netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703,
netPerformanceWithCurrencyEffect: -15.8,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0
})
);
expect(investments).toEqual([
{ date: '2021-11-22', investment: new Big('285.8') },
{ date: '2021-11-30', investment: new Big('0') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: 0 },
{ date: '2021-12-01', investment: 0 }
]);
expect(investmentsByYear).toEqual([
{ date: '2021-01-01', investment: 0 }
]);
expect(performanceByDateRange).toMatchObject({
'1d': {
date: '2021-12-18',
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
totalInvestmentValueWithCurrencyEffect: 0,
valueWithCurrencyEffect: 0
},
max: {
date: '2021-12-18',
netPerformance: -15.8,
netPerformanceInPercentage: -0.05528341497550735,
netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550735,
netPerformanceWithCurrencyEffect: -15.8,
totalInvestmentValueWithCurrencyEffect: 0,
valueWithCurrencyEffect: 0
},
ytd: {
date: '2021-12-18',
netPerformance: -15.8,
netPerformanceInPercentage: -0.05528341497550735,
netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550735,
netPerformanceWithCurrencyEffect: -15.8,
totalInvestmentValueWithCurrencyEffect: 0,
valueWithCurrencyEffect: 0
}
});
});
});
});

276
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy.spec.ts

@ -0,0 +1,276 @@
import {
loadActivitiesFromExportFile,
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 { 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 buy', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const { activities, userCurrency } =
loadActivitiesFromExportFile('baln-buy.json');
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROI,
currency: userCurrency,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const historicalDataDates = portfolioSnapshot.historicalData.map(
({ date }) => {
return date;
}
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('297.8'),
errors: [],
hasErrors: false,
positions: [
{
activitiesCount: 1,
averageInvestment: new Big('273.2'),
averageInvestmentWithCurrencyEffect: new Big('273.2'),
averagePrice: new Big('136.6'),
currency: 'CHF',
dataSource: 'YAHOO',
dateOfFirstActivity: '2021-11-30',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('1.55'),
feeInBaseCurrency: new Big('1.55'),
grossPerformance: new Big('24.6'),
grossPerformancePercentage: new Big('0.09004392386530014641'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.09004392386530014641'
),
grossPerformanceWithCurrencyEffect: new Big('24.6'),
investment: new Big('273.2'),
investmentWithCurrencyEffect: new Big('273.2'),
netPerformance: new Big('23.05'),
netPerformancePercentage: new Big('0.08437042459736456808'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.08437042459736456808')
},
netPerformanceWithCurrencyEffectMap: {
'1d': new Big('10.00'), // 2 * (148.9 - 143.9) -> no fees in this time period
'1y': new Big('23.05'), // 2 * (148.9 - 136.6) - 1.55
'5y': new Big('23.05'), // 2 * (148.9 - 136.6) - 1.55
max: new Big('23.05'), // 2 * (148.9 - 136.6) - 1.55
mtd: new Big('24.60'), // 2 * (148.9 - 136.6) -> no fees in this time period
wtd: new Big('13.80'), // 2 * (148.9 - 142.0) -> no fees in this time period
ytd: new Big('23.05') // 2 * (148.9 - 136.6) - 1.55
},
marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9,
quantity: new Big('2'),
symbol: 'BALN.SW',
tags: [],
valueInBaseCurrency: new Big('297.8')
}
],
totalFeesWithCurrencyEffect: new Big('1.55'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('273.2'),
totalInvestmentWithCurrencyEffect: new Big('273.2'),
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(historicalDataDates).not.toContain('2021-01-01');
expect(historicalDataDates).not.toContain('2021-12-31');
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
date: '2021-12-18',
netPerformance: 23.05,
netPerformanceInPercentage: 0.08437042459736457,
netPerformanceInPercentageWithCurrencyEffect: 0.08437042459736457,
netPerformanceWithCurrencyEffect: 23.05,
totalInvestment: 273.2,
totalInvestmentValueWithCurrencyEffect: 273.2
})
);
expect(investments).toEqual([
{ date: '2021-11-30', investment: new Big('273.2') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: 273.2 },
{ date: '2021-12-01', investment: 0 }
]);
expect(investmentsByYear).toEqual([
{ date: '2021-01-01', investment: 273.2 }
]);
});
it.only('with BALN.SW buy (with unit price lower than closing price)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const { activities, userCurrency } = loadActivitiesFromExportFile(
'baln-buy-with-unit-price-lower-than-closing-price.json'
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROI,
currency: userCurrency,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const snapshotOnBuyDate = portfolioSnapshot.historicalData.find(
({ date }) => {
return date === '2021-11-30';
}
);
// Closing price on 2021-11-30: 136.6
expect(snapshotOnBuyDate?.netPerformanceWithCurrencyEffect).toEqual(1.65); // 2 * (136.6 - 135.0) - 1.55 = 1.65
});
it.only('with BALN.SW buy (with unit price lower than closing price), calculated on buy date', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2021-11-30').getTime());
const { activities, userCurrency } = loadActivitiesFromExportFile(
'baln-buy-with-unit-price-lower-than-closing-price.json'
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROI,
currency: userCurrency,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const snapshotOnBuyDate = portfolioSnapshot.historicalData.find(
({ date }) => {
return date === '2021-11-30';
}
);
// Closing price on 2021-11-30: 136.6
expect(snapshotOnBuyDate?.netPerformanceWithCurrencyEffect).toEqual(1.65); // 2 * (136.6 - 135.0) - 1.55 = 1.65
});
it.only('with BALN.SW buy (on a date without historical market data)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const { activities, userCurrency } =
loadActivitiesFromExportFile('baln-buy.json');
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROI,
currency: userCurrency,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const snapshotOnDateWithoutMarketData =
portfolioSnapshot.historicalData.find(({ date }) => {
return date === '2021-12-05';
});
// No historical market data on 2021-12-05: carry forward the closing
// price of 2021-11-30, which is the date of the activity
expect(snapshotOnDateWithoutMarketData?.value).toEqual(273.2); // 2 * 136.6
expect(
snapshotOnDateWithoutMarketData?.netPerformanceWithCurrencyEffect
).toEqual(-1.55); // 2 * (136.6 - 136.6) - 1.55
});
});
});

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

@ -0,0 +1,282 @@
import {
getPerformanceByDateRange,
loadActivitiesFromExportFile,
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 { 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 { 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;
})
};
});
jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return ExchangeRateDataServiceMock;
})
};
}
);
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 BTCUSD buy and sell partially', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2018-01-01').getTime());
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: ['1d', 'max', 'ytd']
});
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('13298.425356'),
errors: [],
hasErrors: false,
positions: [
{
activitiesCount: 2,
averageInvestment: new Big('624.0246078'),
averageInvestmentWithCurrencyEffect: new Big(
'637.085334599999935914'
),
averagePrice: new Big('320.43'),
currency: 'USD',
dataSource: 'YAHOO',
dateOfFirstActivity: '2015-01-01',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
grossPerformance: new Big('27172.74').mul(0.97373),
grossPerformancePercentage: new Big('42.40043067128546016291'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'41.62112555619955206456'
),
grossPerformanceWithCurrencyEffect: new Big(
'26516.208701400000064086'
),
investment: new Big('320.43').mul(0.97373),
investmentWithCurrencyEffect: new Big('318.542667299999967957'),
marketPrice: 13657.2,
marketPriceInBaseCurrency: 13298.425356,
netPerformance: new Big('27172.74').mul(0.97373),
netPerformancePercentage: new Big('42.40043067128546016291'),
netPerformancePercentageWithCurrencyEffectMap: {
'1d': new Big('-0.04016229506406263535'),
max: new Big('41.62112555619955206456'),
ytd: new Big('-0.04016229506406263535')
},
netPerformanceWithCurrencyEffectMap: {
'1d': new Big('-556.443324'),
max: new Big('26516.208701400000064086'),
ytd: new Big('-556.443324')
},
quantity: new Big('1'),
symbol: 'BTCUSD',
tags: [],
valueInBaseCurrency: new Big('13298.425356')
}
],
totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('320.43').mul(0.97373),
totalInvestmentWithCurrencyEffect: new Big('318.542667299999967957'),
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: new Big('27172.74').mul(0.97373).toNumber(),
netPerformanceInPercentage: 42.40043067128546,
netPerformanceInPercentageWithCurrencyEffect: 41.62112555619955,
netPerformanceWithCurrencyEffect: 26516.2087014,
totalInvestment: 312.0123039,
totalInvestmentValueWithCurrencyEffect: 318.54266729999995
})
);
expect(investments).toEqual([
{ date: '2015-01-01', investment: new Big('640.86') },
{ date: '2017-12-31', investment: new Big('320.43') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2014-12-01', investment: 0 },
{ date: '2015-01-01', investment: 637.0853345999999 },
{ date: '2015-02-01', investment: 0 },
{ date: '2015-03-01', investment: 0 },
{ date: '2015-04-01', investment: 0 },
{ date: '2015-05-01', investment: 0 },
{ date: '2015-06-01', investment: 0 },
{ date: '2015-07-01', investment: 0 },
{ date: '2015-08-01', investment: 0 },
{ date: '2015-09-01', investment: 0 },
{ date: '2015-10-01', investment: 0 },
{ date: '2015-11-01', investment: 0 },
{ date: '2015-12-01', investment: 0 },
{ date: '2016-01-01', investment: 0 },
{ date: '2016-02-01', investment: 0 },
{ date: '2016-03-01', investment: 0 },
{ date: '2016-04-01', investment: 0 },
{ date: '2016-05-01', investment: 0 },
{ date: '2016-06-01', investment: 0 },
{ date: '2016-07-01', investment: 0 },
{ date: '2016-08-01', investment: 0 },
{ date: '2016-09-01', investment: 0 },
{ date: '2016-10-01', investment: 0 },
{ date: '2016-11-01', investment: 0 },
{ date: '2016-12-01', investment: 0 },
{ date: '2017-01-01', investment: 0 },
{ date: '2017-02-01', investment: 0 },
{ date: '2017-03-01', investment: 0 },
{ date: '2017-04-01', investment: 0 },
{ date: '2017-05-01', investment: 0 },
{ date: '2017-06-01', investment: 0 },
{ date: '2017-07-01', investment: 0 },
{ date: '2017-08-01', investment: 0 },
{ date: '2017-09-01', investment: 0 },
{ date: '2017-10-01', investment: 0 },
{ date: '2017-11-01', investment: 0 },
{ date: '2017-12-01', investment: -318.54266729999995 },
{ date: '2018-01-01', investment: 0 }
]);
expect(investmentsByYear).toEqual([
{ date: '2014-01-01', investment: 0 },
{ date: '2015-01-01', investment: 637.0853345999999 },
{ date: '2016-01-01', investment: 0 },
{ date: '2017-01-01', investment: -318.54266729999995 },
{ date: '2018-01-01', investment: 0 }
]);
expect(performanceByDateRange).toMatchObject({
'1d': {
date: '2018-01-01',
netPerformance: -486.0860160000011,
netPerformanceInPercentage: -0.035263202509112565,
netPerformanceInPercentageWithCurrencyEffect: -0.040162295064062624,
netPerformanceWithCurrencyEffect: -556.4433239999998,
totalInvestmentValueWithCurrencyEffect: 318.54266729999995,
valueWithCurrencyEffect: 13298.425356
},
max: {
date: '2018-01-01',
netPerformance: 26458.9121202,
netPerformanceInPercentage: 42.400430671285456,
netPerformanceInPercentageWithCurrencyEffect: 41.62112555619955,
netPerformanceWithCurrencyEffect: 26516.2087014,
totalInvestmentValueWithCurrencyEffect: 318.54266729999995,
valueWithCurrencyEffect: 13298.425356
},
ytd: {
date: '2018-01-01',
netPerformance: -486.0860160000011,
netPerformanceInPercentage: -0.035263202509112565,
netPerformanceInPercentageWithCurrencyEffect: -0.040162295064062624,
netPerformanceWithCurrencyEffect: -556.4433239999998,
totalInvestmentValueWithCurrencyEffect: 318.54266729999995,
valueWithCurrencyEffect: 13298.425356
}
});
});
});
});

202
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-msft-buy-with-dividend.spec.ts

@ -0,0 +1,202 @@
import {
getPerformanceByDateRange,
loadActivitiesFromExportFile,
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 { 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 MSFT buy', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime());
const { activities, userCurrency } = loadActivitiesFromExportFile(
'msft-buy-with-dividend.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: ['1d', 'max', 'ytd']
});
expect(portfolioSnapshot).toMatchObject({
dividendYieldPercent: new Big('0.001144362748184'),
dividendYieldPercentWithCurrencyEffect: new Big('0.001144362748184'),
errors: [],
hasErrors: false,
positions: [
{
activitiesCount: 2,
averagePrice: new Big('298.58'),
currency: 'USD',
dataSource: 'YAHOO',
dateOfFirstActivity: '2021-09-16',
dividend: new Big('0.62'),
dividendInBaseCurrency: new Big('0.62'),
dividendYieldPercent: new Big('0.001144362748184'),
dividendYieldPercentWithCurrencyEffect: new Big(
'0.001144362748184'
),
fee: new Big('19'),
grossPerformance: new Big('33.87'),
grossPerformancePercentage: new Big('0.11343693482483756447'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.11343693482483756447'
),
grossPerformanceWithCurrencyEffect: new Big('33.87'),
investment: new Big('298.58'),
investmentWithCurrencyEffect: new Big('298.58'),
marketPrice: 331.83,
marketPriceInBaseCurrency: 331.83,
netPerformance: new Big('14.87'),
netPerformancePercentage: new Big('0.04980239801728180052'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.04980239801728180052')
},
netPerformanceWithCurrencyEffectMap: {
'1d': new Big('-5.39'),
'5y': new Big('14.87'),
max: new Big('14.87'),
wtd: new Big('-5.39')
},
quantity: new Big('1'),
symbol: 'MSFT',
tags: []
}
],
totalFeesWithCurrencyEffect: new Big('19'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('298.58'),
totalInvestmentWithCurrencyEffect: new Big('298.58'),
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
totalInvestment: 298.58,
totalInvestmentValueWithCurrencyEffect: 298.58
})
);
expect(performanceByDateRange).toMatchObject({
'1d': {
date: '2023-07-10',
dividendInBaseCurrency: 0,
dividendInPercentageWithCurrencyEffect: 0,
netPerformance: -5.390000000000002,
netPerformanceInPercentage: -0.015983630864124316,
netPerformanceInPercentageWithCurrencyEffect: -0.015983630864124316,
netPerformanceWithCurrencyEffect: -5.390000000000002,
totalInvestmentValueWithCurrencyEffect: 298.58,
valueWithCurrencyEffect: 331.83
},
max: {
date: '2023-07-10',
dividendInBaseCurrency: 0.62,
dividendInPercentageWithCurrencyEffect: 0.0020764954116149776,
netPerformance: 14.87,
netPerformanceInPercentage: 0.0498023980172818,
netPerformanceInPercentageWithCurrencyEffect: 0.0498023980172818,
netPerformanceWithCurrencyEffect: 14.87,
totalInvestmentValueWithCurrencyEffect: 298.58,
valueWithCurrencyEffect: 331.83
},
ytd: {
date: '2023-07-10',
dividendInBaseCurrency: 0,
dividendInPercentageWithCurrencyEffect: 0,
netPerformance: -7.6800000000000015,
netPerformanceInPercentage: -0.022620835910577012,
netPerformanceInPercentageWithCurrencyEffect: -0.022620835910577012,
netPerformanceWithCurrencyEffect: -7.6800000000000015,
totalInvestmentValueWithCurrencyEffect: 298.58,
valueWithCurrencyEffect: 331.83
}
});
});
});
});

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

@ -1,28 +1,284 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { AccumulatedValues } from '@ghostfolio/api/app/portfolio/interfaces/accumulated-values.interface';
import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface'; import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface';
import { PortfolioCalculatorActivityItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-activity-item.interface';
import { PortfolioCalculatorHolding } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-holding.interface';
import { PerformancePercentages } from '@ghostfolio/api/app/portfolio/types/performance-percentages.type'; import { PerformancePercentages } from '@ghostfolio/api/app/portfolio/types/performance-percentages.type';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import {
getAnnualizedPerformancePercent,
getIntervalFromDateRange
} from '@ghostfolio/common/calculation-helper';
import {
DATE_FORMAT,
getAssetProfileIdentifier,
parseDate
} from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
HistoricalDataItem
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot } from '@ghostfolio/common/models'; import { PortfolioSnapshot } from '@ghostfolio/common/models';
import { DateRange } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import {
differenceInDays,
eachYearOfInterval,
format,
isBefore,
isThisYear
} from 'date-fns';
/**
* 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.
*
* The invested capital fills the average investment fields of the holding
* performance, which the base class sums per date.
*/
export class RoiPortfolioCalculator extends PortfolioCalculator { export class RoiPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance(): PortfolioSnapshot { protected calculateOverallPerformance(
throw new Error('Method not implemented.'); positions: PortfolioCalculatorHolding[]
): PortfolioSnapshot {
let currentValueInBaseCurrency = new Big(0);
let hasErrors = false;
let totalFeesWithCurrencyEffect = new Big(0);
const totalInterestWithCurrencyEffect = new Big(0);
let totalInvestedCapital = new Big(0);
let totalInvestedCapitalWithCurrencyEffect = new Big(0);
let totalInvestment = new Big(0);
let totalInvestmentWithCurrencyEffect = new Big(0);
for (const currentPosition of positions) {
if (currentPosition.valueInBaseCurrency) {
currentValueInBaseCurrency = currentValueInBaseCurrency.plus(
currentPosition.valueInBaseCurrency
);
} else {
hasErrors = true;
}
if (!currentPosition.includeInPerformance) {
continue;
}
if (currentPosition.feeInBaseCurrency) {
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
currentPosition.feeInBaseCurrency
);
}
if (currentPosition.investment) {
totalInvestment = totalInvestment.plus(currentPosition.investment);
totalInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect.plus(
currentPosition.investmentWithCurrencyEffect
);
} else {
hasErrors = true;
}
if (
!currentPosition.grossPerformance &&
!currentPosition.quantity.eq(0)
) {
hasErrors = true;
}
if (currentPosition.averageInvestment) {
totalInvestedCapital = totalInvestedCapital.plus(
currentPosition.averageInvestment
);
totalInvestedCapitalWithCurrencyEffect =
totalInvestedCapitalWithCurrencyEffect.plus(
currentPosition.averageInvestmentWithCurrencyEffect
);
} else if (!currentPosition.quantity.eq(0)) {
this.logger.warn(
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`
);
hasErrors = true;
}
}
const dateOfFirstActivity = this.getStartDate();
const daysInMarket = dateOfFirstActivity
? differenceInDays(new Date(), dateOfFirstActivity)
: 0;
const totalDividendInBaseCurrency =
this.getDividendInBaseCurrencyOfHoldings(positions);
// A holding without a market price, and a holding which is excluded from
// the performance, gives a dividend but no invested capital. Such a
// holding makes the dividend yield too high. Therefore the dividend yield
// stays 0 in this case.
const hasDividendWithoutInvestedCapital = positions.some(
({ averageInvestment, dividendInBaseCurrency, includeInPerformance }) => {
return (
!dividendInBaseCurrency.eq(0) &&
(!includeInPerformance || averageInvestment.eq(0))
);
}
);
const dividendYieldPercent = getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercentage:
hasDividendWithoutInvestedCapital || totalInvestedCapital.eq(0)
? new Big(0)
: totalDividendInBaseCurrency.div(totalInvestedCapital)
});
const dividendYieldPercentWithCurrencyEffect =
getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercentage:
hasDividendWithoutInvestedCapital ||
totalInvestedCapitalWithCurrencyEffect.eq(0)
? new Big(0)
: totalDividendInBaseCurrency.div(
totalInvestedCapitalWithCurrencyEffect
)
});
return {
currentValueInBaseCurrency,
dividendYieldPercent,
dividendYieldPercentWithCurrencyEffect,
hasErrors,
positions,
totalFeesWithCurrencyEffect,
totalInterestWithCurrencyEffect,
totalInvestment,
totalInvestmentWithCurrencyEffect,
activitiesCount: this.activities.filter(({ type }) => {
return ['BUY', 'SELL'].includes(type);
}).length,
createdAt: new Date(),
errors: [],
historicalData: [],
totalCashInBaseCurrency: new Big(0),
totalLiabilitiesWithCurrencyEffect: new Big(0)
};
} }
protected calculatePerformancePercentages(): { protected calculatePerformancePercentages({
[date: string]: PerformancePercentages; accumulatedValuesByDate
} { }: {
throw new Error('Method not implemented.'); accumulatedValuesByDate: { [date: string]: AccumulatedValues };
}): { [date: string]: PerformancePercentages } {
const performancePercentagesByDate: {
[date: string]: PerformancePercentages;
} = {};
for (const [
date,
{
totalAverageInvestmentValue: totalInvestedCapital,
totalAverageInvestmentValueWithCurrencyEffect:
totalInvestedCapitalWithCurrencyEffect,
totalDividendValueWithCurrencyEffect,
totalNetPerformanceValue,
totalNetPerformanceValueWithCurrencyEffect
}
] of Object.entries(accumulatedValuesByDate)) {
performancePercentagesByDate[date] = {
dividendInPercentageWithCurrencyEffect:
totalInvestedCapitalWithCurrencyEffect.eq(0)
? 0
: totalDividendValueWithCurrencyEffect
.div(totalInvestedCapitalWithCurrencyEffect)
.toNumber(),
netPerformanceInPercentage: totalInvestedCapital.eq(0)
? 0
: totalNetPerformanceValue.div(totalInvestedCapital).toNumber(),
netPerformanceInPercentageWithCurrencyEffect:
totalInvestedCapitalWithCurrencyEffect.eq(0)
? 0
: totalNetPerformanceValueWithCurrencyEffect
.div(totalInvestedCapitalWithCurrencyEffect)
.toNumber()
};
}
return performancePercentagesByDate;
} }
protected calculatePerformancePercentagesForDateRange(): { protected calculatePerformancePercentagesForDateRange({
[date: string]: PerformancePercentages; historicalDataItems
} { }: {
throw new Error('Method not implemented.'); historicalDataItems: HistoricalDataItem[];
}): { [date: string]: PerformancePercentages } {
let investedCapital = 0;
let investedCapitalWithCurrencyEffect = 0;
const performancePercentagesByDate: {
[date: string]: PerformancePercentages;
} = {};
for (const [index, historicalDataItem] of historicalDataItems.entries()) {
if (index === 0) {
// The invested capital of the date range starts with the value at
// the start date
investedCapital = historicalDataItem.value;
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.
const investmentChange =
historicalDataItem.totalInvestment -
historicalDataItems[index - 1].totalInvestment;
if (investmentChange > 0) {
investedCapital += investmentChange;
}
if (historicalDataItem.investmentValueWithCurrencyEffect > 0) {
investedCapitalWithCurrencyEffect +=
historicalDataItem.investmentValueWithCurrencyEffect;
}
}
performancePercentagesByDate[historicalDataItem.date] = {
dividendInPercentageWithCurrencyEffect:
investedCapitalWithCurrencyEffect > 0
? historicalDataItem.dividendInBaseCurrency /
investedCapitalWithCurrencyEffect
: 0,
netPerformanceInPercentage:
investedCapital > 0
? historicalDataItem.netPerformance / investedCapital
: 0,
netPerformanceInPercentageWithCurrencyEffect:
investedCapitalWithCurrencyEffect > 0
? historicalDataItem.netPerformanceWithCurrencyEffect /
investedCapitalWithCurrencyEffect
: 0
};
}
return performancePercentagesByDate;
} }
protected getHoldingPerformance({}: { protected getHoldingPerformance({
chartDates,
dataSource,
end,
exchangeRates,
marketSymbolMap,
start,
symbol
}: {
chartDates: string[]; chartDates: string[];
end: Date; end: Date;
exchangeRates: { [dateString: string]: number }; exchangeRates: { [dateString: string]: number };
@ -31,7 +287,300 @@ export class RoiPortfolioCalculator extends PortfolioCalculator {
}; };
start: Date; start: Date;
} & AssetProfileIdentifier): HoldingPerformance { } & AssetProfileIdentifier): HoldingPerformance {
throw new Error('Method not implemented.'); const investedCapitalValues: { [date: string]: Big } = {};
const investedCapitalValuesWithCurrencyEffect: { [date: string]: Big } = {};
const assetProfileIdentifier = getAssetProfileIdentifier({
dataSource,
symbol
});
let activities: PortfolioCalculatorActivityItem[] =
this.activitiesByAssetProfileIdentifier[assetProfileIdentifier] ?? [];
const isCash = activities[0]?.assetProfile?.assetSubClass === 'CASH';
if (activities.length <= 0) {
return this.getEmptyHoldingPerformance();
}
// The dividends, the interest and the liabilities are derived from the
// activities only. Accumulate them upfront so that they survive the bail
// out for symbols without a market price below.
const {
totalDividend,
totalDividendInBaseCurrency,
totalInterestInBaseCurrency,
totalLiabilitiesInBaseCurrency
} = this.getTotalsFromActivities({ activities, exchangeRates });
const dateOfFirstActivity = parseDate(activities[0].date);
const endDateString = format(end, DATE_FORMAT);
const startDateString = format(start, DATE_FORMAT);
const unitPriceAtStartDate =
marketSymbolMap[startDateString]?.[assetProfileIdentifier];
const unitPriceAtEndDate = this.getUnitPriceAtEndDate({
activities,
dataSource,
isCash,
marketPriceAtEndDate:
marketSymbolMap[endDateString]?.[assetProfileIdentifier]
});
if (
!unitPriceAtEndDate ||
(!unitPriceAtStartDate && isBefore(dateOfFirstActivity, start))
) {
// A missing market price can only affect the quantity which is held. The
// dividends, the interest and the liabilities do not hold any quantity
// and are therefore not in error.
const hasActivitiesWithQuantity = activities.some(({ type }) => {
return ['BUY', 'SELL'].includes(type);
});
return {
...this.getEmptyHoldingPerformance(),
totalDividend,
totalDividendInBaseCurrency,
totalInterestInBaseCurrency,
totalLiabilitiesInBaseCurrency,
hasErrors: hasActivitiesWithQuantity
};
}
activities = this.getActivitiesWithMarketPrices({
activities,
chartDates,
endDateString,
marketSymbolMap,
startDateString,
unitPriceAtEndDate,
unitPriceAtStartDate,
assetProfile: {
dataSource,
symbol,
assetSubClass: isCash ? 'CASH' : undefined
}
});
const {
currentValues,
currentValuesWithCurrencyEffect,
dividendValuesWithCurrencyEffect,
initialValue,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
items,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect
} = this.getHoldingValuation({
activities,
exchangeRates,
unitPriceAtStartDate
});
const indexOfStartActivity = items.findIndex(({ itemType }) => {
return itemType === 'start';
});
const indexOfEndActivity = items.findIndex(({ itemType }) => {
return itemType === 'end';
});
let investedCapital = new Big(0);
let investedCapitalWithCurrencyEffect = new Big(0);
for (let i = indexOfStartActivity + 1; i <= indexOfEndActivity; i += 1) {
const item = items[i];
// A buy amount is a positive transaction investment. A sell amount does
// not reduce the invested capital, so that a sold holding keeps its
// share of the gain.
if (item.transactionInvestment.gt(0)) {
investedCapital = investedCapital.plus(item.transactionInvestment);
investedCapitalWithCurrencyEffect =
investedCapitalWithCurrencyEffect.plus(
item.transactionInvestmentWithCurrencyEffect
);
}
investedCapitalValues[item.date] = investedCapital;
investedCapitalValuesWithCurrencyEffect[item.date] =
investedCapitalWithCurrencyEffect;
}
const {
fees: feesAtStartDate,
grossPerformance: grossPerformanceAtStartDate,
grossPerformanceWithCurrencyEffect:
grossPerformanceAtStartDateWithCurrencyEffect
} = items[indexOfStartActivity];
const {
fees,
grossPerformance,
grossPerformanceWithCurrencyEffect,
investment: totalInvestment,
investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect,
quantity: totalQuantity
} = items[indexOfEndActivity];
const totalGrossPerformance = grossPerformance.minus(
grossPerformanceAtStartDate
);
const totalGrossPerformanceWithCurrencyEffect =
grossPerformanceWithCurrencyEffect.minus(
grossPerformanceAtStartDateWithCurrencyEffect
);
const totalNetPerformance = totalGrossPerformance.minus(
fees.minus(feesAtStartDate)
);
const grossPerformancePercentage = investedCapital.gt(0)
? totalGrossPerformance.div(investedCapital)
: new Big(0);
const grossPerformancePercentageWithCurrencyEffect =
investedCapitalWithCurrencyEffect.gt(0)
? totalGrossPerformanceWithCurrencyEffect.div(
investedCapitalWithCurrencyEffect
)
: new Big(0);
const netPerformancePercentage = investedCapital.gt(0)
? totalNetPerformance.div(investedCapital)
: new Big(0);
const daysInMarket = differenceInDays(new Date(), dateOfFirstActivity);
const dividendYieldPercent = getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercentage: investedCapital.eq(0)
? new Big(0)
: totalDividendInBaseCurrency.div(investedCapital)
});
const dividendYieldPercentWithCurrencyEffect =
getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercentage: investedCapitalWithCurrencyEffect.eq(0)
? new Big(0)
: totalDividendInBaseCurrency.div(investedCapitalWithCurrencyEffect)
});
const netPerformancePercentageWithCurrencyEffectMap: {
[key: DateRange]: Big;
} = {};
const netPerformanceWithCurrencyEffectMap: {
[key: DateRange]: Big;
} = {};
for (const dateRange of [
'1d',
'1y',
'5y',
'max',
'mtd',
'wtd',
'ytd',
...eachYearOfInterval({ end, start })
.filter((date) => {
return !isThisYear(date);
})
.map((date) => {
return format(date, 'yyyy');
})
] as DateRange[]) {
const dateInterval = getIntervalFromDateRange({ dateRange });
const endDate = dateInterval.endDate;
let startDate = dateInterval.startDate;
if (isBefore(startDate, start)) {
startDate = start;
}
const rangeEndDateString = format(endDate, DATE_FORMAT);
const rangeStartDateString = format(startDate, DATE_FORMAT);
// The invested capital of the date range is the value at the start date
// plus the buy amounts during the range
let investedCapitalOfDateRangeWithCurrencyEffect =
currentValuesWithCurrencyEffect[rangeStartDateString] ?? new Big(0);
for (const item of items) {
if (
item.date > rangeStartDateString &&
item.date <= rangeEndDateString &&
item.transactionInvestmentWithCurrencyEffect.gt(0)
) {
investedCapitalOfDateRangeWithCurrencyEffect =
investedCapitalOfDateRangeWithCurrencyEffect.plus(
item.transactionInvestmentWithCurrencyEffect
);
}
}
netPerformanceWithCurrencyEffectMap[dateRange] =
netPerformanceValuesWithCurrencyEffect[rangeEndDateString]?.minus(
// If the date range is 'max', take 0 as a start value. Otherwise,
// the value of the end of the day of the start date is taken which
// differs from the buying price.
dateRange === 'max'
? new Big(0)
: (netPerformanceValuesWithCurrencyEffect[rangeStartDateString] ??
new Big(0))
) ?? new Big(0);
netPerformancePercentageWithCurrencyEffectMap[dateRange] =
investedCapitalOfDateRangeWithCurrencyEffect.gt(0)
? netPerformanceWithCurrencyEffectMap[dateRange].div(
investedCapitalOfDateRangeWithCurrencyEffect
)
: new Big(0);
}
return {
currentValues,
currentValuesWithCurrencyEffect,
dividendValuesWithCurrencyEffect,
dividendYieldPercent,
dividendYieldPercentWithCurrencyEffect,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffectMap,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
netPerformanceWithCurrencyEffectMap,
totalDividend,
totalDividendInBaseCurrency,
totalInterestInBaseCurrency,
totalInvestment,
totalInvestmentWithCurrencyEffect,
totalLiabilitiesInBaseCurrency,
averageInvestment: investedCapital,
averageInvestmentValues: investedCapitalValues,
averageInvestmentValuesWithCurrencyEffect:
investedCapitalValuesWithCurrencyEffect,
averageInvestmentWithCurrencyEffect: investedCapitalWithCurrencyEffect,
grossPerformance: totalGrossPerformance,
grossPerformanceWithCurrencyEffect:
totalGrossPerformanceWithCurrencyEffect,
hasErrors: totalQuantity.gt(0) && (!initialValue || !unitPriceAtEndDate),
netPerformance: totalNetPerformance
};
} }
protected getPerformanceCalculationType() { protected getPerformanceCalculationType() {

27
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html

@ -149,16 +149,23 @@
<div class="flex-nowrap px-3 py-1 row"> <div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 indent-1 text-truncate"> <div class="flex-grow-1 indent-1 text-truncate">
<ng-container i18n>Net Performance</ng-container> <ng-container i18n>Net Performance</ng-container>
@if ( @switch (user?.settings?.performanceCalculationType) {
user?.settings?.performanceCalculationType === @case (PerformanceCalculationType.ROAI) {
PerformanceCalculationType.ROAI <abbr
) { class="d-none d-sm-inline-block initialism ml-2 text-muted"
<abbr i18n-title
class="d-none d-sm-inline-block initialism ml-2 text-muted" title="Return on Average Investment (total return with dividends)"
i18n-title >(ROAI)</abbr
title="Return on Average Investment (total return with dividends)" >
>(ROAI)</abbr }
> @case (PerformanceCalculationType.ROI) {
<abbr
class="d-none d-sm-inline-block initialism ml-2 text-muted"
i18n-title
title="Return on Investment (total return with dividends)"
>(ROI)</abbr
>
}
} }
</div> </div>
<div class="flex-column flex-wrap justify-content-end"> <div class="flex-column flex-wrap justify-content-end">

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

@ -39,7 +39,6 @@
<mat-form-field appearance="outline" class="w-100 without-hint"> <mat-form-field appearance="outline" class="w-100 without-hint">
<mat-select <mat-select
name="performanceCalculationType" name="performanceCalculationType"
[disabled]="true"
[value]="user.settings.performanceCalculationType" [value]="user.settings.performanceCalculationType"
(selectionChange)=" (selectionChange)="
onChangeUserSetting( onChangeUserSetting(
@ -51,6 +50,9 @@
<mat-option value="ROAI" <mat-option value="ROAI"
>Return on Average Investment (ROAI)</mat-option >Return on Average Investment (ROAI)</mat-option
> >
<mat-option value="ROI"
>Return on Investment (ROI)</mat-option
>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>

27
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -254,16 +254,23 @@
<div class="d-flex ml-3 py-1"> <div class="d-flex ml-3 py-1">
<div class="flex-grow-1 mr-2 text-truncate"> <div class="flex-grow-1 mr-2 text-truncate">
<ng-container i18n>Net Performance</ng-container> <ng-container i18n>Net Performance</ng-container>
@if ( @switch (user?.settings?.performanceCalculationType) {
user?.settings?.performanceCalculationType === @case (PerformanceCalculationType.ROAI) {
PerformanceCalculationType.ROAI <abbr
) { class="d-none d-sm-inline-block initialism ml-2 text-muted"
<abbr i18n-title
class="d-none d-sm-inline-block initialism ml-2 text-muted" title="Return on Average Investment (total return with dividends)"
i18n-title >(ROAI)</abbr
title="Return on Average Investment (total return with dividends)" >
>(ROAI)</abbr }
> @case (PerformanceCalculationType.ROI) {
<abbr
class="d-none d-sm-inline-block initialism ml-2 text-muted"
i18n-title
title="Return on Investment (total return with dividends)"
>(ROI)</abbr
>
}
} }
</div> </div>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">

5
libs/common/src/lib/dtos/update-user-setting.dto.ts

@ -5,6 +5,7 @@ import type {
HoldingsViewMode, HoldingsViewMode,
ViewMode ViewMode
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code'; import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code';
import { import {
@ -100,6 +101,10 @@ export class UpdateUserSettingDto {
@IsOptional() @IsOptional()
locale?: string; locale?: string;
@IsIn([PerformanceCalculationType.ROAI, PerformanceCalculationType.ROI])
@IsOptional()
performanceCalculationType?: PerformanceCalculationType;
/** /**
* The target financial amount the user aims to reach before retiring. * The target financial amount the user aims to reach before retiring.
* Can be explicitly set to null to clear the value and calculate it dynamically. * Can be explicitly set to null to clear the value and calculate it dynamically.

Loading…
Cancel
Save