Browse Source

Bugfix/time weighted average investment in ROAI calculator (#8074)

* Fix time weighted average investment

* Update changelog
pull/8080/head
Thomas Kaul 4 days ago
committed by GitHub
parent
commit
6db9228b2a
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 15
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  3. 79
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  4. 31
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-fee-and-dividend.spec.ts
  5. 19
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts
  6. 185
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts
  7. 119
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts

1
CHANGELOG.md

@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixed the visibility of the asset class, asset sub class, fee and quantity fields of a valuable in the create activity dialog
- Fixed the missing mapping for Aland Islands in the country weightings of the _Financial Modeling Prep_ service
- Fixed the net performance percentage of date ranges in the portfolio performance calculation by weighting the average investment by the number of days between the chart dates
## 3.80.2 - 2026-10-06

15
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts

@ -362,6 +362,18 @@ export abstract class PortfolioCalculator {
return chartDate;
});
// The chart dates are the same for each holding and each date range. Thus
// calculate the number of days until the next chart date one time only.
const parsedChartDates = chartDates.map((chartDate) => {
return parseDate(chartDate);
});
const daysUntilNextChartDate = parsedChartDates
.slice(1)
.map((nextChartDate, index) => {
return differenceInDays(nextChartDate, parsedChartDates[index]);
});
const errors: ResponseError['errors'] = [];
let hasAnyHoldingPerformanceErrors = false;
@ -438,6 +450,7 @@ export abstract class PortfolioCalculator {
totalLiabilitiesInBaseCurrency
} = this.getHoldingPerformance({
chartDates,
daysUntilNextChartDate,
marketSymbolMap,
dataSource: item.dataSource,
end: this.endDate,
@ -935,6 +948,7 @@ export abstract class PortfolioCalculator {
protected abstract getHoldingPerformance({
chartDates,
dataSource,
daysUntilNextChartDate,
end,
exchangeRates,
marketSymbolMap,
@ -942,6 +956,7 @@ export abstract class PortfolioCalculator {
symbol
}: {
chartDates: string[];
daysUntilNextChartDate: number[];
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {

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

@ -112,7 +112,7 @@ describe('PortfolioCalculator', () => {
const performanceByDateRange = await getPerformanceByDateRange({
portfolioCalculator,
dateRanges: ['1d', 'max', 'ytd']
dateRanges: ['1d', '2017', 'max', 'ytd']
});
const investments = portfolioCalculator.getInvestments();
@ -162,7 +162,8 @@ describe('PortfolioCalculator', () => {
netPerformancePercentage: new Big('42.41978276196153750666'),
netPerformancePercentageWithCurrencyEffectMap: {
'1d': new Big('-0.04016229506406263535'),
max: new Big('41.72313811883729606471'),
'2017': new Big('43.14843430282283692638'),
max: new Big('41.65910103572163194783'),
ytd: new Big('-0.04016229506406263535')
},
netPerformanceWithCurrencyEffectMap: {
@ -258,11 +259,20 @@ describe('PortfolioCalculator', () => {
totalInvestmentValueWithCurrencyEffect: 318.54266729999995,
valueWithCurrencyEffect: 13298.425356
},
'2017': {
date: '2017-12-31',
netPerformance: 26957.033439,
netPerformanceInPercentage: 44.109822737327406,
netPerformanceInPercentageWithCurrencyEffect: 43.14859823223876,
netPerformanceWithCurrencyEffect: 27081.23736,
totalInvestmentValueWithCurrencyEffect: 318.54266729999995,
valueWithCurrencyEffect: 13854.86868
},
max: {
date: '2018-01-01',
netPerformance: 26458.9121202,
netPerformanceInPercentage: 42.50435329547954,
netPerformanceInPercentageWithCurrencyEffect: 41.72313811883715,
netPerformanceInPercentage: 42.43911719562077,
netPerformanceInPercentageWithCurrencyEffect: 41.65910103572173,
netPerformanceWithCurrencyEffect: 26516.2087014,
totalInvestmentValueWithCurrencyEffect: 318.54266729999995,
valueWithCurrencyEffect: 13298.425356
@ -278,5 +288,66 @@ describe('PortfolioCalculator', () => {
}
});
});
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
// average investment. 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.ROAI,
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.14843430282283692638'),
max: new Big('41.65910103572163194783')
});
// Other weights give another rounding of the floating point numbers
expect(
performanceByDateRange['2017'].netPerformanceInPercentage
).toBeCloseTo(44.109822737327406, 10);
expect(
performanceByDateRange['2017']
.netPerformanceInPercentageWithCurrencyEffect
).toBeCloseTo(43.14859823223876, 10);
expect(performanceByDateRange.max.netPerformanceInPercentage).toBeCloseTo(
42.43911719562077,
10
);
expect(
performanceByDateRange.max.netPerformanceInPercentageWithCurrencyEffect
).toBeCloseTo(41.65910103572173, 10);
});
});
});

31
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-fee-and-dividend.spec.ts

@ -190,9 +190,12 @@ describe('PortfolioCalculator', () => {
expect(performanceByDateRange).toMatchObject({
max: {
dividendInBaseCurrency: 5.62,
dividendInPercentageWithCurrencyEffect: 0.01882242615044535,
dividendInPercentageWithCurrencyEffect: expect.closeTo(
0.01882242615044535,
10
),
netPerformance: 18.87,
netPerformanceInPercentage: 0.06319914260834586
netPerformanceInPercentage: expect.closeTo(0.06319914260834586, 10)
}
});
});
@ -282,9 +285,12 @@ describe('PortfolioCalculator', () => {
expect(performanceByDateRange).toMatchObject({
max: {
dividendInBaseCurrency: 5.62,
dividendInPercentageWithCurrencyEffect: 0.01882242615044535,
dividendInPercentageWithCurrencyEffect: expect.closeTo(
0.01882242615044535,
10
),
netPerformance: 19.87,
netPerformanceInPercentage: 0.06654832875611194
netPerformanceInPercentage: expect.closeTo(0.06654832875611194, 10)
}
});
});
@ -373,9 +379,12 @@ describe('PortfolioCalculator', () => {
expect(performanceByDateRange).toMatchObject({
max: {
dividendInBaseCurrency: 0.62,
dividendInPercentageWithCurrencyEffect: 0.002076495411614967,
dividendInPercentageWithCurrencyEffect: expect.closeTo(
0.002076495411614967,
10
),
netPerformance: 14.87,
netPerformanceInPercentage: 0.049802398017281556
netPerformanceInPercentage: expect.closeTo(0.049802398017281556, 10)
}
});
});
@ -458,11 +467,17 @@ describe('PortfolioCalculator', () => {
},
max: {
dividendInBaseCurrency: 1.3,
dividendInPercentageWithCurrencyEffect: 0.004353941992095899
dividendInPercentageWithCurrencyEffect: expect.closeTo(
0.004353941992095899,
10
)
},
ytd: {
dividendInBaseCurrency: 0.68,
dividendInPercentageWithCurrencyEffect: 0.002002886512915673
dividendInPercentageWithCurrencyEffect: expect.closeTo(
0.002002886512915673,
10
)
}
});
});

