diff --git a/CHANGELOG.md b/CHANGELOG.md index 91a583450..356977e77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,16 +5,109 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## 2.106.0-beta.2 - 2024-08-26 +## Unreleased + +### Changed + +- Upgraded `webpack-bundle-analyzer` from version `4.10.1` to `4.10.2` + +## 2.110.0 - 2024-09-24 + +### Changed + +- Improved the usability of various action menus by introducing horizontal lines to separate the delete action +- Improved the chart in the account detail dialog (experimental) +- Aligned the holdings and regions of the public page with the allocations page +- Considered the user’s language in the link of the access table to share the portfolio +- Improved the language localization for German (`de`) + +## 2.109.0 - 2024-09-21 + +### Added + +- Extended the _Public API_ with a new endpoint that provides portfolio performance metrics (experimental) +- Added the portfolio performance metrics to the public page +- Added a blog post: _Hacktoberfest 2024_ + +### Changed + +- Improved the usability of the create or update access dialog +- Improved the loading indicator of the accounts table +- Exposed the concurrency of the asset profile data gathering as an environment variable (`PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE`) +- Exposed the concurrency of the historical market data gathering as an environment variable (`PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA`) +- Exposed the concurrency of the portfolio snapshot calculation as an environment variable (`PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT`) +- Improved the language localization for German (`de`) +- Improved the language localization for Polish (`pl`) +- Upgraded `prisma` from version `5.19.0` to `5.19.1` + +## 2.108.0 - 2024-09-17 + +### Added + +- Added support for bonds in the import dividends dialog +- Added a _Copy link to clipboard_ action to the access table to share the portfolio +- Added the current market price column to the historical market data table of the admin control +- Introduced filters (`dataSource` and `symbol`) in the accounts endpoint + +### Changed + +- Improved the usability of the toggle component +- Switched to the accounts endpoint in the holding detail dialog +- Added a fallback in the get quotes functionality of the _EOD Historical Data_ service + +## 2.107.1 - 2024-09-12 + +### Fixed + +- Fixed an issue in the activities filters that occurred during destructuring + +## 2.107.0 - 2024-09-10 + +### Added + +- Extended the filters of the activities endpoint by `dataSource` and `symbol` + +### Changed + +- Migrated the portfolio snapshot calculation to the queue design pattern +- Optimized the asynchronous operations using `Promise.all()` in the info service +- Optimized the asynchronous operations using `Promise.all()` in the admin control panel endpoint +- Extracted the users from the admin control panel endpoint to a dedicated endpoint +- Improved the language localization for French (`fr`) +- Improved the language localization for Italian (`it`) +- Upgraded `bull` from version `4.10.4` to `4.16.2` + +## 2.106.0 - 2024-09-07 + +### Added + +- Set up a performance logging service +- Added a loading indicator to the queue jobs table in the admin control panel +- Added a loading indicator to the users table in the admin control panel +- Added the attribute `mode` to the scraper configuration to get quotes instantly ### Changed - Reworked the portfolio calculator +- Improved the caching of the portfolio snapshot in the portfolio calculator by returning cached data and recalculating in the background when it expires +- Exposed the log levels as an environment variable (`LOG_LEVELS`) - Exposed the maximum of chart data items as an environment variable (`MAX_CHART_ITEMS`) +- Changed the data format of the environment variable `CACHE_QUOTES_TTL` from seconds to milliseconds +- Changed the data format of the environment variable `CACHE_TTL` from seconds to milliseconds +- Removed the environment variable `MAX_ITEM_IN_CACHE` +- Improved the error logs of the scraper configuration test in the asset profile details dialog of the admin control +- Improved the language localization for Polish (`pl`) +- Migrated from `cache-manager-redis-store` to `cache-manager-redis-yet` +- Upgraded `cache-manager` from version `3.4.3` to `5.7.6` +- Upgraded `prisma` from version `5.18.0` to `5.19.0` ### Fixed - Fixed an issue in the view mode toggle of the holdings tab on the home page (experimental) +- Fixed an issue on the portfolio activities page by loading the data only once +- Fixed an issue in the carousel component for the testimonial section on the landing page +- Fixed the historical market data gathering in the _Yahoo Finance_ service by switching from `historical()` to `chart()` +- Handled an exception in the historical market data component of the asset profile details dialog in the admin control panel ## 2.105.0 - 2024-08-21 diff --git a/README.md b/README.md index 47316881f..477554749 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ [**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://x.com/ghostfolio_) [![Shield: Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee)](https://www.buymeacoffee.com/ghostfolio) -[![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-orange.svg)](#contributing) -[![Shield: License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) +[![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-limegreen.svg)](#contributing) [![Shield: Docker Pulls](https://img.shields.io/docker/pulls/ghostfolio/ghostfolio?label=Docker%20Pulls)](https://hub.docker.com/r/ghostfolio/ghostfolio) +[![Shield: License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-orange.svg)](https://www.gnu.org/licenses/agpl-3.0) @@ -85,23 +85,24 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c ### Supported Environment Variables -| Name | Type | Default Value | Description | -| ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens | -| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key | -| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key | -| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` | -| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on | -| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) | -| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on | -| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database | -| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database | -| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database | -| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ | -| `REDIS_HOST` | `string` | | The host where _Redis_ is running | -| `REDIS_PASSWORD` | `string` | | The password of _Redis_ | -| `REDIS_PORT` | `number` | | The port where _Redis_ is running | -| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | +| Name | Type | Default Value | Description | +| ------------------------ | --------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens | +| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key | +| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key | +| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` | +| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on | +| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) | +| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` | +| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on | +| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database | +| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database | +| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database | +| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ | +| `REDIS_HOST` | `string` | | The host where _Redis_ is running | +| `REDIS_PASSWORD` | `string` | | The password of _Redis_ | +| `REDIS_PORT` | `number` | | The port where _Redis_ is running | +| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | ### Run with Docker Compose @@ -164,6 +165,10 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous//portfolio` + +**Info:** No Bearer Token is required for authorization + +#### Response + +##### Success + +``` +{ + "performance": { + "1d": { + "relativeChange": 0 // normalized from -1 to 1 + }; + "ytd": { + "relativeChange": 0 // normalized from -1 to 1 + }, + "max": { + "relativeChange": 0 // normalized from -1 to 1 + } + } +} +``` + ## Community Projects Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio diff --git a/apps/api/src/app/account-balance/account-balance.service.ts b/apps/api/src/app/account-balance/account-balance.service.ts index 331263c0c..a587c09f9 100644 --- a/apps/api/src/app/account-balance/account-balance.service.ts +++ b/apps/api/src/app/account-balance/account-balance.service.ts @@ -1,15 +1,19 @@ -import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; +import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; -import { resetHours } from '@ghostfolio/common/helper'; -import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces'; -import { UserWithSettings } from '@ghostfolio/common/types'; +import { DATE_FORMAT, getSum, resetHours } from '@ghostfolio/common/helper'; +import { + AccountBalancesResponse, + Filter, + HistoricalDataItem +} from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { AccountBalance, Prisma } from '@prisma/client'; -import { parseISO } from 'date-fns'; +import { Big } from 'big.js'; +import { format, parseISO } from 'date-fns'; import { CreateAccountBalanceDto } from './create-account-balance.dto'; @@ -91,17 +95,56 @@ export class AccountBalanceService { return accountBalance; } + @LogPerformance + public async getAccountBalanceItems({ + filters, + userCurrency, + userId + }: { + filters?: Filter[]; + userCurrency: string; + userId: string; + }): Promise { + const { balances } = await this.getAccountBalances({ + filters, + userCurrency, + userId, + withExcludedAccounts: false // TODO + }); + const accumulatedBalancesByDate: { [date: string]: HistoricalDataItem } = + {}; + const lastBalancesByAccount: { [accountId: string]: Big } = {}; + + for (const { accountId, date, valueInBaseCurrency } of balances) { + const formattedDate = format(date, DATE_FORMAT); + + lastBalancesByAccount[accountId] = new Big(valueInBaseCurrency); + + const totalBalance = getSum(Object.values(lastBalancesByAccount)); + + // Add or update the accumulated balance for this date + accumulatedBalancesByDate[formattedDate] = { + date: formattedDate, + value: totalBalance.toNumber() + }; + } + + return Object.values(accumulatedBalancesByDate); + } + @LogPerformance public async getAccountBalances({ filters, - user, + userCurrency, + userId, withExcludedAccounts }: { filters?: Filter[]; - user: UserWithSettings; + userCurrency: string; + userId: string; withExcludedAccounts?: boolean; }): Promise { - const where: Prisma.AccountBalanceWhereInput = { userId: user.id }; + const where: Prisma.AccountBalanceWhereInput = { userId }; const accountFilter = filters?.find(({ type }) => { return type === 'ACCOUNT'; @@ -132,10 +175,11 @@ export class AccountBalanceService { balances: balances.map((balance) => { return { ...balance, + accountId: balance.Account.id, valueInBaseCurrency: this.exchangeRateDataService.toCurrency( balance.value, balance.Account.currency, - user.Settings.settings.baseCurrency + userCurrency ) }; }) diff --git a/apps/api/src/app/account/account.controller.ts b/apps/api/src/app/account/account.controller.ts index 594a733f7..44e136793 100644 --- a/apps/api/src/app/account/account.controller.ts +++ b/apps/api/src/app/account/account.controller.ts @@ -3,6 +3,8 @@ import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.servic import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; +import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { @@ -26,6 +28,7 @@ import { Param, Post, Put, + Query, UseGuards, UseInterceptors } from '@nestjs/common'; @@ -44,6 +47,7 @@ export class AccountController { public constructor( private readonly accountBalanceService: AccountBalanceService, private readonly accountService: AccountService, + private readonly apiService: ApiService, private readonly impersonationService: ImpersonationService, private readonly portfolioService: PortfolioService, @Inject(REQUEST) private readonly request: RequestWithUser @@ -84,13 +88,22 @@ export class AccountController { @Get() @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseInterceptors(RedactValuesInResponseInterceptor) + @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getAllAccounts( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, + @Query('dataSource') filterByDataSource?: string, + @Query('symbol') filterBySymbol?: string ): Promise { const impersonationUserId = await this.impersonationService.validateImpersonationId(impersonationId); + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByDataSource, + filterBySymbol + }); + return this.portfolioService.getAccountsWithAggregations({ + filters, userId: impersonationUserId || this.request.user.id, withExcludedAccounts: true }); @@ -124,7 +137,8 @@ export class AccountController { ): Promise { return this.accountBalanceService.getAccountBalances({ filters: [{ id, type: 'ACCOUNT' }], - user: this.request.user + userCurrency: this.request.user.Settings.settings.baseCurrency, + userId: this.request.user.id }); } diff --git a/apps/api/src/app/account/account.module.ts b/apps/api/src/app/account/account.module.ts index 1c2d20216..fb89bb2b6 100644 --- a/apps/api/src/app/account/account.module.ts +++ b/apps/api/src/app/account/account.module.ts @@ -1,6 +1,7 @@ import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module'; import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module'; +import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; @@ -16,6 +17,7 @@ import { AccountService } from './account.service'; exports: [AccountService], imports: [ AccountBalanceModule, + ApiModule, ConfigurationModule, ExchangeRateDataModule, ImpersonationModule, diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 0cf63917f..0cf8d78bd 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -2,10 +2,10 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; -import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; +import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_MEDIUM, @@ -17,6 +17,7 @@ import { AdminData, AdminMarketData, AdminMarketDataDetails, + AdminUsers, EnhancedSymbolProfile } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; @@ -239,9 +240,11 @@ export class AdminController { return { price }; } - throw new Error('Could not parse the current market price'); + throw new Error( + `Could not parse the current market price for ${symbol} (${dataSource})` + ); } catch (error) { - Logger.error(error); + Logger.error(error, 'AdminController'); throw new HttpException(error.message, StatusCodes.BAD_REQUEST); } @@ -350,4 +353,11 @@ export class AdminController { ) { return this.adminService.putSetting(key, data.value); } + + @Get('user') + @HasPermission(permissions.accessAdminControl) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getUsers(): Promise { + return this.adminService.getUsers(); + } } diff --git a/apps/api/src/app/admin/admin.module.ts b/apps/api/src/app/admin/admin.module.ts index 67eed9202..55acd194c 100644 --- a/apps/api/src/app/admin/admin.module.ts +++ b/apps/api/src/app/admin/admin.module.ts @@ -4,12 +4,12 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscriptio import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; -import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; +import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { SymbolProfileOverwriteModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile-overwrite.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index 1ef2abfde..5ac7526f8 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -16,12 +16,17 @@ import { PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_USER_SIGNUP_ENABLED } from '@ghostfolio/common/config'; -import { isCurrency, getCurrencyFromSymbol } from '@ghostfolio/common/helper'; +import { + getAssetProfileIdentifier, + getCurrencyFromSymbol, + isCurrency +} from '@ghostfolio/common/helper'; import { AdminData, AdminMarketData, AdminMarketDataDetails, AdminMarketDataItem, + AdminUsers, AssetProfileIdentifier, EnhancedSymbolProfile, Filter @@ -111,35 +116,42 @@ export class AdminService { } public async get(): Promise { + const exchangeRates = this.exchangeRateDataService + .getCurrencies() + .filter((currency) => { + return currency !== DEFAULT_CURRENCY; + }) + .map((currency) => { + const label1 = DEFAULT_CURRENCY; + const label2 = currency; + + return { + label1, + label2, + dataSource: + DataSource[ + this.configurationService.get('DATA_SOURCE_EXCHANGE_RATES') + ], + symbol: `${label1}${label2}`, + value: this.exchangeRateDataService.toCurrency( + 1, + DEFAULT_CURRENCY, + currency + ) + }; + }); + + const [settings, transactionCount, userCount] = await Promise.all([ + this.propertyService.get(), + this.prismaService.order.count(), + this.prismaService.user.count() + ]); + return { - exchangeRates: this.exchangeRateDataService - .getCurrencies() - .filter((currency) => { - return currency !== DEFAULT_CURRENCY; - }) - .map((currency) => { - const label1 = DEFAULT_CURRENCY; - const label2 = currency; - - return { - label1, - label2, - dataSource: - DataSource[ - this.configurationService.get('DATA_SOURCE_EXCHANGE_RATES') - ], - symbol: `${label1}${label2}`, - value: this.exchangeRateDataService.toCurrency( - 1, - DEFAULT_CURRENCY, - currency - ) - }; - }), - settings: await this.propertyService.get(), - transactionCount: await this.prismaService.order.count(), - userCount: await this.prismaService.user.count(), - users: await this.getUsersWithAnalytics(), + exchangeRates, + settings, + transactionCount, + userCount, version: environment.version }; } @@ -258,6 +270,37 @@ export class AdminService { this.prismaService.symbolProfile.count({ where }) ]); + const lastMarketPrices = await this.prismaService.marketData.findMany({ + distinct: ['dataSource', 'symbol'], + orderBy: { date: 'desc' }, + select: { + dataSource: true, + marketPrice: true, + symbol: true + }, + where: { + dataSource: { + in: assetProfiles.map(({ dataSource }) => { + return dataSource; + }) + }, + symbol: { + in: assetProfiles.map(({ symbol }) => { + return symbol; + }) + } + } + }); + + const lastMarketPriceMap = new Map(); + + for (const { dataSource, marketPrice, symbol } of lastMarketPrices) { + lastMarketPriceMap.set( + getAssetProfileIdentifier({ dataSource, symbol }), + marketPrice + ); + } + let marketData: AdminMarketDataItem[] = await Promise.all( assetProfiles.map( async ({ @@ -279,6 +322,11 @@ export class AdminService { const countriesCount = countries ? Object.keys(countries).length : 0; + + const lastMarketPrice = lastMarketPriceMap.get( + getAssetProfileIdentifier({ dataSource, symbol }) + ); + const marketDataItemCount = marketDataItems.find((marketDataItem) => { return ( @@ -286,6 +334,7 @@ export class AdminService { marketDataItem.symbol === symbol ); })?._count ?? 0; + const sectorsCount = sectors ? Object.keys(sectors).length : 0; return { @@ -296,6 +345,7 @@ export class AdminService { countriesCount, dataSource, id, + lastMarketPrice, name, symbol, marketDataItemCount, @@ -385,6 +435,10 @@ export class AdminService { }; } + public async getUsers(): Promise { + return { users: await this.getUsersWithAnalytics() }; + } + public async patchAssetProfileData({ assetClass, assetSubClass, @@ -509,58 +563,97 @@ export class AdminService { } private async getMarketDataForCurrencies(): Promise { - const marketDataItems = await this.prismaService.marketData.groupBy({ - _count: true, - by: ['dataSource', 'symbol'] - }); - - const marketDataPromise: Promise[] = - this.exchangeRateDataService - .getCurrencyPairs() - .map(async ({ dataSource, symbol }) => { - let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0; - let currency: EnhancedSymbolProfile['currency'] = '-'; - let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity']; - - if (isCurrency(getCurrencyFromSymbol(symbol))) { - currency = getCurrencyFromSymbol(symbol); - ({ activitiesCount, dateOfFirstActivity } = - await this.orderService.getStatisticsByCurrency(currency)); + const currencyPairs = this.exchangeRateDataService.getCurrencyPairs(); + + const [lastMarketPrices, marketDataItems] = await Promise.all([ + this.prismaService.marketData.findMany({ + distinct: ['dataSource', 'symbol'], + orderBy: { date: 'desc' }, + select: { + dataSource: true, + marketPrice: true, + symbol: true + }, + where: { + dataSource: { + in: currencyPairs.map(({ dataSource }) => { + return dataSource; + }) + }, + symbol: { + in: currencyPairs.map(({ symbol }) => { + return symbol; + }) } + } + }), + this.prismaService.marketData.groupBy({ + _count: true, + by: ['dataSource', 'symbol'] + }) + ]); - const marketDataItemCount = - marketDataItems.find((marketDataItem) => { - return ( - marketDataItem.dataSource === dataSource && - marketDataItem.symbol === symbol - ); - })?._count ?? 0; - - return { - activitiesCount, - date: dateOfFirstActivity, - dataSource, - marketDataItemCount, - symbol, - assetClass: AssetClass.LIQUIDITY, - countriesCount: 0, - currency: symbol.replace(DEFAULT_CURRENCY, ''), - id: undefined, - name: symbol, - sectorsCount: 0, - tags: [] - }; - }); + const lastMarketPriceMap = new Map(); + + for (const { dataSource, marketPrice, symbol } of lastMarketPrices) { + lastMarketPriceMap.set( + getAssetProfileIdentifier({ dataSource, symbol }), + marketPrice + ); + } + + const marketDataPromise: Promise[] = currencyPairs.map( + async ({ dataSource, symbol }) => { + let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0; + let currency: EnhancedSymbolProfile['currency'] = '-'; + let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity']; + + if (isCurrency(getCurrencyFromSymbol(symbol))) { + currency = getCurrencyFromSymbol(symbol); + ({ activitiesCount, dateOfFirstActivity } = + await this.orderService.getStatisticsByCurrency(currency)); + } + + const lastMarketPrice = lastMarketPriceMap.get( + getAssetProfileIdentifier({ dataSource, symbol }) + ); + + const marketDataItemCount = + marketDataItems.find((marketDataItem) => { + return ( + marketDataItem.dataSource === dataSource && + marketDataItem.symbol === symbol + ); + })?._count ?? 0; + + return { + activitiesCount, + currency, + dataSource, + lastMarketPrice, + marketDataItemCount, + symbol, + assetClass: AssetClass.LIQUIDITY, + assetSubClass: AssetSubClass.CASH, + countriesCount: 0, + date: dateOfFirstActivity, + id: undefined, + name: symbol, + sectorsCount: 0, + tags: [] + }; + } + ); const marketData = await Promise.all(marketDataPromise); return { marketData, count: marketData.length }; } - private async getUsersWithAnalytics(): Promise { + private async getUsersWithAnalytics(): Promise { let orderBy: any = { createdAt: 'desc' }; - let where; + let where: Prisma.UserWhereInput; if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { orderBy = { diff --git a/apps/api/src/app/admin/queue/queue.module.ts b/apps/api/src/app/admin/queue/queue.module.ts index 46ae3b8a5..22d1cefc6 100644 --- a/apps/api/src/app/admin/queue/queue.module.ts +++ b/apps/api/src/app/admin/queue/queue.module.ts @@ -1,4 +1,5 @@ -import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; +import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; +import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { Module } from '@nestjs/common'; @@ -7,7 +8,7 @@ import { QueueService } from './queue.service'; @Module({ controllers: [QueueController], - imports: [DataGatheringModule], + imports: [DataGatheringModule, PortfolioSnapshotQueueModule], providers: [QueueService] }) export class QueueModule {} diff --git a/apps/api/src/app/admin/queue/queue.service.ts b/apps/api/src/app/admin/queue/queue.service.ts index abae3cad1..7e4f0adb7 100644 --- a/apps/api/src/app/admin/queue/queue.service.ts +++ b/apps/api/src/app/admin/queue/queue.service.ts @@ -1,5 +1,6 @@ import { DATA_GATHERING_QUEUE, + PORTFOLIO_SNAPSHOT_QUEUE, QUEUE_JOB_STATUS_LIST } from '@ghostfolio/common/config'; import { AdminJobs } from '@ghostfolio/common/interfaces'; @@ -12,11 +13,19 @@ import { JobStatus, Queue } from 'bull'; export class QueueService { public constructor( @InjectQueue(DATA_GATHERING_QUEUE) - private readonly dataGatheringQueue: Queue + private readonly dataGatheringQueue: Queue, + @InjectQueue(PORTFOLIO_SNAPSHOT_QUEUE) + private readonly portfolioSnapshotQueue: Queue ) {} public async deleteJob(aId: string) { - return (await this.dataGatheringQueue.getJob(aId))?.remove(); + let job = await this.dataGatheringQueue.getJob(aId); + + if (!job) { + job = await this.portfolioSnapshotQueue.getJob(aId); + } + + return job?.remove(); } public async deleteJobs({ @@ -25,15 +34,21 @@ export class QueueService { status?: JobStatus[]; }) { for (const statusItem of status) { - await this.dataGatheringQueue.clean( - 300, - statusItem === 'waiting' ? 'wait' : statusItem - ); + const queueStatus = statusItem === 'waiting' ? 'wait' : statusItem; + + await this.dataGatheringQueue.clean(300, queueStatus); + await this.portfolioSnapshotQueue.clean(300, queueStatus); } } public async executeJob(aId: string) { - return (await this.dataGatheringQueue.getJob(aId))?.promote(); + let job = await this.dataGatheringQueue.getJob(aId); + + if (!job) { + job = await this.portfolioSnapshotQueue.getJob(aId); + } + + return job?.promote(); } public async getJobs({ @@ -43,10 +58,13 @@ export class QueueService { limit?: number; status?: JobStatus[]; }): Promise { - const jobs = await this.dataGatheringQueue.getJobs(status); + const [dataGatheringJobs, portfolioSnapshotJobs] = await Promise.all([ + this.dataGatheringQueue.getJobs(status), + this.portfolioSnapshotQueue.getJobs(status) + ]); const jobsWithState = await Promise.all( - jobs + [...dataGatheringJobs, ...portfolioSnapshotJobs] .filter((job) => { return job; }) diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index ca19d63bc..2803a0580 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -1,11 +1,12 @@ import { EventsModule } from '@ghostfolio/api/events/events.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { CronService } from '@ghostfolio/api/services/cron.service'; -import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; +import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; +import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module'; import { DEFAULT_LANGUAGE_CODE, @@ -30,6 +31,7 @@ import { AuthDeviceModule } from './auth-device/auth-device.module'; import { AuthModule } from './auth/auth.module'; import { BenchmarkModule } from './benchmark/benchmark.module'; import { CacheModule } from './cache/cache.module'; +import { PublicModule } from './endpoints/public/public.module'; import { ExchangeRateModule } from './exchange-rate/exchange-rate.module'; import { ExportModule } from './export/export.module'; import { HealthModule } from './health/health.module'; @@ -81,8 +83,10 @@ import { UserModule } from './user/user.module'; OrderModule, PlatformModule, PortfolioModule, + PortfolioSnapshotQueueModule, PrismaModule, PropertyModule, + PublicModule, RedisCacheModule, ScheduleModule.forRoot(), ServeStaticModule.forRoot({ diff --git a/apps/api/src/app/benchmark/benchmark.service.ts b/apps/api/src/app/benchmark/benchmark.service.ts index 169ea8cad..f57f5fa30 100644 --- a/apps/api/src/app/benchmark/benchmark.service.ts +++ b/apps/api/src/app/benchmark/benchmark.service.ts @@ -7,7 +7,10 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data/market-d import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; -import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config'; +import { + CACHE_TTL_INFINITE, + PROPERTY_BENCHMARKS +} from '@ghostfolio/common/config'; import { DATE_FORMAT, calculateBenchmarkTrend, @@ -443,7 +446,7 @@ export class BenchmarkService { benchmarks, expiration: expiration.getTime() }), - ms('12 hours') / 1000 + CACHE_TTL_INFINITE ); } diff --git a/apps/api/src/app/endpoints/public/public.controller.ts b/apps/api/src/app/endpoints/public/public.controller.ts new file mode 100644 index 000000000..b0f7bb2c3 --- /dev/null +++ b/apps/api/src/app/endpoints/public/public.controller.ts @@ -0,0 +1,134 @@ +import { AccessService } from '@ghostfolio/api/app/access/access.service'; +import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; +import { getSum } from '@ghostfolio/common/helper'; +import { PublicPortfolioResponse } from '@ghostfolio/common/interfaces'; +import type { RequestWithUser } from '@ghostfolio/common/types'; + +import { + Controller, + Get, + HttpException, + Inject, + Param, + UseInterceptors +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { Big } from 'big.js'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +@Controller('public') +export class PublicController { + public constructor( + private readonly accessService: AccessService, + private readonly configurationService: ConfigurationService, + private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly portfolioService: PortfolioService, + @Inject(REQUEST) private readonly request: RequestWithUser, + private readonly userService: UserService + ) {} + + @Get(':accessId/portfolio') + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async getPublicPortfolio( + @Param('accessId') accessId + ): Promise { + const access = await this.accessService.access({ id: accessId }); + + if (!access) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + let hasDetails = true; + + const user = await this.userService.user({ + id: access.userId + }); + + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + hasDetails = user.subscription.type === 'Premium'; + } + + const [ + { holdings }, + { performance: performance1d }, + { performance: performanceMax }, + { performance: performanceYtd } + ] = await Promise.all([ + this.portfolioService.getDetails({ + impersonationId: access.userId, + userId: user.id, + withMarkets: true + }), + ...['1d', 'max', 'ytd'].map((dateRange) => { + return this.portfolioService.getPerformance({ + dateRange, + impersonationId: undefined, + userId: user.id + }); + }) + ]); + + const publicPortfolioResponse: PublicPortfolioResponse = { + hasDetails, + alias: access.alias, + holdings: {}, + performance: { + '1d': { + relativeChange: + performance1d.netPerformancePercentageWithCurrencyEffect + }, + max: { + relativeChange: + performanceMax.netPerformancePercentageWithCurrencyEffect + }, + ytd: { + relativeChange: + performanceYtd.netPerformancePercentageWithCurrencyEffect + } + } + }; + + const totalValue = getSum( + Object.values(holdings).map(({ currency, marketPrice, quantity }) => { + return new Big( + this.exchangeRateDataService.toCurrency( + quantity * marketPrice, + currency, + this.request.user?.Settings?.settings.baseCurrency ?? + DEFAULT_CURRENCY + ) + ); + }) + ).toNumber(); + + for (const [symbol, portfolioPosition] of Object.entries(holdings)) { + publicPortfolioResponse.holdings[symbol] = { + allocationInPercentage: + portfolioPosition.valueInBaseCurrency / totalValue, + assetClass: hasDetails ? portfolioPosition.assetClass : undefined, + countries: hasDetails ? portfolioPosition.countries : [], + currency: hasDetails ? portfolioPosition.currency : undefined, + dataSource: portfolioPosition.dataSource, + dateOfFirstActivity: portfolioPosition.dateOfFirstActivity, + markets: hasDetails ? portfolioPosition.markets : undefined, + name: portfolioPosition.name, + netPerformancePercentWithCurrencyEffect: + portfolioPosition.netPerformancePercentWithCurrencyEffect, + sectors: hasDetails ? portfolioPosition.sectors : [], + symbol: portfolioPosition.symbol, + url: portfolioPosition.url, + valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue + }; + } + + return publicPortfolioResponse; + } +} diff --git a/apps/api/src/app/endpoints/public/public.module.ts b/apps/api/src/app/endpoints/public/public.module.ts new file mode 100644 index 000000000..9b43522c1 --- /dev/null +++ b/apps/api/src/app/endpoints/public/public.module.ts @@ -0,0 +1,49 @@ +import { AccessModule } from '@ghostfolio/api/app/access/access.module'; +import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; +import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { OrderModule } from '@ghostfolio/api/app/order/order.module'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service'; +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; +import { UserModule } from '@ghostfolio/api/app/user/user.module'; +import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; +import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; +import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; + +import { Module } from '@nestjs/common'; + +import { PublicController } from './public.controller'; + +@Module({ + controllers: [PublicController], + imports: [ + AccessModule, + DataProviderModule, + ExchangeRateDataModule, + ImpersonationModule, + MarketDataModule, + OrderModule, + PortfolioSnapshotQueueModule, + PrismaModule, + RedisCacheModule, + SymbolProfileModule, + TransformDataSourceInRequestModule, + UserModule + ], + providers: [ + AccountBalanceService, + AccountService, + CurrentRateService, + PortfolioCalculatorFactory, + PortfolioService, + RulesService + ] +}) +export class PublicModule {} diff --git a/apps/api/src/app/import/import.module.ts b/apps/api/src/app/import/import.module.ts index 47a4b5db3..142a939a6 100644 --- a/apps/api/src/app/import/import.module.ts +++ b/apps/api/src/app/import/import.module.ts @@ -7,10 +7,10 @@ import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.mo import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; -import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { Module } from '@nestjs/common'; diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 5199a1a7b..11d7dc29c 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -9,9 +9,9 @@ import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.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 { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config'; import { diff --git a/apps/api/src/app/info/info.module.ts b/apps/api/src/app/info/info.module.ts index 473a966ad..9b7854160 100644 --- a/apps/api/src/app/info/info.module.ts +++ b/apps/api/src/app/info/info.module.ts @@ -4,10 +4,10 @@ import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.mo import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; -import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; +import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; diff --git a/apps/api/src/app/info/info.service.ts b/apps/api/src/app/info/info.service.ts index de4a870d2..acd7b315b 100644 --- a/apps/api/src/app/info/info.service.ts +++ b/apps/api/src/app/info/info.service.ts @@ -54,9 +54,6 @@ export class InfoService { public async get(): Promise { const info: Partial = {}; let isReadOnlyMode: boolean; - const platforms = await this.platformService.getPlatforms({ - orderBy: { name: 'asc' } - }); const globalPermissions: string[] = []; @@ -100,22 +97,30 @@ export class InfoService { globalPermissions.push(permissions.enableSystemMessage); } - const isUserSignupEnabled = - await this.propertyService.isUserSignupEnabled(); + const [ + benchmarks, + demoAuthToken, + isUserSignupEnabled, + platforms, + statistics, + subscriptions, + tags + ] = await Promise.all([ + this.benchmarkService.getBenchmarkAssetProfiles(), + this.getDemoAuthToken(), + this.propertyService.isUserSignupEnabled(), + this.platformService.getPlatforms({ + orderBy: { name: 'asc' } + }), + this.getStatistics(), + this.getSubscriptions(), + this.tagService.get() + ]); if (isUserSignupEnabled) { globalPermissions.push(permissions.createUserAccount); } - const [benchmarks, demoAuthToken, statistics, subscriptions, tags] = - await Promise.all([ - this.benchmarkService.getBenchmarkAssetProfiles(), - this.getDemoAuthToken(), - this.getStatistics(), - this.getSubscriptions(), - this.tagService.get() - ]); - return { ...info, benchmarks, diff --git a/apps/api/src/app/order/order.controller.ts b/apps/api/src/app/order/order.controller.ts index 7a9cf3d17..907335aa0 100644 --- a/apps/api/src/app/order/order.controller.ts +++ b/apps/api/src/app/order/order.controller.ts @@ -4,8 +4,8 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; -import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; +import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { DATA_GATHERING_QUEUE_PRIORITY_HIGH, @@ -94,15 +94,18 @@ export class OrderController { @Get() @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseInterceptors(RedactValuesInResponseInterceptor) + @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getAllOrders( @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, @Query('accounts') filterByAccounts?: string, @Query('assetClasses') filterByAssetClasses?: string, + @Query('dataSource') filterByDataSource?: string, @Query('range') dateRange?: DateRange, @Query('skip') skip?: number, @Query('sortColumn') sortColumn?: string, @Query('sortDirection') sortDirection?: Prisma.SortOrder, + @Query('symbol') filterBySymbol?: string, @Query('tags') filterByTags?: string, @Query('take') take?: number ): Promise { @@ -116,6 +119,8 @@ export class OrderController { const filters = this.apiService.buildFiltersFromQueryParams({ filterByAccounts, filterByAssetClasses, + filterByDataSource, + filterBySymbol, filterByTags }); diff --git a/apps/api/src/app/order/order.module.ts b/apps/api/src/app/order/order.module.ts index 55b4cce82..9bc837aa6 100644 --- a/apps/api/src/app/order/order.module.ts +++ b/apps/api/src/app/order/order.module.ts @@ -6,11 +6,11 @@ import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redac import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; -import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { Module } from '@nestjs/common'; diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index 83196f89b..16dea96aa 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -1,9 +1,10 @@ import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor'; import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; -import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; +import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; 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'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { DATA_GATHERING_QUEUE_PRIORITY_HIGH, @@ -351,6 +352,14 @@ export class OrderService { return type; }); + const filterByDataSource = filters?.find(({ type }) => { + return type === 'DATA_SOURCE'; + })?.id; + + const filterBySymbol = filters?.find(({ type }) => { + return type === 'SYMBOL'; + })?.id; + const searchQuery = filters?.find(({ type }) => { return type === 'SEARCH_QUERY'; })?.id; @@ -396,6 +405,29 @@ export class OrderService { }; } + if (filterByDataSource && filterBySymbol) { + if (where.SymbolProfile) { + where.SymbolProfile = { + AND: [ + where.SymbolProfile, + { + AND: [ + { dataSource: filterByDataSource }, + { symbol: filterBySymbol } + ] + } + ] + }; + } else { + where.SymbolProfile = { + AND: [ + { dataSource: filterByDataSource }, + { symbol: filterBySymbol } + ] + }; + } + } + if (searchQuery) { const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [ { id: { mode: 'insensitive', startsWith: searchQuery } }, @@ -546,6 +578,7 @@ export class OrderService { return { activities, count }; } + @LogPerformance public async getOrdersForPortfolioCalculator({ filters, userCurrency, diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts index a43d9b3e6..25001f346 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts @@ -4,6 +4,7 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.s import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; 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 { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; @@ -26,8 +27,8 @@ export class PortfolioCalculatorFactory { private readonly configurationService: ConfigurationService, private readonly currentRateService: CurrentRateService, private readonly exchangeRateDataService: ExchangeRateDataService, - private readonly redisCacheService: RedisCacheService, - private readonly orderservice: OrderService + private readonly portfolioSnapshotService: PortfolioSnapshotService, + private readonly redisCacheService: RedisCacheService ) {} @LogPerformance @@ -57,38 +58,35 @@ export class PortfolioCalculatorFactory { configurationService: this.configurationService, currentRateService: this.currentRateService, exchangeRateDataService: this.exchangeRateDataService, + portfolioSnapshotService: this.portfolioSnapshotService, redisCacheService: this.redisCacheService }); case PerformanceCalculationType.TWR: - return new CPRPortfolioCalculator( - { - accountBalanceItems, - activities, - currency, - currentRateService: this.currentRateService, - userId, - configurationService: this.configurationService, - exchangeRateDataService: this.exchangeRateDataService, - redisCacheService: this.redisCacheService, - filters - }, - this.orderservice - ); + return new CPRPortfolioCalculator({ + accountBalanceItems, + activities, + currency, + currentRateService: this.currentRateService, + userId, + configurationService: this.configurationService, + exchangeRateDataService: this.exchangeRateDataService, + portfolioSnapshotService: this.portfolioSnapshotService, + redisCacheService: this.redisCacheService, + filters + }); case PerformanceCalculationType.CPR: - return new CPRPortfolioCalculator( - { - accountBalanceItems, - activities, - currency, - currentRateService: this.currentRateService, - userId, - configurationService: this.configurationService, - exchangeRateDataService: this.exchangeRateDataService, - redisCacheService: this.redisCacheService, - filters - }, - this.orderservice - ); + return new CPRPortfolioCalculator({ + accountBalanceItems, + activities, + currency, + currentRateService: this.currentRateService, + userId, + configurationService: this.configurationService, + exchangeRateDataService: this.exchangeRateDataService, + portfolioSnapshotService: this.portfolioSnapshotService, + redisCacheService: this.redisCacheService, + filters + }); default: throw new Error('Invalid calculation type'); } diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index d1ee11f0a..29cbe3781 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -1,15 +1,23 @@ -import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface'; +import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface'; import { TransactionPointSymbol } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point-symbol.interface'; import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; +import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; +import { + PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, + PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS, + PORTFOLIO_SNAPSHOT_QUEUE_PRIORITY_HIGH, + PORTFOLIO_SNAPSHOT_QUEUE_PRIORITY_LOW +} from '@ghostfolio/common/config'; import { DATE_FORMAT, getSum, @@ -56,6 +64,7 @@ export abstract class PortfolioCalculator { private endDate: Date; protected exchangeRateDataService: ExchangeRateDataService; private filters: Filter[]; + private portfolioSnapshotService: PortfolioSnapshotService; private redisCacheService: RedisCacheService; private snapshot: PortfolioSnapshot; private snapshotPromise: Promise; @@ -72,6 +81,7 @@ export abstract class PortfolioCalculator { currentRateService, exchangeRateDataService, filters, + portfolioSnapshotService, redisCacheService, userId }: { @@ -82,6 +92,7 @@ export abstract class PortfolioCalculator { currentRateService: CurrentRateService; exchangeRateDataService: ExchangeRateDataService; filters: Filter[]; + portfolioSnapshotService: PortfolioSnapshotService; redisCacheService: RedisCacheService; userId: string; }) { @@ -94,6 +105,10 @@ export abstract class PortfolioCalculator { let dateOfFirstActivity = new Date(); + if (this.accountBalanceItems[0]) { + dateOfFirstActivity = parseDate(this.accountBalanceItems[0].date); + } + this.activities = activities .map( ({ @@ -130,6 +145,7 @@ export abstract class PortfolioCalculator { return a.date?.localeCompare(b.date); }); + this.portfolioSnapshotService = portfolioSnapshotService; this.redisCacheService = redisCacheService; this.userId = userId; @@ -151,7 +167,7 @@ export abstract class PortfolioCalculator { ): PortfolioSnapshot; @LogPerformance - protected async computeSnapshot(): Promise { + public async computeSnapshot(): Promise { const lastTransactionPoint = last(this.transactionPoints); const transactionPoints = this.transactionPoints?.filter(({ date }) => { @@ -258,6 +274,10 @@ export abstract class PortfolioCalculator { ) }); + for (const accountBalanceItem of this.accountBalanceItems) { + chartDateMap[accountBalanceItem.date] = true; + } + const chartDates = sortBy(Object.keys(chartDateMap), (chartDate) => { return chartDate; }); @@ -436,9 +456,28 @@ export abstract class PortfolioCalculator { } } - let lastDate = chartDates[0]; + const accountBalanceItemsMap = this.accountBalanceItems.reduce( + (map, { date, value }) => { + map[date] = new Big(value); + + return map; + }, + {} as { [date: string]: Big } + ); + + const accountBalanceMap: { [date: string]: Big } = {}; + + let lastKnownBalance = new Big(0); for (const dateString of chartDates) { + if (accountBalanceItemsMap[dateString] !== undefined) { + // If there's an exact balance for this date, update lastKnownBalance + lastKnownBalance = accountBalanceItemsMap[dateString]; + } + + // Add the most recent balance to the accountBalanceMap + accountBalanceMap[dateString] = lastKnownBalance; + for (const symbol of Object.keys(valuesBySymbol)) { const symbolValues = valuesBySymbol[symbol]; @@ -481,18 +520,7 @@ export abstract class PortfolioCalculator { accumulatedValuesByDate[dateString] ?.investmentValueWithCurrencyEffect ?? new Big(0) ).add(investmentValueWithCurrencyEffect), - totalAccountBalanceWithCurrencyEffect: this.accountBalanceItems.some( - ({ date }) => { - return date === dateString; - } - ) - ? new Big( - this.accountBalanceItems.find(({ date }) => { - return date === dateString; - }).value - ) - : (accumulatedValuesByDate[lastDate] - ?.totalAccountBalanceWithCurrencyEffect ?? new Big(0)), + totalAccountBalanceWithCurrencyEffect: accountBalanceMap[dateString], totalCurrentValue: ( accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0) ).add(currentValue), @@ -526,8 +554,6 @@ export abstract class PortfolioCalculator { ).add(timeWeightedInvestmentValueWithCurrencyEffect) }; } - - lastDate = dateString; } const historicalData: HistoricalDataItem[] = Object.entries( @@ -730,12 +756,12 @@ export abstract class PortfolioCalculator { timeWeightedInvestmentValue === 0 ? 0 : netPerformanceWithCurrencyEffectSinceStartDate / - timeWeightedInvestmentValue, + timeWeightedInvestmentValue // TODO: Add net worth with valuables // netWorth: totalCurrentValueWithCurrencyEffect // .plus(totalAccountBalanceWithCurrencyEffect) // .toNumber() - netWorth: 0 + // netWorth: 0 }); } } @@ -814,7 +840,7 @@ export abstract class PortfolioCalculator { endDate: Date; startDate: Date; step: number; - }) { + }): { [date: string]: true } { // Create a map of all relevant chart dates: // 1. Add transaction point dates let chartDateMap = this.transactionPoints.reduce((result, { date }) => { @@ -1017,19 +1043,34 @@ export abstract class PortfolioCalculator { protected async initialize() { const startTimeTotal = performance.now(); - const cachedSnapshot = await this.redisCacheService.get( - this.redisCacheService.getPortfolioSnapshotKey({ - filters: this.filters, - userId: this.userId - }) - ); + let cachedPortfolioSnapshot: PortfolioSnapshot; + let isCachedPortfolioSnapshotExpired = false; + const jobId = this.userId; + + try { + const cachedPortfolioSnapshotValue = await this.redisCacheService.get( + this.redisCacheService.getPortfolioSnapshotKey({ + filters: this.filters, + userId: this.userId + }) + ); - if (cachedSnapshot) { - this.snapshot = plainToClass( + const { expiration, portfolioSnapshot }: PortfolioSnapshotValue = + JSON.parse(cachedPortfolioSnapshotValue); + + cachedPortfolioSnapshot = plainToClass( PortfolioSnapshot, - JSON.parse(cachedSnapshot) + portfolioSnapshot ); + if (isAfter(new Date(), new Date(expiration))) { + isCachedPortfolioSnapshotExpired = true; + } + } catch {} + + if (cachedPortfolioSnapshot) { + this.snapshot = cachedPortfolioSnapshot; + Logger.debug( `Fetched portfolio snapshot from cache in ${( (performance.now() - startTimeTotal) / @@ -1037,25 +1078,46 @@ export abstract class PortfolioCalculator { ).toFixed(3)} seconds`, 'PortfolioCalculator' ); - } else { - this.snapshot = await this.computeSnapshot(); - this.redisCacheService.set( - this.redisCacheService.getPortfolioSnapshotKey({ + if (isCachedPortfolioSnapshotExpired) { + // Compute in the background + this.portfolioSnapshotService.addJobToQueue({ + data: { + filters: this.filters, + userCurrency: this.currency, + userId: this.userId + }, + name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, + opts: { + ...PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS, + jobId, + priority: PORTFOLIO_SNAPSHOT_QUEUE_PRIORITY_LOW + } + }); + } + } else { + // Wait for computation + await this.portfolioSnapshotService.addJobToQueue({ + data: { filters: this.filters, + userCurrency: this.currency, userId: this.userId - }), - JSON.stringify(this.snapshot), - this.configurationService.get('CACHE_QUOTES_TTL') - ); + }, + name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, + opts: { + ...PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS, + jobId, + priority: PORTFOLIO_SNAPSHOT_QUEUE_PRIORITY_HIGH + } + }); - Logger.debug( - `Computed portfolio snapshot in ${( - (performance.now() - startTimeTotal) / - 1000 - ).toFixed(3)} seconds`, - 'PortfolioCalculator' - ); + const job = await this.portfolioSnapshotService.getJob(jobId); + + if (job) { + await job.finished(); + } + + await this.initialize(); } } } diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts index a60156ee6..37499f0e3 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts @@ -14,6 +14,8 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s 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 { Big } from 'big.js'; @@ -28,6 +30,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { }; }); +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { return { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -41,7 +55,8 @@ describe('PortfolioCalculator', () => { let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; - let factory: PortfolioCalculatorFactory; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; let redisCacheService: RedisCacheService; beforeEach(() => { @@ -56,14 +71,16 @@ describe('PortfolioCalculator', () => { null ); + portfolioSnapshotService = new PortfolioSnapshotService(null); + redisCacheService = new RedisCacheService(null, null); - factory = new PortfolioCalculatorFactory( + portfolioCalculatorFactory = new PortfolioCalculatorFactory( configurationService, currentRateService, exchangeRateDataService, - redisCacheService, - null + portfolioSnapshotService, + redisCacheService ); }); @@ -119,14 +136,14 @@ describe('PortfolioCalculator', () => { } ]; - const portfolioCalculator = factory.createCalculator({ + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.TWR, currency: 'CHF', userId: userDummyData.id }); - const portfolioSnapshot = await portfolioCalculator.getSnapshot(); + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); const investments = portfolioCalculator.getInvestments(); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts index 39424ed27..23c594e5b 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts @@ -14,6 +14,8 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s 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 { Big } from 'big.js'; @@ -28,6 +30,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { }; }); +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { return { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -41,7 +55,8 @@ describe('PortfolioCalculator', () => { let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; - let factory: PortfolioCalculatorFactory; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; let redisCacheService: RedisCacheService; beforeEach(() => { @@ -56,14 +71,16 @@ describe('PortfolioCalculator', () => { null ); + portfolioSnapshotService = new PortfolioSnapshotService(null); + redisCacheService = new RedisCacheService(null, null); - factory = new PortfolioCalculatorFactory( + portfolioCalculatorFactory = new PortfolioCalculatorFactory( configurationService, currentRateService, exchangeRateDataService, - redisCacheService, - null + portfolioSnapshotService, + redisCacheService ); }); @@ -104,14 +121,14 @@ describe('PortfolioCalculator', () => { } ]; - const portfolioCalculator = factory.createCalculator({ + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.TWR, currency: 'CHF', userId: userDummyData.id }); - const portfolioSnapshot = await portfolioCalculator.getSnapshot(); + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); const investments = portfolioCalculator.getInvestments(); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts index 49d4ec276..90f6a59d1 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts @@ -14,6 +14,8 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s 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 { Big } from 'big.js'; @@ -28,6 +30,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { }; }); +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { return { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -41,7 +55,8 @@ describe('PortfolioCalculator', () => { let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; - let factory: PortfolioCalculatorFactory; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; let redisCacheService: RedisCacheService; beforeEach(() => { @@ -56,14 +71,16 @@ describe('PortfolioCalculator', () => { null ); + portfolioSnapshotService = new PortfolioSnapshotService(null); + redisCacheService = new RedisCacheService(null, null); - factory = new PortfolioCalculatorFactory( + portfolioCalculatorFactory = new PortfolioCalculatorFactory( configurationService, currentRateService, exchangeRateDataService, - redisCacheService, - null + portfolioSnapshotService, + redisCacheService ); }); @@ -89,14 +106,14 @@ describe('PortfolioCalculator', () => { } ]; - const portfolioCalculator = factory.createCalculator({ + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.TWR, currency: 'CHF', userId: userDummyData.id }); - const portfolioSnapshot = await portfolioCalculator.getSnapshot(); + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); const investments = portfolioCalculator.getInvestments(); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts index 4961ad071..e232b42c4 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts @@ -15,6 +15,8 @@ import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cac import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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 { Big } from 'big.js'; @@ -29,6 +31,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { }; }); +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { return { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -54,7 +68,8 @@ describe('PortfolioCalculator', () => { let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; - let factory: PortfolioCalculatorFactory; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; let redisCacheService: RedisCacheService; beforeEach(() => { @@ -69,14 +84,16 @@ describe('PortfolioCalculator', () => { null ); + portfolioSnapshotService = new PortfolioSnapshotService(null); + redisCacheService = new RedisCacheService(null, null); - factory = new PortfolioCalculatorFactory( + portfolioCalculatorFactory = new PortfolioCalculatorFactory( configurationService, currentRateService, exchangeRateDataService, - redisCacheService, - null + portfolioSnapshotService, + redisCacheService ); }); @@ -118,14 +135,14 @@ describe('PortfolioCalculator', () => { } ]; - const portfolioCalculator = factory.createCalculator({ + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.TWR, currency: 'CHF', userId: userDummyData.id }); - const portfolioSnapshot = await portfolioCalculator.getSnapshot(); + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); const investments = portfolioCalculator.getInvestments(); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts index 0aec5f1ef..fe379a92a 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts @@ -14,6 +14,8 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s 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 { Big } from 'big.js'; @@ -28,6 +30,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { }; }); +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { return { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -41,7 +55,8 @@ describe('PortfolioCalculator', () => { let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; - let factory: PortfolioCalculatorFactory; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; let redisCacheService: RedisCacheService; beforeEach(() => { @@ -56,14 +71,16 @@ describe('PortfolioCalculator', () => { null ); + portfolioSnapshotService = new PortfolioSnapshotService(null); + redisCacheService = new RedisCacheService(null, null); - factory = new PortfolioCalculatorFactory( + portfolioCalculatorFactory = new PortfolioCalculatorFactory( configurationService, currentRateService, exchangeRateDataService, - redisCacheService, - null + portfolioSnapshotService, + redisCacheService ); }); @@ -89,14 +106,14 @@ describe('PortfolioCalculator', () => { } ]; - const portfolioCalculator = factory.createCalculator({ + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.TWR, currency: 'USD', userId: userDummyData.id }); - const portfolioSnapshot = await portfolioCalculator.getSnapshot(); + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); expect(portfolioSnapshot).toMatchObject({ currentValueInBaseCurrency: new Big('0'), diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts index 3969d3b13..60fe6dc6b 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts @@ -15,6 +15,8 @@ import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cac import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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 { Big } from 'big.js'; @@ -29,6 +31,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { }; }); +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { return { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -54,7 +68,8 @@ describe('PortfolioCalculator', () => { let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; - let factory: PortfolioCalculatorFactory; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; let redisCacheService: RedisCacheService; beforeEach(() => { @@ -69,14 +84,16 @@ describe('PortfolioCalculator', () => { null ); + portfolioSnapshotService = new PortfolioSnapshotService(null); + redisCacheService = new RedisCacheService(null, null); - factory = new PortfolioCalculatorFactory( + portfolioCalculatorFactory = new PortfolioCalculatorFactory( configurationService, currentRateService, exchangeRateDataService, - redisCacheService, - null + portfolioSnapshotService, + redisCacheService ); }); @@ -102,14 +119,14 @@ describe('PortfolioCalculator', () => { } ]; - const portfolioCalculator = factory.createCalculator({ + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.TWR, currency: 'CHF', userId: userDummyData.id }); - const portfolioSnapshot = await portfolioCalculator.getSnapshot(); + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); const investments = portfolioCalculator.getInvestments(); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts index 86c5882cd..228568374 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts @@ -14,6 +14,8 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s 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 { Big } from 'big.js'; @@ -28,6 +30,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { }; }); +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { return { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -41,7 +55,8 @@ describe('PortfolioCalculator', () => { let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; - let factory: PortfolioCalculatorFactory; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; let redisCacheService: RedisCacheService; beforeEach(() => { @@ -56,14 +71,16 @@ describe('PortfolioCalculator', () => { null ); + portfolioSnapshotService = new PortfolioSnapshotService(null); + redisCacheService = new RedisCacheService(null, null); - factory = new PortfolioCalculatorFactory( + portfolioCalculatorFactory = new PortfolioCalculatorFactory( configurationService, currentRateService, exchangeRateDataService, - redisCacheService, - null + portfolioSnapshotService, + redisCacheService ); }); @@ -89,14 +106,14 @@ describe('PortfolioCalculator', () => { } ]; - const portfolioCalculator = factory.createCalculator({ + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.TWR, currency: 'USD', userId: userDummyData.id }); - const portfolioSnapshot = await portfolioCalculator.getSnapshot(); + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); expect(portfolioSnapshot).toMatchObject({ currentValueInBaseCurrency: new Big('0'), diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts index 433627ef7..5fa90e94c 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts @@ -14,6 +14,8 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s 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 { Big } from 'big.js'; @@ -27,6 +29,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { }; }); +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { return { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -40,7 +54,8 @@ describe('PortfolioCalculator', () => { let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; - let factory: PortfolioCalculatorFactory; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; let redisCacheService: RedisCacheService; beforeEach(() => { @@ -55,14 +70,16 @@ describe('PortfolioCalculator', () => { null ); + portfolioSnapshotService = new PortfolioSnapshotService(null); + redisCacheService = new RedisCacheService(null, null); - factory = new PortfolioCalculatorFactory( + portfolioCalculatorFactory = new PortfolioCalculatorFactory( configurationService, currentRateService, exchangeRateDataService, - redisCacheService, - null + portfolioSnapshotService, + redisCacheService ); }); @@ -88,17 +105,18 @@ describe('PortfolioCalculator', () => { } ]; - const portfolioCalculator = factory.createCalculator({ + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.TWR, currency: 'USD', userId: userDummyData.id }); - const liabilitiesInBaseCurrency = - await portfolioCalculator.getLiabilitiesInBaseCurrency(); + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); - expect(liabilitiesInBaseCurrency).toEqual(new Big(3000)); + expect(portfolioSnapshot.totalLiabilitiesWithCurrencyEffect).toEqual( + new Big(3000) + ); }); }); }); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts index da2a748a8..97b860400 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts @@ -15,6 +15,8 @@ import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cac import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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 { Big } from 'big.js'; @@ -29,6 +31,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { }; }); +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { return { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -54,7 +68,8 @@ describe('PortfolioCalculator', () => { let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; - let factory: PortfolioCalculatorFactory; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; let redisCacheService: RedisCacheService; beforeEach(() => { @@ -69,14 +84,16 @@ describe('PortfolioCalculator', () => { null ); + portfolioSnapshotService = new PortfolioSnapshotService(null); + redisCacheService = new RedisCacheService(null, null); - factory = new PortfolioCalculatorFactory( + portfolioCalculatorFactory = new PortfolioCalculatorFactory( configurationService, currentRateService, exchangeRateDataService, - redisCacheService, - null + portfolioSnapshotService, + redisCacheService ); }); @@ -117,14 +134,14 @@ describe('PortfolioCalculator', () => { } ]; - const portfolioCalculator = factory.createCalculator({ + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.TWR, currency: 'USD', userId: userDummyData.id }); - const portfolioSnapshot = await portfolioCalculator.getSnapshot(); + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); expect(portfolioSnapshot).toMatchObject({ errors: [], diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts index df1031684..84898490f 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts @@ -9,11 +9,11 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s 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 { Big } from 'big.js'; -import { subDays } from 'date-fns'; -import { last } from 'lodash'; jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { return { @@ -24,6 +24,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { }; }); +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { return { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -37,7 +49,8 @@ describe('PortfolioCalculator', () => { let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; - let factory: PortfolioCalculatorFactory; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; let redisCacheService: RedisCacheService; beforeEach(() => { @@ -52,14 +65,16 @@ describe('PortfolioCalculator', () => { null ); + portfolioSnapshotService = new PortfolioSnapshotService(null); + redisCacheService = new RedisCacheService(null, null); - factory = new PortfolioCalculatorFactory( + portfolioCalculatorFactory = new PortfolioCalculatorFactory( configurationService, currentRateService, exchangeRateDataService, - redisCacheService, - null + portfolioSnapshotService, + redisCacheService ); }); @@ -67,14 +82,14 @@ describe('PortfolioCalculator', () => { it('with no orders', async () => { jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); - const portfolioCalculator = factory.createCalculator({ + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities: [], calculationType: PerformanceCalculationType.TWR, currency: 'CHF', userId: userDummyData.id }); - const portfolioSnapshot = await portfolioCalculator.getSnapshot(); + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); const investments = portfolioCalculator.getInvestments(); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts index 0c33efafb..30eb79754 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts @@ -14,6 +14,8 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s 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 { Big } from 'big.js'; @@ -28,6 +30,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { }; }); +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { return { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -41,7 +55,8 @@ describe('PortfolioCalculator', () => { let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; - let factory: PortfolioCalculatorFactory; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; let redisCacheService: RedisCacheService; beforeEach(() => { @@ -56,14 +71,16 @@ describe('PortfolioCalculator', () => { null ); + portfolioSnapshotService = new PortfolioSnapshotService(null); + redisCacheService = new RedisCacheService(null, null); - factory = new PortfolioCalculatorFactory( + portfolioCalculatorFactory = new PortfolioCalculatorFactory( configurationService, currentRateService, exchangeRateDataService, - redisCacheService, - null + portfolioSnapshotService, + redisCacheService ); }); @@ -104,14 +121,14 @@ describe('PortfolioCalculator', () => { } ]; - const portfolioCalculator = factory.createCalculator({ + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.TWR, currency: 'CHF', userId: userDummyData.id }); - const portfolioSnapshot = await portfolioCalculator.getSnapshot(); + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); const investments = portfolioCalculator.getInvestments(); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts index f8a01310b..db5aaf6bc 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts @@ -14,6 +14,8 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s 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 { Big } from 'big.js'; @@ -28,6 +30,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { }; }); +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { return { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -41,7 +55,8 @@ describe('PortfolioCalculator', () => { let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; - let factory: PortfolioCalculatorFactory; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; let redisCacheService: RedisCacheService; beforeEach(() => { @@ -56,14 +71,16 @@ describe('PortfolioCalculator', () => { null ); + portfolioSnapshotService = new PortfolioSnapshotService(null); + redisCacheService = new RedisCacheService(null, null); - factory = new PortfolioCalculatorFactory( + portfolioCalculatorFactory = new PortfolioCalculatorFactory( configurationService, currentRateService, exchangeRateDataService, - redisCacheService, - null + portfolioSnapshotService, + redisCacheService ); }); @@ -104,14 +121,14 @@ describe('PortfolioCalculator', () => { } ]; - const portfolioCalculator = factory.createCalculator({ + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.TWR, currency: 'CHF', userId: userDummyData.id }); - const portfolioSnapshot = await portfolioCalculator.getSnapshot(); + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); const investments = portfolioCalculator.getInvestments(); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.spec.ts index a2ad578bd..d8431cd83 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.spec.ts @@ -3,12 +3,14 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.s import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; 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'; describe('PortfolioCalculator', () => { let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; - let factory: PortfolioCalculatorFactory; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; let redisCacheService: RedisCacheService; beforeEach(() => { @@ -23,14 +25,16 @@ describe('PortfolioCalculator', () => { null ); + portfolioSnapshotService = new PortfolioSnapshotService(null); + redisCacheService = new RedisCacheService(null, null); - factory = new PortfolioCalculatorFactory( + portfolioCalculatorFactory = new PortfolioCalculatorFactory( configurationService, currentRateService, exchangeRateDataService, - redisCacheService, - null + portfolioSnapshotService, + redisCacheService ); }); diff --git a/apps/api/src/app/portfolio/current-rate.service.ts b/apps/api/src/app/portfolio/current-rate.service.ts index 24119162d..cd1994826 100644 --- a/apps/api/src/app/portfolio/current-rate.service.ts +++ b/apps/api/src/app/portfolio/current-rate.service.ts @@ -1,4 +1,5 @@ import { OrderService } from '@ghostfolio/api/app/order/order.service'; +import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { resetHours } from '@ghostfolio/common/helper'; @@ -27,6 +28,7 @@ export class CurrentRateService { @Inject(REQUEST) private readonly request: RequestWithUser ) {} + @LogPerformance // TODO: Pass user instead of using this.request.user public async getValues({ dataGatheringItems, diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-holding-detail.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-holding-detail.interface.ts index 5c345408e..bf75dfd1a 100644 --- a/apps/api/src/app/portfolio/interfaces/portfolio-holding-detail.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/portfolio-holding-detail.interface.ts @@ -5,10 +5,9 @@ import { HistoricalDataItem } from '@ghostfolio/common/interfaces'; -import { Account, Tag } from '@prisma/client'; +import { Tag } from '@prisma/client'; export interface PortfolioHoldingDetail { - accounts: Account[]; averagePrice: number; dataProviderInfo: DataProviderInfo; dividendInBaseCurrency: number; diff --git a/apps/api/src/app/portfolio/interfaces/snapshot-value.interface.ts b/apps/api/src/app/portfolio/interfaces/snapshot-value.interface.ts new file mode 100644 index 000000000..3d205416c --- /dev/null +++ b/apps/api/src/app/portfolio/interfaces/snapshot-value.interface.ts @@ -0,0 +1,4 @@ +export interface PortfolioSnapshotValue { + expiration: number; + portfolioSnapshot: string; +} diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index b2be4c934..29b30069e 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -1,32 +1,27 @@ import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor'; import { AccessService } from '@ghostfolio/api/app/access/access.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service'; -import { UserService } from '@ghostfolio/api/app/user/user.service'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { hasNotDefinedValuesInObject, nullifyValuesInObject } from '@ghostfolio/api/helper/object.helper'; +import { PerformanceLoggingInterceptor } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; -import { - DEFAULT_CURRENCY, - HEADER_KEY_IMPERSONATION -} from '@ghostfolio/common/config'; +import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { PortfolioDetails, PortfolioDividends, PortfolioHoldingsResponse, PortfolioInvestments, PortfolioPerformanceResponse, - PortfolioPublicDetails, PortfolioReport } from '@ghostfolio/common/interfaces'; import { @@ -70,12 +65,10 @@ export class PortfolioController { private readonly accessService: AccessService, private readonly apiService: ApiService, private readonly configurationService: ConfigurationService, - private readonly exchangeRateDataService: ExchangeRateDataService, private readonly impersonationService: ImpersonationService, private readonly orderService: OrderService, private readonly portfolioService: PortfolioService, - @Inject(REQUEST) private readonly request: RequestWithUser, - private readonly userService: UserService + @Inject(REQUEST) private readonly request: RequestWithUser ) {} @Get('details') @@ -410,6 +403,7 @@ export class PortfolioController { @Get('performance') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(PerformanceLoggingInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) @Version('2') @LogPerformance @@ -517,75 +511,6 @@ export class PortfolioController { return performanceInformation; } - @Get('public/:accessId') - @UseInterceptors(TransformDataSourceInResponseInterceptor) - public async getPublic( - @Param('accessId') accessId - ): Promise { - const access = await this.accessService.access({ id: accessId }); - - if (!access) { - throw new HttpException( - getReasonPhrase(StatusCodes.NOT_FOUND), - StatusCodes.NOT_FOUND - ); - } - - let hasDetails = true; - - const user = await this.userService.user({ - id: access.userId - }); - - if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { - hasDetails = user.subscription.type === 'Premium'; - } - - const { holdings } = await this.portfolioService.getDetails({ - filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }], - impersonationId: access.userId, - userId: user.id, - withMarkets: true - }); - - const portfolioPublicDetails: PortfolioPublicDetails = { - hasDetails, - alias: access.alias, - holdings: {} - }; - - const totalValue = Object.values(holdings) - .map((portfolioPosition) => { - return this.exchangeRateDataService.toCurrency( - portfolioPosition.quantity * portfolioPosition.marketPrice, - portfolioPosition.currency, - this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY - ); - }) - .reduce((a, b) => a + b, 0); - - for (const [symbol, portfolioPosition] of Object.entries(holdings)) { - portfolioPublicDetails.holdings[symbol] = { - allocationInPercentage: - portfolioPosition.valueInBaseCurrency / totalValue, - countries: hasDetails ? portfolioPosition.countries : [], - currency: hasDetails ? portfolioPosition.currency : undefined, - dataSource: portfolioPosition.dataSource, - dateOfFirstActivity: portfolioPosition.dateOfFirstActivity, - markets: hasDetails ? portfolioPosition.markets : undefined, - name: portfolioPosition.name, - netPerformancePercentWithCurrencyEffect: - portfolioPosition.netPerformancePercentWithCurrencyEffect, - sectors: hasDetails ? portfolioPosition.sectors : [], - symbol: portfolioPosition.symbol, - url: portfolioPosition.url, - valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue - }; - } - - return portfolioPublicDetails; - } - @Get('position/:dataSource/:symbol') @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) diff --git a/apps/api/src/app/portfolio/portfolio.module.ts b/apps/api/src/app/portfolio/portfolio.module.ts index 7f1f375b1..7ae74ee5f 100644 --- a/apps/api/src/app/portfolio/portfolio.module.ts +++ b/apps/api/src/app/portfolio/portfolio.module.ts @@ -4,17 +4,19 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module'; +import { PerformanceLoggingModule } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.module'; import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; -import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; +import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { Module } from '@nestjs/common'; @@ -38,6 +40,8 @@ import { RulesService } from './rules.service'; ImpersonationModule, MarketDataModule, OrderModule, + PerformanceLoggingModule, + PortfolioSnapshotQueueModule, PrismaModule, RedactValuesInResponseModule, RedisCacheModule, diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index b833c3bca..c26e2ab0b 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -75,7 +75,7 @@ import { parseISO, set } from 'date-fns'; -import { isEmpty, last, uniq, uniqBy } from 'lodash'; +import { isEmpty, last, uniq } from 'lodash'; import { CPRPortfolioCalculator } from './calculator/constantPortfolioReturn/portfolio-calculator'; import { PortfolioCalculator } from './calculator/portfolio-calculator'; @@ -119,12 +119,33 @@ export class PortfolioService { }): Promise { const where: Prisma.AccountWhereInput = { userId }; - const accountFilter = filters?.find(({ type }) => { + const filterByAccount = filters?.find(({ type }) => { return type === 'ACCOUNT'; - }); + })?.id; + + const filterByDataSource = filters?.find(({ type }) => { + return type === 'DATA_SOURCE'; + })?.id; + + const filterBySymbol = filters?.find(({ type }) => { + return type === 'SYMBOL'; + })?.id; - if (accountFilter) { - where.id = accountFilter.id; + if (filterByAccount) { + where.id = filterByAccount; + } + + if (filterByDataSource && filterBySymbol) { + where.Order = { + some: { + SymbolProfile: { + AND: [ + { dataSource: filterByDataSource }, + { symbol: filterBySymbol } + ] + } + } + }; } const [accounts, details] = await Promise.all([ @@ -684,16 +705,8 @@ export class PortfolioService { userId }); - const orders = activities.filter(({ SymbolProfile }) => { - return ( - SymbolProfile.dataSource === aDataSource && - SymbolProfile.symbol === aSymbol - ); - }); - - if (orders.length <= 0) { + if (activities.length === 0) { return { - accounts: [], averagePrice: undefined, dataProviderInfo: undefined, stakeRewards: undefined, @@ -729,12 +742,8 @@ export class PortfolioService { ]); const portfolioCalculator = this.calculatorFactory.createCalculator({ + activities, userId, - activities: orders.filter((order) => { - return ['BUY', 'DIVIDEND', 'ITEM', 'SELL', 'STAKE'].includes( - order.type - ); - }), calculationType: PerformanceCalculationType.TWR, currency: userCurrency }); @@ -744,8 +753,8 @@ export class PortfolioService { const { positions } = await portfolioCalculator.getSnapshot(); - const position = positions.find(({ symbol }) => { - return symbol === aSymbol; + const position = positions.find(({ dataSource, symbol }) => { + return dataSource === aDataSource && symbol === aSymbol; }); if (position) { @@ -758,19 +767,18 @@ export class PortfolioService { firstBuyDate, marketPrice, quantity, + symbol, tags, timeWeightedInvestment, timeWeightedInvestmentWithCurrencyEffect, transactionCount } = position; - const accounts: PortfolioHoldingDetail['accounts'] = uniqBy( - orders.filter(({ Account }) => { - return Account; - }), - 'Account.id' - ).map(({ Account }) => { - return Account; + const activitiesOfPosition = activities.filter(({ SymbolProfile }) => { + return ( + SymbolProfile.dataSource === dataSource && + SymbolProfile.symbol === symbol + ); }); const dividendYieldPercent = getAnnualizedPerformancePercent({ @@ -827,8 +835,8 @@ export class PortfolioService { ); const historicalDataArray: HistoricalDataItem[] = []; - let maxPrice = Math.max(orders[0].unitPrice, marketPrice); - let minPrice = Math.min(orders[0].unitPrice, marketPrice); + let maxPrice = Math.max(activitiesOfPosition[0].unitPrice, marketPrice); + let minPrice = Math.min(activitiesOfPosition[0].unitPrice, marketPrice); if (historicalData[aSymbol]) { let j = -1; @@ -872,20 +880,18 @@ export class PortfolioService { } else { // Add historical entry for buy date, if no historical data available historicalDataArray.push({ - averagePrice: orders[0].unitPrice, + averagePrice: activitiesOfPosition[0].unitPrice, date: firstBuyDate, - marketPrice: orders[0].unitPrice, - quantity: orders[0].quantity + marketPrice: activitiesOfPosition[0].unitPrice, + quantity: activitiesOfPosition[0].quantity }); } return { - accounts, firstBuyDate, marketPrice, maxPrice, minPrice, - orders, SymbolProfile, tags, transactionCount, @@ -918,6 +924,7 @@ export class PortfolioService { ]?.toNumber(), netPerformanceWithCurrencyEffect: position.netPerformanceWithCurrencyEffectMap?.['max']?.toNumber(), + orders: activitiesOfPosition, quantity: quantity.toNumber(), value: this.exchangeRateDataService.toCurrency( quantity.mul(marketPrice ?? 0).toNumber(), @@ -975,9 +982,7 @@ export class PortfolioService { marketPrice, maxPrice, minPrice, - orders, SymbolProfile, - accounts: [], averagePrice: 0, dataProviderInfo: undefined, stakeRewards: 0, @@ -996,6 +1001,7 @@ export class PortfolioService { netPerformancePercent: undefined, netPerformancePercentWithCurrencyEffect: undefined, netPerformanceWithCurrencyEffect: undefined, + orders: [], quantity: 0, tags: [], transactionCount: undefined, @@ -1027,7 +1033,7 @@ export class PortfolioService { userCurrency: this.getUserCurrency() }); - if (activities?.length <= 0) { + if (activities.length === 0) { return { hasErrors: false, positions: [] @@ -1154,7 +1160,6 @@ export class PortfolioService { dateRange = 'max', filters, impersonationId, - portfolioCalculator, userId, withExcludedAccounts = false, calculateTimeWeightedPerformance = false @@ -1162,7 +1167,6 @@ export class PortfolioService { dateRange?: DateRange; filters?: Filter[]; impersonationId: string; - portfolioCalculator?: PortfolioCalculator; userId: string; withExcludedAccounts?: boolean; calculateTimeWeightedPerformance?: boolean; @@ -1171,35 +1175,12 @@ export class PortfolioService { const user = await this.userService.user({ id: userId }); const userCurrency = this.getUserCurrency(user); - const accountBalances = await this.accountBalanceService.getAccountBalances( - { filters, user, withExcludedAccounts } - ); - - let accountBalanceItems: HistoricalDataItem[] = Object.values( - // Reduce the array to a map with unique dates as keys - accountBalances.balances.reduce( - ( - map: { [date: string]: HistoricalDataItem }, - { date, valueInBaseCurrency } - ) => { - const formattedDate = format(date, DATE_FORMAT); - - if (map[formattedDate]) { - // If the value exists, add the current value to the existing one - map[formattedDate].value += valueInBaseCurrency; - } else { - // Otherwise, initialize the value for that date - map[formattedDate] = { - date: formattedDate, - value: valueInBaseCurrency - }; - } - - return map; - }, - {} - ) - ); + const accountBalanceItems = + await this.accountBalanceService.getAccountBalanceItems({ + filters, + userId, + userCurrency + }); const { activities } = await this.orderService.getOrdersForPortfolioCalculator({ @@ -1208,7 +1189,7 @@ export class PortfolioService { userId }); - if (accountBalanceItems?.length <= 0 && activities?.length <= 0) { + if (accountBalanceItems.length === 0 && activities.length === 0) { return { chart: [], firstOrderDate: undefined, @@ -1225,16 +1206,14 @@ export class PortfolioService { }; } - portfolioCalculator = - portfolioCalculator ?? - this.calculatorFactory.createCalculator({ - accountBalanceItems, - activities, - filters, - userId, - calculationType: PerformanceCalculationType.TWR, - currency: userCurrency - }); + const portfolioCalculator = this.calculatorFactory.createCalculator({ + accountBalanceItems, + activities, + filters, + userId, + calculationType: PerformanceCalculationType.TWR, + currency: userCurrency + }); const { endDate, startDate } = getIntervalFromDateRange(dateRange); const range = { end: endDate, start: startDate }; @@ -1544,6 +1523,8 @@ export class PortfolioService { }: { holdings: PortfolioDetails['holdings']; }) { + // TODO: Use current value of activities instead of holdings + // tagged with EMERGENCY_FUND_TAG_ID const emergencyFundHoldings = Object.values(holdings).filter(({ tags }) => { return ( tags?.some(({ id }) => { diff --git a/apps/api/src/app/portfolio/rules.service.ts b/apps/api/src/app/portfolio/rules.service.ts index 6b6526144..fd9d794b2 100644 --- a/apps/api/src/app/portfolio/rules.service.ts +++ b/apps/api/src/app/portfolio/rules.service.ts @@ -1,6 +1,9 @@ import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { Rule } from '@ghostfolio/api/models/rule'; -import { UserSettings } from '@ghostfolio/common/interfaces'; +import { + PortfolioReportRule, + UserSettings +} from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; @@ -11,19 +14,23 @@ export class RulesService { public async evaluate( aRules: Rule[], aUserSettings: UserSettings - ) { + ): Promise { return aRules.map((rule) => { - if (rule.getSettings(aUserSettings)?.isActive) { - const { evaluation, value } = rule.evaluate( - rule.getSettings(aUserSettings) - ); + const settings = rule.getSettings(aUserSettings); + + if (settings?.isActive) { + const { evaluation, value } = rule.evaluate(settings); return { evaluation, value, isActive: true, key: rule.getKey(), - name: rule.getName() + name: rule.getName(), + settings: { + thresholdMax: settings['thresholdMax'], + thresholdMin: settings['thresholdMin'] + } }; } else { return { diff --git a/apps/api/src/app/redis-cache/interfaces/redis-cache.interface.ts b/apps/api/src/app/redis-cache/interfaces/redis-cache.interface.ts deleted file mode 100644 index 194da0bc8..000000000 --- a/apps/api/src/app/redis-cache/interfaces/redis-cache.interface.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Cache } from 'cache-manager'; - -import type { RedisStore } from './redis-store.interface'; - -export interface RedisCache extends Cache { - store: RedisStore; -} diff --git a/apps/api/src/app/redis-cache/interfaces/redis-store.interface.ts b/apps/api/src/app/redis-cache/interfaces/redis-store.interface.ts deleted file mode 100644 index 2ad5df485..000000000 --- a/apps/api/src/app/redis-cache/interfaces/redis-store.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Store } from 'cache-manager'; -import { createClient } from 'redis'; - -export interface RedisStore extends Store { - getClient: () => ReturnType; - isCacheableValue: (value: any) => boolean; - name: 'redis'; -} diff --git a/apps/api/src/app/redis-cache/redis-cache.module.ts b/apps/api/src/app/redis-cache/redis-cache.module.ts index 4b4168168..a507479b9 100644 --- a/apps/api/src/app/redis-cache/redis-cache.module.ts +++ b/apps/api/src/app/redis-cache/redis-cache.module.ts @@ -3,31 +3,31 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con import { CacheModule } from '@nestjs/cache-manager'; import { Module } from '@nestjs/common'; -import * as redisStore from 'cache-manager-redis-store'; +import { redisStore } from 'cache-manager-redis-yet'; import type { RedisClientOptions } from 'redis'; import { RedisCacheService } from './redis-cache.service'; @Module({ + exports: [RedisCacheService], imports: [ - CacheModule.registerAsync({ + CacheModule.registerAsync({ imports: [ConfigurationModule], inject: [ConfigurationService], useFactory: async (configurationService: ConfigurationService) => { + const redisPassword = encodeURIComponent( + configurationService.get('REDIS_PASSWORD') + ); + return { - db: configurationService.get('REDIS_DB'), - host: configurationService.get('REDIS_HOST'), - max: configurationService.get('MAX_ITEM_IN_CACHE'), - password: configurationService.get('REDIS_PASSWORD'), - port: configurationService.get('REDIS_PORT'), store: redisStore, - ttl: configurationService.get('CACHE_TTL') + ttl: configurationService.get('CACHE_TTL'), + url: `redis://${redisPassword ? `:${redisPassword}` : ''}@${configurationService.get('REDIS_HOST')}:${configurationService.get('REDIS_PORT')}/${configurationService.get('REDIS_DB')}` }; } }), ConfigurationModule ], - providers: [RedisCacheService], - exports: [RedisCacheService] + providers: [RedisCacheService] }) export class RedisCacheModule {} diff --git a/apps/api/src/app/redis-cache/redis-cache.service.mock.ts b/apps/api/src/app/redis-cache/redis-cache.service.mock.ts index 2422e88ab..2779308bd 100644 --- a/apps/api/src/app/redis-cache/redis-cache.service.mock.ts +++ b/apps/api/src/app/redis-cache/redis-cache.service.mock.ts @@ -1,13 +1,28 @@ -import { RedisCacheService } from './redis-cache.service'; +import { Filter } from '@ghostfolio/common/interfaces'; + +import { Milliseconds } from 'cache-manager'; export const RedisCacheServiceMock = { + cache: new Map(), get: (key: string): Promise => { - return Promise.resolve(null); + const value = RedisCacheServiceMock.cache.get(key) || null; + + return Promise.resolve(value); }, - getPortfolioSnapshotKey: (userId: string): string => { - return `portfolio-snapshot-${userId}`; + getPortfolioSnapshotKey: ({ + filters, + userId + }: { + filters?: Filter[]; + userId: string; + }): string => { + const filtersHash = filters?.length; + + return `portfolio-snapshot-${userId}${filtersHash > 0 ? `-${filtersHash}` : ''}`; }, - set: (key: string, value: string, ttlInSeconds?: number): Promise => { + set: (key: string, value: string, ttl?: Milliseconds): Promise => { + RedisCacheServiceMock.cache.set(key, value); + return Promise.resolve(value); } }; diff --git a/apps/api/src/app/redis-cache/redis-cache.service.ts b/apps/api/src/app/redis-cache/redis-cache.service.ts index 341dc4acf..c972c30a1 100644 --- a/apps/api/src/app/redis-cache/redis-cache.service.ts +++ b/apps/api/src/app/redis-cache/redis-cache.service.ts @@ -4,17 +4,17 @@ import { AssetProfileIdentifier, Filter } from '@ghostfolio/common/interfaces'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Inject, Injectable, Logger } from '@nestjs/common'; +import { Milliseconds } from 'cache-manager'; +import { RedisCache } from 'cache-manager-redis-yet'; import { createHash } from 'crypto'; -import type { RedisCache } from './interfaces/redis-cache.interface'; - @Injectable() export class RedisCacheService { public constructor( @Inject(CACHE_MANAGER) private readonly cache: RedisCache, private readonly configurationService: ConfigurationService ) { - const client = cache.store.getClient(); + const client = cache.store.client; client.on('error', (error) => { Logger.error(error, 'RedisCacheService'); @@ -81,11 +81,11 @@ export class RedisCacheService { return this.cache.reset(); } - public async set(key: string, value: string, ttlInSeconds?: number) { + public async set(key: string, value: string, ttl?: Milliseconds) { return this.cache.set( key, value, - ttlInSeconds ?? this.configurationService.get('CACHE_TTL') + ttl ?? this.configurationService.get('CACHE_TTL') ); } } diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index f8746881f..edb27aead 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -371,7 +371,7 @@ export class UserService { const hashedAccessToken = this.createAccessToken( accessToken, - process.env.ACCESS_TOKEN_SALT + this.configurationService.get('ACCESS_TOKEN_SALT') ); user = await this.prismaService.user.update({ diff --git a/apps/api/src/assets/sitemap.xml b/apps/api/src/assets/sitemap.xml index 860024c61..93d6b38d3 100644 --- a/apps/api/src/assets/sitemap.xml +++ b/apps/api/src/assets/sitemap.xml @@ -172,6 +172,10 @@ https://ghostfol.io/en/blog/2023/11/hacktoberfest-2023-debriefing ${currentDate}T00:00:00+00:00 + + https://ghostfol.io/en/blog/2024/09/hacktoberfest-2024 + ${currentDate}T00:00:00+00:00 + https://ghostfol.io/en/faq ${currentDate}T00:00:00+00:00 @@ -446,12 +450,46 @@ https://ghostfol.io/nl/veelgestelde-vragen ${currentDate}T00:00:00+00:00 + + https://ghostfol.io/pl + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pl/blog + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pl/cennik + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pl/funkcje + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pl/o-ghostfolio + ${currentDate}T00:00:00+00:00 + + + + https://ghostfol.io/pl/rynki + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pl/zarejestruj + ${currentDate}T00:00:00+00:00 + https://ghostfol.io/pt ${currentDate}T00:00:00+00:00 diff --git a/apps/api/src/interceptors/performance-logging/performance-logging.interceptor.ts b/apps/api/src/interceptors/performance-logging/performance-logging.interceptor.ts new file mode 100644 index 000000000..d863f0ec3 --- /dev/null +++ b/apps/api/src/interceptors/performance-logging/performance-logging.interceptor.ts @@ -0,0 +1,80 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; + +import { PerformanceLoggingService } from './performance-logging.service'; + +@Injectable() +export class PerformanceLoggingInterceptor implements NestInterceptor { + public constructor( + private readonly performanceLoggingService: PerformanceLoggingService + ) {} + + public intercept( + context: ExecutionContext, + next: CallHandler + ): Observable { + const startTime = performance.now(); + + const className = context.getClass().name; + const methodName = context.getHandler().name; + + return next.handle().pipe( + tap(() => { + return this.performanceLoggingService.logPerformance({ + className, + methodName, + startTime + }); + }) + ); + } +} + +export function LogPerformance( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor +) { + const originalMethod = descriptor.value; + + descriptor.value = async function (...args: any[]) { + const startTime = performance.now(); + const performanceLoggingService = new PerformanceLoggingService(); + + const result = originalMethod.apply(this, args); + + if (result instanceof Promise) { + // Handle async method + return result + .then((res: any) => { + performanceLoggingService.logPerformance({ + startTime, + className: target.constructor.name, + methodName: propertyKey + }); + + return res; + }) + .catch((error: any) => { + throw error; + }); + } else { + // Handle sync method + performanceLoggingService.logPerformance({ + startTime, + className: target.constructor.name, + methodName: propertyKey + }); + + return result; + } + }; + + return descriptor; +} diff --git a/apps/api/src/interceptors/performance-logging/performance-logging.module.ts b/apps/api/src/interceptors/performance-logging/performance-logging.module.ts new file mode 100644 index 000000000..a26b381e5 --- /dev/null +++ b/apps/api/src/interceptors/performance-logging/performance-logging.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; + +import { PerformanceLoggingInterceptor } from './performance-logging.interceptor'; +import { PerformanceLoggingService } from './performance-logging.service'; + +@Module({ + exports: [PerformanceLoggingInterceptor, PerformanceLoggingService], + providers: [PerformanceLoggingInterceptor, PerformanceLoggingService] +}) +export class PerformanceLoggingModule {} diff --git a/apps/api/src/interceptors/performance-logging/performance-logging.service.ts b/apps/api/src/interceptors/performance-logging/performance-logging.service.ts new file mode 100644 index 000000000..1b1faf8e0 --- /dev/null +++ b/apps/api/src/interceptors/performance-logging/performance-logging.service.ts @@ -0,0 +1,21 @@ +import { Injectable, Logger } from '@nestjs/common'; + +@Injectable() +export class PerformanceLoggingService { + public logPerformance({ + className, + methodName, + startTime + }: { + className: string; + methodName: string; + startTime: number; + }) { + const endTime = performance.now(); + + Logger.debug( + `Completed execution of ${methodName}() in ${((endTime - startTime) / 1000).toFixed(3)} seconds`, + className + ); + } +} diff --git a/apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts b/apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts index df99c2da3..227d06d7b 100644 --- a/apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts +++ b/apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts @@ -39,12 +39,12 @@ export class TransformDataSourceInRequestInterceptor }); } - if (request.body.dataSource && !DataSource[request.body.dataSource]) { - request.body.dataSource = decodeDataSource(request.body.dataSource); - } + for (const type of ['body', 'params', 'query']) { + const dataSourceValue = request[type]?.dataSource; - if (request.params.dataSource && !DataSource[request.params.dataSource]) { - request.params.dataSource = decodeDataSource(request.params.dataSource); + if (dataSourceValue && !DataSource[dataSourceValue]) { + request[type].dataSource = decodeDataSource(dataSourceValue); + } } } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index ebf3f586a..0671273b7 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,4 +1,9 @@ -import { Logger, ValidationPipe, VersioningType } from '@nestjs/common'; +import { + Logger, + LogLevel, + ValidationPipe, + VersioningType +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; import type { NestExpressApplication } from '@nestjs/platform-express'; @@ -13,36 +18,20 @@ import { HtmlTemplateMiddleware } from './middlewares/html-template.middleware'; async function bootstrap() { const configApp = await NestFactory.create(AppModule); const configService = configApp.get(ConfigService); + let customLogLevels: LogLevel[]; - let logLevelArray = []; - let logLevel = configService.get('LOG_LEVEL'); - Logger.log(`Log-Level: ${logLevel}`); - - switch (logLevel?.toLowerCase()) { - case 'verbose': - logLevelArray = ['debug', 'error', 'log', 'verbose', 'warn']; - break; - case 'debug': - logLevelArray = ['debug', 'error', 'log', 'warn']; - break; - case 'log': - logLevelArray = ['error', 'log', 'warn']; - break; - case 'warn': - logLevelArray = ['error', 'warn']; - break; - case 'error': - logLevelArray = ['error']; - break; - default: - logLevelArray = environment.production - ? ['error', 'log', 'warn'] - : ['debug', 'error', 'log', 'verbose', 'warn']; - break; - } + try { + customLogLevels = JSON.parse( + configService.get('LOG_LEVELS') + ) as LogLevel[]; + } catch {} const app = await NestFactory.create(AppModule, { - logger: logLevelArray + logger: + customLogLevels ?? + (environment.production + ? ['error', 'log', 'warn'] + : ['debug', 'error', 'log', 'verbose', 'warn']) }); app.enableCors(); diff --git a/apps/api/src/middlewares/html-template.middleware.ts b/apps/api/src/middlewares/html-template.middleware.ts index b38a90ef8..7b7cb09f2 100644 --- a/apps/api/src/middlewares/html-template.middleware.ts +++ b/apps/api/src/middlewares/html-template.middleware.ts @@ -83,6 +83,10 @@ const locales = { '/en/blog/2023/11/hacktoberfest-2023-debriefing': { featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png', title: `Hacktoberfest 2023 Debriefing - ${title}` + }, + '/en/blog/2024/09/hacktoberfest-2024': { + featureGraphicPath: 'assets/images/blog/hacktoberfest-2024.png', + title: `Hacktoberfest 2024 - ${title}` } }; diff --git a/apps/api/src/services/api/api.service.ts b/apps/api/src/services/api/api.service.ts index e961ec037..5bcc6bb1d 100644 --- a/apps/api/src/services/api/api.service.ts +++ b/apps/api/src/services/api/api.service.ts @@ -10,22 +10,28 @@ export class ApiService { filterByAccounts, filterByAssetClasses, filterByAssetSubClasses, + filterByDataSource, filterByHoldingType, filterBySearchQuery, + filterBySymbol, filterByTags }: { filterByAccounts?: string; filterByAssetClasses?: string; filterByAssetSubClasses?: string; + filterByDataSource?: string; filterByHoldingType?: string; filterBySearchQuery?: string; + filterBySymbol?: string; filterByTags?: string; }): Filter[] { const accountIds = filterByAccounts?.split(',') ?? []; const assetClasses = filterByAssetClasses?.split(',') ?? []; const assetSubClasses = filterByAssetSubClasses?.split(',') ?? []; + const dataSource = filterByDataSource; const holdingType = filterByHoldingType; const searchQuery = filterBySearchQuery?.toLowerCase(); + const symbol = filterBySymbol; const tagIds = filterByTags?.split(',') ?? []; const filters = [ @@ -55,6 +61,13 @@ export class ApiService { }) ]; + if (dataSource) { + filters.push({ + id: dataSource, + type: 'DATA_SOURCE' + }); + } + if (holdingType) { filters.push({ id: holdingType, @@ -69,6 +82,13 @@ export class ApiService { }); } + if (symbol) { + filters.push({ + id: symbol, + type: 'SYMBOL' + }); + } + return filters; } } diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index 3980c2989..cca393a2a 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -1,5 +1,11 @@ import { Environment } from '@ghostfolio/api/services/interfaces/environment.interface'; -import { DEFAULT_ROOT_URL } from '@ghostfolio/common/config'; +import { + CACHE_TTL_NO_CACHE, + DEFAULT_PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE, + DEFAULT_PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA, + DEFAULT_PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT, + DEFAULT_ROOT_URL +} from '@ghostfolio/common/config'; import { Injectable } from '@nestjs/common'; import { DataSource } from '@prisma/client'; @@ -21,9 +27,8 @@ export class ConfigurationService { API_KEY_FINANCIAL_MODELING_PREP: str({ default: '' }), API_KEY_OPEN_FIGI: str({ default: '' }), API_KEY_RAPID_API: str({ default: '' }), - CACHE_QUOTES_TTL: num({ default: ms('1 minute') / 1000 }), - CACHE_TTL: num({ default: 1 }), - LOG_LEVEL: str({ default: '' }), + CACHE_QUOTES_TTL: num({ default: ms('1 minute') }), + CACHE_TTL: num({ default: CACHE_TTL_NO_CACHE }), DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }), DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }), DATA_SOURCES: json({ @@ -44,8 +49,16 @@ export class ConfigurationService { JWT_SECRET_KEY: str({}), MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }), MAX_CHART_ITEMS: num({ default: 365 }), - MAX_ITEM_IN_CACHE: num({ default: 9999 }), PORT: port({ default: 3333 }), + PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE: num({ + default: DEFAULT_PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE + }), + PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA: num({ + default: DEFAULT_PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA + }), + PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT: num({ + default: DEFAULT_PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT + }), REDIS_DB: num({ default: 0 }), REDIS_HOST: str({ default: 'localhost' }), REDIS_PASSWORD: str({ default: '' }), diff --git a/apps/api/src/services/cron.service.ts b/apps/api/src/services/cron.service.ts index 864891c6a..17e970c1b 100644 --- a/apps/api/src/services/cron.service.ts +++ b/apps/api/src/services/cron.service.ts @@ -9,9 +9,9 @@ import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { Injectable } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; -import { DataGatheringService } from './data-gathering/data-gathering.service'; import { ExchangeRateDataService } from './exchange-rate-data/exchange-rate-data.service'; import { PropertyService } from './property/property.service'; +import { DataGatheringService } from './queues/data-gathering/data-gathering.service'; import { TwitterBotService } from './twitter-bot/twitter-bot.service'; @Injectable() diff --git a/apps/api/src/services/data-provider/coingecko/coingecko.service.ts b/apps/api/src/services/data-provider/coingecko/coingecko.service.ts index d673dd7aa..067a6fbf9 100644 --- a/apps/api/src/services/data-provider/coingecko/coingecko.service.ts +++ b/apps/api/src/services/data-provider/coingecko/coingecko.service.ts @@ -206,9 +206,9 @@ export class CoinGeckoService implements DataProviderInterface { let message = error; if (error?.code === 'ABORT_ERR') { - message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${this.configurationService.get( - 'REQUEST_TIMEOUT' - )}ms`; + message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${( + this.configurationService.get('REQUEST_TIMEOUT') / 1000 + ).toFixed(3)} seconds`; } Logger.error(message, 'CoinGeckoService'); diff --git a/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts b/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts index 1fe9e0ad1..8b2a1828b 100644 --- a/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts +++ b/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts @@ -18,6 +18,7 @@ import { } from '@ghostfolio/common/config'; import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper'; import { DataProviderInfo } from '@ghostfolio/common/interfaces'; +import { MarketState } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; import { @@ -229,7 +230,12 @@ export class EodHistoricalDataService implements DataProviderInterface { } ).json(); - const quotes = + const quotes: { + close: number; + code: string; + previousClose: number; + timestamp: number; + }[] = eodHistoricalDataSymbols.length === 1 ? [realTimeResponse] : realTimeResponse; @@ -243,7 +249,7 @@ export class EodHistoricalDataService implements DataProviderInterface { }) ); - for (const { close, code, timestamp } of quotes) { + for (const { close, code, previousClose, timestamp } of quotes) { let currency: string; if (this.isForex(code)) { @@ -267,15 +273,21 @@ export class EodHistoricalDataService implements DataProviderInterface { } } - if (isNumber(close)) { + if (isNumber(close) || isNumber(previousClose)) { + const marketPrice: number = isNumber(close) ? close : previousClose; + let marketState: MarketState = 'closed'; + + if (this.isForex(code) || isToday(new Date(timestamp * 1000))) { + marketState = 'open'; + } else if (!isNumber(close)) { + marketState = 'delayed'; + } + response[this.convertFromEodSymbol(code)] = { currency, - dataSource: this.getName(), - marketPrice: close, - marketState: - this.isForex(code) || isToday(new Date(timestamp * 1000)) - ? 'open' - : 'closed' + marketPrice, + marketState, + dataSource: this.getName() }; } else { Logger.error( @@ -290,9 +302,9 @@ export class EodHistoricalDataService implements DataProviderInterface { let message = error; if (error?.code === 'ABORT_ERR') { - message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${this.configurationService.get( - 'REQUEST_TIMEOUT' - )}ms`; + message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${( + this.configurationService.get('REQUEST_TIMEOUT') / 1000 + ).toFixed(3)} seconds`; } Logger.error(message, 'EodHistoricalDataService'); diff --git a/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts b/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts index 2faaf8db8..cf9c5ef9b 100644 --- a/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts +++ b/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts @@ -154,9 +154,9 @@ export class FinancialModelingPrepService implements DataProviderInterface { let message = error; if (error?.code === 'ABORT_ERR') { - message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${this.configurationService.get( - 'REQUEST_TIMEOUT' - )}ms`; + message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${( + this.configurationService.get('REQUEST_TIMEOUT') / 1000 + ).toFixed(3)} seconds`; } Logger.error(message, 'FinancialModelingPrepService'); diff --git a/apps/api/src/services/data-provider/manual/manual.service.ts b/apps/api/src/services/data-provider/manual/manual.service.ts index 588585cab..e77122e05 100644 --- a/apps/api/src/services/data-provider/manual/manual.service.ts +++ b/apps/api/src/services/data-provider/manual/manual.service.ts @@ -174,11 +174,42 @@ export class ManualService implements DataProviderInterface { ) .then((_result) => _result.flat()); + const symbolProfilesWithScraperConfigurationAndInstantMode = + symbolProfiles.filter(({ scraperConfiguration }) => { + return scraperConfiguration?.mode === 'instant'; + }); + + const scraperResultPromises = + symbolProfilesWithScraperConfigurationAndInstantMode.map( + async ({ scraperConfiguration, symbol }) => { + try { + const marketPrice = await this.scrape(scraperConfiguration); + return { marketPrice, symbol }; + } catch (error) { + Logger.error( + `Could not get quote for ${symbol} (${this.getName()}): [${error.name}] ${error.message}`, + 'ManualService' + ); + return { symbol, marketPrice: undefined }; + } + } + ); + + // Wait for all scraping requests to complete concurrently + const scraperResults = await Promise.all(scraperResultPromises); + for (const { currency, symbol } of symbolProfiles) { - let marketPrice = + let { marketPrice } = + scraperResults.find((result) => { + return result.symbol === symbol; + }) ?? {}; + + marketPrice = + marketPrice ?? marketData.find((marketDataItem) => { return marketDataItem.symbol === symbol; - })?.marketPrice ?? 0; + })?.marketPrice ?? + 0; response[symbol] = { currency, diff --git a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts index e0d88f0c6..a8f7d261e 100644 --- a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts +++ b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts @@ -20,6 +20,11 @@ import { Injectable, Logger } from '@nestjs/common'; import { DataSource, SymbolProfile } from '@prisma/client'; import { addDays, format, isSameDay } from 'date-fns'; import yahooFinance from 'yahoo-finance2'; +import { ChartResultArray } from 'yahoo-finance2/dist/esm/src/modules/chart'; +import { + HistoricalDividendsResult, + HistoricalHistoryResult +} from 'yahoo-finance2/dist/esm/src/modules/historical'; import { Quote } from 'yahoo-finance2/dist/esm/src/modules/quote'; @Injectable() @@ -60,18 +65,19 @@ export class YahooFinanceService implements DataProviderInterface { } try { - const historicalResult = await yahooFinance.historical( - this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol( - symbol - ), - { - events: 'dividends', - interval: granularity === 'month' ? '1mo' : '1d', - period1: format(from, DATE_FORMAT), - period2: format(to, DATE_FORMAT) - } + const historicalResult = this.convertToDividendResult( + await yahooFinance.chart( + this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol( + symbol + ), + { + events: 'dividends', + interval: granularity === 'month' ? '1mo' : '1d', + period1: format(from, DATE_FORMAT), + period2: format(to, DATE_FORMAT) + } + ) ); - const response: { [date: string]: IDataProviderHistoricalResponse; } = {}; @@ -108,15 +114,17 @@ export class YahooFinanceService implements DataProviderInterface { } try { - const historicalResult = await yahooFinance.historical( - this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol( - symbol - ), - { - interval: '1d', - period1: format(from, DATE_FORMAT), - period2: format(to, DATE_FORMAT) - } + const historicalResult = this.convertToHistoricalResult( + await yahooFinance.chart( + this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol( + symbol + ), + { + interval: '1d', + period1: format(from, DATE_FORMAT), + period2: format(to, DATE_FORMAT) + } + ) ); const response: { @@ -302,6 +310,20 @@ export class YahooFinanceService implements DataProviderInterface { return { items }; } + private convertToDividendResult( + result: ChartResultArray + ): HistoricalDividendsResult { + return result.events.dividends.map(({ amount: dividends, date }) => { + return { date, dividends }; + }); + } + + private convertToHistoricalResult( + result: ChartResultArray + ): HistoricalHistoryResult { + return result.quotes; + } + private async getQuotesWithQuoteSummary(aYahooFinanceSymbols: string[]) { const quoteSummaryPromises = aYahooFinanceSymbols.map((symbol) => { return yahooFinance.quoteSummary(symbol).catch(() => { 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 1f08034cd..31b2f885c 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 @@ -1,3 +1,4 @@ +import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; @@ -46,6 +47,7 @@ export class ExchangeRateDataService { return this.currencyPairs; } + @LogPerformance public async getExchangeRatesByCurrency({ currencies, endDate = new Date(), diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index c0dfb1806..d07937787 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -29,7 +29,6 @@ export interface Environment extends CleanedEnvAccessors { JWT_SECRET_KEY: string; MAX_ACTIVITIES_TO_IMPORT: number; MAX_CHART_ITEMS: number; - MAX_ITEM_IN_CACHE: number; PORT: number; REDIS_DB: number; REDIS_HOST: string; diff --git a/apps/api/src/services/data-gathering/data-gathering.module.ts b/apps/api/src/services/queues/data-gathering/data-gathering.module.ts similarity index 93% rename from apps/api/src/services/data-gathering/data-gathering.module.ts rename to apps/api/src/services/queues/data-gathering/data-gathering.module.ts index f3ab2fc9c..b51823476 100644 --- a/apps/api/src/services/data-gathering/data-gathering.module.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.module.ts @@ -1,11 +1,11 @@ import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; -import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; +import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config'; diff --git a/apps/api/src/services/data-gathering/data-gathering.processor.ts b/apps/api/src/services/queues/data-gathering/data-gathering.processor.ts similarity index 84% rename from apps/api/src/services/data-gathering/data-gathering.processor.ts rename to apps/api/src/services/queues/data-gathering/data-gathering.processor.ts index d8a6a7644..2745aa288 100644 --- a/apps/api/src/services/data-gathering/data-gathering.processor.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.processor.ts @@ -3,8 +3,10 @@ import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfac import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { DATA_GATHERING_QUEUE, + DEFAULT_PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE, + DEFAULT_PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA, GATHER_ASSET_PROFILE_PROCESS, - GATHER_HISTORICAL_MARKET_DATA_PROCESS + GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME } from '@ghostfolio/common/config'; import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; @@ -34,7 +36,14 @@ export class DataGatheringProcessor { private readonly marketDataService: MarketDataService ) {} - @Process({ concurrency: 1, name: GATHER_ASSET_PROFILE_PROCESS }) + @Process({ + concurrency: parseInt( + process.env.PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE ?? + DEFAULT_PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE.toString(), + 10 + ), + name: GATHER_ASSET_PROFILE_PROCESS + }) public async gatherAssetProfile(job: Job) { try { Logger.log( @@ -58,7 +67,14 @@ export class DataGatheringProcessor { } } - @Process({ concurrency: 1, name: GATHER_HISTORICAL_MARKET_DATA_PROCESS }) + @Process({ + concurrency: parseInt( + process.env.PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA ?? + DEFAULT_PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA.toString(), + 10 + ), + name: GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME + }) public async gatherHistoricalMarketData(job: Job) { try { const { dataSource, date, symbol } = job.data; @@ -69,7 +85,7 @@ export class DataGatheringProcessor { currentDate, DATE_FORMAT )}`, - `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})` + `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})` ); const historicalData = await this.dataProviderService.getHistoricalRaw({ @@ -123,12 +139,12 @@ export class DataGatheringProcessor { currentDate, DATE_FORMAT )}`, - `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})` + `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})` ); } catch (error) { Logger.error( error, - `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})` + `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})` ); throw new Error(error); diff --git a/apps/api/src/services/data-gathering/data-gathering.service.ts b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts similarity index 98% rename from apps/api/src/services/data-gathering/data-gathering.service.ts rename to apps/api/src/services/queues/data-gathering/data-gathering.service.ts index 1fef46808..a66e05b72 100644 --- a/apps/api/src/services/data-gathering/data-gathering.service.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts @@ -11,8 +11,8 @@ import { DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_LOW, DATA_GATHERING_QUEUE_PRIORITY_MEDIUM, - GATHER_HISTORICAL_MARKET_DATA_PROCESS, - GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS, + GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME, + GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS, PROPERTY_BENCHMARKS } from '@ghostfolio/common/config'; import { @@ -282,9 +282,9 @@ export class DataGatheringService { date, symbol }, - name: GATHER_HISTORICAL_MARKET_DATA_PROCESS, + name: GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME, opts: { - ...GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS, + ...GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS, priority, jobId: `${getAssetProfileIdentifier({ dataSource, diff --git a/apps/api/src/services/queues/portfolio-snapshot/interfaces/portfolio-snapshot-queue-job.interface.ts b/apps/api/src/services/queues/portfolio-snapshot/interfaces/portfolio-snapshot-queue-job.interface.ts new file mode 100644 index 000000000..24948e211 --- /dev/null +++ b/apps/api/src/services/queues/portfolio-snapshot/interfaces/portfolio-snapshot-queue-job.interface.ts @@ -0,0 +1,7 @@ +import { Filter } from '@ghostfolio/common/interfaces'; + +export interface IPortfolioSnapshotQueueJob { + filters: Filter[]; + userCurrency: string; + userId: string; +} diff --git a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts new file mode 100644 index 000000000..620feda53 --- /dev/null +++ b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts @@ -0,0 +1,39 @@ +import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module'; +import { OrderModule } from '@ghostfolio/api/app/order/order.module'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PORTFOLIO_SNAPSHOT_QUEUE } from '@ghostfolio/common/config'; + +import { BullModule } from '@nestjs/bull'; +import { Module } from '@nestjs/common'; + +import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor'; + +@Module({ + exports: [BullModule, PortfolioSnapshotService], + imports: [ + AccountBalanceModule, + BullModule.registerQueue({ + name: PORTFOLIO_SNAPSHOT_QUEUE + }), + ConfigurationModule, + DataProviderModule, + ExchangeRateDataModule, + MarketDataModule, + OrderModule, + RedisCacheModule + ], + providers: [ + CurrentRateService, + PortfolioCalculatorFactory, + PortfolioSnapshotProcessor, + PortfolioSnapshotService + ] +}) +export class PortfolioSnapshotQueueModule {} diff --git a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts new file mode 100644 index 000000000..7c89e9c23 --- /dev/null +++ b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts @@ -0,0 +1,114 @@ +import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; +import { OrderService } from '@ghostfolio/api/app/order/order.service'; +import { + PerformanceCalculationType, + PortfolioCalculatorFactory +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { + CACHE_TTL_INFINITE, + DEFAULT_PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT, + PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, + PORTFOLIO_SNAPSHOT_QUEUE +} from '@ghostfolio/common/config'; + +import { Process, Processor } from '@nestjs/bull'; +import { Injectable, Logger } from '@nestjs/common'; +import { Job } from 'bull'; +import { addMilliseconds } from 'date-fns'; + +import { IPortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface'; + +@Injectable() +@Processor(PORTFOLIO_SNAPSHOT_QUEUE) +export class PortfolioSnapshotProcessor { + public constructor( + private readonly accountBalanceService: AccountBalanceService, + private readonly calculatorFactory: PortfolioCalculatorFactory, + private readonly configurationService: ConfigurationService, + private readonly orderService: OrderService, + private readonly redisCacheService: RedisCacheService + ) {} + + @Process({ + concurrency: parseInt( + process.env.PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT ?? + DEFAULT_PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT.toString(), + 10 + ), + name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME + }) + public async calculatePortfolioSnapshot( + job: Job + ) { + try { + const startTime = performance.now(); + + Logger.log( + `Portfolio snapshot calculation of user '${job.data.userId}' has been started`, + `PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})` + ); + + const { activities } = + await this.orderService.getOrdersForPortfolioCalculator({ + filters: job.data.filters, + userCurrency: job.data.userCurrency, + userId: job.data.userId + }); + + const accountBalanceItems = + await this.accountBalanceService.getAccountBalanceItems({ + filters: job.data.filters, + userCurrency: job.data.userCurrency, + userId: job.data.userId + }); + + const portfolioCalculator = this.calculatorFactory.createCalculator({ + accountBalanceItems, + activities, + calculationType: PerformanceCalculationType.TWR, + currency: job.data.userCurrency, + filters: job.data.filters, + userId: job.data.userId + }); + + const snapshot = await portfolioCalculator.computeSnapshot(); + + Logger.log( + `Portfolio snapshot calculation of user '${job.data.userId}' has been completed in ${( + (performance.now() - startTime) / + 1000 + ).toFixed(3)} seconds`, + `PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})` + ); + + const expiration = addMilliseconds( + new Date(), + this.configurationService.get('CACHE_QUOTES_TTL') + ); + + this.redisCacheService.set( + this.redisCacheService.getPortfolioSnapshotKey({ + filters: job.data.filters, + userId: job.data.userId + }), + JSON.stringify(({ + expiration: expiration.getTime(), + portfolioSnapshot: snapshot + })), + CACHE_TTL_INFINITE + ); + + return snapshot; + } catch (error) { + Logger.error( + error, + `PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})` + ); + + throw new Error(error); + } + } +} diff --git a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock.ts b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock.ts new file mode 100644 index 000000000..8d7526906 --- /dev/null +++ b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock.ts @@ -0,0 +1,34 @@ +import { Job, JobOptions } from 'bull'; +import { setTimeout } from 'timers/promises'; + +import { IPortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface'; + +export const PortfolioSnapshotServiceMock = { + addJobToQueue({ + data, + name, + opts + }: { + data: IPortfolioSnapshotQueueJob; + name: string; + opts?: JobOptions; + }): Promise> { + const mockJob: Partial> = { + finished: async () => { + await setTimeout(100); + + return Promise.resolve(); + } + }; + + this.jobsStore.set(opts?.jobId, mockJob); + + return Promise.resolve(mockJob as Job); + }, + getJob(jobId: string): Promise> { + const job = this.jobsStore.get(jobId); + + return Promise.resolve(job as Job); + }, + jobsStore: new Map>>() +}; diff --git a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.ts b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.ts new file mode 100644 index 000000000..27ebdee53 --- /dev/null +++ b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.ts @@ -0,0 +1,31 @@ +import { PORTFOLIO_SNAPSHOT_QUEUE } from '@ghostfolio/common/config'; + +import { InjectQueue } from '@nestjs/bull'; +import { Injectable } from '@nestjs/common'; +import { JobOptions, Queue } from 'bull'; + +import { IPortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface'; + +@Injectable() +export class PortfolioSnapshotService { + public constructor( + @InjectQueue(PORTFOLIO_SNAPSHOT_QUEUE) + private readonly portfolioSnapshotQueue: Queue + ) {} + + public async addJobToQueue({ + data, + name, + opts + }: { + data: IPortfolioSnapshotQueueJob; + name: string; + opts?: JobOptions; + }) { + return this.portfolioSnapshotQueue.add(name, data, opts); + } + + public async getJob(jobId: string) { + return this.portfolioSnapshotQueue.getJob(jobId); + } +} diff --git a/apps/api/src/services/symbol-profile/symbol-profile.service.ts b/apps/api/src/services/symbol-profile/symbol-profile.service.ts index a225b184c..e02e85271 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -288,6 +288,8 @@ export class SymbolProfileService { headers: scraperConfiguration.headers as ScraperConfiguration['headers'], locale: scraperConfiguration.locale as string, + mode: + (scraperConfiguration.mode as ScraperConfiguration['mode']) ?? 'lazy', selector: scraperConfiguration.selector as string, url: scraperConfiguration.url as string }; diff --git a/apps/client/src/app/app.component.html b/apps/client/src/app/app.component.html index 806360c0f..db6a00e7b 100644 --- a/apps/client/src/app/app.component.html +++ b/apps/client/src/app/app.component.html @@ -168,11 +168,9 @@
  • Nederlands
  • - +
  • + Polski +
  • Português
  • diff --git a/apps/client/src/app/app.component.ts b/apps/client/src/app/app.component.ts index fbc358590..44a554173 100644 --- a/apps/client/src/app/app.component.ts +++ b/apps/client/src/app/app.component.ts @@ -57,19 +57,25 @@ export class AppComponent implements OnDestroy, OnInit { public hasTabs = false; public info: InfoItem; public pageTitle: string; - public routerLinkAbout = ['/' + $localize`about`]; - public routerLinkAboutChangelog = ['/' + $localize`about`, 'changelog']; - public routerLinkAboutLicense = ['/' + $localize`about`, $localize`license`]; + public routerLinkAbout = ['/' + $localize`:snake-case:about`]; + public routerLinkAboutChangelog = [ + '/' + $localize`:snake-case:about`, + 'changelog' + ]; + public routerLinkAboutLicense = [ + '/' + $localize`:snake-case:about`, + $localize`:snake-case:license` + ]; public routerLinkAboutPrivacyPolicy = [ - '/' + $localize`about`, - $localize`privacy-policy` + '/' + $localize`:snake-case:about`, + $localize`:snake-case:privacy-policy` ]; - public routerLinkFaq = ['/' + $localize`faq`]; - public routerLinkFeatures = ['/' + $localize`features`]; - public routerLinkMarkets = ['/' + $localize`markets`]; - public routerLinkPricing = ['/' + $localize`pricing`]; - public routerLinkRegister = ['/' + $localize`register`]; - public routerLinkResources = ['/' + $localize`resources`]; + public routerLinkFaq = ['/' + $localize`:snake-case:faq`]; + public routerLinkFeatures = ['/' + $localize`:snake-case:features`]; + public routerLinkMarkets = ['/' + $localize`:snake-case:markets`]; + public routerLinkPricing = ['/' + $localize`:snake-case:pricing`]; + public routerLinkRegister = ['/' + $localize`:snake-case:register`]; + public routerLinkResources = ['/' + $localize`:snake-case:resources`]; public showFooter = false; public user: User; diff --git a/apps/client/src/app/components/access-table/access-table.component.html b/apps/client/src/app/components/access-table/access-table.component.html index b1befc8c9..2a20e4631 100644 --- a/apps/client/src/app/components/access-table/access-table.component.html +++ b/apps/client/src/app/components/access-table/access-table.component.html @@ -35,12 +35,19 @@ @if (element.type === 'PUBLIC') { + @if (user?.settings?.isExperimentalFeatures) { +
    + GET {{ baseUrl }}/api/v1/public/{{ + element.id + }}/portfolio +
    + } } @@ -58,6 +65,12 @@ + @if (element.type === 'PUBLIC') { + +
    + } diff --git a/apps/client/src/app/components/access-table/access-table.component.ts b/apps/client/src/app/components/access-table/access-table.component.ts index 7772451d4..8b147fdd3 100644 --- a/apps/client/src/app/components/access-table/access-table.component.ts +++ b/apps/client/src/app/components/access-table/access-table.component.ts @@ -1,8 +1,9 @@ import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type'; import { NotificationService } from '@ghostfolio/client/core/notification/notification.service'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; -import { Access } from '@ghostfolio/common/interfaces'; +import { Access, User } from '@ghostfolio/common/interfaces'; +import { Clipboard } from '@angular/cdk/clipboard'; import { ChangeDetectionStrategy, Component, @@ -23,15 +24,18 @@ import { MatTableDataSource } from '@angular/material/table'; export class AccessTableComponent implements OnChanges, OnInit { @Input() accesses: Access[]; @Input() showActions: boolean; + @Input() user: User; @Output() accessDeleted = new EventEmitter(); public baseUrl = window.location.origin; public dataSource: MatTableDataSource; - public defaultLanguageCode = DEFAULT_LANGUAGE_CODE; public displayedColumns = []; - public constructor(private notificationService: NotificationService) {} + public constructor( + private clipboard: Clipboard, + private notificationService: NotificationService + ) {} public ngOnInit() {} @@ -47,6 +51,16 @@ export class AccessTableComponent implements OnChanges, OnInit { } } + public getPublicUrl(aId: string): string { + const languageCode = this.user?.settings?.language ?? DEFAULT_LANGUAGE_CODE; + + return `${this.baseUrl}/${languageCode}/p/${aId}`; + } + + public onCopyToClipboard(aId: string): void { + this.clipboard.copy(this.getPublicUrl(aId)); + } + public onDeleteAccess(aId: string) { this.notificationService.confirm({ confirmFn: () => { diff --git a/apps/client/src/app/components/access-table/access-table.module.ts b/apps/client/src/app/components/access-table/access-table.module.ts index 2ace3cfc1..4cbc7b580 100644 --- a/apps/client/src/app/components/access-table/access-table.module.ts +++ b/apps/client/src/app/components/access-table/access-table.module.ts @@ -1,3 +1,4 @@ +import { ClipboardModule } from '@angular/cdk/clipboard'; import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; @@ -11,6 +12,7 @@ import { AccessTableComponent } from './access-table.component'; declarations: [AccessTableComponent], exports: [AccessTableComponent], imports: [ + ClipboardModule, CommonModule, MatButtonModule, MatMenuModule, diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts index 1cec23aba..d6791760b 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts @@ -2,7 +2,7 @@ import { CreateAccountBalanceDto } from '@ghostfolio/api/app/account-balance/cre import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { DataService } from '@ghostfolio/client/services/data.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { downloadAsFile } from '@ghostfolio/common/helper'; +import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper'; import { AccountBalancesResponse, HistoricalDataItem, @@ -27,7 +27,7 @@ import { Router } from '@angular/router'; import { Big } from 'big.js'; import { format, parseISO } from 'date-fns'; import { isNumber } from 'lodash'; -import { Subject } from 'rxjs'; +import { forkJoin, Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { AccountDetailDialogParams } from './interfaces/interfaces'; @@ -87,11 +87,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit { } public ngOnInit() { - this.fetchAccount(); - this.fetchAccountBalances(); - this.fetchActivities(); - this.fetchPortfolioHoldings(); - this.fetchPortfolioPerformance(); + this.initialize(); } public onCloneActivity(aActivity: Activity) { @@ -111,9 +107,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit { .postAccountBalance(accountBalance) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(() => { - this.fetchAccount(); - this.fetchAccountBalances(); - this.fetchPortfolioPerformance(); + this.initialize(); }); } @@ -122,9 +116,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit { .deleteAccountBalance(aId) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(() => { - this.fetchAccount(); - this.fetchAccountBalances(); - this.fetchPortfolioPerformance(); + this.initialize(); }); } @@ -198,17 +190,6 @@ export class AccountDetailDialog implements OnDestroy, OnInit { ); } - private fetchAccountBalances() { - this.dataService - .fetchAccountBalances(this.data.accountId) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(({ balances }) => { - this.accountBalances = balances; - - this.changeDetectorRef.markForCheck(); - }); - } - private fetchActivities() { this.isLoadingActivities = true; @@ -229,6 +210,58 @@ export class AccountDetailDialog implements OnDestroy, OnInit { }); } + private fetchChart() { + this.isLoadingChart = true; + + forkJoin({ + accountBalances: this.dataService + .fetchAccountBalances(this.data.accountId) + .pipe(takeUntil(this.unsubscribeSubject)), + portfolioPerformance: this.dataService + .fetchPortfolioPerformance({ + filters: [ + { + id: this.data.accountId, + type: 'ACCOUNT' + } + ], + range: 'max', + withExcludedAccounts: true, + withItems: true + }) + .pipe(takeUntil(this.unsubscribeSubject)) + }).subscribe({ + error: () => { + this.isLoadingChart = false; + }, + next: ({ accountBalances, portfolioPerformance }) => { + this.accountBalances = accountBalances.balances; + + if (portfolioPerformance.chart.length > 0) { + this.historicalDataItems = portfolioPerformance.chart.map( + ({ date, netWorth, netWorthInPercentage }) => ({ + date, + value: isNumber(netWorth) ? netWorth : netWorthInPercentage + }) + ); + } else { + this.historicalDataItems = this.accountBalances.map( + ({ date, valueInBaseCurrency }) => { + return { + date: format(date, DATE_FORMAT), + value: valueInBaseCurrency + }; + } + ); + } + + this.isLoadingChart = false; + + this.changeDetectorRef.markForCheck(); + } + }); + } + private fetchPortfolioHoldings() { this.dataService .fetchPortfolioHoldings({ @@ -247,36 +280,11 @@ export class AccountDetailDialog implements OnDestroy, OnInit { }); } - private fetchPortfolioPerformance() { - this.isLoadingChart = true; - - this.dataService - .fetchPortfolioPerformance({ - filters: [ - { - id: this.data.accountId, - type: 'ACCOUNT' - } - ], - range: 'max', - withExcludedAccounts: true, - withItems: true - }) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(({ chart }) => { - this.historicalDataItems = chart.map( - ({ date, netWorth, netWorthInPercentage }) => { - return { - date, - value: isNumber(netWorth) ? netWorth : netWorthInPercentage - }; - } - ); - - this.isLoadingChart = false; - - this.changeDetectorRef.markForCheck(); - }); + private initialize() { + this.fetchAccount(); + this.fetchActivities(); + this.fetchChart(); + this.fetchPortfolioHoldings(); } public ngOnDestroy() { diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html index 9f55250ec..8459037f6 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html @@ -20,20 +20,20 @@ - + @if (user?.settings?.isExperimentalFeatures) { +
    + +
    + }
    diff --git a/apps/client/src/app/components/accounts-table/accounts-table.component.html b/apps/client/src/app/components/accounts-table/accounts-table.component.html index f5039395b..ecb51fc29 100644 --- a/apps/client/src/app/components/accounts-table/accounts-table.component.html +++ b/apps/client/src/app/components/accounts-table/accounts-table.component.html @@ -278,6 +278,7 @@ Edit +
    +
    @@ -183,6 +186,16 @@ + @if (isLoading) { + + }
    diff --git a/apps/client/src/app/components/admin-jobs/admin-jobs.module.ts b/apps/client/src/app/components/admin-jobs/admin-jobs.module.ts index fe717b904..cca66a04a 100644 --- a/apps/client/src/app/components/admin-jobs/admin-jobs.module.ts +++ b/apps/client/src/app/components/admin-jobs/admin-jobs.module.ts @@ -5,6 +5,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatMenuModule } from '@angular/material/menu'; import { MatSelectModule } from '@angular/material/select'; import { MatTableModule } from '@angular/material/table'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { AdminJobsComponent } from './admin-jobs.component'; @@ -17,6 +18,7 @@ import { AdminJobsComponent } from './admin-jobs.component'; MatMenuModule, MatSelectModule, MatTableModule, + NgxSkeletonLoaderModule, ReactiveFormsModule ], schemas: [CUSTOM_ELEMENTS_SCHEMA] diff --git a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts b/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts index 7e7168a6e..a1261c37f 100644 --- a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts +++ b/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts @@ -93,52 +93,52 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit { }; }); - let date = parseISO(this.dateOfFirstActivity); - - const missingMarketData: Partial[] = []; - - if (this.historicalDataItems?.[0]?.date) { - while ( - isBefore( - date, - parse(this.historicalDataItems[0].date, DATE_FORMAT, new Date()) - ) - ) { - missingMarketData.push({ - date, - marketPrice: undefined - }); - - date = addDays(date, 1); + if (this.dateOfFirstActivity) { + let date = parseISO(this.dateOfFirstActivity); + + const missingMarketData: Partial[] = []; + + if (this.historicalDataItems?.[0]?.date) { + while ( + isBefore( + date, + parse(this.historicalDataItems[0].date, DATE_FORMAT, new Date()) + ) + ) { + missingMarketData.push({ + date, + marketPrice: undefined + }); + + date = addDays(date, 1); + } } - } - const marketDataItems = [...missingMarketData, ...this.marketData]; + const marketDataItems = [...missingMarketData, ...this.marketData]; - if (!isToday(last(marketDataItems)?.date)) { - marketDataItems.push({ date: new Date() }); - } + if (!isToday(last(marketDataItems)?.date)) { + marketDataItems.push({ date: new Date() }); + } - this.marketDataByMonth = {}; + this.marketDataByMonth = {}; - for (const marketDataItem of marketDataItems) { - const currentDay = parseInt(format(marketDataItem.date, 'd'), 10); - const key = format(marketDataItem.date, 'yyyy-MM'); + for (const marketDataItem of marketDataItems) { + const currentDay = parseInt(format(marketDataItem.date, 'd'), 10); + const key = format(marketDataItem.date, 'yyyy-MM'); - if (!this.marketDataByMonth[key]) { - this.marketDataByMonth[key] = {}; - } + if (!this.marketDataByMonth[key]) { + this.marketDataByMonth[key] = {}; + } - this.marketDataByMonth[key][ - currentDay < 10 ? `0${currentDay}` : currentDay - ] = { - date: marketDataItem.date, - day: currentDay, - marketPrice: marketDataItem.marketPrice - }; - } + this.marketDataByMonth[key][ + currentDay < 10 ? `0${currentDay}` : currentDay + ] = { + date: marketDataItem.date, + day: currentDay, + marketPrice: marketDataItem.marketPrice + }; + } - if (this.dateOfFirstActivity) { // Fill up missing months const dates = Object.keys(this.marketDataByMonth).sort(); const startDate = min([ diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts index 98a1d0480..549708d87 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts @@ -142,6 +142,7 @@ export class AdminMarketDataComponent 'dataSource', 'assetClass', 'assetSubClass', + 'lastMarketPrice', 'date', 'activitiesCount', 'marketDataItemCount', diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.html b/apps/client/src/app/components/admin-market-data/admin-market-data.html index f3b2d8ddd..de5707d02 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.html +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.html @@ -99,6 +99,21 @@ + + + Market Price + + +
    + +
    + +
    + First Activity @@ -182,6 +197,7 @@ +
    +
    +
    +
    } + + diff --git a/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.scss b/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.scss new file mode 100644 index 000000000..dc9093b45 --- /dev/null +++ b/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.scss @@ -0,0 +1,2 @@ +:host { +} diff --git a/apps/client/src/app/components/rule/rule.component.html b/apps/client/src/app/components/rule/rule.component.html index 80b442b7b..f19436aba 100644 --- a/apps/client/src/app/components/rule/rule.component.html +++ b/apps/client/src/app/components/rule/rule.component.html @@ -62,6 +62,11 @@ + @if (rule?.isActive && !isEmpty(rule.settings) && false) { + + } } +
    +
    } -
    +
    diff --git a/libs/ui/src/lib/carousel/carousel.component.ts b/libs/ui/src/lib/carousel/carousel.component.ts index 7f93297dd..8b766aa6d 100644 --- a/libs/ui/src/lib/carousel/carousel.component.ts +++ b/libs/ui/src/lib/carousel/carousel.component.ts @@ -1,24 +1,18 @@ -import { FocusKeyManager } from '@angular/cdk/a11y'; -import { LEFT_ARROW, RIGHT_ARROW, TAB } from '@angular/cdk/keycodes'; import { - AfterContentInit, CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy, Component, - ContentChildren, + contentChildren, ElementRef, HostBinding, Inject, Input, Optional, - QueryList, ViewChild } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { ANIMATION_MODULE_TYPE } from '@angular/platform-browser/animations'; -import { CarouselItem } from './carousel-item.directive'; - @Component({ changeDetection: ChangeDetectionStrategy.OnPush, imports: [MatButtonModule], @@ -28,9 +22,7 @@ import { CarouselItem } from './carousel-item.directive'; styleUrls: ['./carousel.component.scss'], templateUrl: './carousel.component.html' }) -export class GfCarouselComponent implements AfterContentInit { - @ContentChildren(CarouselItem) public items!: QueryList; - +export class GfCarouselComponent { @HostBinding('class.animations-disabled') public readonly animationsDisabled: boolean; @@ -38,11 +30,11 @@ export class GfCarouselComponent implements AfterContentInit { @ViewChild('list') public list!: ElementRef; + public items = contentChildren('carouselItem', { read: ElementRef }); public showPrevArrow = false; public showNextArrow = true; private index = 0; - private keyManager!: FocusKeyManager; private position = 0; public constructor( @@ -51,12 +43,8 @@ export class GfCarouselComponent implements AfterContentInit { this.animationsDisabled = animationsModule === 'NoopAnimations'; } - public ngAfterContentInit() { - this.keyManager = new FocusKeyManager(this.items); - } - public next() { - for (let i = this.index; i < this.items.length; i++) { + for (let i = this.index; i < this.items().length; i++) { if (this.isOutOfView(i)) { this.index = i; this.scrollToActiveItem(); @@ -65,31 +53,6 @@ export class GfCarouselComponent implements AfterContentInit { } } - public onKeydown({ keyCode }: KeyboardEvent) { - const manager = this.keyManager; - const previousActiveIndex = manager.activeItemIndex; - - if (keyCode === LEFT_ARROW) { - manager.setPreviousItemActive(); - } else if (keyCode === RIGHT_ARROW) { - manager.setNextItemActive(); - } else if (keyCode === TAB && !manager.activeItem) { - manager.setFirstItemActive(); - } - - if ( - manager.activeItemIndex != null && - manager.activeItemIndex !== previousActiveIndex - ) { - this.index = manager.activeItemIndex; - this.updateItemTabIndices(); - - if (this.isOutOfView(this.index)) { - this.scrollToActiveItem(); - } - } - } - public previous() { for (let i = this.index; i > -1; i--) { if (this.isOutOfView(i)) { @@ -101,8 +64,7 @@ export class GfCarouselComponent implements AfterContentInit { } private isOutOfView(index: number, side?: 'start' | 'end') { - const { offsetWidth, offsetLeft } = - this.items.toArray()[index].element.nativeElement; + const { offsetWidth, offsetLeft } = this.items()[index].nativeElement; if ((!side || side === 'start') && offsetLeft - this.position < 0) { return true; @@ -120,33 +82,23 @@ export class GfCarouselComponent implements AfterContentInit { return; } - const itemsArray = this.items.toArray(); let targetItemIndex = this.index; if (this.index > 0 && !this.isOutOfView(this.index - 1)) { targetItemIndex = - itemsArray.findIndex((_, i) => !this.isOutOfView(i)) + 1; + this.items().findIndex((_, i) => !this.isOutOfView(i)) + 1; } - this.position = - itemsArray[targetItemIndex].element.nativeElement.offsetLeft; + this.position = this.items()[targetItemIndex].nativeElement.offsetLeft; this.list.nativeElement.style.transform = `translateX(-${this.position}px)`; this.showPrevArrow = this.index > 0; this.showNextArrow = false; - for (let i = itemsArray.length - 1; i > -1; i--) { + for (let i = this.items().length - 1; i > -1; i--) { if (this.isOutOfView(i, 'end')) { this.showNextArrow = true; break; } } } - - private updateItemTabIndices() { - this.items.forEach((item: CarouselItem) => { - if (this.keyManager != null) { - item.tabindex = item === this.keyManager.activeItem ? '0' : '-1'; - } - }); - } } diff --git a/libs/ui/src/lib/membership-card/membership-card.component.ts b/libs/ui/src/lib/membership-card/membership-card.component.ts index f82dee3f1..b19072946 100644 --- a/libs/ui/src/lib/membership-card/membership-card.component.ts +++ b/libs/ui/src/lib/membership-card/membership-card.component.ts @@ -22,5 +22,5 @@ export class GfMembershipCardComponent { @Input() public expiresAt: string; @Input() public name: string; - public routerLinkPricing = ['/' + $localize`pricing`]; + public routerLinkPricing = ['/' + $localize`:snake-case:pricing`]; } diff --git a/libs/ui/src/lib/value/value.component.html b/libs/ui/src/lib/value/value.component.html index d1e498bcc..a691c54e8 100644 --- a/libs/ui/src/lib/value/value.component.html +++ b/libs/ui/src/lib/value/value.component.html @@ -5,7 +5,7 @@
    diff --git a/nx.json b/nx.json index 1a1f05379..97a1270e2 100644 --- a/nx.json +++ b/nx.json @@ -70,7 +70,6 @@ "!{projectRoot}/webpack.config.js" ] }, - "nxCloudAccessToken": "Mjg0ZGQ2YjAtNGI4NS00NmYwLThhOWEtMWZmNmQzODM4YzU4fHJlYWQ=", "parallel": 1, "defaultBase": "origin/main", "useInferencePlugins": false diff --git a/package-lock.json b/package-lock.json index 7912c1d12..d867034c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostfolio", - "version": "2.106.0-beta.2", - "lockfileVersion": 2, + "version": "2.110.0", + "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostfolio", - "version": "2.106.0-beta.2", + "version": "2.110.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -30,7 +30,7 @@ "@dinero.js/currencies": "2.0.0-alpha.8", "@internationalized/number": "3.5.2", "@nestjs/bull": "10.0.1", - "@nestjs/cache-manager": "2.1.0", + "@nestjs/cache-manager": "2.2.2", "@nestjs/common": "10.1.3", "@nestjs/config": "3.0.0", "@nestjs/core": "10.1.3", @@ -40,7 +40,7 @@ "@nestjs/platform-express": "10.1.3", "@nestjs/schedule": "3.0.2", "@nestjs/serve-static": "4.0.0", - "@prisma/client": "5.18.0", + "@prisma/client": "5.19.1", "@simplewebauthn/browser": "9.0.1", "@simplewebauthn/server": "9.0.3", "@stripe/stripe-js": "3.5.0", @@ -49,9 +49,9 @@ "big.js": "6.2.1", "body-parser": "1.20.2", "bootstrap": "4.6.0", - "bull": "4.10.4", - "cache-manager": "3.6.3", - "cache-manager-redis-store": "2.0.0", + "bull": "4.16.2", + "cache-manager": "5.7.6", + "cache-manager-redis-yet": "5.1.4", "chart.js": "4.2.0", "chartjs-adapter-date-fns": "3.0.0", "chartjs-chart-treemap": "2.3.1", @@ -85,7 +85,7 @@ "passport": "0.7.0", "passport-google-oauth20": "2.0.0", "passport-jwt": "4.0.1", - "prisma": "5.18.0", + "prisma": "5.19.1", "reflect-metadata": "0.1.13", "rxjs": "7.5.6", "stripe": "15.11.0", @@ -127,7 +127,7 @@ "@trivago/prettier-plugin-sort-imports": "4.3.0", "@types/big.js": "6.2.2", "@types/body-parser": "1.19.5", - "@types/cache-manager": "3.4.2", + "@types/cache-manager": "4.0.6", "@types/color": "3.0.6", "@types/google-spreadsheet": "3.1.5", "@types/jest": "29.4.4", @@ -159,7 +159,7 @@ "ts-node": "10.9.2", "tslib": "2.6.0", "typescript": "5.5.3", - "webpack-bundle-analyzer": "4.10.1" + "webpack-bundle-analyzer": "4.10.2" }, "engines": { "node": ">=20" @@ -7135,14 +7135,14 @@ "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" }, "node_modules/@nestjs/cache-manager": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-2.1.0.tgz", - "integrity": "sha512-9kep3a8Mq5cMuXN/anGhSYc0P48CRBXk5wyJJRBFxhNkCH8AIzZF4CASGVDIEMmm3OjVcEUHojjyJwCODS17Qw==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-2.2.2.tgz", + "integrity": "sha512-+n7rpU1QABeW2WV17Dl1vZCG3vWjJU1MaamWgZvbGxYE9EeCM0lVLfw3z7acgDTNwOy+K68xuQPoIMxD0bhjlA==", + "license": "MIT", "peerDependencies": { "@nestjs/common": "^9.0.0 || ^10.0.0", "@nestjs/core": "^9.0.0 || ^10.0.0", "cache-manager": "<=5", - "reflect-metadata": "^0.1.12", "rxjs": "^7.0.0" } }, @@ -10005,9 +10005,9 @@ "dev": true }, "node_modules/@prisma/client": { - "version": "5.18.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.18.0.tgz", - "integrity": "sha512-BWivkLh+af1kqC89zCJYkHsRcyWsM8/JHpsDMM76DjP3ZdEquJhXa4IeX+HkWPnwJ5FanxEJFZZDTWiDs/Kvyw==", + "version": "5.19.1", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.19.1.tgz", + "integrity": "sha512-x30GFguInsgt+4z5I4WbkZP2CGpotJMUXy+Gl/aaUjHn2o1DnLYNTA+q9XdYmAQZM8fIIkvUiA2NpgosM3fneg==", "hasInstallScript": true, "engines": { "node": ">=16.13" @@ -10022,43 +10022,112 @@ } }, "node_modules/@prisma/debug": { - "version": "5.18.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.18.0.tgz", - "integrity": "sha512-f+ZvpTLidSo3LMJxQPVgAxdAjzv5OpzAo/eF8qZqbwvgi2F5cTOI9XCpdRzJYA0iGfajjwjOKKrVq64vkxEfUw==" + "version": "5.19.1", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.19.1.tgz", + "integrity": "sha512-lAG6A6QnG2AskAukIEucYJZxxcSqKsMK74ZFVfCTOM/7UiyJQi48v6TQ47d6qKG3LbMslqOvnTX25dj/qvclGg==", + "license": "Apache-2.0" }, "node_modules/@prisma/engines": { - "version": "5.18.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.18.0.tgz", - "integrity": "sha512-ofmpGLeJ2q2P0wa/XaEgTnX/IsLnvSp/gZts0zjgLNdBhfuj2lowOOPmDcfKljLQUXMvAek3lw5T01kHmCG8rg==", + "version": "5.19.1", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.19.1.tgz", + "integrity": "sha512-kR/PoxZDrfUmbbXqqb8SlBBgCjvGaJYMCOe189PEYzq9rKqitQ2fvT/VJ8PDSe8tTNxhc2KzsCfCAL+Iwm/7Cg==", "hasInstallScript": true, "dependencies": { - "@prisma/debug": "5.18.0", - "@prisma/engines-version": "5.18.0-25.4c784e32044a8a016d99474bd02a3b6123742169", - "@prisma/fetch-engine": "5.18.0", - "@prisma/get-platform": "5.18.0" + "@prisma/debug": "5.19.1", + "@prisma/engines-version": "5.19.1-2.69d742ee20b815d88e17e54db4a2a7a3b30324e3", + "@prisma/fetch-engine": "5.19.1", + "@prisma/get-platform": "5.19.1" } }, "node_modules/@prisma/engines-version": { - "version": "5.18.0-25.4c784e32044a8a016d99474bd02a3b6123742169", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.18.0-25.4c784e32044a8a016d99474bd02a3b6123742169.tgz", - "integrity": "sha512-a/+LpJj8vYU3nmtkg+N3X51ddbt35yYrRe8wqHTJtYQt7l1f8kjIBcCs6sHJvodW/EK5XGvboOiwm47fmNrbgg==" + "version": "5.19.1-2.69d742ee20b815d88e17e54db4a2a7a3b30324e3", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.19.1-2.69d742ee20b815d88e17e54db4a2a7a3b30324e3.tgz", + "integrity": "sha512-xR6rt+z5LnNqTP5BBc+8+ySgf4WNMimOKXRn6xfNRDSpHvbOEmd7+qAOmzCrddEc4Cp8nFC0txU14dstjH7FXA==", + "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { - "version": "5.18.0", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.18.0.tgz", - "integrity": "sha512-I/3u0x2n31rGaAuBRx2YK4eB7R/1zCuayo2DGwSpGyrJWsZesrV7QVw7ND0/Suxeo/vLkJ5OwuBqHoCxvTHpOg==", + "version": "5.19.1", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.19.1.tgz", + "integrity": "sha512-pCq74rtlOVJfn4pLmdJj+eI4P7w2dugOnnTXpRilP/6n5b2aZiA4ulJlE0ddCbTPkfHmOL9BfaRgA8o+1rfdHw==", + "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "5.18.0", - "@prisma/engines-version": "5.18.0-25.4c784e32044a8a016d99474bd02a3b6123742169", - "@prisma/get-platform": "5.18.0" + "@prisma/debug": "5.19.1", + "@prisma/engines-version": "5.19.1-2.69d742ee20b815d88e17e54db4a2a7a3b30324e3", + "@prisma/get-platform": "5.19.1" } }, "node_modules/@prisma/get-platform": { - "version": "5.18.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.18.0.tgz", - "integrity": "sha512-Tk+m7+uhqcKDgnMnFN0lRiH7Ewea0OEsZZs9pqXa7i3+7svS3FSCqDBCaM9x5fmhhkufiG0BtunJVDka+46DlA==", + "version": "5.19.1", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.19.1.tgz", + "integrity": "sha512-sCeoJ+7yt0UjnR+AXZL7vXlg5eNxaFOwC23h0KvW1YIXUoa7+W2ZcAUhoEQBmJTW4GrFqCuZ8YSP0mkDa4k3Zg==", + "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "5.18.0" + "@prisma/debug": "5.19.1" + } + }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.0.tgz", + "integrity": "sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" } }, "node_modules/@rollup/rollup-android-arm-eabi": { @@ -11828,10 +11897,11 @@ } }, "node_modules/@types/cache-manager": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/@types/cache-manager/-/cache-manager-3.4.2.tgz", - "integrity": "sha512-1IwA74t5ID4KWo0Kndal16MhiPSZgMe1fGc+MLT6j5r+Ab7jku36PFTl4PP6MiWw0BJscM9QpZEo00qixNQoRg==", - "dev": true + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@types/cache-manager/-/cache-manager-4.0.6.tgz", + "integrity": "sha512-8qL93MF05/xrzFm/LSPtzNEOE1eQF3VwGHAcQEylgp5hDSTe41jtFwbSYAPfyYcVa28y1vYSjIt0c1fLLUiC/Q==", + "dev": true, + "license": "MIT" }, "node_modules/@types/cacheable-request": { "version": "6.0.3", @@ -13633,9 +13703,10 @@ "dev": true }, "node_modules/async": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", - "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==" + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.0.tgz", + "integrity": "sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw==", + "dev": true }, "node_modules/asynckit": { "version": "0.4.0", @@ -14475,17 +14546,17 @@ } }, "node_modules/bull": { - "version": "4.10.4", - "resolved": "https://registry.npmjs.org/bull/-/bull-4.10.4.tgz", - "integrity": "sha512-o9m/7HjS/Or3vqRd59evBlWCXd9Lp+ALppKseoSKHaykK46SmRjAilX98PgmOz1yeVaurt8D5UtvEt4bUjM3eA==", + "version": "4.16.2", + "resolved": "https://registry.npmjs.org/bull/-/bull-4.16.2.tgz", + "integrity": "sha512-VCy33UdPGiIoZHDTrslGXKXWxcIUHNH5Z82pihr8HicbIfAH4SHug1HxlwKEbibVv85hq8rJ9tKAW/cuxv2T0A==", + "license": "MIT", "dependencies": { "cron-parser": "^4.2.1", - "debuglog": "^1.0.0", "get-port": "^5.1.1", - "ioredis": "^5.0.0", + "ioredis": "^5.3.2", "lodash": "^4.17.21", - "msgpackr": "^1.5.2", - "semver": "^7.3.2", + "msgpackr": "^1.10.1", + "semver": "^7.5.2", "uuid": "^8.3.0" }, "engines": { @@ -14626,41 +14697,50 @@ } }, "node_modules/cache-manager": { - "version": "3.6.3", - "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-3.6.3.tgz", - "integrity": "sha512-dS4DnV6c6cQcVH5OxzIU1XZaACXwvVIiUPkFytnRmLOACuBGv3GQgRQ1RJGRRw4/9DF14ZK2RFlZu1TUgDniMg==", + "version": "5.7.6", + "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-5.7.6.tgz", + "integrity": "sha512-wBxnBHjDxF1RXpHCBD6HGvKER003Ts7IIm0CHpggliHzN1RZditb7rXoduE1rplc2DEFYKxhLKgFuchXMJje9w==", + "license": "MIT", "dependencies": { - "async": "3.2.3", + "eventemitter3": "^5.0.1", "lodash.clonedeep": "^4.5.0", - "lru-cache": "6.0.0" - } - }, - "node_modules/cache-manager-redis-store": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/cache-manager-redis-store/-/cache-manager-redis-store-2.0.0.tgz", - "integrity": "sha512-bWLWlUg6nCYHiJLCCYxY2MgvwvKnvlWwrbuynrzpjEIhfArD2GC9LtutIHFEPeyGVQN6C+WEw+P3r+BFBwhswg==", - "dependencies": { - "redis": "^3.0.2" + "lru-cache": "^10.2.2", + "promise-coalesce": "^1.1.2" }, "engines": { - "node": ">= 8.3" + "node": ">= 18" } }, - "node_modules/cache-manager/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "node_modules/cache-manager-redis-yet": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/cache-manager-redis-yet/-/cache-manager-redis-yet-5.1.4.tgz", + "integrity": "sha512-2mXZjo+txfH2m+mSTHTITNq8c5SssU2nP7NutzrocO3Mw/SbjHcDo+mriI3ZuR63ov/oUUIaF9iF+MzDqVzMoQ==", + "license": "MIT", "dependencies": { - "yallist": "^4.0.0" + "@redis/bloom": "^1.2.0", + "@redis/client": "^1.6.0", + "@redis/graph": "^1.1.1", + "@redis/json": "^1.0.7", + "@redis/search": "^1.2.0", + "@redis/time-series": "^1.1.0", + "cache-manager": "^5.7.6", + "redis": "^4.7.0" }, "engines": { - "node": ">=10" + "node": ">= 18" } }, - "node_modules/cache-manager/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "node_modules/cache-manager/node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/cache-manager/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" }, "node_modules/cacheable-lookup": { "version": "5.0.4", @@ -17068,15 +17148,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, - "node_modules/debuglog": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/debuglog/-/debuglog-1.0.1.tgz", - "integrity": "sha512-syBZ+rnAK3EgMsH2aYEOLUW7mZSY9Gb+0wUMCFsZvcmiz+HigA0LOcq/HoQqVuGG+EKykunc7QG2bzrponfaSw==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "engines": { - "node": "*" - } - }, "node_modules/decimal.js": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", @@ -19812,6 +19883,15 @@ "node": ">=10" } }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -24841,7 +24921,8 @@ "node_modules/lodash.clonedeep": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", - "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" }, "node_modules/lodash.clonedeepwith": { "version": "4.5.0", @@ -28986,18 +29067,21 @@ } }, "node_modules/prisma": { - "version": "5.18.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.18.0.tgz", - "integrity": "sha512-+TrSIxZsh64OPOmaSgVPH7ALL9dfU0jceYaMJXsNrTkFHO7/3RANi5K2ZiPB1De9+KDxCWn7jvRq8y8pvk+o9g==", + "version": "5.19.1", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.19.1.tgz", + "integrity": "sha512-c5K9MiDaa+VAAyh1OiYk76PXOme9s3E992D7kvvIOhCrNsBQfy2mP2QAQtX0WNj140IgG++12kwZpYB9iIydNQ==", "hasInstallScript": true, "dependencies": { - "@prisma/engines": "5.18.0" + "@prisma/engines": "5.19.1" }, "bin": { "prisma": "build/index.js" }, "engines": { "node": ">=16.13" + }, + "optionalDependencies": { + "fsevents": "2.3.3" } }, "node_modules/prismjs": { @@ -29032,6 +29116,15 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, + "node_modules/promise-coalesce": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/promise-coalesce/-/promise-coalesce-1.1.2.tgz", + "integrity": "sha512-zLaJ9b8hnC564fnJH6NFSOGZYYdzrAJn2JUUIwzoQb32fG2QAakpDNM+CZo1km6keXkRXRM+hml1BFAPVnPkxg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16" + } + }, "node_modules/promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -29369,28 +29462,22 @@ } }, "node_modules/redis": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz", - "integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.0.tgz", + "integrity": "sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==", + "license": "MIT", + "workspaces": [ + "./packages/*" + ], "dependencies": { - "denque": "^1.5.0", - "redis-commands": "^1.7.0", - "redis-errors": "^1.2.0", - "redis-parser": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-redis" + "@redis/bloom": "1.2.0", + "@redis/client": "1.6.0", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" } }, - "node_modules/redis-commands": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz", - "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==" - }, "node_modules/redis-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", @@ -29410,14 +29497,6 @@ "node": ">=4" } }, - "node_modules/redis/node_modules/denque": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", - "integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==", - "engines": { - "node": ">=0.10" - } - }, "node_modules/reflect-metadata": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", @@ -33404,10 +33483,11 @@ } }, "node_modules/webpack-bundle-analyzer": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.1.tgz", - "integrity": "sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==", + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz", + "integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==", "dev": true, + "license": "MIT", "dependencies": { "@discoveryjs/json-ext": "0.5.7", "acorn": "^8.0.4", @@ -33417,7 +33497,6 @@ "escape-string-regexp": "^4.0.0", "gzip-size": "^6.0.0", "html-escaper": "^2.0.2", - "is-plain-object": "^5.0.0", "opener": "^1.5.2", "picocolors": "^1.0.0", "sirv": "^2.0.3", @@ -33451,15 +33530,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/webpack-bundle-analyzer/node_modules/is-plain-object": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", - "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/webpack-bundle-analyzer/node_modules/ws": { "version": "7.5.10", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", diff --git a/package.json b/package.json index eb6d20e1f..ede7992cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "2.106.0-beta.2", + "version": "2.110.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio", @@ -75,7 +75,7 @@ "@dinero.js/currencies": "2.0.0-alpha.8", "@internationalized/number": "3.5.2", "@nestjs/bull": "10.0.1", - "@nestjs/cache-manager": "2.1.0", + "@nestjs/cache-manager": "2.2.2", "@nestjs/common": "10.1.3", "@nestjs/config": "3.0.0", "@nestjs/core": "10.1.3", @@ -85,7 +85,7 @@ "@nestjs/platform-express": "10.1.3", "@nestjs/schedule": "3.0.2", "@nestjs/serve-static": "4.0.0", - "@prisma/client": "5.18.0", + "@prisma/client": "5.19.1", "@simplewebauthn/browser": "9.0.1", "@simplewebauthn/server": "9.0.3", "@stripe/stripe-js": "3.5.0", @@ -94,9 +94,9 @@ "big.js": "6.2.1", "body-parser": "1.20.2", "bootstrap": "4.6.0", - "bull": "4.10.4", - "cache-manager": "3.6.3", - "cache-manager-redis-store": "2.0.0", + "bull": "4.16.2", + "cache-manager": "5.7.6", + "cache-manager-redis-yet": "5.1.4", "chart.js": "4.2.0", "chartjs-adapter-date-fns": "3.0.0", "chartjs-chart-treemap": "2.3.1", @@ -130,7 +130,7 @@ "passport": "0.7.0", "passport-google-oauth20": "2.0.0", "passport-jwt": "4.0.1", - "prisma": "5.18.0", + "prisma": "5.19.1", "reflect-metadata": "0.1.13", "rxjs": "7.5.6", "stripe": "15.11.0", @@ -172,7 +172,7 @@ "@trivago/prettier-plugin-sort-imports": "4.3.0", "@types/big.js": "6.2.2", "@types/body-parser": "1.19.5", - "@types/cache-manager": "3.4.2", + "@types/cache-manager": "4.0.6", "@types/color": "3.0.6", "@types/google-spreadsheet": "3.1.5", "@types/jest": "29.4.4", @@ -204,7 +204,7 @@ "ts-node": "10.9.2", "tslib": "2.6.0", "typescript": "5.5.3", - "webpack-bundle-analyzer": "4.10.1" + "webpack-bundle-analyzer": "4.10.2" }, "engines": { "node": ">=20"