Browse Source

Implement performance percentages of ROI portfolio calculator

pull/8188/head
Pedro Moreira 1 week ago
parent
commit
1255af450a
  1. 9
      apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts
  2. 10
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  3. 4
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  4. 279
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.spec.ts
  5. 118
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts
  6. 9
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts
  7. 2
      libs/common/src/lib/interfaces/historical-data-item.interface.ts

9
apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts

@ -1,7 +1,10 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface';
import { PerformancePercentages } from '@ghostfolio/api/app/portfolio/types/performance-percentages.type';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import {
AssetProfileIdentifier,
HistoricalDataItem
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot } from '@ghostfolio/common/models';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
@ -22,6 +25,10 @@ export class MwrPortfolioCalculator extends PortfolioCalculator {
throw new Error('Method not implemented.');
}
protected getAdditionalHistoricalDataItemValues(): Partial<HistoricalDataItem> {
throw new Error('Method not implemented.');
}
protected getHoldingPerformance({}: {
chartDates: string[];
end: Date;

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

@ -735,6 +735,7 @@ export abstract class PortfolioCalculator {
return {
...performancePercentagesByDate[date],
...this.getAdditionalHistoricalDataItemValues(values),
date,
dividendInBaseCurrency: totalDividendValueWithCurrencyEffect.toNumber(),
investmentValueWithCurrencyEffect:
@ -912,6 +913,15 @@ export abstract class PortfolioCalculator {
});
}
/**
* Returns the values of a historical data item which the performance
* calculation type requires in addition, e.g. to calculate the performance
* percentages of a date range
*/
protected abstract getAdditionalHistoricalDataItemValues(
accumulatedValues: AccumulatedValues
): Partial<HistoricalDataItem>;
public getDataProviderInfos() {
return this.dataProviderInfos;
}

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

@ -250,6 +250,10 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
return performancePercentagesByDate;
}
protected getAdditionalHistoricalDataItemValues() {
return {};
}
protected getHoldingPerformance({
chartDates,
dataSource,

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

@ -0,0 +1,279 @@
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 { AccumulatedValues } from '@ghostfolio/api/app/portfolio/interfaces/accumulated-values.interface';
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';
import { Big } from 'big.js';
import { PortfolioCalculator } from '../portfolio-calculator';
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 getAccumulatedValues({
totalBuyValue,
totalDividendValue = 0,
totalNetPerformanceValue
}: {
totalBuyValue: number;
totalDividendValue?: number;
totalNetPerformanceValue: number;
}): AccumulatedValues {
return {
investmentValueWithCurrencyEffect: new Big(0),
totalAverageInvestmentValue: new Big(0),
totalAverageInvestmentValueWithCurrencyEffect: new Big(0),
totalBuyValue: new Big(totalBuyValue),
totalBuyValueWithCurrencyEffect: new Big(totalBuyValue),
totalCashValueWithCurrencyEffect: new Big(0),
totalCurrentValue: new Big(0),
totalCurrentValueWithCurrencyEffect: new Big(0),
totalDividendValueWithCurrencyEffect: new Big(totalDividendValue),
totalInvestmentValue: new Big(0),
totalInvestmentValueWithCurrencyEffect: new Big(0),
totalNetPerformanceValue: new Big(totalNetPerformanceValue),
totalNetPerformanceValueWithCurrencyEffect: new Big(
totalNetPerformanceValue
),
totalNetWorthValueWithCurrencyEffect: new Big(0)
};
}
function getHistoricalDataItem({
date,
dividendInBaseCurrency = 0,
netPerformance,
totalBuyValue,
value
}: {
date: string;
dividendInBaseCurrency?: number;
netPerformance: number;
totalBuyValue: number;
value: number;
}): HistoricalDataItem {
return {
date,
dividendInBaseCurrency,
netPerformance,
totalBuyValue,
value,
netPerformanceWithCurrencyEffect: netPerformance,
totalBuyValueWithCurrencyEffect: totalBuyValue,
valueWithCurrencyEffect: value
};
}
describe('RoiPortfolioCalculator', () => {
let portfolioCalculator: PortfolioCalculator;
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
const portfolioCalculatorFactory = new PortfolioCalculatorFactory(
new ConfigurationService(),
new CurrentRateService(null, null, null),
new ExchangeRateDataService(null, null, null, null),
new PortfolioSnapshotService(null, null),
new RedisCacheService(null, null)
);
portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities: [],
calculationType: PerformanceCalculationType.ROI,
currency: 'CHF',
userId: userDummyData.id
});
});
describe('calculate performance percentages', () => {
it('divides the net performance by the total buy value', () => {
const performancePercentagesByDate = portfolioCalculator[
'calculatePerformancePercentages'
]({
accumulatedValuesByDate: {
'2024-12-31': getAccumulatedValues({
totalBuyValue: 10000,
totalDividendValue: 100,
totalNetPerformanceValue: 500
})
}
});
expect(performancePercentagesByDate['2024-12-31']).toEqual({
dividendInPercentageWithCurrencyEffect: 0.01,
netPerformanceInPercentage: 0.05,
netPerformanceInPercentageWithCurrencyEffect: 0.05
});
});
it('returns 0 without a buy', () => {
const performancePercentagesByDate = portfolioCalculator[
'calculatePerformancePercentages'
]({
accumulatedValuesByDate: {
'2024-12-31': getAccumulatedValues({
totalBuyValue: 0,
totalNetPerformanceValue: 0
})
}
});
expect(performancePercentagesByDate['2024-12-31']).toEqual({
dividendInPercentageWithCurrencyEffect: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0
});
});
});
describe('calculate performance percentages for date range', () => {
it('with buys only within the date range', () => {
const performancePercentagesByDate = portfolioCalculator[
'calculatePerformancePercentagesForDateRange'
]({
historicalDataItems: [
getHistoricalDataItem({
date: '2023-12-31',
netPerformance: 0,
totalBuyValue: 0,
value: 0
}),
getHistoricalDataItem({
date: '2024-01-01',
netPerformance: 0,
totalBuyValue: 1000,
value: 1000
}),
getHistoricalDataItem({
date: '2024-12-31',
netPerformance: 500,
totalBuyValue: 10000,
value: 10500
})
]
});
// 500 / (0 + 10000 - 0) = 0.05
expect(
performancePercentagesByDate['2024-12-31'].netPerformanceInPercentage
).toBeCloseTo(0.05, 10);
expect(
performancePercentagesByDate['2024-12-31']
.netPerformanceInPercentageWithCurrencyEffect
).toBeCloseTo(0.05, 10);
});
it('with a value at the start date of the date range', () => {
const performancePercentagesByDate = portfolioCalculator[
'calculatePerformancePercentagesForDateRange'
]({
historicalDataItems: [
// The netPerformance is already rebased to the start date
getHistoricalDataItem({
date: '2024-01-01',
netPerformance: 0,
totalBuyValue: 1000,
value: 2000
}),
getHistoricalDataItem({
date: '2024-12-31',
dividendInBaseCurrency: 30,
netPerformance: 300,
totalBuyValue: 2000,
value: 3300
})
]
});
// 300 / (2000 + 2000 - 1000) = 0.1
expect(
performancePercentagesByDate['2024-12-31'].netPerformanceInPercentage
).toBeCloseTo(0.1, 10);
// 30 / (2000 + 2000 - 1000) = 0.01
expect(
performancePercentagesByDate['2024-12-31']
.dividendInPercentageWithCurrencyEffect
).toBeCloseTo(0.01, 10);
});
it('returns 0 without a value and without a buy', () => {
const performancePercentagesByDate = portfolioCalculator[
'calculatePerformancePercentagesForDateRange'
]({
historicalDataItems: [
getHistoricalDataItem({
date: '2024-01-01',
netPerformance: 0,
totalBuyValue: 0,
value: 0
}),
getHistoricalDataItem({
date: '2024-12-31',
netPerformance: 0,
totalBuyValue: 0,
value: 0
})
]
});
expect(performancePercentagesByDate['2024-12-31']).toEqual({
dividendInPercentageWithCurrencyEffect: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0
});
});
});
describe('get additional historical data item values', () => {
it('returns the total buy values', () => {
expect(
portfolioCalculator['getAdditionalHistoricalDataItemValues'](
getAccumulatedValues({
totalBuyValue: 10000,
totalNetPerformanceValue: 500
})
)
).toEqual({
totalBuyValue: 10000,
totalBuyValueWithCurrencyEffect: 10000
});
});
});
});

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