19
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts

@ -177,10 +177,16 @@ describe('PortfolioCalculator', () => {
max: {
date: '2023-07-10',
dividendInBaseCurrency: 0.62,
dividendInPercentageWithCurrencyEffect: 0.002076495411614967,
dividendInPercentageWithCurrencyEffect: expect.closeTo(
0.002076495411614967,
10
),
netPerformance: 14.87,
netPerformanceInPercentage: 0.049802398017281556,
netPerformanceInPercentageWithCurrencyEffect: 0.049802398017281556,
netPerformanceInPercentage: expect.closeTo(0.049802398017281556, 10),
netPerformanceInPercentageWithCurrencyEffect: expect.closeTo(
0.049802398017281556,
10
),
netPerformanceWithCurrencyEffect: 14.87,
totalInvestmentValueWithCurrencyEffect: 298.58,
valueWithCurrencyEffect: 331.83
@ -190,8 +196,11 @@ describe('PortfolioCalculator', () => {
dividendInBaseCurrency: 0,
dividendInPercentageWithCurrencyEffect: 0,
netPerformance: -7.6800000000000015,
netPerformanceInPercentage: -0.022620835910577015,
netPerformanceInPercentageWithCurrencyEffect: -0.022620835910577015,
netPerformanceInPercentage: expect.closeTo(-0.022620835910577015, 10),
netPerformanceInPercentageWithCurrencyEffect: expect.closeTo(
-0.022620835910577015,
10
),
netPerformanceWithCurrencyEffect: -7.6800000000000015,
totalInvestmentValueWithCurrencyEffect: 298.58,
valueWithCurrencyEffect: 331.83

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

@ -1,3 +1,186 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { 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 { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
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;
})
};
});
function getHistoricalDataItem({
date,
investment,
netPerformance
}: {
date: string;
investment: number;
netPerformance: number;
}): HistoricalDataItem {
return {
date,
netPerformance,
dividendInBaseCurrency: 0,
netPerformanceWithCurrencyEffect: netPerformance,
totalInvestment: investment,
totalInvestmentValueWithCurrencyEffect: investment,
value: investment + netPerformance,
valueWithCurrencyEffect: investment + netPerformance
};
}
describe('PortfolioCalculator', () => {
test.skip('Skip empty test', () => 1);
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculator: PortfolioCalculator;
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
);
portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities: [],
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
userId: userDummyData.id
});
});
describe('calculate performance percentages for date range', () => {
it('with a buy followed by a gap between chart dates', () => {
const performancePercentagesByDate = portfolioCalculator[
'calculatePerformancePercentagesForDateRange'
]({
historicalDataItems: [
getHistoricalDataItem({
date: '2017-01-01',
investment: 100,
netPerformance: 0
}),
// Buy, followed by a gap of 21 days until the next chart date
getHistoricalDataItem({
date: '2017-01-02',
investment: 200,
netPerformance: 0
}),
// Sell
getHistoricalDataItem({
date: '2017-01-23',
investment: 100,
netPerformance: 44
})
]
});
// The investment of each chart date applies to each day until the next
// chart date. The investment of the last chart date counts for this day
// only: (1 * 100 + 21 * 200 + 1 * 100) / 23 = 4400 / 23
expect(
performancePercentagesByDate['2017-01-23'].netPerformanceInPercentage
).toBeCloseTo(0.23, 10);
expect(
performancePercentagesByDate['2017-01-23']
.netPerformanceInPercentageWithCurrencyEffect
).toBeCloseTo(0.23, 10);
});
it('with an additional chart date of the same investment', () => {
const performancePercentagesByDate = portfolioCalculator[
'calculatePerformancePercentagesForDateRange'
]({
historicalDataItems: [
getHistoricalDataItem({
date: '2017-01-01',
investment: 100,
netPerformance: 0
}),
getHistoricalDataItem({
date: '2017-01-02',
investment: 200,
netPerformance: 0
}),
// An additional chart date splits the gap but must not change the
// average investment
getHistoricalDataItem({
date: '2017-01-12',
investment: 200,
netPerformance: 20
}),
getHistoricalDataItem({
date: '2017-01-23',
investment: 100,
netPerformance: 44
})
]
});
expect(
performancePercentagesByDate['2017-01-23'].netPerformanceInPercentage
).toBeCloseTo(0.23, 10);
expect(
performancePercentagesByDate['2017-01-23']
.netPerformanceInPercentageWithCurrencyEffect
).toBeCloseTo(0.23, 10);
});
});
});

