Browse Source

Feature/return on investment (ROI) portfolio calculator (#7993)

* Implement ROI portfolio calculator

* Update changelog
main
Thomas Kaul 6 hours ago
committed by GitHub
parent
commit
e1feba1087
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 2
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  3. 185
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy-and-buy.spec.ts
  4. 222
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy-and-sell.spec.ts
  5. 276
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-buy.spec.ts
  6. 237
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-sell-and-buy-on-same-day.spec.ts
  7. 366
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  8. 357
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-cash.spec.ts
  9. 202
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-msft-buy-with-dividend.spec.ts
  10. 610
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts
  11. 7
      apps/api/src/app/user/user.controller.ts
  12. 15
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
  13. 7
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  14. 15
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  15. 5
      libs/common/src/lib/dtos/update-user-setting.dto.ts

1
CHANGELOG.md

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added the _Return on Investment_ (ROI) performance calculation type (experimental)
- Added a tool to update the cash balance of an account to the server of the Model Context Protocol (MCP) (experimental)
### Changed

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
})

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
});
});
});

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
}
});
});
});
});

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

@ -0,0 +1,366 @@
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 { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
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', '2017', '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'),
'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')
},
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
},
'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,
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
}
});
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
});
});
});
});

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
}
});
});
});
});

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

@ -1,28 +1,332 @@
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 { 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 { 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 { DateRange } from '@ghostfolio/common/types';
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. It fills the
* average investment of the holding performance. The average investment values
* per date stay empty, because the invested capital of the portfolio is not the
* sum of the holdings.
*
* 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(): PortfolioSnapshot {
throw new Error('Method not implemented.');
protected calculateOverallPerformance(
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({
accumulatedValuesByDate
}: {
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;
} {
throw new Error('Method not implemented.');
} = {};
// The dates are in chronological order
for (const [
date,
{
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
);
}
protected calculatePerformancePercentagesForDateRange(): {
previousTotalInvestmentValue = totalInvestmentValue;
previousTotalInvestmentValueWithCurrencyEffect =
totalInvestmentValueWithCurrencyEffect;
performancePercentagesByDate[date] = {
dividendInPercentageWithCurrencyEffect:
investedCapitalWithCurrencyEffect.eq(0)
? 0
: totalDividendValueWithCurrencyEffect
.div(investedCapitalWithCurrencyEffect)
.toNumber(),
netPerformanceInPercentage: investedCapital.eq(0)
? 0
: totalNetPerformanceValue.div(investedCapital).toNumber(),
netPerformanceInPercentageWithCurrencyEffect:
investedCapitalWithCurrencyEffect.eq(0)
? 0
: totalNetPerformanceValueWithCurrencyEffect
.div(investedCapitalWithCurrencyEffect)
.toNumber()
};
}
return performancePercentagesByDate;
}
protected calculatePerformancePercentagesForDateRange({
historicalDataItems
}: {
historicalDataItems: HistoricalDataItem[];
}): { [date: string]: PerformancePercentages } {
let investedCapital = 0;
let investedCapitalWithCurrencyEffect = 0;
const performancePercentagesByDate: {
[date: string]: PerformancePercentages;
} {
throw new Error('Method not implemented.');
} = {};
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 {
// 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;
if (investmentChange > 0) {
investedCapital += investmentChange;
}
const investmentChangeWithCurrencyEffect =
historicalDataItem.totalInvestmentValueWithCurrencyEffect -
historicalDataItems[index - 1].totalInvestmentValueWithCurrencyEffect;
if (investmentChangeWithCurrencyEffect > 0) {
investedCapitalWithCurrencyEffect +=
investmentChangeWithCurrencyEffect;
}
}
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[];
end: Date;
exchangeRates: { [dateString: string]: number };
@ -31,7 +335,291 @@ export class RoiPortfolioCalculator extends PortfolioCalculator {
};
start: Date;
} & AssetProfileIdentifier): HoldingPerformance {
throw new Error('Method not implemented.');
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
);
}
}
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: {},
averageInvestmentValuesWithCurrencyEffect: {},
averageInvestmentWithCurrencyEffect: investedCapitalWithCurrencyEffect,
grossPerformance: totalGrossPerformance,
grossPerformanceWithCurrencyEffect:
totalGrossPerformanceWithCurrencyEffect,
hasErrors: totalQuantity.gt(0) && (!initialValue || !unitPriceAtEndDate),
netPerformance: totalNetPerformance
};
}
protected getPerformanceCalculationType() {

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

@ -244,6 +244,13 @@ export class UserController {
delete userSettings.isGhostfolioAiEnabled;
}
if (
!userSettings.isExperimentalFeatures ||
this.request.user.subscription
) {
delete userSettings.performanceCalculationType;
}
return this.userService.updateUserSetting({
emitPortfolioChangedEvent,
userSettings,

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

@ -149,10 +149,8 @@
<div class="d-flex py-1">
<div class="flex-grow-1 indent-1 text-truncate">
<ng-container i18n>Net Performance</ng-container>
@if (
user?.settings?.performanceCalculationType ===
PerformanceCalculationType.ROAI
) {
@switch (user?.settings?.performanceCalculationType) {
@case (PerformanceCalculationType.ROAI) {
<abbr
class="d-none d-sm-inline-block initialism ms-2 text-body-secondary"
i18n-title
@ -160,6 +158,15 @@
>(ROAI)</abbr
>
}
@case (PerformanceCalculationType.ROI) {
<abbr
class="d-none d-sm-inline-block initialism ms-2 text-body-secondary"
i18n-title
title="Return on Investment (total return with dividends)"
>(ROI)</abbr
>
}
}
</div>
<div class="flex-column flex-wrap justify-content-end">
<gf-value

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

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

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

@ -254,10 +254,8 @@
<div class="d-flex ms-3 py-1">
<div class="flex-grow-1 me-2 text-truncate">
<ng-container i18n>Net Performance</ng-container>
@if (
user?.settings?.performanceCalculationType ===
PerformanceCalculationType.ROAI
) {
@switch (user?.settings?.performanceCalculationType) {
@case (PerformanceCalculationType.ROAI) {
<abbr
class="d-none d-sm-inline-block initialism ms-2 text-body-secondary"
i18n-title
@ -265,6 +263,15 @@
>(ROAI)</abbr
>
}
@case (PerformanceCalculationType.ROI) {
<abbr
class="d-none d-sm-inline-block initialism ms-2 text-body-secondary"
i18n-title
title="Return on Investment (total return with dividends)"
>(ROI)</abbr
>
}
}
</div>
<div class="d-flex justify-content-end">
<gf-value

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

@ -5,6 +5,7 @@ import type {
HoldingsViewMode,
ViewMode
} from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code';
import {
@ -100,6 +101,10 @@ export class UpdateUserSettingDto {
@IsOptional()
locale?: string;
@IsIn([PerformanceCalculationType.ROAI, PerformanceCalculationType.ROI])
@IsOptional()
performanceCalculationType?: PerformanceCalculationType;
/**
* 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.

Loading…
Cancel
Save