diff --git a/CHANGELOG.md b/CHANGELOG.md index e2e9a0251..a4680958e 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 ## 2.221.0 - 2025-12-01 +### Added + +- Extended the holdings endpoint to include the performance with currency effect for cash + ### Changed - Refactored the API query parameters in various data provider services diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index 001d43b7a..4e0723833 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 @@ -43,7 +47,9 @@ import { randomUUID } from 'node:crypto'; export class OrderService { public constructor( private readonly accountService: AccountService, + private readonly accountBalanceService: AccountBalanceService, private readonly dataGatheringService: DataGatheringService, + private readonly dataProviderService: DataProviderService, private readonly eventEmitter: EventEmitter2, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly prismaService: PrismaService, @@ -317,6 +323,98 @@ export class OrderService { return count; } + 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({ + filters: [{ id: account.id, type: 'ACCOUNT' }], + userCurrency, + userId + }); + + 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: 'LIQUIDITY', + assetSubClass: 'CASH', + countries: [], + createdAt: new Date(balanceItem.date), + currency: account.currency, + dataSource: + this.dataProviderService.getDataSourceForExchangeRates(), + holdings: [], + id: account.currency, + isActive: true, + sectors: [], + symbol: account.currency, + updatedAt: new Date(balanceItem.date) + }, + symbolProfileId: account.currency, + type: '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: 'BUY', + value: balanceItem.value - currentBalance, + valueInBaseCurrency: + balanceItem.valueInBaseCurrency - currentBalanceInBaseCurrency + }); + } else if (currentBalance > balanceItem.value) { + // SELL + activities.push({ + ...syntheticActivityTemplate, + quantity: currentBalance - balanceItem.value, + type: '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: { @@ -620,12 +718,28 @@ export class OrderService { userCurrency: string; userId: string; }) { - return this.getOrders({ + const cashDetails = await this.accountService.getCashDetails({ + filters, + userId, + currency: userCurrency + }); + const cashOrders = await this.getCashOrders({ + cashDetails, + userCurrency, + userId + }); + + const nonCashOrders = await this.getOrders({ filters, userCurrency, userId, withExcludedAccountsAndActivities: false // TODO }); + + return { + activities: [...nonCashOrders.activities, ...cashOrders.activities], + count: nonCashOrders.count + cashOrders.count + }; } public async getStatisticsByCurrency( diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 084c8f4ed..407783f3c 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -487,6 +487,7 @@ export class PortfolioService { (user.settings?.settings as UserSettings)?.emergencyFund ?? 0 ); + // Activities for non-cash assets const { activities } = await this.orderService.getOrdersForPortfolioCalculator({ filters, @@ -494,6 +495,13 @@ export class PortfolioService { userId }); + // Synthetic activities for cash + const cashDetails = await this.accountService.getCashDetails({ + filters, + userId, + currency: userCurrency + }); + const portfolioCalculator = this.calculatorFactory.createCalculator({ activities, filters, @@ -505,12 +513,6 @@ export class PortfolioService { const { createdAt, currentValueInBaseCurrency, hasErrors, positions } = await portfolioCalculator.getSnapshot(); - const cashDetails = await this.accountService.getCashDetails({ - filters, - userId, - currency: userCurrency - }); - const holdings: PortfolioDetails['holdings'] = {}; const totalValueInBaseCurrency = currentValueInBaseCurrency.plus( @@ -522,10 +524,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'; @@ -621,10 +619,10 @@ export class PortfolioService { allocationInPercentage: filteredValueInBaseCurrency.eq(0) ? 0 : valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(), - assetClass: assetProfile.assetClass, - assetSubClass: assetProfile.assetSubClass, - countries: assetProfile.countries, - dataSource: assetProfile.dataSource, + assetClass: assetProfile?.assetClass, + assetSubClass: assetProfile?.assetSubClass, + countries: assetProfile?.countries, + dataSource: assetProfile?.dataSource, dateOfFirstActivity: parseDate(firstBuyDate), dividend: dividend?.toNumber() ?? 0, grossPerformance: grossPerformance?.toNumber() ?? 0, @@ -633,8 +631,8 @@ export class PortfolioService { grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0, grossPerformanceWithCurrencyEffect: grossPerformanceWithCurrencyEffect?.toNumber() ?? 0, - holdings: assetProfile.holdings.map( - ({ allocationInPercentage, name }) => { + holdings: + assetProfile?.holdings.map(({ allocationInPercentage, name }) => { return { allocationInPercentage, name, @@ -642,10 +640,9 @@ export class PortfolioService { .mul(allocationInPercentage) .toNumber() }; - } - ), + }) ?? [], investment: investment.toNumber(), - name: assetProfile.name, + name: assetProfile?.name, netPerformance: netPerformance?.toNumber() ?? 0, netPerformancePercent: netPerformancePercentage?.toNumber() ?? 0, netPerformancePercentWithCurrencyEffect: @@ -655,24 +652,12 @@ export class PortfolioService { netPerformanceWithCurrencyEffect: netPerformanceWithCurrencyEffectMap?.[dateRange]?.toNumber() ?? 0, quantity: quantity.toNumber(), - sectors: assetProfile.sectors, - url: assetProfile.url, + sectors: assetProfile?.sectors, + url: assetProfile?.url, valueInBaseCurrency: valueInBaseCurrency.toNumber() }; } - 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, @@ -2157,7 +2142,7 @@ export class PortfolioService { accounts[account?.id || UNKNOWN_KEY] = { balance: 0, currency: account?.currency, - name: account.name, + name: account?.name, valueInBaseCurrency: currentValueOfSymbolInBaseCurrency }; } @@ -2171,7 +2156,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.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; + }; +}