119
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts

@ -29,7 +29,6 @@ import {
isBefore,
isThisYear
} from 'date-fns';
import { sum } from 'lodash-es';
export class RoaiPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance(
@ -210,16 +209,21 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
}: {
historicalDataItems: HistoricalDataItem[];
}): { [date: string]: PerformancePercentages } {
const averageInvestmentValues: number[] = [];
const averageInvestmentValuesWithCurrencyEffect: number[] = [];
let grossPerformanceAtStartDate: number;
let grossPerformanceWithCurrencyEffectAtStartDate: number;
let previousDate: Date;
let sumOfWeightedInvestments = 0;
let sumOfWeightedInvestmentsWithCurrencyEffect = 0;
let totalInvestmentDays = 0;
let totalInvestmentDaysWithCurrencyEffect = 0;
const performancePercentagesByDate: {
[date: string]: PerformancePercentages;
} = {};
for (const [index, historicalDataItem] of historicalDataItems.entries()) {
const date = parseDate(historicalDataItem.date);
// Take the values at the start date from the first day of the date
// range
if (index === 0) {
@ -229,34 +233,53 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
grossPerformanceWithCurrencyEffectAtStartDate =
historicalDataItem.valueWithCurrencyEffect -
historicalDataItem.totalInvestmentValueWithCurrencyEffect;
}
} else {
// The chart dates are not evenly spaced, and the investment changes on
// a chart date only. Thus the investment of the previous chart date
// applies to each day until this chart date.
const previousHistoricalDataItem = historicalDataItems[index - 1];
// Add the gross performance at the start date of the range to the
// investment of each day. Thus the range starts with the value of its
// first day, and subsequent buy and sell activities stay included.
if (historicalDataItem.totalInvestment > 0) {
averageInvestmentValues.push(
historicalDataItem.totalInvestment + grossPerformanceAtStartDate
);
const daysSincePreviousChartDate = differenceInDays(date, previousDate);
if (previousHistoricalDataItem.totalInvestment > 0) {
sumOfWeightedInvestments +=
(previousHistoricalDataItem.totalInvestment +
grossPerformanceAtStartDate) *
daysSincePreviousChartDate;
totalInvestmentDays += daysSincePreviousChartDate;
}
if (historicalDataItem.totalInvestmentValueWithCurrencyEffect > 0) {
averageInvestmentValuesWithCurrencyEffect.push(
historicalDataItem.totalInvestmentValueWithCurrencyEffect +
grossPerformanceWithCurrencyEffectAtStartDate
);
if (
previousHistoricalDataItem.totalInvestmentValueWithCurrencyEffect > 0
) {
sumOfWeightedInvestmentsWithCurrencyEffect +=
(previousHistoricalDataItem.totalInvestmentValueWithCurrencyEffect +
grossPerformanceWithCurrencyEffectAtStartDate) *
daysSincePreviousChartDate;
totalInvestmentDaysWithCurrencyEffect += daysSincePreviousChartDate;
}
}
const averageInvestmentValue =
averageInvestmentValues.length > 0
? sum(averageInvestmentValues) / averageInvestmentValues.length
: 0;
// Add the gross performance at the start date of the range to the
// investment of each day. Thus the range starts with the value of its
// first day, and subsequent buy and sell activities stay included.
const averageInvestmentValue = this.getAverageInvestment({
grossPerformanceAtStartDate,
sumOfWeightedInvestments,
totalInvestmentDays,
investment: historicalDataItem.totalInvestment
});
const averageInvestmentValueWithCurrencyEffect =
averageInvestmentValuesWithCurrencyEffect.length > 0
? sum(averageInvestmentValuesWithCurrencyEffect) /
averageInvestmentValuesWithCurrencyEffect.length
: 0;
this.getAverageInvestment({
grossPerformanceAtStartDate:
grossPerformanceWithCurrencyEffectAtStartDate,
investment: historicalDataItem.totalInvestmentValueWithCurrencyEffect,
sumOfWeightedInvestments: sumOfWeightedInvestmentsWithCurrencyEffect,
totalInvestmentDays: totalInvestmentDaysWithCurrencyEffect
});
performancePercentagesByDate[historicalDataItem.date] = {
dividendInPercentageWithCurrencyEffect:
@ -274,6 +297,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
averageInvestmentValueWithCurrencyEffect
: 0
};
previousDate = date;
}
return performancePercentagesByDate;
@ -282,6 +307,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
protected getHoldingPerformance({
chartDates,
dataSource,
daysUntilNextChartDate,
end,
exchangeRates,
marketSymbolMap,
@ -289,6 +315,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
symbol
}: {
chartDates: string[];
daysUntilNextChartDate: number[];
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {
@ -642,17 +669,28 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
break;
}
const isLastChartDateOfRange =
i === chartDates.length - 1 || chartDates[i + 1] > rangeEndDateString;
// The chart dates are not evenly spaced, and the investment changes
// on a chart date only. Thus the investment of a chart date applies to
// each day until the next chart date. The investment of the last chart
// date of the range counts for this day only.
const investmentDays = isLastChartDateOfRange
? 1
: daysUntilNextChartDate[i];
if (
investmentValuesAccumulatedWithCurrencyEffect[date] instanceof Big &&
investmentValuesAccumulatedWithCurrencyEffect[date].gt(0)
) {
average = average.add(
investmentValuesAccumulatedWithCurrencyEffect[date].add(
grossPerformanceAtDateRangeStartWithCurrencyEffect
)
investmentValuesAccumulatedWithCurrencyEffect[date]
.add(grossPerformanceAtDateRangeStartWithCurrencyEffect)
.mul(investmentDays)
);
dayCount++;
dayCount += investmentDays;
}
}
@ -753,4 +791,29 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
protected getPerformanceCalculationType() {
return PerformanceCalculationType.ROAI;
}
private getAverageInvestment({
grossPerformanceAtStartDate,
investment,
sumOfWeightedInvestments,
totalInvestmentDays
}: {
grossPerformanceAtStartDate: number;
investment: number;
sumOfWeightedInvestments: number;
totalInvestmentDays: number;
}) {
// The investment of the current chart date counts for this day only
if (investment > 0) {
return (
(sumOfWeightedInvestments +
(investment + grossPerformanceAtStartDate)) /
(totalInvestmentDays + 1)
);
}
return totalInvestmentDays > 0
? sumOfWeightedInvestments / totalInvestmentDays
: 0;
}
}

Loading…
Cancel
Save