@ -1,25 +1,125 @@
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 { PerformancePercentages } from '@ghostfolio/api/app/portfolio/types/performance-percentages.type';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import {
AssetProfileIdentifier,
HistoricalDataItem
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot } from '@ghostfolio/common/models';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
export class RoiPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance(): PortfolioSnapshot {
throw new Error('Method not implemented.');
}
protected calculatePerformancePercentages(): {
[date: string]: PerformancePercentages;
} {
throw new Error('Method not implemented.');
protected calculatePerformancePercentages({
accumulatedValuesByDate
}: {
accumulatedValuesByDate: { [date: string]: AccumulatedValues };
}): { [date: string]: PerformancePercentages } {
const performancePercentagesByDate: {
[date: string]: PerformancePercentages;
} = {};
// The portfolio snapshot starts before the first activity, thus the
// invested capital of each date is the total buy value
for (const [
date,
{
totalBuyValue,
totalBuyValueWithCurrencyEffect,
totalDividendValueWithCurrencyEffect,
totalNetPerformanceValue,
totalNetPerformanceValueWithCurrencyEffect
}
] of Object.entries(accumulatedValuesByDate)) {
performancePercentagesByDate[date] = {
dividendInPercentageWithCurrencyEffect:
totalBuyValueWithCurrencyEffect.eq(0)
? 0
: totalDividendValueWithCurrencyEffect
.div(totalBuyValueWithCurrencyEffect)
.toNumber(),
netPerformanceInPercentage: totalBuyValue.eq(0)
? 0
: totalNetPerformanceValue.div(totalBuyValue).toNumber(),
netPerformanceInPercentageWithCurrencyEffect:
totalBuyValueWithCurrencyEffect.eq(0)
? 0
: totalNetPerformanceValueWithCurrencyEffect
.div(totalBuyValueWithCurrencyEffect)
.toNumber()
};
}
return performancePercentagesByDate;
}
protected calculatePerformancePercentagesForDateRange(): {
[date: string]: PerformancePercentages;
} {
throw new Error('Method not implemented.');
protected calculatePerformancePercentagesForDateRange({
historicalDataItems
}: {
historicalDataItems: HistoricalDataItem[];
}): { [date: string]: PerformancePercentages } {
const performancePercentagesByDate: {
[date: string]: PerformancePercentages;
} = {};
if (historicalDataItems.length === 0) {
return performancePercentagesByDate;
}
// The invested capital of the date range is the value at its start date
// plus the value of each buy within the date range
const [firstHistoricalDataItem] = historicalDataItems;
for (const historicalDataItem of historicalDataItems) {
const investedCapital = new Big(firstHistoricalDataItem.value ?? 0)
.plus(historicalDataItem.totalBuyValue ?? 0)
.minus(firstHistoricalDataItem.totalBuyValue ?? 0);
const investedCapitalWithCurrencyEffect = new Big(
firstHistoricalDataItem.valueWithCurrencyEffect ?? 0
)
.plus(historicalDataItem.totalBuyValueWithCurrencyEffect ?? 0)
.minus(firstHistoricalDataItem.totalBuyValueWithCurrencyEffect ?? 0);
performancePercentagesByDate[historicalDataItem.date] = {
dividendInPercentageWithCurrencyEffect:
investedCapitalWithCurrencyEffect.gt(0)
? new Big(historicalDataItem.dividendInBaseCurrency ?? 0)
.div(investedCapitalWithCurrencyEffect)
.toNumber()
: 0,
netPerformanceInPercentage: investedCapital.gt(0)
? new Big(historicalDataItem.netPerformance ?? 0)
.div(investedCapital)
.toNumber()
: 0,
netPerformanceInPercentageWithCurrencyEffect:
investedCapitalWithCurrencyEffect.gt(0)
? new Big(historicalDataItem.netPerformanceWithCurrencyEffect ?? 0)
.div(investedCapitalWithCurrencyEffect)
.toNumber()
: 0
};
}
return performancePercentagesByDate;
}
protected getAdditionalHistoricalDataItemValues({
totalBuyValue,
totalBuyValueWithCurrencyEffect
}: AccumulatedValues): Partial<HistoricalDataItem> {
return {
totalBuyValue: totalBuyValue.toNumber(),
totalBuyValueWithCurrencyEffect:
totalBuyValueWithCurrencyEffect.toNumber()
};
}
protected getHoldingPerformance({}: {

9
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts

@ -1,7 +1,10 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface';
import { PerformancePercentages } from '@ghostfolio/api/app/portfolio/types/performance-percentages.type';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import {
AssetProfileIdentifier,
HistoricalDataItem
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot } from '@ghostfolio/common/models';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
@ -22,6 +25,10 @@ export class TwrPortfolioCalculator extends PortfolioCalculator {
throw new Error('Method not implemented.');
}
protected getAdditionalHistoricalDataItemValues(): Partial<HistoricalDataItem> {
throw new Error('Method not implemented.');
}
protected getHoldingPerformance({}: {
chartDates: string[];
end: Date;

2
libs/common/src/lib/interfaces/historical-data-item.interface.ts

@ -13,6 +13,8 @@ export interface HistoricalDataItem {
netWorth?: number;
netWorthInPercentage?: number;
quantity?: number;
totalBuyValue?: number;
totalBuyValueWithCurrencyEffect?: number;
totalCashInBaseCurrency?: number;
totalInvestment?: number;
totalInvestmentValueWithCurrencyEffect?: number;

Loading…
Cancel
Save