Browse Source

Merge ef555af936 into 90c90e179e

pull/8221/merge
PJ 11 hours ago
committed by GitHub
parent
commit
fc3aec7d35
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 15
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  2. 986
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.spec.ts
  3. 614
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts

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

@ -101,6 +101,9 @@ export abstract class PortfolioCalculator {
private dataProviderInfos: DataProviderInfo[]; private dataProviderInfos: DataProviderInfo[];
private endDate: Date; private endDate: Date;
private exchangeRateDataService: ExchangeRateDataService; private exchangeRateDataService: ExchangeRateDataService;
private exchangeRatesByCurrency: {
[currencyPair: string]: { [date: string]: number };
} = {};
private filters: Filter[]; private filters: Filter[];
private holdingBalancesByDate: HoldingBalancesAtDate[]; private holdingBalancesByDate: HoldingBalancesAtDate[];
private portfolioSnapshotService: PortfolioSnapshotService; private portfolioSnapshotService: PortfolioSnapshotService;
@ -308,7 +311,7 @@ export abstract class PortfolioCalculator {
currencies[getAssetProfileIdentifier({ dataSource, symbol })] = currency; currencies[getAssetProfileIdentifier({ dataSource, symbol })] = currency;
} }
const exchangeRatesByCurrency = this.exchangeRatesByCurrency =
await this.exchangeRateDataService.getExchangeRatesByCurrency({ await this.exchangeRateDataService.getExchangeRatesByCurrency({
currencies: Array.from(new Set(Object.values(currencies))), currencies: Array.from(new Set(Object.values(currencies))),
endDate: this.endDate, endDate: this.endDate,
@ -316,6 +319,8 @@ export abstract class PortfolioCalculator {
targetCurrency: this.currency targetCurrency: this.currency
}); });
const exchangeRatesByCurrency = this.exchangeRatesByCurrency;
const { const {
dataProviderInfos, dataProviderInfos,
errors: currentRateErrors, errors: currentRateErrors,
@ -905,6 +910,14 @@ export abstract class PortfolioCalculator {
return this.dataProviderInfos; return this.dataProviderInfos;
} }
protected getBaseCurrency() {
return this.currency;
}
protected getExchangeRatesByCurrency() {
return this.exchangeRatesByCurrency;
}
protected getDividendInBaseCurrencyOfHoldings( protected getDividendInBaseCurrencyOfHoldings(
holdings: PortfolioSnapshotHolding[] holdings: PortfolioSnapshotHolding[]
) { ) {

986
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.spec.ts

@ -0,0 +1,986 @@
import {
activityDummyData,
assetProfileDummyData,
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 { eachDayOfInterval, isSameDay } from 'date-fns';
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 createActivity({
currency = 'USD',
dataSource = 'YAHOO',
date,
fee = 0,
name = 'TWR Test Asset',
quantity,
symbol = 'TWR_TEST',
type,
unitPrice
}: {
currency?: string;
dataSource?: Activity['assetProfile']['dataSource'];
date: string;
fee?: number;
name?: string;
quantity: number;
symbol?: string;
type: Activity['type'];
unitPrice: number;
}): Activity {
return {
...activityDummyData,
assetProfile: {
...assetProfileDummyData,
currency,
dataSource,
name,
symbol
},
date: parseDate(date),
feeInAssetProfileCurrency: fee,
feeInBaseCurrency: fee,
quantity,
type,
unitPriceInAssetProfileCurrency: unitPrice
};
}
function mockMarketPrices({
currentRateService,
getMarketPrice
}: {
currentRateService: CurrentRateService;
getMarketPrice: ({
date,
symbol
}: {
date: Date;
symbol: string;
}) => number | undefined;
}) {
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) {
const marketPrice = getMarketPrice({ date, symbol });
if (marketPrice !== undefined) {
values.push({
date,
dataSource,
symbol,
marketPrice
});
}
}
}
return Promise.resolve({
values,
dataProviderInfos: [],
errors: []
});
});
}
describe('PortfolioCalculator (TWR)', () => {
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
);
});
it('calculates a 10 percent return without external cash flows', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2025-01-02').getTime());
const activities: Activity[] = [
createActivity({
date: '2025-01-01',
quantity: 1,
type: 'BUY',
unitPrice: 100
})
];
mockMarketPrices({
currentRateService,
getMarketPrice: ({ date, symbol }) => {
if (symbol !== 'TWR_TEST') {
return undefined;
}
return isSameDay(parseDate('2025-01-02'), date) ? 110 : 100;
}
});
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
usePortfolioSnapshotCache: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(
portfolioSnapshot.historicalData.at(-1)
?.netPerformanceInPercentageWithCurrencyEffect
).toBeCloseTo(0.1, 12);
});
it('ignores an external cash inflow when there is no investment return', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2025-01-02').getTime());
const activities: Activity[] = [
createActivity({
date: '2025-01-01',
quantity: 1,
type: 'BUY',
unitPrice: 100
}),
createActivity({
date: '2025-01-02',
quantity: 1,
type: 'BUY',
unitPrice: 100
})
];
mockMarketPrices({
currentRateService,
getMarketPrice: ({ symbol }) => {
return symbol === 'TWR_TEST' ? 100 : undefined;
}
});
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
usePortfolioSnapshotCache: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(
portfolioSnapshot.historicalData.at(-1)
?.netPerformanceInPercentageWithCurrencyEffect
).toBeCloseTo(0, 12);
});
it('chain-links returns across an external cash inflow', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2025-01-03').getTime());
const activities: Activity[] = [
createActivity({
date: '2025-01-01',
quantity: 10,
type: 'BUY',
unitPrice: 100
}),
createActivity({
date: '2025-01-02',
quantity: 1000 / 110,
type: 'BUY',
unitPrice: 110
})
];
mockMarketPrices({
currentRateService,
getMarketPrice: ({ date, symbol }) => {
if (symbol !== 'TWR_TEST') {
return undefined;
}
if (isSameDay(parseDate('2025-01-02'), date)) {
return 110;
}
if (isSameDay(parseDate('2025-01-03'), date)) {
return 121;
}
return 100;
}
});
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
usePortfolioSnapshotCache: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(
portfolioSnapshot.historicalData.at(-1)
?.netPerformanceInPercentageWithCurrencyEffect
).toBeCloseTo(0.21, 12);
expect(
portfolioSnapshot.positions[0].netPerformancePercentageWithCurrencyEffectMap.max.toNumber()
).toBeCloseTo(0.21, 12);
expect(
portfolioSnapshot.positions[0].netPerformancePercentageWithCurrencyEffectMap[
'1d'
].toNumber()
).toBeCloseTo(0.1, 12);
const { chart } = await portfolioCalculator.getPerformance({
end: parseDate('2025-01-03'),
start: parseDate('2025-01-02')
});
expect(
chart.at(-1)?.netPerformanceInPercentageWithCurrencyEffect
).toBeCloseTo(0.1, 12);
});
it('ignores an external cash outflow when there is no investment return', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2025-01-02').getTime());
const activities: Activity[] = [
createActivity({
date: '2025-01-01',
quantity: 2,
type: 'BUY',
unitPrice: 100
}),
createActivity({
date: '2025-01-02',
quantity: 1,
type: 'SELL',
unitPrice: 100
})
];
mockMarketPrices({
currentRateService,
getMarketPrice: ({ symbol }) => {
return symbol === 'TWR_TEST' ? 100 : undefined;
}
});
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
usePortfolioSnapshotCache: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(
portfolioSnapshot.historicalData.at(-1)
?.netPerformanceInPercentageWithCurrencyEffect
).toBeCloseTo(0, 12);
});
it('treats a same-day sell and buy across holdings as an internal transfer', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2025-01-02').getTime());
const activities: Activity[] = [
createActivity({
date: '2025-01-01',
name: 'TWR Test Asset A',
quantity: 2,
symbol: 'TWR_A',
type: 'BUY',
unitPrice: 100
}),
createActivity({
date: '2025-01-02',
name: 'TWR Test Asset A',
quantity: 1,
symbol: 'TWR_A',
type: 'SELL',
unitPrice: 100
}),
createActivity({
date: '2025-01-02',
name: 'TWR Test Asset B',
quantity: 1,
symbol: 'TWR_B',
type: 'BUY',
unitPrice: 100
})
];
mockMarketPrices({
currentRateService,
getMarketPrice: ({ symbol }) => {
return ['TWR_A', 'TWR_B'].includes(symbol) ? 100 : undefined;
}
});
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
usePortfolioSnapshotCache: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(
portfolioSnapshot.historicalData.at(-1)
?.netPerformanceInPercentageWithCurrencyEffect
).toBeCloseTo(0, 12);
});
it('aggregates multiple same-day buys of the same holding as external cash flow', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2025-01-02').getTime());
const activities: Activity[] = [
createActivity({
date: '2025-01-01',
quantity: 1,
type: 'BUY',
unitPrice: 100
}),
createActivity({
date: '2025-01-02',
quantity: 1,
type: 'BUY',
unitPrice: 100
}),
createActivity({
date: '2025-01-02',
quantity: 2,
type: 'BUY',
unitPrice: 100
})
];
mockMarketPrices({
currentRateService,
getMarketPrice: ({ symbol }) => {
return symbol === 'TWR_TEST' ? 100 : undefined;
}
});
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
usePortfolioSnapshotCache: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(
portfolioSnapshot.historicalData.at(-1)
?.netPerformanceInPercentageWithCurrencyEffect
).toBeCloseTo(0, 12);
expect(
portfolioSnapshot.positions[0].netPerformancePercentage.toNumber()
).toBeCloseTo(0, 12);
});
it('treats dividends as investment return instead of external cash flow', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2025-01-02').getTime());
const activities: Activity[] = [
createActivity({
date: '2025-01-01',
quantity: 1,
type: 'BUY',
unitPrice: 100
}),
createActivity({
date: '2025-01-02',
quantity: 1,
type: 'DIVIDEND',
unitPrice: 10
})
];
mockMarketPrices({
currentRateService,
getMarketPrice: ({ symbol }) => {
return symbol === 'TWR_TEST' ? 100 : undefined;
}
});
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
usePortfolioSnapshotCache: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(
portfolioSnapshot.historicalData.at(-1)
?.netPerformanceInPercentageWithCurrencyEffect
).toBeCloseTo(0.1, 12);
expect(
portfolioSnapshot.historicalData.at(-1)
?.dividendInPercentageWithCurrencyEffect
).toBeCloseTo(0.1, 12);
expect(
portfolioSnapshot.positions[0].grossPerformancePercentage.toNumber()
).toBeCloseTo(0.1, 12);
expect(
portfolioSnapshot.positions[0].grossPerformancePercentageWithCurrencyEffect.toNumber()
).toBeCloseTo(0.1, 12);
expect(
portfolioSnapshot.positions[0].netPerformancePercentage.toNumber()
).toBeCloseTo(0.1, 12);
});
it('chain-links the dividend component across multiple periods', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2025-01-03').getTime());
const activities: Activity[] = [
createActivity({
date: '2025-01-01',
quantity: 1,
type: 'BUY',
unitPrice: 100
}),
createActivity({
date: '2025-01-02',
quantity: 1,
type: 'DIVIDEND',
unitPrice: 10
})
];
mockMarketPrices({
currentRateService,
getMarketPrice: ({ date, symbol }) => {
if (symbol !== 'TWR_TEST') {
return undefined;
}
if (isSameDay(parseDate('2025-01-02'), date)) {
return 110;
}
if (isSameDay(parseDate('2025-01-03'), date)) {
return 121;
}
return 100;
}
});
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
usePortfolioSnapshotCache: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const latestHistoricalDataItem = portfolioSnapshot.historicalData.at(-1);
expect(
latestHistoricalDataItem?.netPerformanceInPercentageWithCurrencyEffect
).toBeCloseTo(0.32, 12);
expect(
latestHistoricalDataItem?.dividendInPercentageWithCurrencyEffect
).toBeCloseTo(0.11, 12);
expect(
(latestHistoricalDataItem?.netPerformanceInPercentageWithCurrencyEffect ??
0) -
(latestHistoricalDataItem?.dividendInPercentageWithCurrencyEffect ?? 0)
).toBeCloseTo(0.21, 12);
const { chart } = await portfolioCalculator.getPerformance({
end: parseDate('2025-01-03'),
start: parseDate('2025-01-02')
});
expect(
chart.at(-1)?.netPerformanceInPercentageWithCurrencyEffect
).toBeCloseTo(0.1, 12);
expect(chart.at(-1)?.dividendInPercentageWithCurrencyEffect).toBeCloseTo(
0,
12
);
});
it('treats fees as negative investment return instead of external cash flow', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2025-01-02').getTime());
const activities: Activity[] = [
createActivity({
date: '2025-01-01',
quantity: 1,
type: 'BUY',
unitPrice: 100
}),
createActivity({
date: '2025-01-02',
fee: 10,
quantity: 0,
type: 'FEE',
unitPrice: 0
})
];
mockMarketPrices({
currentRateService,
getMarketPrice: ({ symbol }) => {
return symbol === 'TWR_TEST' ? 100 : undefined;
}
});
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
usePortfolioSnapshotCache: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(
portfolioSnapshot.historicalData.at(-1)
?.netPerformanceInPercentageWithCurrencyEffect
).toBeCloseTo(-0.1, 12);
expect(
portfolioSnapshot.positions[0].grossPerformancePercentage.toNumber()
).toBeCloseTo(0, 12);
expect(
portfolioSnapshot.positions[0].grossPerformancePercentageWithCurrencyEffect.toNumber()
).toBeCloseTo(0, 12);
expect(
portfolioSnapshot.positions[0].netPerformancePercentage.toNumber()
).toBeCloseTo(-0.1, 12);
});
it('includes a standalone fee in portfolio return', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2025-01-02').getTime());
const activities: Activity[] = [
createActivity({
date: '2025-01-01',
quantity: 1,
type: 'BUY',
unitPrice: 100
}),
createActivity({
dataSource: 'MANUAL',
date: '2025-01-02',
fee: 10,
name: 'Account Fee',
quantity: 0,
symbol: 'TWR_STANDALONE_FEE',
type: 'FEE',
unitPrice: 0
})
];
mockMarketPrices({
currentRateService,
getMarketPrice: ({ symbol }) => {
return symbol === 'TWR_TEST' ? 100 : undefined;
}
});
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
usePortfolioSnapshotCache: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(portfolioSnapshot.positions).toHaveLength(1);
expect(
portfolioSnapshot.historicalData.at(-1)
?.netPerformanceInPercentageWithCurrencyEffect
).toBeCloseTo(-0.1, 12);
});
it('includes standalone interest in portfolio return', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2025-01-02').getTime());
const activities: Activity[] = [
createActivity({
date: '2025-01-01',
quantity: 1,
type: 'BUY',
unitPrice: 100
}),
createActivity({
dataSource: 'MANUAL',
date: '2025-01-02',
name: 'Interest Income',
quantity: 1,
symbol: 'TWR_STANDALONE_INTEREST',
type: 'INTEREST',
unitPrice: 10
})
];
mockMarketPrices({
currentRateService,
getMarketPrice: ({ symbol }) => {
return symbol === 'TWR_TEST' ? 100 : undefined;
}
});
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
usePortfolioSnapshotCache: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(portfolioSnapshot.positions).toHaveLength(1);
expect(
portfolioSnapshot.historicalData.at(-1)
?.netPerformanceInPercentageWithCurrencyEffect
).toBeCloseTo(0.1, 12);
});
it('treats interest as investment return instead of external cash flow', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2025-01-02').getTime());
const activities: Activity[] = [
createActivity({
date: '2025-01-01',
quantity: 1,
type: 'BUY',
unitPrice: 100
}),
createActivity({
date: '2025-01-02',
quantity: 1,
type: 'INTEREST',
unitPrice: 10
})
];
mockMarketPrices({
currentRateService,
getMarketPrice: ({ symbol }) => {
return symbol === 'TWR_TEST' ? 100 : undefined;
}
});
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
usePortfolioSnapshotCache: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(
portfolioSnapshot.historicalData.at(-1)
?.netPerformanceInPercentageWithCurrencyEffect
).toBeCloseTo(0.1, 12);
expect(
portfolioSnapshot.positions[0].grossPerformancePercentage.toNumber()
).toBeCloseTo(0, 12);
expect(
portfolioSnapshot.positions[0].grossPerformancePercentageWithCurrencyEffect.toNumber()
).toBeCloseTo(0, 12);
expect(
portfolioSnapshot.positions[0].netPerformancePercentage.toNumber()
).toBeCloseTo(0, 12);
});
it('preserves investment return when a position is fully closed', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2025-01-02').getTime());
const activities: Activity[] = [
createActivity({
date: '2025-01-01',
quantity: 1,
type: 'BUY',
unitPrice: 100
}),
createActivity({
date: '2025-01-02',
quantity: 1,
type: 'SELL',
unitPrice: 110
})
];
mockMarketPrices({
currentRateService,
getMarketPrice: ({ date, symbol }) => {
if (symbol !== 'TWR_TEST') {
return undefined;
}
return isSameDay(date, parseDate('2025-01-01')) ? 100 : 110;
}
});
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
usePortfolioSnapshotCache: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(
portfolioSnapshot.historicalData.at(-1)
?.netPerformanceInPercentageWithCurrencyEffect
).toBeCloseTo(0.1, 12);
expect(
portfolioSnapshot.positions[0].grossPerformancePercentage.toNumber()
).toBeCloseTo(0.1, 12);
expect(
portfolioSnapshot.positions[0].grossPerformancePercentageWithCurrencyEffect.toNumber()
).toBeCloseTo(0.1, 12);
expect(
portfolioSnapshot.positions[0].netPerformancePercentage.toNumber()
).toBeCloseTo(0.1, 12);
});
it('calculates a new date range after a cumulative 100 percent loss', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2025-01-04').getTime());
const activities: Activity[] = [
createActivity({
date: '2025-01-01',
quantity: 1,
type: 'BUY',
unitPrice: 100
}),
createActivity({
date: '2025-01-02',
fee: 100,
quantity: 0,
type: 'FEE',
unitPrice: 0
}),
createActivity({
date: '2025-01-03',
quantity: 1,
type: 'BUY',
unitPrice: 100
})
];
mockMarketPrices({
currentRateService,
getMarketPrice: ({ date, symbol }) => {
if (symbol !== 'TWR_TEST') {
return undefined;
}
return isSameDay(parseDate('2025-01-04'), date) ? 110 : 100;
}
});
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
usePortfolioSnapshotCache: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(
portfolioSnapshot.historicalData.at(-1)
?.netPerformanceInPercentageWithCurrencyEffect
).toBeCloseTo(-1, 12);
expect(
portfolioSnapshot.positions[0].netPerformancePercentageWithCurrencyEffectMap[
'1d'
].toNumber()
).toBeCloseTo(0.1, 12);
const { chart } = await portfolioCalculator.getPerformance({
end: parseDate('2025-01-04'),
start: parseDate('2025-01-03')
});
expect(
chart.at(-1)?.netPerformanceInPercentageWithCurrencyEffect
).toBeCloseTo(0.1, 12);
});
it('separates investment return from currency effect', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2025-01-02').getTime());
const activities: Activity[] = [
createActivity({
currency: 'EUR',
date: '2025-01-01',
name: 'TWR EUR Test Asset',
quantity: 1,
symbol: 'TWR_EUR_TEST',
type: 'BUY',
unitPrice: 100
})
];
jest
.spyOn(exchangeRateDataService, 'getExchangeRatesByCurrency')
.mockResolvedValue({
EURUSD: {
'2025-01-01': 1.1,
'2025-01-02': 1.2
}
});
mockMarketPrices({
currentRateService,
getMarketPrice: ({ symbol }) => {
return symbol === 'TWR_EUR_TEST' ? 100 : undefined;
}
});
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
usePortfolioSnapshotCache: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const latestHistoricalDataItem = portfolioSnapshot.historicalData.at(-1);
expect(latestHistoricalDataItem?.netPerformanceInPercentage).toBeCloseTo(
0,
12
);
expect(
latestHistoricalDataItem?.netPerformanceInPercentageWithCurrencyEffect
).toBeCloseTo(1.2 / 1.1 - 1, 12);
expect(
portfolioSnapshot.positions[0].grossPerformancePercentage.toNumber()
).toBeCloseTo(0, 12);
expect(
portfolioSnapshot.positions[0].grossPerformancePercentageWithCurrencyEffect.toNumber()
).toBeCloseTo(1.2 / 1.1 - 1, 12);
expect(
portfolioSnapshot.positions[0].netPerformancePercentage.toNumber()
).toBeCloseTo(0, 12);
});
});

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

@ -1,28 +1,240 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { AccumulatedValues } from '@ghostfolio/api/app/portfolio/interfaces/accumulated-values.interface';
import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface'; import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface';
import { PortfolioCalculatorHolding } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-holding.interface';
import { PerformancePercentages } from '@ghostfolio/api/app/portfolio/types/performance-percentages.type'; import { PerformancePercentages } from '@ghostfolio/api/app/portfolio/types/performance-percentages.type';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
HistoricalDataItem
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot } from '@ghostfolio/common/models'; import { PortfolioSnapshot } from '@ghostfolio/common/models';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
export class TwrPortfolioCalculator extends PortfolioCalculator { export class TwrPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance(): PortfolioSnapshot { protected calculateOverallPerformance({
throw new Error('Method not implemented.'); accumulatedValuesByDate,
positions
}: {
accumulatedValuesByDate: { [date: string]: AccumulatedValues };
positions: PortfolioCalculatorHolding[];
}): PortfolioSnapshot {
const latestAccumulatedValues = Object.values(accumulatedValuesByDate).at(
-1
);
return this.getOverallPerformance({
investedCapital:
latestAccumulatedValues?.totalInvestmentValue ?? new Big(0),
investedCapitalWithCurrencyEffect:
latestAccumulatedValues?.totalInvestmentValueWithCurrencyEffect ??
new Big(0),
positions
});
} }
protected calculatePerformancePercentages(): { protected calculatePerformancePercentages({
[date: string]: PerformancePercentages; accumulatedValuesByDate
} { }: {
throw new Error('Method not implemented.'); accumulatedValuesByDate: { [date: string]: AccumulatedValues };
}): { [date: string]: PerformancePercentages } {
const lastDate = Object.keys(accumulatedValuesByDate).at(-1);
const {
cashFlowsByDate,
dividendAdjustmentsByDate,
performanceAdjustmentsByDate
} = this.getPortfolioAdjustmentsByDate({
currentExchangeRateDate: lastDate
});
const performancePercentagesByDate: {
[date: string]: PerformancePercentages;
} = {};
let cumulativeReturn = new Big(1);
let cumulativeReturnWithCurrencyEffect = new Big(1);
let cumulativeReturnWithoutDividendsWithCurrencyEffect = new Big(1);
let previousValue = new Big(0);
let previousValueWithCurrencyEffect = new Big(0);
for (const [date, accumulatedValues] of Object.entries(
accumulatedValuesByDate
)) {
const cashFlow = cashFlowsByDate[date] ?? {
value: new Big(0),
valueWithCurrencyEffect: new Big(0)
};
const performanceAdjustment = performanceAdjustmentsByDate[date] ?? {
value: new Big(0),
valueWithCurrencyEffect: new Big(0)
};
const dividendAdjustment = dividendAdjustmentsByDate[date] ?? new Big(0);
if (previousValue.gt(0)) {
cumulativeReturn = cumulativeReturn.mul(
accumulatedValues.totalCurrentValue
.minus(cashFlow.value)
.plus(performanceAdjustment.value)
.div(previousValue)
);
}
if (previousValueWithCurrencyEffect.gt(0)) {
cumulativeReturnWithCurrencyEffect =
cumulativeReturnWithCurrencyEffect.mul(
accumulatedValues.totalCurrentValueWithCurrencyEffect
.minus(cashFlow.valueWithCurrencyEffect)
.plus(performanceAdjustment.valueWithCurrencyEffect)
.div(previousValueWithCurrencyEffect)
);
cumulativeReturnWithoutDividendsWithCurrencyEffect =
cumulativeReturnWithoutDividendsWithCurrencyEffect.mul(
accumulatedValues.totalCurrentValueWithCurrencyEffect
.minus(cashFlow.valueWithCurrencyEffect)
.plus(performanceAdjustment.valueWithCurrencyEffect)
.minus(dividendAdjustment)
.div(previousValueWithCurrencyEffect)
);
}
performancePercentagesByDate[date] = {
dividendInPercentageWithCurrencyEffect:
cumulativeReturnWithCurrencyEffect
.minus(cumulativeReturnWithoutDividendsWithCurrencyEffect)
.toNumber(),
netPerformanceInPercentage: cumulativeReturn.minus(1).toNumber(),
netPerformanceInPercentageWithCurrencyEffect:
cumulativeReturnWithCurrencyEffect.minus(1).toNumber()
};
previousValue = accumulatedValues.totalCurrentValue;
previousValueWithCurrencyEffect =
accumulatedValues.totalCurrentValueWithCurrencyEffect;
}
return performancePercentagesByDate;
} }
protected calculatePerformancePercentagesForDateRange(): { protected calculatePerformancePercentagesForDateRange({
[date: string]: PerformancePercentages; historicalDataItems
} { }: {
throw new Error('Method not implemented.'); historicalDataItems: HistoricalDataItem[];
}): { [date: string]: PerformancePercentages } {
const performancePercentagesByDate: {
[date: string]: PerformancePercentages;
} = {};
if (historicalDataItems.length === 0) {
return performancePercentagesByDate;
}
const {
cashFlowsByDate,
dividendAdjustmentsByDate,
performanceAdjustmentsByDate
} = this.getPortfolioAdjustmentsByDate({});
let cumulativeReturn = new Big(1);
let cumulativeReturnWithCurrencyEffect = new Big(1);
let cumulativeReturnWithoutDividendsWithCurrencyEffect = new Big(1);
let previousValue: Big;
let previousValueWithCurrencyEffect: Big;
for (const [index, historicalDataItem] of historicalDataItems.entries()) {
const currentValue = new Big(historicalDataItem.value ?? 0);
const currentValueWithCurrencyEffect = new Big(
historicalDataItem.valueWithCurrencyEffect ?? 0
);
if (index === 0) {
performancePercentagesByDate[historicalDataItem.date] = {
dividendInPercentageWithCurrencyEffect: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0
};
previousValue = currentValue;
previousValueWithCurrencyEffect = currentValueWithCurrencyEffect;
continue;
}
const cashFlow = cashFlowsByDate[historicalDataItem.date] ?? {
value: new Big(0),
valueWithCurrencyEffect: new Big(0)
};
const performanceAdjustment = performanceAdjustmentsByDate[
historicalDataItem.date
] ?? {
value: new Big(0),
valueWithCurrencyEffect: new Big(0)
};
const dividendAdjustment =
dividendAdjustmentsByDate[historicalDataItem.date] ?? new Big(0);
if (previousValue.gt(0)) {
cumulativeReturn = cumulativeReturn.mul(
currentValue
.minus(cashFlow.value)
.plus(performanceAdjustment.value)
.div(previousValue)
);
}
if (previousValueWithCurrencyEffect.gt(0)) {
cumulativeReturnWithCurrencyEffect =
cumulativeReturnWithCurrencyEffect.mul(
currentValueWithCurrencyEffect
.minus(cashFlow.valueWithCurrencyEffect)
.plus(performanceAdjustment.valueWithCurrencyEffect)
.div(previousValueWithCurrencyEffect)
);
cumulativeReturnWithoutDividendsWithCurrencyEffect =
cumulativeReturnWithoutDividendsWithCurrencyEffect.mul(
currentValueWithCurrencyEffect
.minus(cashFlow.valueWithCurrencyEffect)
.plus(performanceAdjustment.valueWithCurrencyEffect)
.minus(dividendAdjustment)
.div(previousValueWithCurrencyEffect)
);
}
performancePercentagesByDate[historicalDataItem.date] = {
dividendInPercentageWithCurrencyEffect:
cumulativeReturnWithCurrencyEffect
.minus(cumulativeReturnWithoutDividendsWithCurrencyEffect)
.toNumber(),
netPerformanceInPercentage: cumulativeReturn.minus(1).toNumber(),
netPerformanceInPercentageWithCurrencyEffect:
cumulativeReturnWithCurrencyEffect.minus(1).toNumber()
};
previousValue = currentValue;
previousValueWithCurrencyEffect = currentValueWithCurrencyEffect;
}
return performancePercentagesByDate;
} }
protected getHoldingPerformance({}: { protected getHoldingPerformance({
chartDates,
dataSource,
end,
exchangeRates,
marketSymbolMap,
start,
symbol
}: {
chartDates: string[]; chartDates: string[];
end: Date; end: Date;
exchangeRates: { [dateString: string]: number }; exchangeRates: { [dateString: string]: number };
@ -31,10 +243,386 @@ export class TwrPortfolioCalculator extends PortfolioCalculator {
}; };
start: Date; start: Date;
} & AssetProfileIdentifier): HoldingPerformance { } & AssetProfileIdentifier): HoldingPerformance {
throw new Error('Method not implemented.'); const { holdingPerformance, valuation } =
this.getMethodIndependentHoldingPerformance({
chartDates,
dataSource,
end,
exchangeRates,
marketSymbolMap,
start,
symbol
});
if (!valuation) {
return holdingPerformance;
}
const { currentValues, currentValuesWithCurrencyEffect, dateRanges } =
valuation;
const assetProfileIdentifier = getAssetProfileIdentifier({
dataSource,
symbol
});
const activities =
this.activitiesByAssetProfileIdentifier[assetProfileIdentifier] ?? [];
const firstActivity = activities[0];
const isCashInBaseCurrency =
firstActivity?.assetProfile.assetSubClass === 'CASH' &&
firstActivity.assetProfile.currency === this.getBaseCurrency() &&
firstActivity.assetProfile.symbol === this.getBaseCurrency();
const cashFlowsByDate: {
[date: string]: {
value: Big;
valueWithCurrencyEffect: Big;
};
} = {};
const currentExchangeRate = exchangeRates?.[chartDates.at(-1) ?? ''] ?? 1;
for (const activity of activities) {
const exchangeRateAtActivityDate = exchangeRates?.[activity.date] ?? 1;
if (!isCashInBaseCurrency && ['BUY', 'SELL'].includes(activity.type)) {
const factor = activity.type === 'BUY' ? 1 : -1;
const transactionValue = activity.quantity
.mul(activity.unitPrice)
.mul(factor);
const currentCashFlow = cashFlowsByDate[activity.date] ?? {
value: new Big(0),
valueWithCurrencyEffect: new Big(0)
};
cashFlowsByDate[activity.date] = {
value: currentCashFlow.value.plus(
transactionValue.mul(currentExchangeRate)
),
valueWithCurrencyEffect: currentCashFlow.valueWithCurrencyEffect.plus(
transactionValue.mul(exchangeRateAtActivityDate)
)
};
}
}
const dividendAdjustmentsByDate: {
[date: string]: {
value: Big;
valueWithCurrencyEffect: Big;
};
} = {};
const feeAdjustmentsByDate: {
[date: string]: {
value: Big;
valueWithCurrencyEffect: Big;
};
} = {};
for (const activity of activities) {
const exchangeRateAtActivityDate = exchangeRates?.[activity.date] ?? 1;
if (activity.type === 'DIVIDEND') {
const dividend = activity.quantity.mul(activity.unitPrice);
const currentDividendAdjustment = dividendAdjustmentsByDate[
activity.date
] ?? {
value: new Big(0),
valueWithCurrencyEffect: new Big(0)
};
dividendAdjustmentsByDate[activity.date] = {
value: currentDividendAdjustment.value.plus(
dividend.mul(currentExchangeRate)
),
valueWithCurrencyEffect:
currentDividendAdjustment.valueWithCurrencyEffect.plus(
dividend.mul(exchangeRateAtActivityDate)
)
};
}
if (!activity.fee.eq(0)) {
const currentFeeAdjustment = feeAdjustmentsByDate[activity.date] ?? {
value: new Big(0),
valueWithCurrencyEffect: new Big(0)
};
feeAdjustmentsByDate[activity.date] = {
value: currentFeeAdjustment.value.plus(
activity.fee.mul(currentExchangeRate)
),
valueWithCurrencyEffect:
currentFeeAdjustment.valueWithCurrencyEffect.plus(
activity.fee.mul(exchangeRateAtActivityDate)
)
};
}
}
let cumulativeGrossReturn = new Big(1);
let cumulativeGrossReturnWithCurrencyEffect = new Big(1);
let cumulativeNetReturn = new Big(1);
let cumulativeNetReturnWithCurrencyEffect = new Big(1);
let previousValue = new Big(0);
let previousValueWithCurrencyEffect = new Big(0);
const cumulativeNetReturnWithCurrencyEffectByDate: {
[date: string]: Big;
} = {};
for (const date of chartDates) {
const currentValue = currentValues[date] ?? new Big(0);
const currentValueWithCurrencyEffect =
currentValuesWithCurrencyEffect[date] ?? new Big(0);
const cashFlow = cashFlowsByDate[date] ?? {
value: new Big(0),
valueWithCurrencyEffect: new Big(0)
};
const dividendAdjustment = dividendAdjustmentsByDate[date] ?? {
value: new Big(0),
valueWithCurrencyEffect: new Big(0)
};
const feeAdjustment = feeAdjustmentsByDate[date] ?? {
value: new Big(0),
valueWithCurrencyEffect: new Big(0)
};
if (previousValue.gt(0)) {
cumulativeGrossReturn = cumulativeGrossReturn.mul(
currentValue
.minus(cashFlow.value)
.plus(dividendAdjustment.value)
.div(previousValue)
);
cumulativeNetReturn = cumulativeNetReturn.mul(
currentValue
.minus(cashFlow.value)
.plus(dividendAdjustment.value)
.minus(feeAdjustment.value)
.div(previousValue)
);
}
if (previousValueWithCurrencyEffect.gt(0)) {
cumulativeGrossReturnWithCurrencyEffect =
cumulativeGrossReturnWithCurrencyEffect.mul(
currentValueWithCurrencyEffect
.minus(cashFlow.valueWithCurrencyEffect)
.plus(dividendAdjustment.valueWithCurrencyEffect)
.div(previousValueWithCurrencyEffect)
);
cumulativeNetReturnWithCurrencyEffect =
cumulativeNetReturnWithCurrencyEffect.mul(
currentValueWithCurrencyEffect
.minus(cashFlow.valueWithCurrencyEffect)
.plus(dividendAdjustment.valueWithCurrencyEffect)
.minus(feeAdjustment.valueWithCurrencyEffect)
.div(previousValueWithCurrencyEffect)
);
}
cumulativeNetReturnWithCurrencyEffectByDate[date] =
cumulativeNetReturnWithCurrencyEffect;
previousValue = currentValue;
previousValueWithCurrencyEffect = currentValueWithCurrencyEffect;
}
const netPerformancePercentageWithCurrencyEffectMap = {
...holdingPerformance.netPerformancePercentageWithCurrencyEffectMap
};
for (const { dateRange, endDateString, startDateString } of dateRanges) {
if (dateRange === 'max') {
netPerformancePercentageWithCurrencyEffectMap[dateRange] =
cumulativeNetReturnWithCurrencyEffect.minus(1);
continue;
}
let cumulativeRangeReturnWithCurrencyEffect = new Big(1);
let previousRangeValueWithCurrencyEffect =
currentValuesWithCurrencyEffect[startDateString] ?? new Big(0);
for (const date of chartDates) {
if (date <= startDateString || date > endDateString) {
continue;
}
const currentValueWithCurrencyEffect =
currentValuesWithCurrencyEffect[date] ?? new Big(0);
const cashFlow = cashFlowsByDate[date] ?? {
value: new Big(0),
valueWithCurrencyEffect: new Big(0)
};
const dividendAdjustment = dividendAdjustmentsByDate[date] ?? {
value: new Big(0),
valueWithCurrencyEffect: new Big(0)
};
const feeAdjustment = feeAdjustmentsByDate[date] ?? {
value: new Big(0),
valueWithCurrencyEffect: new Big(0)
};
if (previousRangeValueWithCurrencyEffect.gt(0)) {
cumulativeRangeReturnWithCurrencyEffect =
cumulativeRangeReturnWithCurrencyEffect.mul(
currentValueWithCurrencyEffect
.minus(cashFlow.valueWithCurrencyEffect)
.plus(dividendAdjustment.valueWithCurrencyEffect)
.minus(feeAdjustment.valueWithCurrencyEffect)
.div(previousRangeValueWithCurrencyEffect)
);
}
previousRangeValueWithCurrencyEffect = currentValueWithCurrencyEffect;
}
netPerformancePercentageWithCurrencyEffectMap[dateRange] =
cumulativeRangeReturnWithCurrencyEffect.minus(1);
}
return {
...holdingPerformance,
grossPerformancePercentage: cumulativeGrossReturn.minus(1),
grossPerformancePercentageWithCurrencyEffect:
cumulativeGrossReturnWithCurrencyEffect.minus(1),
netPerformancePercentage: cumulativeNetReturn.minus(1),
netPerformancePercentageWithCurrencyEffectMap
};
} }
protected getPerformanceCalculationType() { protected getPerformanceCalculationType() {
return PerformanceCalculationType.TWR; return PerformanceCalculationType.TWR;
} }
private getPortfolioAdjustmentsByDate({
currentExchangeRateDate
}: {
currentExchangeRateDate?: string;
}) {
const cashFlowsByDate: {
[date: string]: {
value: Big;
valueWithCurrencyEffect: Big;
};
} = {};
const performanceAdjustmentsByDate: {
[date: string]: {
value: Big;
valueWithCurrencyEffect: Big;
};
} = {};
const dividendAdjustmentsByDate: {
[date: string]: Big;
} = {};
const exchangeRatesByCurrency = this.getExchangeRatesByCurrency();
for (const activity of this.activities) {
const exchangeRates =
exchangeRatesByCurrency[
`${activity.assetProfile.currency}${this.getBaseCurrency()}`
] ?? {};
const lastExchangeRateDate = Object.keys(exchangeRates).at(-1);
const currentExchangeRate =
exchangeRates[currentExchangeRateDate ?? lastExchangeRateDate ?? ''] ??
1;
const exchangeRateAtActivityDate = exchangeRates[activity.date] ?? 1;
const isCashInBaseCurrency =
activity.assetProfile.assetSubClass === 'CASH' &&
activity.assetProfile.currency === this.getBaseCurrency() &&
activity.assetProfile.symbol === this.getBaseCurrency();
if (!isCashInBaseCurrency && ['BUY', 'SELL'].includes(activity.type)) {
const factor = activity.type === 'BUY' ? 1 : -1;
const transactionValue = activity.quantity
.mul(activity.unitPrice)
.mul(factor);
const currentCashFlow = cashFlowsByDate[activity.date] ?? {
value: new Big(0),
valueWithCurrencyEffect: new Big(0)
};
cashFlowsByDate[activity.date] = {
value: currentCashFlow.value.plus(
transactionValue.mul(currentExchangeRate)
),
valueWithCurrencyEffect: currentCashFlow.valueWithCurrencyEffect.plus(
transactionValue.mul(exchangeRateAtActivityDate)
)
};
}
let performanceAdjustment = activity.fee.mul(currentExchangeRate).mul(-1);
let performanceAdjustmentWithCurrencyEffect = activity.fee
.mul(exchangeRateAtActivityDate)
.mul(-1);
if (['DIVIDEND', 'INTEREST'].includes(activity.type)) {
const income = activity.quantity.mul(activity.unitPrice);
performanceAdjustment = performanceAdjustment.plus(
income.mul(currentExchangeRate)
);
performanceAdjustmentWithCurrencyEffect =
performanceAdjustmentWithCurrencyEffect.plus(
income.mul(exchangeRateAtActivityDate)
);
if (activity.type === 'DIVIDEND') {
dividendAdjustmentsByDate[activity.date] = (
dividendAdjustmentsByDate[activity.date] ?? new Big(0)
).plus(income.mul(exchangeRateAtActivityDate));
}
}
if (
!performanceAdjustment.eq(0) ||
!performanceAdjustmentWithCurrencyEffect.eq(0)
) {
const currentPerformanceAdjustment = performanceAdjustmentsByDate[
activity.date
] ?? {
value: new Big(0),
valueWithCurrencyEffect: new Big(0)
};
performanceAdjustmentsByDate[activity.date] = {
value: currentPerformanceAdjustment.value.plus(performanceAdjustment),
valueWithCurrencyEffect:
currentPerformanceAdjustment.valueWithCurrencyEffect.plus(
performanceAdjustmentWithCurrencyEffect
)
};
}
}
return {
cashFlowsByDate,
dividendAdjustmentsByDate,
performanceAdjustmentsByDate
};
}
} }

Loading…
Cancel
Save