From 3943ca9f8818a7329ebd6489d663d8944f14dd12 Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Sat, 3 Jan 2026 18:03:58 +0700 Subject: [PATCH] Feature/extend holdings endpoint to include performance with currency effects for cash positions (#5650) * Extend holdings endpoint to include performance with currency effects for cash positions * Update changelog --- CHANGELOG.md | 4 + apps/api/src/app/order/order.service.ts | 139 ++++++++- .../calculator/portfolio-calculator.ts | 23 +- .../roai/portfolio-calculator-cash.spec.ts | 290 ++++++++++++++++++ .../calculator/roai/portfolio-calculator.ts | 13 +- .../interfaces/portfolio-order.interface.ts | 2 +- .../transaction-point-symbol.interface.ts | 3 +- .../src/app/portfolio/portfolio.service.ts | 54 ++-- .../exchange-rate-data.service.mock.ts | 6 +- .../exchange-rate-data.service.ts | 4 +- .../exchange-rate-data.interface.ts | 5 + 11 files changed, 510 insertions(+), 33 deletions(-) create mode 100644 apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts create mode 100644 apps/api/src/services/exchange-rate-data/interfaces/exchange-rate-data.interface.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d5b26a07a..fa9cefbc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Extended the portfolio holdings to include performance with currency effects for cash positions + ### Changed - Integrated the endpoint to get all platforms (`GET api/v1/platforms`) into the create or update account dialog diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index 001d43b7a..57fe5d3b6 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -1,7 +1,10 @@ +import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; +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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; @@ -16,6 +19,7 @@ import { import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { ActivitiesResponse, + Activity, AssetProfileIdentifier, EnhancedSymbolProfile, Filter @@ -42,8 +46,10 @@ import { randomUUID } from 'node:crypto'; @Injectable() export class OrderService { public constructor( + private readonly accountBalanceService: AccountBalanceService, private readonly accountService: AccountService, private readonly dataGatheringService: DataGatheringService, + private readonly dataProviderService: DataProviderService, private readonly eventEmitter: EventEmitter2, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly prismaService: PrismaService, @@ -317,6 +323,111 @@ export class OrderService { return count; } + /** + * Generates synthetic orders for cash holdings based on account balance history. + * Treat currencies as assets with a fixed unit price of 1.0 (in their own currency) to allow + * performance tracking based on exchange rate fluctuations. + * + * @param cashDetails - The cash balance details. + * @param userCurrency - The base currency of the user. + * @param userId - The ID of the user. + * @returns A response containing the list of synthetic cash activities. + */ + public async getCashOrders({ + cashDetails, + userCurrency, + userId + }: { + cashDetails: CashDetails; + userCurrency: string; + userId: string; + }): Promise { + const activities: Activity[] = []; + + for (const account of cashDetails.accounts) { + const { balances } = await this.accountBalanceService.getAccountBalances({ + userCurrency, + userId, + filters: [{ id: account.id, type: 'ACCOUNT' }] + }); + + let currentBalance = 0; + let currentBalanceInBaseCurrency = 0; + + for (const balanceItem of balances) { + const syntheticActivityTemplate: Activity = { + userId, + accountId: account.id, + accountUserId: account.userId, + comment: account.name, + createdAt: new Date(balanceItem.date), + currency: account.currency, + date: new Date(balanceItem.date), + fee: 0, + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + id: balanceItem.id, + isDraft: false, + quantity: 1, + SymbolProfile: { + activitiesCount: 0, + assetClass: AssetClass.LIQUIDITY, + assetSubClass: AssetSubClass.CASH, + countries: [], + createdAt: new Date(balanceItem.date), + currency: account.currency, + dataSource: + this.dataProviderService.getDataSourceForExchangeRates(), + holdings: [], + id: account.currency, + isActive: true, + name: account.currency, + sectors: [], + symbol: account.currency, + updatedAt: new Date(balanceItem.date) + }, + symbolProfileId: account.currency, + type: ActivityType.BUY, + unitPrice: 1, + unitPriceInAssetProfileCurrency: 1, + updatedAt: new Date(balanceItem.date), + valueInBaseCurrency: 0, + value: 0 + }; + + if (currentBalance < balanceItem.value) { + // BUY + activities.push({ + ...syntheticActivityTemplate, + quantity: balanceItem.value - currentBalance, + type: ActivityType.BUY, + value: balanceItem.value - currentBalance, + valueInBaseCurrency: + balanceItem.valueInBaseCurrency - currentBalanceInBaseCurrency + }); + } else if (currentBalance > balanceItem.value) { + // SELL + activities.push({ + ...syntheticActivityTemplate, + quantity: currentBalance - balanceItem.value, + type: ActivityType.SELL, + value: currentBalance - balanceItem.value, + valueInBaseCurrency: + currentBalanceInBaseCurrency - balanceItem.valueInBaseCurrency + }); + } + + currentBalance = balanceItem.value; + currentBalanceInBaseCurrency = balanceItem.valueInBaseCurrency; + } + } + + return { + activities, + count: activities.length + }; + } + public async getLatestOrder({ dataSource, symbol }: AssetProfileIdentifier) { return this.prismaService.order.findFirst({ orderBy: { @@ -610,6 +721,15 @@ export class OrderService { return { activities, count }; } + /** + * Retrieves all orders required for the portfolio calculator, including both standard asset orders + * and synthetic orders representing cash activities. + * + * @param filters - Optional filters to apply to the orders. + * @param userCurrency - The base currency of the user. + * @param userId - The ID of the user. + * @returns An object containing the combined list of activities and the total count. + */ @LogPerformance public async getOrdersForPortfolioCalculator({ filters, @@ -620,12 +740,29 @@ export class OrderService { userCurrency: string; userId: string; }) { - return this.getOrders({ + const nonCashOrders = await this.getOrders({ filters, userCurrency, userId, withExcludedAccountsAndActivities: false // TODO }); + + const cashDetails = await this.accountService.getCashDetails({ + filters, + userId, + currency: userCurrency + }); + + const cashOrders = await this.getCashOrders({ + cashDetails, + userCurrency, + userId + }); + + return { + activities: [...nonCashOrders.activities, ...cashOrders.activities], + count: nonCashOrders.count + cashOrders.count + }; } public async getStatisticsByCurrency( diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index ee4219b58..d2b3c0625 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -203,13 +203,19 @@ export abstract class PortfolioCalculator { let totalInterestWithCurrencyEffect = new Big(0); let totalLiabilitiesWithCurrencyEffect = new Big(0); - for (const { currency, dataSource, symbol } of transactionPoints[ - firstIndex - 1 - ].items) { - dataGatheringItems.push({ - dataSource, - symbol - }); + for (const { + assetSubClass, + currency, + dataSource, + symbol + } of transactionPoints[firstIndex - 1].items) { + // Gather data for all assets except CASH + if (assetSubClass !== 'CASH') { + dataGatheringItems.push({ + dataSource, + symbol + }); + } currencies[symbol] = currency; } @@ -933,6 +939,7 @@ export abstract class PortfolioCalculator { } of this.activities) { let currentTransactionPointItem: TransactionPointSymbol; + const assetSubClass = SymbolProfile.assetSubClass; const currency = SymbolProfile.currency; const dataSource = SymbolProfile.dataSource; const factor = getFactor(type); @@ -977,6 +984,7 @@ export abstract class PortfolioCalculator { } currentTransactionPointItem = { + assetSubClass, currency, dataSource, investment, @@ -995,6 +1003,7 @@ export abstract class PortfolioCalculator { }; } else { currentTransactionPointItem = { + assetSubClass, currency, dataSource, fee, diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts new file mode 100644 index 000000000..db6e08151 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts @@ -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 { HistoricalDataItem } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { DataSource } from '@prisma/client'; +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 + } + ); + + jest.spyOn(currentRateService, 'getValues').mockResolvedValue({ + dataProviderInfos: [], + errors: [], + values: [] + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'CHF', + userId: userDummyData.id + }); + + const { historicalData } = await portfolioCalculator.computeSnapshot(); + + const historicalData20231231 = historicalData.find(({ date }) => { + return date === '2023-12-31'; + }); + const historicalData20240101 = historicalData.find(({ date }) => { + return date === '2024-01-01'; + }); + const historicalData20241231 = historicalData.find(({ date }) => { + return date === '2024-12-31'; + }); + + /** + * Investment value with currency effect: 1000 USD * 0.85 = 850 CHF + * Total investment: 1000 USD * 0.91 = 910 CHF + * Value (current): 1000 USD * 0.91 = 910 CHF + * Value with currency effect: 1000 USD * 0.85 = 850 CHF + */ + expect(historicalData20231231).toMatchObject({ + date: '2023-12-31', + investmentValueWithCurrencyEffect: 850, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + netWorth: 850, + totalAccountBalance: 0, + totalInvestment: 910, + totalInvestmentValueWithCurrencyEffect: 850, + value: 910, + valueWithCurrencyEffect: 850 + }); + + /** + * Net performance with currency effect: (1000 * 0.86) - (1000 * 0.85) = 10 CHF + * Total investment: 1000 USD * 0.91 = 910 CHF + * Total investment value with currency effect: 1000 USD * 0.85 = 850 CHF + * Value (current): 1000 USD * 0.91 = 910 CHF + * Value with currency effect: 1000 USD * 0.86 = 860 CHF + */ + expect(historicalData20240101).toMatchObject({ + date: '2024-01-01', + investmentValueWithCurrencyEffect: 0, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0.011764705882352941, + netPerformanceWithCurrencyEffect: 10, + netWorth: 860, + totalAccountBalance: 0, + totalInvestment: 910, + totalInvestmentValueWithCurrencyEffect: 850, + value: 910, + valueWithCurrencyEffect: 860 + }); + + /** + * Investment value with currency effect: 1000 USD * 0.90 = 900 CHF + * Net performance: (1000 USD * 1.0) - (1000 USD * 1.0) = 0 CHF + * Net performance with currency effect: (1000 USD * 0.9) - (1000 USD * 0.85) = 50 CHF + * Total investment: 2000 USD * 0.91 = 1820 CHF + * Total investment value with currency effect: (1000 USD * 0.85) + (1000 USD * 0.90) = 1750 CHF + * Value (current): 2000 USD * 0.91 = 1820 CHF + * Value with currency effect: 2000 USD * 0.9 = 1800 CHF + */ + expect(historicalData20241231).toMatchObject({ + date: '2024-12-31', + investmentValueWithCurrencyEffect: 900, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0.058823529411764705, + netPerformanceWithCurrencyEffect: 50, + netWorth: 1800, + totalAccountBalance: 0, + totalInvestment: 1820, + totalInvestmentValueWithCurrencyEffect: 1750, + value: 1820, + valueWithCurrencyEffect: 1800 + }); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts index d4fad7d93..070d7543b 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -188,6 +188,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { }) ); + const isCash = orders[0]?.SymbolProfile?.assetSubClass === 'CASH'; + if (orders.length <= 0) { return { currentValues: {}, @@ -244,6 +246,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { // For BUY / SELL activities with a MANUAL data source where no historical market price is available, // the calculation should fall back to using the activity’s unit price. unitPriceAtEndDate = latestActivity.unitPrice; + } else if (isCash) { + unitPriceAtEndDate = new Big(1); } if ( @@ -295,7 +299,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { quantity: new Big(0), SymbolProfile: { dataSource, - symbol + symbol, + assetSubClass: isCash ? 'CASH' : undefined }, type: 'BUY', unitPrice: unitPriceAtStartDate @@ -308,7 +313,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { itemType: 'end', SymbolProfile: { dataSource, - symbol + symbol, + assetSubClass: isCash ? 'CASH' : undefined }, quantity: new Big(0), type: 'BUY', @@ -348,7 +354,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { quantity: new Big(0), SymbolProfile: { dataSource, - symbol + symbol, + assetSubClass: isCash ? 'CASH' : undefined }, type: 'BUY', unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice, diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts index 9362184c7..fcc8322fc 100644 --- a/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts @@ -6,7 +6,7 @@ export interface PortfolioOrder extends Pick { quantity: Big; SymbolProfile: Pick< Activity['SymbolProfile'], - 'currency' | 'dataSource' | 'name' | 'symbol' | 'userId' + 'assetSubClass' | 'currency' | 'dataSource' | 'name' | 'symbol' | 'userId' >; unitPrice: Big; } diff --git a/apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts b/apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts index f4ceadf3b..14e2e1f37 100644 --- a/apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts @@ -1,7 +1,8 @@ -import { DataSource, Tag } from '@prisma/client'; +import { AssetSubClass, DataSource, Tag } from '@prisma/client'; import { Big } from 'big.js'; export interface TransactionPointSymbol { + assetSubClass: AssetSubClass; averagePrice: Big; currency: string; dataSource: DataSource; diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index faabee79b..5613af9e7 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -522,10 +522,6 @@ export class PortfolioService { return type === 'ACCOUNT'; }) ?? false; - const isFilteredByCash = filters?.some(({ id, type }) => { - return id === AssetClass.LIQUIDITY && type === 'ASSET_CLASS'; - }); - const isFilteredByClosedHoldings = filters?.some(({ id, type }) => { return id === 'CLOSED' && type === 'HOLDING_TYPE'; @@ -557,6 +553,9 @@ export class PortfolioService { assetProfileIdentifiers ); + const cashSymbolProfiles = this.getCashSymbolProfiles(cashDetails); + symbolProfiles.push(...cashSymbolProfiles); + const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {}; for (const symbolProfile of symbolProfiles) { symbolProfileMap[symbolProfile.symbol] = symbolProfile; @@ -661,18 +660,6 @@ export class PortfolioService { }; } - if (filters?.length === 0 || isFilteredByAccount || isFilteredByCash) { - const cashPositions = this.getCashPositions({ - cashDetails, - userCurrency, - value: filteredValueInBaseCurrency - }); - - for (const symbol of Object.keys(cashPositions)) { - holdings[symbol] = cashPositions[symbol]; - } - } - const { accounts, platforms } = await this.getValueOfAccountsAndPlatforms({ activities, filters, @@ -1548,6 +1535,37 @@ export class PortfolioService { return cashPositions; } + private getCashSymbolProfiles(cashDetails: CashDetails) { + const cashSymbols = [ + ...new Set(cashDetails.accounts.map(({ currency }) => currency)) + ]; + + return cashSymbols.map((currency) => { + const account = cashDetails.accounts.find( + ({ currency: accountCurrency }) => { + return accountCurrency === currency; + } + ); + + return { + currency, + activitiesCount: 0, + assetClass: AssetClass.LIQUIDITY, + assetSubClass: AssetSubClass.CASH, + countries: [], + createdAt: account.createdAt, + dataSource: DataSource.MANUAL, + holdings: [], + id: currency, + isActive: true, + name: currency, + sectors: [], + symbol: currency, + updatedAt: account.updatedAt + }; + }); + } + private getDividendsByGroup({ dividends, groupBy @@ -2158,7 +2176,7 @@ export class PortfolioService { accounts[account?.id || UNKNOWN_KEY] = { balance: 0, currency: account?.currency, - name: account.name, + name: account?.name, valueInBaseCurrency: currentValueOfSymbolInBaseCurrency }; } @@ -2172,7 +2190,7 @@ export class PortfolioService { platforms[account?.platformId || UNKNOWN_KEY] = { balance: 0, currency: account?.currency, - name: account.platform?.name, + name: account?.platform?.name, valueInBaseCurrency: currentValueOfSymbolInBaseCurrency }; } diff --git a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts index 076375523..857c1b5a5 100644 --- a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts @@ -14,7 +14,11 @@ export const ExchangeRateDataServiceMock = { '2017-12-31': 0.9787, '2018-01-01': 0.97373, '2023-01-03': 0.9238, - '2023-07-10': 0.8854 + '2023-07-10': 0.8854, + '2023-12-31': 0.85, + '2024-01-01': 0.86, + '2024-12-31': 0.9, + '2025-01-01': 0.91 } }); } else if (targetCurrency === 'EUR') { diff --git a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts index 8c1ba5b41..024bdf4e1 100644 --- a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts @@ -26,6 +26,8 @@ import { import { isNumber } from 'lodash'; import ms from 'ms'; +import { ExchangeRatesByCurrency } from './interfaces/exchange-rate-data.interface'; + @Injectable() export class ExchangeRateDataService { private currencies: string[] = []; @@ -59,7 +61,7 @@ export class ExchangeRateDataService { endDate?: Date; startDate: Date; targetCurrency: string; - }) { + }): Promise { if (!startDate) { return {}; } diff --git a/apps/api/src/services/exchange-rate-data/interfaces/exchange-rate-data.interface.ts b/apps/api/src/services/exchange-rate-data/interfaces/exchange-rate-data.interface.ts new file mode 100644 index 000000000..8e0d2c0d4 --- /dev/null +++ b/apps/api/src/services/exchange-rate-data/interfaces/exchange-rate-data.interface.ts @@ -0,0 +1,5 @@ +export interface ExchangeRatesByCurrency { + [currency: string]: { + [dateString: string]: number; + }; +}