mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
3 changed files with 1601 additions and 14 deletions
@ -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); |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue