mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
229 changed files with 19440 additions and 7074 deletions
@ -1,8 +1,8 @@ |
|||||
{ |
{ |
||||
"recommendations": [ |
"recommendations": [ |
||||
"angular.ng-template", |
"angular.ng-template", |
||||
|
"esbenp.prettier-vscode", |
||||
"firsttris.vscode-jest-runner", |
"firsttris.vscode-jest-runner", |
||||
"nrwl.angular-console", |
"nrwl.angular-console" |
||||
"prettier.prettier-vscode" |
|
||||
] |
] |
||||
} |
} |
||||
|
|||||
@ -1,4 +1,4 @@ |
|||||
{ |
{ |
||||
"editor.defaultFormatter": "prettier.prettier-vscode", |
"editor.defaultFormatter": "esbenp.prettier-vscode", |
||||
"editor.formatOnSave": true |
"editor.formatOnSave": true |
||||
} |
} |
||||
|
|||||
@ -0,0 +1,290 @@ |
|||||
|
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; |
||||
|
import { AccountService } from '@ghostfolio/api/app/account/account.service'; |
||||
|
import { OrderService } from '@ghostfolio/api/app/order/order.service'; |
||||
|
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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; |
||||
|
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; |
||||
|
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
import { TimelinePosition } from '@ghostfolio/common/models'; |
||||
|
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
||||
|
|
||||
|
import { DataSource } from '@prisma/client'; |
||||
|
import { Big } from 'big.js'; |
||||
|
import { randomUUID } from 'node:crypto'; |
||||
|
|
||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { |
||||
|
return { |
||||
|
CurrentRateService: jest.fn().mockImplementation(() => { |
||||
|
return CurrentRateServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
jest.mock( |
||||
|
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', |
||||
|
() => { |
||||
|
return { |
||||
|
ExchangeRateDataService: jest.fn().mockImplementation(() => { |
||||
|
return ExchangeRateDataServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
jest.mock( |
||||
|
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', |
||||
|
() => { |
||||
|
return { |
||||
|
PortfolioSnapshotService: jest.fn().mockImplementation(() => { |
||||
|
return PortfolioSnapshotServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { |
||||
|
return { |
||||
|
RedisCacheService: jest.fn().mockImplementation(() => { |
||||
|
return RedisCacheServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
describe('PortfolioCalculator', () => { |
||||
|
let accountBalanceService: AccountBalanceService; |
||||
|
let accountService: AccountService; |
||||
|
let configurationService: ConfigurationService; |
||||
|
let currentRateService: CurrentRateService; |
||||
|
let dataProviderService: DataProviderService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let orderService: OrderService; |
||||
|
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
||||
|
let portfolioSnapshotService: PortfolioSnapshotService; |
||||
|
let redisCacheService: RedisCacheService; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
configurationService = new ConfigurationService(); |
||||
|
|
||||
|
exchangeRateDataService = new ExchangeRateDataService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
accountBalanceService = new AccountBalanceService( |
||||
|
null, |
||||
|
exchangeRateDataService, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
accountService = new AccountService( |
||||
|
accountBalanceService, |
||||
|
null, |
||||
|
exchangeRateDataService, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
redisCacheService = new RedisCacheService(null, configurationService); |
||||
|
|
||||
|
dataProviderService = new DataProviderService( |
||||
|
configurationService, |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
redisCacheService |
||||
|
); |
||||
|
|
||||
|
currentRateService = new CurrentRateService( |
||||
|
dataProviderService, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
orderService = new OrderService( |
||||
|
accountBalanceService, |
||||
|
accountService, |
||||
|
null, |
||||
|
dataProviderService, |
||||
|
null, |
||||
|
exchangeRateDataService, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
portfolioSnapshotService = new PortfolioSnapshotService(null); |
||||
|
|
||||
|
portfolioCalculatorFactory = new PortfolioCalculatorFactory( |
||||
|
configurationService, |
||||
|
currentRateService, |
||||
|
exchangeRateDataService, |
||||
|
portfolioSnapshotService, |
||||
|
redisCacheService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('Cash Performance', () => { |
||||
|
it('should calculate performance for cash assets in CHF default currency', async () => { |
||||
|
jest.useFakeTimers().setSystemTime(parseDate('2025-01-01').getTime()); |
||||
|
|
||||
|
const accountId = randomUUID(); |
||||
|
|
||||
|
jest |
||||
|
.spyOn(accountBalanceService, 'getAccountBalances') |
||||
|
.mockResolvedValue({ |
||||
|
balances: [ |
||||
|
{ |
||||
|
accountId, |
||||
|
id: randomUUID(), |
||||
|
date: parseDate('2023-12-31'), |
||||
|
value: 1000, |
||||
|
valueInBaseCurrency: 850 |
||||
|
}, |
||||
|
{ |
||||
|
accountId, |
||||
|
id: randomUUID(), |
||||
|
date: parseDate('2024-12-31'), |
||||
|
value: 2000, |
||||
|
valueInBaseCurrency: 1800 |
||||
|
} |
||||
|
] |
||||
|
}); |
||||
|
|
||||
|
jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({ |
||||
|
accounts: [ |
||||
|
{ |
||||
|
balance: 2000, |
||||
|
comment: null, |
||||
|
createdAt: parseDate('2023-12-31'), |
||||
|
currency: 'USD', |
||||
|
id: accountId, |
||||
|
isExcluded: false, |
||||
|
name: 'USD', |
||||
|
platformId: null, |
||||
|
updatedAt: parseDate('2023-12-31'), |
||||
|
userId: userDummyData.id |
||||
|
} |
||||
|
], |
||||
|
balanceInBaseCurrency: 1820 |
||||
|
}); |
||||
|
|
||||
|
jest |
||||
|
.spyOn(dataProviderService, 'getDataSourceForExchangeRates') |
||||
|
.mockReturnValue(DataSource.YAHOO); |
||||
|
|
||||
|
jest.spyOn(orderService, 'getOrders').mockResolvedValue({ |
||||
|
activities: [], |
||||
|
count: 0 |
||||
|
}); |
||||
|
|
||||
|
const { activities } = await orderService.getOrdersForPortfolioCalculator( |
||||
|
{ |
||||
|
userCurrency: 'CHF', |
||||
|
userId: userDummyData.id, |
||||
|
withCash: true |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
jest.spyOn(currentRateService, 'getValues').mockResolvedValue({ |
||||
|
dataProviderInfos: [], |
||||
|
errors: [], |
||||
|
values: [] |
||||
|
}); |
||||
|
|
||||
|
const accountBalanceItems = |
||||
|
await accountBalanceService.getAccountBalanceItems({ |
||||
|
userCurrency: 'CHF', |
||||
|
userId: userDummyData.id |
||||
|
}); |
||||
|
|
||||
|
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ |
||||
|
accountBalanceItems, |
||||
|
activities, |
||||
|
calculationType: PerformanceCalculationType.ROAI, |
||||
|
currency: 'CHF', |
||||
|
userId: userDummyData.id |
||||
|
}); |
||||
|
|
||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
||||
|
|
||||
|
const position = portfolioSnapshot.positions.find(({ symbol }) => { |
||||
|
return symbol === 'USD'; |
||||
|
}); |
||||
|
|
||||
|
/** |
||||
|
* Investment: 2000 USD * 0.91 = 1820 CHF |
||||
|
* Investment value with currency effect: (1000 USD * 0.85) + (1000 USD * 0.90) = 1750 CHF |
||||
|
* Net performance: (1000 USD * 1.0) - (1000 USD * 1.0) = 0 CHF |
||||
|
* Total account balance: 2000 USD * 0.85 = 1700 CHF (using the exchange rate on 2024-12-31) |
||||
|
* Value in base currency: 2000 USD * 0.91 = 1820 CHF |
||||
|
*/ |
||||
|
expect(position).toMatchObject<TimelinePosition>({ |
||||
|
activitiesCount: 2, |
||||
|
averagePrice: new Big(1), |
||||
|
currency: 'USD', |
||||
|
dataSource: DataSource.YAHOO, |
||||
|
dateOfFirstActivity: '2023-12-31', |
||||
|
dividend: new Big(0), |
||||
|
dividendInBaseCurrency: new Big(0), |
||||
|
fee: new Big(0), |
||||
|
feeInBaseCurrency: new Big(0), |
||||
|
grossPerformance: new Big(0), |
||||
|
grossPerformancePercentage: new Big(0), |
||||
|
grossPerformancePercentageWithCurrencyEffect: new Big( |
||||
|
'0.08211603004634809014' |
||||
|
), |
||||
|
grossPerformanceWithCurrencyEffect: new Big(70), |
||||
|
includeInTotalAssetValue: false, |
||||
|
investment: new Big(1820), |
||||
|
investmentWithCurrencyEffect: new Big(1750), |
||||
|
marketPrice: 1, |
||||
|
marketPriceInBaseCurrency: 0.91, |
||||
|
netPerformance: new Big(0), |
||||
|
netPerformancePercentage: new Big(0), |
||||
|
netPerformancePercentageWithCurrencyEffectMap: { |
||||
|
'1d': new Big('0.01111111111111111111'), |
||||
|
'1y': new Big('0.06937181021989792704'), |
||||
|
'5y': new Big('0.0818817546090273363'), |
||||
|
max: new Big('0.0818817546090273363'), |
||||
|
mtd: new Big('0.01111111111111111111'), |
||||
|
wtd: new Big('-0.05517241379310344828'), |
||||
|
ytd: new Big('0.01111111111111111111') |
||||
|
}, |
||||
|
netPerformanceWithCurrencyEffectMap: { |
||||
|
'1d': new Big(20), |
||||
|
'1y': new Big(60), |
||||
|
'5y': new Big(70), |
||||
|
max: new Big(70), |
||||
|
mtd: new Big(20), |
||||
|
wtd: new Big(-80), |
||||
|
ytd: new Big(20) |
||||
|
}, |
||||
|
quantity: new Big(2000), |
||||
|
symbol: 'USD', |
||||
|
timeWeightedInvestment: new Big('912.47956403269754768392'), |
||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big( |
||||
|
'852.45231607629427792916' |
||||
|
), |
||||
|
valueInBaseCurrency: new Big(1820) |
||||
|
}); |
||||
|
|
||||
|
expect(portfolioSnapshot).toMatchObject({ |
||||
|
hasErrors: false, |
||||
|
totalFeesWithCurrencyEffect: new Big(0), |
||||
|
totalInterestWithCurrencyEffect: new Big(0), |
||||
|
totalLiabilitiesWithCurrencyEffect: new Big(0) |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -1,18 +1,20 @@ |
|||||
import { DataSource, Tag } from '@prisma/client'; |
import { AssetSubClass, DataSource, Tag } from '@prisma/client'; |
||||
import { Big } from 'big.js'; |
import { Big } from 'big.js'; |
||||
|
|
||||
export interface TransactionPointSymbol { |
export interface TransactionPointSymbol { |
||||
|
activitiesCount: number; |
||||
|
assetSubClass: AssetSubClass; |
||||
averagePrice: Big; |
averagePrice: Big; |
||||
currency: string; |
currency: string; |
||||
dataSource: DataSource; |
dataSource: DataSource; |
||||
|
dateOfFirstActivity: string; |
||||
dividend: Big; |
dividend: Big; |
||||
fee: Big; |
fee: Big; |
||||
firstBuyDate: string; |
feeInBaseCurrency: Big; |
||||
includeInHoldings: boolean; |
includeInHoldings: boolean; |
||||
investment: Big; |
investment: Big; |
||||
quantity: Big; |
quantity: Big; |
||||
skipErrors: boolean; |
skipErrors: boolean; |
||||
symbol: string; |
symbol: string; |
||||
tags?: Tag[]; |
tags?: Tag[]; |
||||
transactionCount: number; |
|
||||
} |
} |
||||
|
|||||
File diff suppressed because it is too large
@ -0,0 +1,5 @@ |
|||||
|
export interface ExchangeRatesByCurrency { |
||||
|
[currency: string]: { |
||||
|
[dateString: string]: number; |
||||
|
}; |
||||
|
} |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue