diff --git a/.env.example b/.env.example index f7bd74856..8df547e37 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,5 @@ POSTGRES_USER=user POSTGRES_PASSWORD= ACCESS_TOKEN_SALT= -ALPHA_VANTAGE_API_KEY= DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer JWT_SECRET_KEY= diff --git a/.github/workflows/build-code.yml b/.github/workflows/build-code.yml index 262729f29..26ac7226b 100644 --- a/.github/workflows/build-code.yml +++ b/.github/workflows/build-code.yml @@ -33,4 +33,4 @@ jobs: run: yarn test - name: Build application - run: yarn build:all + run: yarn build:production diff --git a/.prettierrc b/.prettierrc index 30f191d91..6a8ad9afa 100644 --- a/.prettierrc +++ b/.prettierrc @@ -9,6 +9,7 @@ ], "attributeSort": "ASC", "endOfLine": "auto", + "plugins": ["prettier-plugin-organize-attributes"], "printWidth": 80, "singleQuote": true, "tabWidth": 2, diff --git a/CHANGELOG.md b/CHANGELOG.md index 821fe9d05..8694d30dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,304 @@ 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.4.0 - 2023-09-19 + +### Added + +- Added support for interest on account level (experimental) + +### Changed + +- Improved the preselected currency based on the account's currency in the create or edit activity dialog +- Unlocked the experimental features setting for all users +- Upgraded `prisma` from version `5.2.0` to `5.3.1` + +### Fixed + +- Fixed a memory leak related to the server's timezone (behind UTC) in the data gathering + +## 2.3.0 - 2023-09-17 + +### Added + +- Added support for fees on account level (experimental) + +### Fixed + +- Fixed the export functionality for liabilities + +## 2.2.0 - 2023-09-17 + +### Added + +- Introduced a sidebar navigation on desktop + +### Changed + +- Improved the style of the system message +- Upgraded _Postgres_ from version `12` to `15` in the `docker-compose` files + +## 2.1.0 - 2023-09-15 + +### Added + +- Added support to drop a file in the import activities dialog +- Added a timeout to all data source requests + +### Changed + +- Harmonized the style of the user interface for granting and revoking public access to share the portfolio +- Removed the account type from the user interface as a preparation to remove it from the `Account` database schema +- Improved the logger output of the info service +- Harmonized the logger output: ` ()` +- Improved the language localization for German (`de`) +- Improved the language localization for Italian (`it`) +- Improved the language localization for Dutch (`nl`) +- Improved the read-only mode + +### Fixed + +- Fixed the timeout in _EOD Historical Data_ requests +- Fixed an issue with the portfolio summary caused by the language localization for Dutch (`nl`) + +## 2.0.0 - 2023-09-09 + +### Added + +- Added support for the cryptocurrency _CyberConnect_ +- Added a blog post: _Announcing Ghostfolio 2.0_ + +### Changed + +- **Breaking Change**: Removed the deprecated environment variable `BASE_CURRENCY` +- Improved the validation in the activities import +- Deactivated _Internet Identity_ as a social login provider for the account registration +- Improved the language localization for German (`de`) +- Refreshed the cryptocurrencies list +- Changed the version in the `docker-compose` files from `3.7` to `3.9` +- Upgraded `yahoo-finance2` from version `2.4.4` to `2.5.0` + +### Fixed + +- Fixed an issue in the _Yahoo Finance_ data enhancer where countries and sectors have been removed + +## 1.305.0 - 2023-09-03 + +### Added + +- Added _Hacker News_ to the _As seen in_ section on the landing page + +### Changed + +- Shortened the page titles +- Improved the language localization for German (`de`) +- Upgraded `prisma` from version `4.16.2` to `5.2.0` +- Upgraded `replace-in-file` from version `6.3.5` to `7.0.1` +- Upgraded `yahoo-finance2` from version `2.4.3` to `2.4.4` + +### Fixed + +- Fixed the alignment in the header navigation +- Fixed the alignment in the menu of the impersonation mode + +## 1.304.0 - 2023-08-27 + +### Added + +- Added health check endpoints for data enhancers + +### Changed + +- Upgraded `Nx` from version `16.7.2` to `16.7.4` +- Upgraded `prettier` from version `2.8.4` to `3.0.2` + +## 1.303.0 - 2023-08-23 + +### Added + +- Added a blog post: _Ghostfolio joins OSS Friends_ + +### Changed + +- Refreshed the cryptocurrencies list +- Improved the _OSS Friends_ page + +### Fixed + +- Fixed an issue with the _Trackinsight_ data enhancer for asset profile data + +## 1.302.0 - 2023-08-20 + +### Changed + +- Improved the language localization for German (`de`) +- Upgraded `angular` from version `16.1.8` to `16.2.1` +- Upgraded `Nx` from version `16.6.0` to `16.7.2` + +## 1.301.1 - 2023-08-19 + +### Added + +- Added the data export feature to the user account page +- Added a currencies preset to the historical market data table of the admin control panel +- Added the _OSS Friends_ page + +### Changed + +- Improved the localized meta data in `html` files + +### Fixed + +- Fixed the rows with cash positions in the holdings table +- Fixed an issue with the date parsing in the historical market data editor of the admin control panel + +## 1.300.0 - 2023-08-11 + +### Added + +- Added more durations in the coupon system + +### Changed + +- Migrated the remaining requests from `bent` to `got` + +## 1.299.1 - 2023-08-10 + +### Changed + +- Optimized the activities import by allowing a different currency than the asset's official one +- Added a timeout to the _EOD Historical Data_ requests +- Migrated the requests from `bent` to `got` in the _EOD Historical Data_ service + +### Fixed + +- Fixed the editing of the emergency fund +- Fixed the historical data gathering interval for asset profiles used as benchmarks having activities + +## 1.298.0 - 2023-08-06 + +### Changed + +- Improved the language localization for German (`de`) +- Upgraded `ng-extract-i18n-merge` from version `2.6.0` to `2.7.0` +- Upgraded `Nx` from version `16.5.5` to `16.6.0` + +### Fixed + +- Fixed the styles of various components (card, progress, tab) after the upgrade to `@angular/material` `16` + +## 1.297.4 - 2023-08-05 + +### Added + +- Added the footer to the public page +- Added a `copy-assets` `Nx` target to the client build + +### Changed + +- Improved the alignment of the region percentages on the allocations page +- Improved the alignment of the region percentages on the public page +- Improved the redirection of the home page to the localized home page +- Improved the language localization for German (`de`) +- Upgraded `angular` from version `15.2.5` to `16.1.8` +- Upgraded `nestjs` from version `9.1.4` to `10.1.3` +- Upgraded `Nx` from version `16.0.3` to `16.5.5` + +## 1.296.0 - 2023-08-01 + +### Changed + +- Optimized the validation in the activities import by reducing the list to unique asset profiles +- Optimized the data gathering in the activities import + +## 1.295.0 - 2023-07-30 + +### Added + +- Added a step by step introduction for new users + +### Fixed + +- Removed the _Stay signed in_ setting on _Sign in with fingerprint_ activation + +## 1.294.0 - 2023-07-29 + +### Changed + +- Extended the allocations by market chart on the allocations page by unavailable data + +### Fixed + +- Considered liabilities in the total account value calculation + +## 1.293.0 - 2023-07-26 + +### Added + +- Added error handling for the _Redis_ connections to keep the app running if the connection fails + +### Changed + +- Set the `lastmod` dates of `sitemap.xml` dynamically + +### Fixed + +- Fixed the missing values in the holdings table +- Fixed the `no such file or directory` error caused by the missing `favicon.ico` file + +## 1.292.0 - 2023-07-24 + +### Added + +- Introduced the allocations by market chart on the allocations page + +### Changed + +- Upgraded `yahoo-finance2` from version `2.4.2` to `2.4.3` + +### Fixed + +- Fixed an issue in the public page + +## 1.291.0 - 2023-07-23 + +### Added + +- Broken down the emergency fund by cash and assets +- Added support for account balance time series + +### Changed + +- Renamed queries to presets in the historical market data table of the admin control panel + +## 1.290.0 - 2023-07-16 + +### Added + +- Added hints to the activity types in the create or edit activity dialog +- Added queries to the historical market data table of the admin control panel + +### Changed + +- Improved the usability of the login dialog +- Disabled the caching in the health check endpoints for data providers +- Improved the content of the Frequently Asked Questions (FAQ) page +- Upgraded `prisma` from version `4.15.0` to `4.16.2` + +## 1.289.0 - 2023-07-14 + +### Changed + +- Upgraded `yahoo-finance2` from version `2.4.1` to `2.4.2` + +## 1.288.0 - 2023-07-12 + +### Changed + +- Improved the loading state during filtering on the allocations page +- Beautified the names with ampersand (`&`) in the asset profile +- Improved the language localization for German (`de`) + ## 1.287.0 - 2023-07-09 ### Changed @@ -464,7 +762,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Changed the slide toggles to checkboxes on the account page +- Changed the slide toggles to checkboxes on the user account page - Changed the slide toggles to checkboxes in the admin control panel - Increased the density of the theme - Migrated the style of various components to `@angular/material` `15` (mdc) @@ -1026,7 +1324,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Improved the language selector on the account page +- Improved the language selector on the user account page - Improved the wording in the _X-ray_ section (net worth instead of investment) - Extended the asset profile details dialog in the admin control panel - Updated the browserslist database @@ -1271,7 +1569,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Set up the language localization for Italiano (`it`) +- Set up the language localization for Italian (`it`) - Extended the landing page ## 1.195.0 - 20.09.2022 @@ -1444,7 +1742,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added a language selector to the account page +- Added a language selector to the user account page - Added support for translated labels in the value component ### Changed @@ -1773,7 +2071,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added the user id to the account page +- Added the user id to the user account page - Added a new view with jobs of the queue to the admin control panel ### Changed @@ -2694,7 +2992,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Supported the management of additional currencies in the admin control panel - Introduced the system message -- Introduced the read only mode +- Introduced the read-only mode ### Changed @@ -3428,7 +3726,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Respected the cash balance on the analysis page -- Improved the settings selectors on the account page +- Improved the settings selectors on the user account page - Harmonized the slogan to "Open Source Wealth Management Software" ### Fixed @@ -3894,7 +4192,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added a gradient to the line charts -- Added a selector to set the base currency on the account page +- Added a selector to set the base currency on the user account page ## 0.81.0 - 06.04.2021 @@ -4208,7 +4506,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Added the membership status to the account page +- Added the membership status to the user account page ### Fixed diff --git a/Dockerfile b/Dockerfile index cdebe275d..91b2cd3e6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,7 +33,7 @@ COPY ./tsconfig.base.json tsconfig.base.json COPY ./libs libs COPY ./apps apps -RUN yarn build:all +RUN yarn build:production # Prepare the dist image with additional node_modules WORKDIR /ghostfolio/dist/apps/api @@ -58,4 +58,4 @@ RUN apt update && apt install -y \ COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps WORKDIR /ghostfolio/apps/api EXPOSE ${PORT:-3333} -CMD [ "yarn", "start:prod" ] +CMD [ "yarn", "start:production" ] diff --git a/README.md b/README.md index 3872caac6..e514bf2b4 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ [![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) +New: [Ghostfolio 2.0](https://ghostfol.io/en/blog/2023/09/ghostfolio-2) + **Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation. @@ -136,9 +138,9 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d 1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d` At each start, the container will automatically apply the database schema migrations if needed. -### Run with _Unraid_ (Community) +### Home Server Systems (Community) -Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio). +Ghostfolio is available for various home server systems, including [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio). ## Development @@ -153,7 +155,6 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https:// ### Setup 1. Run `yarn install` -1. Run `yarn build:dev` to build the source code including the assets 1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io) 1. Run `yarn database:setup` to initialize the database schema 1. Start the server and the client (see [_Development_](#Development)) @@ -263,7 +264,9 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/ { - return this.prismaService.account.findUnique({ - where: accountWhereUniqueInput + public async account({ + id_userId + }: Prisma.AccountWhereUniqueInput): Promise { + const { id, userId } = id_userId; + + const [account] = await this.accounts({ + where: { id, userId } }); + + return account; } public async accountWithOrders( @@ -50,9 +56,11 @@ export class AccountService { Platform?: Platform; })[] > { - const { include, skip, take, cursor, where, orderBy } = params; + const { include = {}, skip, take, cursor, where, orderBy } = params; - return this.prismaService.account.findMany({ + include.balances = { orderBy: { date: 'desc' }, take: 1 }; + + const accounts = await this.prismaService.account.findMany({ cursor, include, orderBy, @@ -60,15 +68,36 @@ export class AccountService { take, where }); + + return accounts.map((account) => { + account = { ...account, balance: account.balances[0]?.value ?? 0 }; + + delete account.balances; + + return account; + }); } public async createAccount( data: Prisma.AccountCreateInput, aUserId: string ): Promise { - return this.prismaService.account.create({ + const account = await this.prismaService.account.create({ data }); + + await this.prismaService.accountBalance.create({ + data: { + Account: { + connect: { + id_userId: { id: account.id, userId: aUserId } + } + }, + value: data.balance + } + }); + + return account; } public async deleteAccount( @@ -167,6 +196,18 @@ export class AccountService { aUserId: string ): Promise { const { data, where } = params; + + await this.prismaService.accountBalance.create({ + data: { + Account: { + connect: { + id_userId: where.id_userId + } + }, + value: data.balance + } + }); + return this.prismaService.account.update({ data, where @@ -202,16 +243,17 @@ export class AccountService { ); if (amountInCurrencyOfAccount) { - await this.prismaService.account.update({ - data: { - balance: new Big(balance).plus(amountInCurrencyOfAccount).toNumber() - }, - where: { - id_userId: { - userId, - id: accountId + await this.accountBalanceService.createAccountBalance({ + date, + Account: { + connect: { + id_userId: { + userId, + id: accountId + } } - } + }, + value: new Big(balance).plus(amountInCurrencyOfAccount).toNumber() }); } } diff --git a/apps/api/src/app/account/create-account.dto.ts b/apps/api/src/app/account/create-account.dto.ts index c700e7fa9..eb24d959a 100644 --- a/apps/api/src/app/account/create-account.dto.ts +++ b/apps/api/src/app/account/create-account.dto.ts @@ -10,6 +10,7 @@ import { import { isString } from 'lodash'; export class CreateAccountDto { + @IsOptional() @IsString() accountType: AccountType; diff --git a/apps/api/src/app/account/update-account.dto.ts b/apps/api/src/app/account/update-account.dto.ts index d8c62aff7..a91914482 100644 --- a/apps/api/src/app/account/update-account.dto.ts +++ b/apps/api/src/app/account/update-account.dto.ts @@ -10,6 +10,7 @@ import { import { isString } from 'lodash'; export class UpdateAccountDto { + @IsOptional() @IsString() accountType: AccountType; diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 75b2b2bb9..67e106ff8 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -7,6 +7,7 @@ import { GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS_OPTIONS } from '@ghostfolio/common/config'; +import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { AdminData, AdminMarketData, @@ -15,7 +16,10 @@ import { Filter } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; -import type { RequestWithUser } from '@ghostfolio/common/types'; +import type { + MarketDataPreset, + RequestWithUser +} from '@ghostfolio/common/types'; import { Body, Controller, @@ -34,7 +38,7 @@ import { import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client'; -import { isDate } from 'date-fns'; +import { isDate, parseISO } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { AdminService } from './admin.service'; @@ -113,7 +117,7 @@ export class AdminController { name: GATHER_ASSET_PROFILE_PROCESS, opts: { ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, - jobId: `${dataSource}-${symbol}` + jobId: getAssetProfileIdentifier({ dataSource, symbol }) } }; }) @@ -149,7 +153,7 @@ export class AdminController { name: GATHER_ASSET_PROFILE_PROCESS, opts: { ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, - jobId: `${dataSource}-${symbol}` + jobId: getAssetProfileIdentifier({ dataSource, symbol }) } }; }) @@ -182,7 +186,7 @@ export class AdminController { name: GATHER_ASSET_PROFILE_PROCESS, opts: { ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, - jobId: `${dataSource}-${symbol}` + jobId: getAssetProfileIdentifier({ dataSource, symbol }) } }); } @@ -229,7 +233,7 @@ export class AdminController { ); } - const date = new Date(dateString); + const date = parseISO(dateString); if (!isDate(date)) { throw new HttpException( @@ -249,6 +253,7 @@ export class AdminController { @UseGuards(AuthGuard('jwt')) public async getMarketData( @Query('assetSubClasses') filterByAssetSubClasses?: string, + @Query('presetId') presetId?: MarketDataPreset, @Query('skip') skip?: number, @Query('sortColumn') sortColumn?: string, @Query('sortDirection') sortDirection?: Prisma.SortOrder, @@ -279,6 +284,7 @@ export class AdminController { return this.adminService.getMarketData({ filters, + presetId, sortColumn, sortDirection, skip: isNaN(skip) ? undefined : skip, @@ -327,7 +333,7 @@ export class AdminController { ); } - const date = new Date(dateString); + const date = parseISO(dateString); return this.marketDataService.updateMarketData({ data: { marketPrice: data.marketPrice, state: 'CLOSE' }, diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index 4dce77982..a45fbe634 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -7,16 +7,20 @@ 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 { - DEFAULT_PAGE_SIZE, - PROPERTY_CURRENCIES + DEFAULT_CURRENCY, + PROPERTY_CURRENCIES, + PROPERTY_IS_READ_ONLY_MODE, + PROPERTY_IS_USER_SIGNUP_ENABLED } from '@ghostfolio/common/config'; import { AdminData, AdminMarketData, AdminMarketDataDetails, + AdminMarketDataItem, Filter, UniqueAsset } from '@ghostfolio/common/interfaces'; +import { MarketDataPreset } from '@ghostfolio/common/types'; import { BadRequestException, Injectable } from '@nestjs/common'; import { AssetSubClass, Prisma, Property, SymbolProfile } from '@prisma/client'; import { differenceInDays } from 'date-fns'; @@ -24,8 +28,6 @@ import { groupBy } from 'lodash'; @Injectable() export class AdminService { - private baseCurrency: string; - public constructor( private readonly configurationService: ConfigurationService, private readonly dataProviderService: DataProviderService, @@ -35,9 +37,7 @@ export class AdminService { private readonly propertyService: PropertyService, private readonly subscriptionService: SubscriptionService, private readonly symbolProfileService: SymbolProfileService - ) { - this.baseCurrency = this.configurationService.get('BASE_CURRENCY'); - } + ) {} public async addAssetProfile({ dataSource, @@ -81,15 +81,15 @@ export class AdminService { exchangeRates: this.exchangeRateDataService .getCurrencies() .filter((currency) => { - return currency !== this.baseCurrency; + return currency !== DEFAULT_CURRENCY; }) .map((currency) => { return { - label1: this.baseCurrency, + label1: DEFAULT_CURRENCY, label2: currency, value: this.exchangeRateDataService.toCurrency( 1, - this.baseCurrency, + DEFAULT_CURRENCY, currency ) }; @@ -103,12 +103,14 @@ export class AdminService { public async getMarketData({ filters, + presetId, sortColumn, sortDirection, skip, - take = DEFAULT_PAGE_SIZE + take = Number.MAX_SAFE_INTEGER }: { filters?: Filter[]; + presetId?: MarketDataPreset; skip?: number; sortColumn?: string; sortDirection?: Prisma.SortOrder; @@ -118,6 +120,15 @@ export class AdminService { [{ symbol: 'asc' }]; const where: Prisma.SymbolProfileWhereInput = {}; + if (presetId === 'CURRENCIES') { + return this.getMarketDataForCurrencies(); + } else if ( + presetId === 'ETF_WITHOUT_COUNTRIES' || + presetId === 'ETF_WITHOUT_SECTORS' + ) { + filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }]; + } + const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy( filters, (filter) => { @@ -146,7 +157,7 @@ export class AdminService { } } - const [assetProfiles, count] = await Promise.all([ + let [assetProfiles, count] = await Promise.all([ this.prismaService.symbolProfile.findMany({ orderBy, skip, @@ -174,44 +185,60 @@ export class AdminService { this.prismaService.symbolProfile.count({ where }) ]); - return { - count, - marketData: assetProfiles.map( - ({ - _count, + let marketData = assetProfiles.map( + ({ + _count, + assetClass, + assetSubClass, + comment, + countries, + dataSource, + Order, + sectors, + symbol + }) => { + const countriesCount = countries ? Object.keys(countries).length : 0; + const marketDataItemCount = + marketDataItems.find((marketDataItem) => { + return ( + marketDataItem.dataSource === dataSource && + marketDataItem.symbol === symbol + ); + })?._count ?? 0; + const sectorsCount = sectors ? Object.keys(sectors).length : 0; + + return { assetClass, assetSubClass, comment, - countries, + countriesCount, dataSource, - Order, - sectors, - symbol - }) => { - const countriesCount = countries ? Object.keys(countries).length : 0; - const marketDataItemCount = - marketDataItems.find((marketDataItem) => { - return ( - marketDataItem.dataSource === dataSource && - marketDataItem.symbol === symbol - ); - })?._count ?? 0; - const sectorsCount = sectors ? Object.keys(sectors).length : 0; + symbol, + marketDataItemCount, + sectorsCount, + activitiesCount: _count.Order, + date: Order?.[0]?.date + }; + } + ); - return { - assetClass, - assetSubClass, - comment, - countriesCount, - dataSource, - symbol, - marketDataItemCount, - sectorsCount, - activitiesCount: _count.Order, - date: Order?.[0]?.date - }; - } - ) + if (presetId) { + if (presetId === 'ETF_WITHOUT_COUNTRIES') { + marketData = marketData.filter(({ countriesCount }) => { + return countriesCount === 0; + }); + } else if (presetId === 'ETF_WITHOUT_SECTORS') { + marketData = marketData.filter(({ sectorsCount }) => { + return sectorsCount === 0; + }); + } + + count = marketData.length; + } + + return { + count, + marketData }; } @@ -280,13 +307,45 @@ export class AdminService { response = await this.propertyService.delete({ key }); } - if (key === PROPERTY_CURRENCIES) { + if (key === PROPERTY_IS_READ_ONLY_MODE && value === 'true') { + await this.putSetting(PROPERTY_IS_USER_SIGNUP_ENABLED, 'false'); + } else if (key === PROPERTY_CURRENCIES) { await this.exchangeRateDataService.initialize(); } return response; } + private async getMarketDataForCurrencies(): Promise { + const marketDataItems = await this.prismaService.marketData.groupBy({ + _count: true, + by: ['dataSource', 'symbol'] + }); + + const marketData: AdminMarketDataItem[] = this.exchangeRateDataService + .getCurrencyPairs() + .map(({ dataSource, symbol }) => { + const marketDataItemCount = + marketDataItems.find((marketDataItem) => { + return ( + marketDataItem.dataSource === dataSource && + marketDataItem.symbol === symbol + ); + })?._count ?? 0; + + return { + dataSource, + marketDataItemCount, + symbol, + assetClass: 'CASH', + countriesCount: 0, + sectorsCount: 0 + }; + }); + + return { marketData, count: marketData.length }; + } + private async getUsersWithAnalytics(): Promise { let orderBy: any = { createdAt: 'desc' diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 625ee9ded..a521e7fa9 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -7,11 +7,16 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data- import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module'; +import { + DEFAULT_LANGUAGE_CODE, + SUPPORTED_LANGUAGE_CODES +} from '@ghostfolio/common/config'; import { BullModule } from '@nestjs/bull'; -import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { ScheduleModule } from '@nestjs/schedule'; import { ServeStaticModule } from '@nestjs/serve-static'; +import { StatusCodes } from 'http-status-codes'; import { AccessModule } from './access/access.module'; import { AccountModule } from './account/account.module'; @@ -23,7 +28,6 @@ import { BenchmarkModule } from './benchmark/benchmark.module'; import { CacheModule } from './cache/cache.module'; import { ExchangeRateModule } from './exchange-rate/exchange-rate.module'; import { ExportModule } from './export/export.module'; -import { FrontendMiddleware } from './frontend.middleware'; import { HealthModule } from './health/health.module'; import { ImportModule } from './import/import.module'; import { InfoModule } from './info/info.module'; @@ -32,6 +36,7 @@ import { OrderModule } from './order/order.module'; import { PlatformModule } from './platform/platform.module'; import { PortfolioModule } from './portfolio/portfolio.module'; import { RedisCacheModule } from './redis-cache/redis-cache.module'; +import { SitemapModule } from './sitemap/sitemap.module'; import { SubscriptionModule } from './subscription/subscription.module'; import { SymbolModule } from './symbol/symbol.module'; import { UserModule } from './user/user.module'; @@ -70,19 +75,30 @@ import { UserModule } from './user/user.module'; RedisCacheModule, ScheduleModule.forRoot(), ServeStaticModule.forRoot({ + exclude: ['/api*', '/sitemap.xml'], + rootPath: join(__dirname, '..', 'client'), serveStaticOptions: { - /*etag: false // Disable etag header to fix PWA - setHeaders: (res, path) => { - if (path.includes('ngsw.json')) { - // Disable cache (https://stackoverflow.com/questions/22632593/how-to-disable-webpage-caching-in-expressjs-nodejs/39775595) - // https://gertjans.home.xs4all.nl/javascript/cache-control.html#no-cache - res.set('Cache-Control', 'no-cache, no-store, must-revalidate'); + setHeaders: (res) => { + if (res.req?.path === '/') { + let languageCode = DEFAULT_LANGUAGE_CODE; + + try { + const code = res.req.headers['accept-language'] + .split(',')[0] + .split('-')[0]; + + if (SUPPORTED_LANGUAGE_CODES.includes(code)) { + languageCode = code; + } + } catch {} + + res.set('Location', `/${languageCode}`); + res.statusCode = StatusCodes.MOVED_PERMANENTLY; } - }*/ - }, - rootPath: join(__dirname, '..', 'client'), - exclude: ['/api*'] + } + } }), + SitemapModule, SubscriptionModule, SymbolModule, TwitterBotModule, @@ -91,10 +107,4 @@ import { UserModule } from './user/user.module'; controllers: [AppController], providers: [CronService] }) -export class AppModule { - configure(consumer: MiddlewareConsumer) { - consumer - .apply(FrontendMiddleware) - .forRoutes({ path: '*', method: RequestMethod.ALL }); - } -} +export class AppModule {} diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index 0c6b047bf..376109b8d 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -41,9 +41,8 @@ export class AuthController { @Param('accessToken') accessToken: string ): Promise { try { - const authToken = await this.authService.validateAnonymousLogin( - accessToken - ); + const authToken = + await this.authService.validateAnonymousLogin(accessToken); return { authToken }; } catch { throw new HttpException( diff --git a/apps/api/src/app/auth/auth.service.ts b/apps/api/src/app/auth/auth.service.ts index 2f31e722b..c7270f8c3 100644 --- a/apps/api/src/app/auth/auth.service.ts +++ b/apps/api/src/app/auth/auth.service.ts @@ -55,7 +55,7 @@ export class AuthService { const isUserSignupEnabled = await this.propertyService.isUserSignupEnabled(); - if (!isUserSignupEnabled) { + if (!isUserSignupEnabled || true) { throw new Error('Sign up forbidden'); } diff --git a/apps/api/src/app/benchmark/benchmark.service.ts b/apps/api/src/app/benchmark/benchmark.service.ts index 73b48068b..785c2801a 100644 --- a/apps/api/src/app/benchmark/benchmark.service.ts +++ b/apps/api/src/app/benchmark/benchmark.service.ts @@ -66,11 +66,11 @@ export class BenchmarkService { const promises: Promise[] = []; - const quotes = await this.dataProviderService.getQuotes( - benchmarkAssetProfiles.map(({ dataSource, symbol }) => { + const quotes = await this.dataProviderService.getQuotes({ + items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => { return { dataSource, symbol }; }) - ); + }); for (const { dataSource, symbol } of benchmarkAssetProfiles) { promises.push(this.marketDataService.getMax({ dataSource, symbol })); diff --git a/apps/api/src/app/exchange-rate/exchange-rate.controller.ts b/apps/api/src/app/exchange-rate/exchange-rate.controller.ts index ca9b67ced..8e01c4ca9 100644 --- a/apps/api/src/app/exchange-rate/exchange-rate.controller.ts +++ b/apps/api/src/app/exchange-rate/exchange-rate.controller.ts @@ -7,6 +7,7 @@ import { UseGuards } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; +import { parseISO } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { ExchangeRateService } from './exchange-rate.service'; @@ -23,7 +24,7 @@ export class ExchangeRateController { @Param('dateString') dateString: string, @Param('symbol') symbol: string ): Promise { - const date = new Date(dateString); + const date = parseISO(dateString); const exchangeRate = await this.exchangeRateService.getExchangeRate({ date, diff --git a/apps/api/src/app/export/export.module.ts b/apps/api/src/app/export/export.module.ts index 186e8dc59..ca4588925 100644 --- a/apps/api/src/app/export/export.module.ts +++ b/apps/api/src/app/export/export.module.ts @@ -1,8 +1,9 @@ +import { AccountModule } from '@ghostfolio/api/app/account/account.module'; +import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.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 { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { Module } from '@nestjs/common'; import { ExportController } from './export.controller'; @@ -10,10 +11,11 @@ import { ExportService } from './export.service'; @Module({ imports: [ + AccountModule, ConfigurationModule, DataGatheringModule, DataProviderModule, - PrismaModule, + OrderModule, RedisCacheModule ], controllers: [ExportController], diff --git a/apps/api/src/app/export/export.service.ts b/apps/api/src/app/export/export.service.ts index eaeea0f07..2134a6520 100644 --- a/apps/api/src/app/export/export.service.ts +++ b/apps/api/src/app/export/export.service.ts @@ -1,11 +1,15 @@ +import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { environment } from '@ghostfolio/api/environments/environment'; -import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { Export } from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; @Injectable() export class ExportService { - public constructor(private readonly prismaService: PrismaService) {} + public constructor( + private readonly accountService: AccountService, + private readonly orderService: OrderService + ) {} public async export({ activityIds, @@ -14,36 +18,30 @@ export class ExportService { activityIds?: string[]; userId: string; }): Promise { - const accounts = await this.prismaService.account.findMany({ - orderBy: { - name: 'asc' - }, - select: { - accountType: true, - balance: true, - comment: true, - currency: true, - id: true, - isExcluded: true, - name: true, - platformId: true - }, - where: { userId } - }); + const accounts = ( + await this.accountService.accounts({ + orderBy: { + name: 'asc' + }, + where: { userId } + }) + ).map( + ({ balance, comment, currency, id, isExcluded, name, platformId }) => { + return { + balance, + comment, + currency, + id, + isExcluded, + name, + platformId + }; + } + ); - let activities = await this.prismaService.order.findMany({ + let activities = await this.orderService.orders({ + include: { SymbolProfile: true }, orderBy: { date: 'desc' }, - select: { - accountId: true, - comment: true, - date: true, - fee: true, - id: true, - quantity: true, - SymbolProfile: true, - type: true, - unitPrice: true - }, where: { userId } }); @@ -79,7 +77,13 @@ export class ExportService { currency: SymbolProfile.currency, dataSource: SymbolProfile.dataSource, date: date.toISOString(), - symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol + symbol: + type === 'FEE' || + type === 'INTEREST' || + type === 'ITEM' || + type === 'LIABILITY' + ? SymbolProfile.name + : SymbolProfile.symbol }; } ) diff --git a/apps/api/src/app/frontend.middleware.ts b/apps/api/src/app/frontend.middleware.ts deleted file mode 100644 index baf1953b0..000000000 --- a/apps/api/src/app/frontend.middleware.ts +++ /dev/null @@ -1,237 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -import { environment } from '@ghostfolio/api/environments/environment'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; -import { DATE_FORMAT } from '@ghostfolio/common/helper'; -import { Injectable, NestMiddleware } from '@nestjs/common'; -import { format } from 'date-fns'; -import { NextFunction, Request, Response } from 'express'; - -@Injectable() -export class FrontendMiddleware implements NestMiddleware { - public indexHtmlDe = ''; - public indexHtmlEn = ''; - public indexHtmlEs = ''; - public indexHtmlFr = ''; - public indexHtmlIt = ''; - public indexHtmlNl = ''; - public indexHtmlPt = ''; - - private static readonly DEFAULT_DESCRIPTION = - 'Ghostfolio is a personal finance dashboard to keep track of your assets like stocks, ETFs or cryptocurrencies across multiple platforms.'; - - public constructor( - private readonly configurationService: ConfigurationService - ) { - try { - this.indexHtmlDe = fs.readFileSync( - this.getPathOfIndexHtmlFile('de'), - 'utf8' - ); - this.indexHtmlEn = fs.readFileSync( - this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE), - 'utf8' - ); - this.indexHtmlEs = fs.readFileSync( - this.getPathOfIndexHtmlFile('es'), - 'utf8' - ); - this.indexHtmlFr = fs.readFileSync( - this.getPathOfIndexHtmlFile('fr'), - 'utf8' - ); - this.indexHtmlIt = fs.readFileSync( - this.getPathOfIndexHtmlFile('it'), - 'utf8' - ); - this.indexHtmlNl = fs.readFileSync( - this.getPathOfIndexHtmlFile('nl'), - 'utf8' - ); - this.indexHtmlPt = fs.readFileSync( - this.getPathOfIndexHtmlFile('pt'), - 'utf8' - ); - } catch {} - } - - public use(request: Request, response: Response, next: NextFunction) { - const currentDate = format(new Date(), DATE_FORMAT); - let featureGraphicPath = 'assets/cover.png'; - let title = 'Ghostfolio – Open Source Wealth Management Software'; - - if (request.path.startsWith('/en/blog/2022/08/500-stars-on-github')) { - featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg'; - title = `500 Stars - ${title}`; - } else if (request.path.startsWith('/en/blog/2022/10/hacktoberfest-2022')) { - featureGraphicPath = 'assets/images/blog/hacktoberfest-2022.png'; - title = `Hacktoberfest 2022 - ${title}`; - } else if (request.path.startsWith('/en/blog/2022/11/black-friday-2022')) { - featureGraphicPath = 'assets/images/blog/black-friday-2022.jpg'; - title = `Black Friday 2022 - ${title}`; - } else if ( - request.path.startsWith( - '/en/blog/2022/12/the-importance-of-tracking-your-personal-finances' - ) - ) { - featureGraphicPath = 'assets/images/blog/20221226.jpg'; - title = `The importance of tracking your personal finances - ${title}`; - } else if ( - request.path.startsWith( - '/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt' - ) - ) { - featureGraphicPath = 'assets/images/blog/ghostfolio-x-sackgeld.png'; - title = `Ghostfolio auf Sackgeld.com vorgestellt - ${title}`; - } else if ( - request.path.startsWith('/en/blog/2023/02/ghostfolio-meets-umbrel') - ) { - featureGraphicPath = 'assets/images/blog/ghostfolio-x-umbrel.png'; - title = `Ghostfolio meets Umbrel - ${title}`; - } else if ( - request.path.startsWith( - '/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github' - ) - ) { - featureGraphicPath = 'assets/images/blog/1000-stars-on-github.jpg'; - title = `Ghostfolio reaches 1’000 Stars on GitHub - ${title}`; - } else if ( - request.path.startsWith( - '/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio' - ) - ) { - featureGraphicPath = 'assets/images/blog/20230520.jpg'; - title = `Unlock your Financial Potential with Ghostfolio - ${title}`; - } else if ( - request.path.startsWith('/en/blog/2023/07/exploring-the-path-to-fire') - ) { - featureGraphicPath = 'assets/images/blog/20230701.jpg'; - title = `Exploring the Path to FIRE - ${title}`; - } - - if ( - request.path.startsWith('/api/') || - this.isFileRequest(request.url) || - !environment.production - ) { - // Skip - next(); - } else if (request.path === '/de' || request.path.startsWith('/de/')) { - response.send( - this.interpolate(this.indexHtmlDe, { - currentDate, - featureGraphicPath, - title, - description: - 'Mit dem Finanz-Dashboard Ghostfolio können Sie Ihr Vermögen in Form von Aktien, ETFs oder Kryptowährungen verteilt über mehrere Finanzinstitute überwachen.', - languageCode: 'de', - path: request.path, - rootUrl: this.configurationService.get('ROOT_URL') - }) - ); - } else if (request.path === '/es' || request.path.startsWith('/es/')) { - response.send( - this.interpolate(this.indexHtmlEs, { - currentDate, - featureGraphicPath, - title, - description: - 'Ghostfolio es un dashboard de finanzas personales para hacer un seguimiento de tus activos como acciones, ETFs o criptodivisas a través de múltiples plataformas.', - languageCode: 'es', - path: request.path, - rootUrl: this.configurationService.get('ROOT_URL') - }) - ); - } else if (request.path === '/fr' || request.path.startsWith('/fr/')) { - response.send( - this.interpolate(this.indexHtmlFr, { - currentDate, - featureGraphicPath, - title, - description: - 'Ghostfolio est un dashboard de finances personnelles qui permet de suivre vos actifs comme les actions, les ETF ou les crypto-monnaies sur plusieurs plateformes.', - languageCode: 'fr', - path: request.path, - rootUrl: this.configurationService.get('ROOT_URL') - }) - ); - } else if (request.path === '/it' || request.path.startsWith('/it/')) { - response.send( - this.interpolate(this.indexHtmlIt, { - currentDate, - featureGraphicPath, - title, - description: - 'Ghostfolio è un dashboard di finanza personale per tenere traccia delle vostre attività come azioni, ETF o criptovalute su più piattaforme.', - languageCode: 'it', - path: request.path, - rootUrl: this.configurationService.get('ROOT_URL') - }) - ); - } else if (request.path === '/nl' || request.path.startsWith('/nl/')) { - response.send( - this.interpolate(this.indexHtmlNl, { - currentDate, - featureGraphicPath, - title, - description: - 'Ghostfolio is een persoonlijk financieel dashboard om uw activa zoals aandelen, ETF’s of cryptocurrencies over meerdere platforms bij te houden.', - languageCode: 'nl', - path: request.path, - rootUrl: this.configurationService.get('ROOT_URL') - }) - ); - } else if (request.path === '/pt' || request.path.startsWith('/pt/')) { - response.send( - this.interpolate(this.indexHtmlPt, { - currentDate, - featureGraphicPath, - title, - description: - 'Ghostfolio é um dashboard de finanças pessoais para acompanhar os seus activos como acções, ETFs ou criptomoedas em múltiplas plataformas.', - languageCode: 'pt', - path: request.path, - rootUrl: this.configurationService.get('ROOT_URL') - }) - ); - } else { - response.send( - this.interpolate(this.indexHtmlEn, { - currentDate, - featureGraphicPath, - title, - description: FrontendMiddleware.DEFAULT_DESCRIPTION, - languageCode: DEFAULT_LANGUAGE_CODE, - path: request.path, - rootUrl: this.configurationService.get('ROOT_URL') - }) - ); - } - } - - private getPathOfIndexHtmlFile(aLocale: string) { - return path.join(__dirname, '..', 'client', aLocale, 'index.html'); - } - - private interpolate(template: string, context: any) { - return template.replace(/[$]{([^}]+)}/g, (_, objectPath) => { - const properties = objectPath.split('.'); - return properties.reduce( - (previous, current) => previous?.[current], - context - ); - }); - } - - private isFileRequest(filename: string) { - if (filename === '/assets/LICENSE') { - return true; - } else if (filename.includes('auth/ey')) { - return false; - } - - return filename.split('.').pop() !== filename; - } -} diff --git a/apps/api/src/app/health/health.controller.ts b/apps/api/src/app/health/health.controller.ts index d9af97981..cc430c0dc 100644 --- a/apps/api/src/app/health/health.controller.ts +++ b/apps/api/src/app/health/health.controller.ts @@ -18,6 +18,19 @@ export class HealthController { @Get() public async getHealth() {} + @Get('data-enhancer/:name') + public async getHealthOfDataEnhancer(@Param('name') name: string) { + const hasResponse = + await this.healthService.hasResponseFromDataEnhancer(name); + + if (hasResponse !== true) { + throw new HttpException( + getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE), + StatusCodes.SERVICE_UNAVAILABLE + ); + } + } + @Get('data-provider/:dataSource') @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getHealthOfDataProvider( @@ -30,9 +43,8 @@ export class HealthController { ); } - const hasResponse = await this.healthService.hasResponseFromDataProvider( - dataSource - ); + const hasResponse = + await this.healthService.hasResponseFromDataProvider(dataSource); if (hasResponse !== true) { throw new HttpException( diff --git a/apps/api/src/app/health/health.module.ts b/apps/api/src/app/health/health.module.ts index 1c5292027..b6952c3b5 100644 --- a/apps/api/src/app/health/health.module.ts +++ b/apps/api/src/app/health/health.module.ts @@ -1,4 +1,5 @@ import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +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 { Module } from '@nestjs/common'; @@ -7,7 +8,7 @@ import { HealthService } from './health.service'; @Module({ controllers: [HealthController], - imports: [ConfigurationModule, DataProviderModule], + imports: [ConfigurationModule, DataEnhancerModule, DataProviderModule], providers: [HealthService] }) export class HealthModule {} diff --git a/apps/api/src/app/health/health.service.ts b/apps/api/src/app/health/health.service.ts index afbcc0a74..8fac2dde9 100644 --- a/apps/api/src/app/health/health.service.ts +++ b/apps/api/src/app/health/health.service.ts @@ -1,3 +1,4 @@ +import { DataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { Injectable } from '@nestjs/common'; import { DataSource } from '@prisma/client'; @@ -5,9 +6,14 @@ import { DataSource } from '@prisma/client'; @Injectable() export class HealthService { public constructor( + private readonly dataEnhancerService: DataEnhancerService, private readonly dataProviderService: DataProviderService ) {} + public async hasResponseFromDataEnhancer(aName: string) { + return this.dataEnhancerService.enhance(aName); + } + public async hasResponseFromDataProvider(aDataSource: DataSource) { return this.dataProviderService.checkQuote(aDataSource); } diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 65611ce0d..da0e4806c 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -8,10 +8,15 @@ import { 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 { 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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; -import { parseDate } from '@ghostfolio/common/helper'; +import { + DATE_FORMAT, + getAssetProfileIdentifier, + parseDate +} from '@ghostfolio/common/helper'; import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { AccountWithPlatform, @@ -20,13 +25,15 @@ import { import { Injectable } from '@nestjs/common'; import { DataSource, Prisma, SymbolProfile } from '@prisma/client'; import Big from 'big.js'; -import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns'; +import { endOfToday, format, isAfter, isSameDay, parseISO } from 'date-fns'; +import { uniqBy } from 'lodash'; import { v4 as uuidv4 } from 'uuid'; @Injectable() export class ImportService { public constructor( private readonly accountService: AccountService, + private readonly dataGatheringService: DataGatheringService, private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly orderService: OrderService, @@ -220,8 +227,7 @@ export class ImportService { const assetProfiles = await this.validateActivities({ activitiesDto, - maxActivitiesToImport, - userId + maxActivitiesToImport }); const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({ @@ -243,17 +249,47 @@ export class ImportService { const activities: Activity[] = []; - for (const { - accountId, - comment, - date, - error, - fee, - quantity, - SymbolProfile: assetProfile, - type, - unitPrice - } of activitiesExtendedWithErrors) { + for (let [ + index, + { + accountId, + comment, + date, + error, + fee, + quantity, + SymbolProfile, + type, + unitPrice + } + ] of activitiesExtendedWithErrors.entries()) { + const assetProfile = assetProfiles[ + getAssetProfileIdentifier({ + dataSource: SymbolProfile.dataSource, + symbol: SymbolProfile.symbol + }) + ] ?? { + currency: SymbolProfile.currency, + dataSource: SymbolProfile.dataSource, + symbol: SymbolProfile.symbol + }; + const { + assetClass, + assetSubClass, + countries, + createdAt, + currency, + dataSource, + id, + isin, + name, + scraperConfiguration, + sectors, + symbol, + symbolMapping, + url, + updatedAt + } = assetProfile; const validatedAccount = accounts.find(({ id }) => { return id === accountId; }); @@ -264,6 +300,35 @@ export class ImportService { Account?: { id: string; name: string }; }); + if (SymbolProfile.currency !== assetProfile.currency) { + // Convert the unit price and fee to the asset currency if the imported + // activity is in a different currency + unitPrice = await this.exchangeRateDataService.toCurrencyAtDate( + unitPrice, + SymbolProfile.currency, + assetProfile.currency, + date + ); + + if (!unitPrice) { + throw new Error( + `activities.${index} historical exchange rate at ${format( + date, + DATE_FORMAT + )} is not available from "${SymbolProfile.currency}" to "${ + assetProfile.currency + }"` + ); + } + + fee = await this.exchangeRateDataService.toCurrencyAtDate( + fee, + SymbolProfile.currency, + assetProfile.currency, + date + ); + } + if (isDryRun) { order = { comment, @@ -279,23 +344,22 @@ export class ImportService { id: uuidv4(), isDraft: isAfter(date, endOfToday()), SymbolProfile: { - assetClass: assetProfile.assetClass, - assetSubClass: assetProfile.assetSubClass, - comment: assetProfile.comment, - countries: assetProfile.countries, - createdAt: assetProfile.createdAt, - currency: assetProfile.currency, - dataSource: assetProfile.dataSource, - id: assetProfile.id, - isin: assetProfile.isin, - name: assetProfile.name, - scraperConfiguration: assetProfile.scraperConfiguration, - sectors: assetProfile.sectors, - symbol: assetProfile.currency, - symbolMapping: assetProfile.symbolMapping, - updatedAt: assetProfile.updatedAt, - url: assetProfile.url, - ...assetProfiles[assetProfile.symbol] + assetClass, + assetSubClass, + countries, + createdAt, + currency, + dataSource, + id, + isin, + name, + scraperConfiguration, + sectors, + symbol, + symbolMapping, + updatedAt, + url, + comment: assetProfile.comment }, Account: validatedAccount, symbolProfileId: undefined, @@ -318,14 +382,14 @@ export class ImportService { SymbolProfile: { connectOrCreate: { create: { - currency: assetProfile.currency, - dataSource: assetProfile.dataSource, - symbol: assetProfile.symbol + currency, + dataSource, + symbol }, where: { dataSource_symbol: { - dataSource: assetProfile.dataSource, - symbol: assetProfile.symbol + dataSource, + symbol } } } @@ -337,24 +401,49 @@ export class ImportService { const value = new Big(quantity).mul(unitPrice).toNumber(); - //@ts-ignore activities.push({ ...order, error, value, feeInBaseCurrency: this.exchangeRateDataService.toCurrency( fee, - assetProfile.currency, + currency, userCurrency ), + //@ts-ignore + SymbolProfile: assetProfile, valueInBaseCurrency: this.exchangeRateDataService.toCurrency( value, - assetProfile.currency, + currency, userCurrency ) }); } + activities.sort((activity1, activity2) => { + return Number(activity1.date) - Number(activity2.date); + }); + + if (!isDryRun) { + // Gather symbol data in the background, if not dry run + const uniqueActivities = uniqBy(activities, ({ SymbolProfile }) => { + return getAssetProfileIdentifier({ + dataSource: SymbolProfile.dataSource, + symbol: SymbolProfile.symbol + }); + }); + + this.dataGatheringService.gatherSymbols( + uniqueActivities.map(({ date, SymbolProfile }) => { + return { + date, + dataSource: SymbolProfile.dataSource, + symbol: SymbolProfile.symbol + }; + }) + ); + } + return activities; } @@ -446,25 +535,30 @@ export class ImportService { private async validateActivities({ activitiesDto, - maxActivitiesToImport, - userId + maxActivitiesToImport }: { activitiesDto: Partial[]; maxActivitiesToImport: number; - userId: string; }) { if (activitiesDto?.length > maxActivitiesToImport) { throw new Error(`Too many activities (${maxActivitiesToImport} at most)`); } const assetProfiles: { - [symbol: string]: Partial; + [assetProfileIdentifier: string]: Partial; } = {}; + const uniqueActivitiesDto = uniqBy( + activitiesDto, + ({ dataSource, symbol }) => { + return getAssetProfileIdentifier({ dataSource, symbol }); + } + ); + for (const [ index, { currency, dataSource, symbol } - ] of activitiesDto.entries()) { + ] of uniqueActivitiesDto.entries()) { if (dataSource !== 'MANUAL') { const assetProfile = ( await this.dataProviderService.getAssetProfiles([ @@ -472,19 +566,26 @@ export class ImportService { ]) )?.[symbol]; - if (assetProfile === undefined) { + if (!assetProfile?.name) { throw new Error( `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` ); } - if (assetProfile.currency !== currency) { + if ( + assetProfile.currency !== currency && + !this.exchangeRateDataService.hasCurrencyPair( + currency, + assetProfile.currency + ) + ) { throw new Error( - `activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}"` + `activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}" and no exchange rate is available from "${currency}" to "${assetProfile.currency}"` ); } - assetProfiles[symbol] = assetProfile; + assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] = + assetProfile; } } diff --git a/apps/api/src/app/info/info.module.ts b/apps/api/src/app/info/info.module.ts index 75d681b56..a6b5b5b0b 100644 --- a/apps/api/src/app/info/info.module.ts +++ b/apps/api/src/app/info/info.module.ts @@ -1,6 +1,7 @@ import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module'; import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; +import { UserModule } from '@ghostfolio/api/app/user/user.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'; @@ -28,11 +29,11 @@ import { InfoService } from './info.service'; signOptions: { expiresIn: '30 days' } }), PlatformModule, - PrismaModule, PropertyModule, RedisCacheModule, SymbolProfileModule, - TagModule + TagModule, + UserModule ], providers: [InfoService] }) diff --git a/apps/api/src/app/info/info.service.ts b/apps/api/src/app/info/info.service.ts index 187135a35..f2c45a72b 100644 --- a/apps/api/src/app/info/info.service.ts +++ b/apps/api/src/app/info/info.service.ts @@ -1,12 +1,14 @@ import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service'; import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { UserService } from '@ghostfolio/api/app/user/user.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; -import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { + DEFAULT_CURRENCY, + DEFAULT_REQUEST_TIMEOUT, PROPERTY_BETTER_UPTIME_MONITOR_ID, PROPERTY_COUNTRIES_OF_SUBSCRIBERS, PROPERTY_DEMO_USER_ID, @@ -30,9 +32,9 @@ import { permissions } from '@ghostfolio/common/permissions'; import { SubscriptionOffer } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; -import * as bent from 'bent'; import * as cheerio from 'cheerio'; import { format, subDays } from 'date-fns'; +import got from 'got'; @Injectable() export class InfoService { @@ -44,10 +46,10 @@ export class InfoService { private readonly exchangeRateDataService: ExchangeRateDataService, private readonly jwtService: JwtService, private readonly platformService: PlatformService, - private readonly prismaService: PrismaService, private readonly propertyService: PropertyService, private readonly redisCacheService: RedisCacheService, - private readonly tagService: TagService + private readonly tagService: TagService, + private readonly userService: UserService ) {} public async get(): Promise { @@ -139,18 +141,13 @@ export class InfoService { subscriptions, systemMessage, tags, - baseCurrency: this.configurationService.get('BASE_CURRENCY'), + baseCurrency: DEFAULT_CURRENCY, currencies: this.exchangeRateDataService.getCurrencies() }; } private async countActiveUsers(aDays: number) { - return await this.prismaService.user.count({ - orderBy: { - Analytics: { - updatedAt: 'desc' - } - }, + return this.userService.count({ where: { AND: [ { @@ -172,20 +169,24 @@ export class InfoService { private async countDockerHubPulls(): Promise { try { - const get = bent( + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const { pull_count } = await got( `https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`, - 'GET', - 'json', - 200, { - 'User-Agent': 'request' + headers: { 'User-Agent': 'request' }, + // @ts-ignore + signal: abortController.signal } - ); + ).json(); - const { pull_count } = await get(); return pull_count; } catch (error) { - Logger.error(error, 'InfoService'); + Logger.error(error, 'InfoService - DockerHub'); return undefined; } @@ -193,16 +194,18 @@ export class InfoService { private async countGitHubContributors(): Promise { try { - const get = bent( - 'https://github.com/ghostfolio/ghostfolio', - 'GET', - 'string', - 200, - {} - ); + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const { body } = await got('https://github.com/ghostfolio/ghostfolio', { + // @ts-ignore + signal: abortController.signal + }); - const html = await get(); - const $ = cheerio.load(html); + const $ = cheerio.load(body); return extractNumberFromString( $( @@ -210,7 +213,7 @@ export class InfoService { ).text() ); } catch (error) { - Logger.error(error, 'InfoService'); + Logger.error(error, 'InfoService - GitHub'); return undefined; } @@ -218,30 +221,31 @@ export class InfoService { private async countGitHubStargazers(): Promise { try { - const get = bent( + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const { stargazers_count } = await got( `https://api.github.com/repos/ghostfolio/ghostfolio`, - 'GET', - 'json', - 200, { - 'User-Agent': 'request' + headers: { 'User-Agent': 'request' }, + // @ts-ignore + signal: abortController.signal } - ); + ).json(); - const { stargazers_count } = await get(); return stargazers_count; } catch (error) { - Logger.error(error, 'InfoService'); + Logger.error(error, 'InfoService - GitHub'); return undefined; } } private async countNewUsers(aDays: number) { - return await this.prismaService.user.count({ - orderBy: { - createdAt: 'desc' - }, + return this.userService.count({ where: { AND: [ { @@ -332,11 +336,10 @@ export class InfoService { return undefined; } - const stripeConfig = (await this.prismaService.property.findUnique({ - where: { key: PROPERTY_STRIPE_CONFIG } - })) ?? { value: '{}' }; - - return JSON.parse(stripeConfig.value); + return ( + ((await this.propertyService.getByKey(PROPERTY_STRIPE_CONFIG)) as any) ?? + {} + ); } private async getUptime(): Promise { @@ -346,25 +349,31 @@ export class InfoService { PROPERTY_BETTER_UPTIME_MONITOR_ID )) as string; - const get = bent( - `https://betteruptime.com/api/v2/monitors/${monitorId}/sla?from=${format( + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const { data } = await got( + `https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format( subDays(new Date(), 90), DATE_FORMAT )}&to${format(new Date(), DATE_FORMAT)}`, - 'GET', - 'json', - 200, { - Authorization: `Bearer ${this.configurationService.get( - 'BETTER_UPTIME_API_KEY' - )}` + headers: { + Authorization: `Bearer ${this.configurationService.get( + 'BETTER_UPTIME_API_KEY' + )}` + }, + // @ts-ignore + signal: abortController.signal } - ); + ).json(); - const { data } = await get(); return data.attributes.availability / 100; } catch (error) { - Logger.error(error, 'InfoService'); + Logger.error(error, 'InfoService - Better Stack'); return undefined; } diff --git a/apps/api/src/app/logo/logo.service.ts b/apps/api/src/app/logo/logo.service.ts index d2e377fc0..80ae1d6a9 100644 --- a/apps/api/src/app/logo/logo.service.ts +++ b/apps/api/src/app/logo/logo.service.ts @@ -1,8 +1,9 @@ import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config'; import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { HttpException, Injectable } from '@nestjs/common'; import { DataSource } from '@prisma/client'; -import * as bent from 'bent'; +import got from 'got'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; @Injectable() @@ -41,15 +42,19 @@ export class LogoService { } private getBuffer(aUrl: string) { - const get = bent( + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + return got( `https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`, - 'GET', - 'buffer', - 200, { - 'User-Agent': 'request' + headers: { 'User-Agent': 'request' }, + // @ts-ignore + signal: abortController.signal } - ); - return get(); + ).buffer(); } } diff --git a/apps/api/src/app/order/order.controller.ts b/apps/api/src/app/order/order.controller.ts index c478860d2..0e617462e 100644 --- a/apps/api/src/app/order/order.controller.ts +++ b/apps/api/src/app/order/order.controller.ts @@ -2,6 +2,7 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/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 { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; @@ -36,6 +37,7 @@ import { UpdateOrderDto } from './update-order.dto'; export class OrderController { public constructor( private readonly apiService: ApiService, + private readonly dataGatheringService: DataGatheringService, private readonly impersonationService: ImpersonationService, private readonly orderService: OrderService, @Inject(REQUEST) private readonly request: RequestWithUser @@ -123,7 +125,7 @@ export class OrderController { ); } - return this.orderService.createOrder({ + const order = await this.orderService.createOrder({ ...data, date: parseISO(data.date), SymbolProfile: { @@ -144,6 +146,19 @@ export class OrderController { User: { connect: { id: this.request.user.id } }, userId: this.request.user.id }); + + if (!order.isDraft) { + // Gather symbol data in the background, if not draft + this.dataGatheringService.gatherSymbols([ + { + dataSource: data.dataSource, + date: order.date, + symbol: data.symbol + } + ]); + } + + return order; } @Put(':id') diff --git a/apps/api/src/app/order/order.module.ts b/apps/api/src/app/order/order.module.ts index c8742f9d2..8f033058d 100644 --- a/apps/api/src/app/order/order.module.ts +++ b/apps/api/src/app/order/order.module.ts @@ -2,6 +2,7 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { CacheModule } from '@ghostfolio/api/app/cache/cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module'; +import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service'; 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'; @@ -31,6 +32,6 @@ import { OrderService } from './order.service'; SymbolProfileModule, UserModule ], - providers: [AccountService, OrderService] + providers: [AccountBalanceService, AccountService, OrderService] }) export class OrderModule {} diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index be5708a90..3c20f9ba0 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -7,6 +7,7 @@ import { GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS_OPTIONS } from '@ghostfolio/common/config'; +import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { Filter } from '@ghostfolio/common/interfaces'; import { OrderWithAccount } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; @@ -96,7 +97,12 @@ export class OrderService { const updateAccountBalance = data.updateAccountBalance ?? false; const userId = data.userId; - if (data.type === 'ITEM' || data.type === 'LIABILITY') { + if ( + data.type === 'FEE' || + data.type === 'INTEREST' || + data.type === 'ITEM' || + data.type === 'LIABILITY' + ) { const assetClass = data.assetClass; const assetSubClass = data.assetSubClass; currency = data.SymbolProfile.connectOrCreate.create.currency; @@ -117,7 +123,7 @@ export class OrderService { }; } - await this.dataGatheringService.addJobToQueue({ + this.dataGatheringService.addJobToQueue({ data: { dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, symbol: data.SymbolProfile.connectOrCreate.create.symbol @@ -125,25 +131,12 @@ export class OrderService { name: GATHER_ASSET_PROFILE_PROCESS, opts: { ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, - jobId: `${data.SymbolProfile.connectOrCreate.create.dataSource}-${data.SymbolProfile.connectOrCreate.create.symbol}` - } - }); - - const isDraft = - data.type === 'LIABILITY' - ? false - : isAfter(data.date as Date, endOfToday()); - - if (!isDraft) { - // Gather symbol data of order in the background, if not draft - this.dataGatheringService.gatherSymbols([ - { + jobId: getAssetProfileIdentifier({ dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, - date: data.date, symbol: data.SymbolProfile.connectOrCreate.create.symbol - } - ]); - } + }) + } + }); delete data.accountId; delete data.assetClass; @@ -162,6 +155,14 @@ export class OrderService { const orderData: Prisma.OrderCreateInput = data; + const isDraft = + data.type === 'FEE' || + data.type === 'INTEREST' || + data.type === 'ITEM' || + data.type === 'LIABILITY' + ? false + : isAfter(data.date as Date, endOfToday()); + const order = await this.prismaService.order.create({ data: { ...orderData, @@ -204,7 +205,12 @@ export class OrderService { where }); - if (order.type === 'ITEM' || order.type === 'LIABILITY') { + if ( + order.type === 'FEE' || + order.type === 'INTEREST' || + order.type === 'ITEM' || + order.type === 'LIABILITY' + ) { await this.symbolProfileService.deleteById(order.symbolProfileId); } @@ -375,7 +381,12 @@ export class OrderService { let isDraft = false; - if (data.type === 'ITEM' || data.type === 'LIABILITY') { + if ( + data.type === 'FEE' || + data.type === 'INTEREST' || + data.type === 'ITEM' || + data.type === 'LIABILITY' + ) { delete data.SymbolProfile.connect; } else { delete data.SymbolProfile.update; diff --git a/apps/api/src/app/portfolio/current-rate.service.spec.ts b/apps/api/src/app/portfolio/current-rate.service.spec.ts index c9711aa7b..88790d2be 100644 --- a/apps/api/src/app/portfolio/current-rate.service.spec.ts +++ b/apps/api/src/app/portfolio/current-rate.service.spec.ts @@ -105,7 +105,6 @@ describe('CurrentRateService', () => { null, null, null, - null, null ); marketDataService = new MarketDataService(null); diff --git a/apps/api/src/app/portfolio/current-rate.service.ts b/apps/api/src/app/portfolio/current-rate.service.ts index 6d48573f7..a4ee317bb 100644 --- a/apps/api/src/app/portfolio/current-rate.service.ts +++ b/apps/api/src/app/portfolio/current-rate.service.ts @@ -38,7 +38,7 @@ export class CurrentRateService { if (includeToday) { promises.push( this.dataProviderService - .getQuotes(dataGatheringItems) + .getQuotes({ items: dataGatheringItems }) .then((dataResultProvider) => { const result: GetValueObject[] = []; for (const dataGatheringItem of dataGatheringItems) { diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts index 2466e81af..cc3a97752 100644 --- a/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts @@ -1,4 +1,4 @@ -import { DataSource, Type as TypeOfOrder } from '@prisma/client'; +import { DataSource, Tag, Type as TypeOfOrder } from '@prisma/client'; import Big from 'big.js'; export interface PortfolioOrder { @@ -9,6 +9,7 @@ export interface PortfolioOrder { name: string; quantity: Big; symbol: string; + tags?: Tag[]; type: TypeOfOrder; unitPrice: Big; } diff --git a/apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts b/apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts index cc199119e..5350adccc 100644 --- a/apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts @@ -1,4 +1,4 @@ -import { DataSource } from '@prisma/client'; +import { DataSource, Tag } from '@prisma/client'; import Big from 'big.js'; export interface TransactionPointSymbol { @@ -9,5 +9,6 @@ export interface TransactionPointSymbol { investment: Big; quantity: Big; symbol: string; + tags?: Tag[]; transactionCount: number; } diff --git a/apps/api/src/app/portfolio/portfolio-calculator.ts b/apps/api/src/app/portfolio/portfolio-calculator.ts index 9addb29dd..c11e514e4 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator.ts @@ -114,6 +114,7 @@ export class PortfolioCalculator { firstBuyDate: oldAccumulatedSymbol.firstBuyDate, quantity: newQuantity, symbol: order.symbol, + tags: order.tags, transactionCount: oldAccumulatedSymbol.transactionCount + 1 }; } else { @@ -125,6 +126,7 @@ export class PortfolioCalculator { investment: unitPrice.mul(order.quantity).mul(factor), quantity: order.quantity.mul(factor), symbol: order.symbol, + tags: order.tags, transactionCount: 1 }; } @@ -492,6 +494,7 @@ export class PortfolioCalculator { : null, quantity: item.quantity, symbol: item.symbol, + tags: item.tags, transactionCount: item.transactionCount }); @@ -781,7 +784,7 @@ export class PortfolioCalculator { ); } else if (!currentPosition.quantity.eq(0)) { Logger.warn( - `Missing historical market data for symbol ${currentPosition.symbol}`, + `Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`, 'PortfolioCalculator' ); hasErrors = true; diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 06f841e12..ef6f3af99 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -10,7 +10,10 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc 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 { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; +import { + DEFAULT_CURRENCY, + HEADER_KEY_IMPERSONATION +} from '@ghostfolio/common/config'; import { PortfolioDetails, PortfolioDividends, @@ -47,8 +50,6 @@ import { PortfolioService } from './portfolio.service'; @Controller('portfolio') export class PortfolioController { - private baseCurrency: string; - public constructor( private readonly accessService: AccessService, private readonly apiService: ApiService, @@ -57,9 +58,7 @@ export class PortfolioController { private readonly portfolioService: PortfolioService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly userService: UserService - ) { - this.baseCurrency = this.configurationService.get('BASE_CURRENCY'); - } + ) {} @Get('details') @UseGuards(AuthGuard('jwt')) @@ -134,7 +133,7 @@ export class PortfolioController { portfolioPosition.netPerformance = null; portfolioPosition.quantity = null; portfolioPosition.valueInPercentage = - portfolioPosition.value / totalValue; + portfolioPosition.valueInBaseCurrency / totalValue; } for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) { @@ -161,10 +160,12 @@ export class PortfolioController { 'emergencyFund', 'excludedAccountsAndActivities', 'fees', + 'fireWealth', 'items', 'liabilities', 'netWorth', 'totalBuy', + 'totalInvestment', 'totalSell' ]); } @@ -177,6 +178,9 @@ export class PortfolioController { countries: hasDetails ? portfolioPosition.countries : [], currency: hasDetails ? portfolioPosition.currency : undefined, markets: hasDetails ? portfolioPosition.markets : undefined, + marketsAdvanced: hasDetails + ? portfolioPosition.marketsAdvanced + : undefined, sectors: hasDetails ? portfolioPosition.sectors : [] }; } @@ -437,15 +441,15 @@ export class PortfolioController { return this.exchangeRateDataService.toCurrency( portfolioPosition.quantity * portfolioPosition.marketPrice, portfolioPosition.currency, - this.request.user?.Settings?.settings.baseCurrency ?? - this.baseCurrency + 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.value / totalValue, + allocationInPercentage: + portfolioPosition.valueInBaseCurrency / totalValue, countries: hasDetails ? portfolioPosition.countries : [], currency: hasDetails ? portfolioPosition.currency : undefined, dataSource: portfolioPosition.dataSource, @@ -456,7 +460,7 @@ export class PortfolioController { sectors: hasDetails ? portfolioPosition.sectors : [], symbol: portfolioPosition.symbol, url: portfolioPosition.url, - valueInPercentage: portfolioPosition.value / totalValue + valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue }; } diff --git a/apps/api/src/app/portfolio/portfolio.module.ts b/apps/api/src/app/portfolio/portfolio.module.ts index fa11476ac..3b4ee5d76 100644 --- a/apps/api/src/app/portfolio/portfolio.module.ts +++ b/apps/api/src/app/portfolio/portfolio.module.ts @@ -2,6 +2,7 @@ import { AccessModule } from '@ghostfolio/api/app/access/access.module'; import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module'; +import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service'; 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'; @@ -36,6 +37,7 @@ import { RulesService } from './rules.service'; UserModule ], providers: [ + AccountBalanceService, AccountService, CurrentRateService, PortfolioService, diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 66f3841a4..228ab18f6 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -11,12 +11,12 @@ import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/ac import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment'; import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment'; import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { + DEFAULT_CURRENCY, EMERGENCY_FUND_TAG_ID, MAX_CHART_ITEMS, UNKNOWN_KEY @@ -42,7 +42,6 @@ import type { AccountWithValue, DateRange, GroupBy, - Market, OrderWithAccount, RequestWithUser, UserWithSettings @@ -57,12 +56,11 @@ import { Platform, Prisma, Tag, - Type as TypeOfOrder + Type as ActivityType } from '@prisma/client'; import Big from 'big.js'; import { differenceInDays, - endOfToday, format, isAfter, isBefore, @@ -84,16 +82,15 @@ import { import { PortfolioCalculator } from './portfolio-calculator'; import { RulesService } from './rules.service'; +const asiaPacificMarkets = require('../../assets/countries/asia-pacific-markets.json'); const developedMarkets = require('../../assets/countries/developed-markets.json'); const emergingMarkets = require('../../assets/countries/emerging-markets.json'); +const europeMarkets = require('../../assets/countries/europe-markets.json'); @Injectable() export class PortfolioService { - private baseCurrency: string; - public constructor( private readonly accountService: AccountService, - private readonly configurationService: ConfigurationService, private readonly currentRateService: CurrentRateService, private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, @@ -103,9 +100,7 @@ export class PortfolioService { private readonly rulesService: RulesService, private readonly symbolProfileService: SymbolProfileService, private readonly userService: UserService - ) { - this.baseCurrency = this.configurationService.get('BASE_CURRENCY'); - } + ) {} public async getAccounts({ filters, @@ -469,9 +464,8 @@ export class PortfolioService { transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT) ); const startDate = this.getStartDate(dateRange, portfolioStart); - const currentPositions = await portfolioCalculator.getCurrentPositions( - startDate - ); + const currentPositions = + await portfolioCalculator.getCurrentPositions(startDate); const cashDetails = await this.accountService.getCashDetails({ filters, @@ -504,15 +498,17 @@ export class PortfolioService { ); } - const dataGatheringItems = currentPositions.positions.map((position) => { - return { - dataSource: position.dataSource, - symbol: position.symbol - }; - }); + const dataGatheringItems = currentPositions.positions.map( + ({ dataSource, symbol }) => { + return { + dataSource, + symbol + }; + } + ); const [dataProviderResponses, symbolProfiles] = await Promise.all([ - this.dataProviderService.getQuotes(dataGatheringItems), + this.dataProviderService.getQuotes({ items: dataGatheringItems }), this.symbolProfileService.getSymbolProfiles(dataGatheringItems) ]); @@ -536,30 +532,79 @@ export class PortfolioService { const symbolProfile = symbolProfileMap[item.symbol]; const dataProviderResponse = dataProviderResponses[item.symbol]; - const markets: { [key in Market]: number } = { + const markets: PortfolioPosition['markets'] = { + [UNKNOWN_KEY]: 0, developedMarkets: 0, emergingMarkets: 0, otherMarkets: 0 }; + const marketsAdvanced: PortfolioPosition['marketsAdvanced'] = { + [UNKNOWN_KEY]: 0, + asiaPacific: 0, + emergingMarkets: 0, + europe: 0, + japan: 0, + northAmerica: 0, + otherMarkets: 0 + }; - for (const country of symbolProfile.countries) { - if (developedMarkets.includes(country.code)) { - markets.developedMarkets = new Big(markets.developedMarkets) - .plus(country.weight) - .toNumber(); - } else if (emergingMarkets.includes(country.code)) { - markets.emergingMarkets = new Big(markets.emergingMarkets) - .plus(country.weight) - .toNumber(); - } else { - markets.otherMarkets = new Big(markets.otherMarkets) - .plus(country.weight) - .toNumber(); + if (symbolProfile.countries.length > 0) { + for (const country of symbolProfile.countries) { + if (developedMarkets.includes(country.code)) { + markets.developedMarkets = new Big(markets.developedMarkets) + .plus(country.weight) + .toNumber(); + } else if (emergingMarkets.includes(country.code)) { + markets.emergingMarkets = new Big(markets.emergingMarkets) + .plus(country.weight) + .toNumber(); + } else { + markets.otherMarkets = new Big(markets.otherMarkets) + .plus(country.weight) + .toNumber(); + } + + if (country.code === 'JP') { + marketsAdvanced.japan = new Big(marketsAdvanced.japan) + .plus(country.weight) + .toNumber(); + } else if (country.code === 'CA' || country.code === 'US') { + marketsAdvanced.northAmerica = new Big(marketsAdvanced.northAmerica) + .plus(country.weight) + .toNumber(); + } else if (asiaPacificMarkets.includes(country.code)) { + marketsAdvanced.asiaPacific = new Big(marketsAdvanced.asiaPacific) + .plus(country.weight) + .toNumber(); + } else if (emergingMarkets.includes(country.code)) { + marketsAdvanced.emergingMarkets = new Big( + marketsAdvanced.emergingMarkets + ) + .plus(country.weight) + .toNumber(); + } else if (europeMarkets.includes(country.code)) { + marketsAdvanced.europe = new Big(marketsAdvanced.europe) + .plus(country.weight) + .toNumber(); + } else { + marketsAdvanced.otherMarkets = new Big(marketsAdvanced.otherMarkets) + .plus(country.weight) + .toNumber(); + } } + } else { + markets[UNKNOWN_KEY] = new Big(markets[UNKNOWN_KEY]) + .plus(value) + .toNumber(); + + marketsAdvanced[UNKNOWN_KEY] = new Big(marketsAdvanced[UNKNOWN_KEY]) + .plus(value) + .toNumber(); } holdings[item.symbol] = { markets, + marketsAdvanced, allocationInPercentage: filteredValueInBaseCurrency.eq(0) ? 0 : value.div(filteredValueInBaseCurrency).toNumber(), @@ -581,9 +626,10 @@ export class PortfolioService { quantity: item.quantity.toNumber(), sectors: symbolProfile.sectors, symbol: item.symbol, + tags: item.tags, transactionCount: item.transactionCount, url: symbolProfile.url, - value: value.toNumber() + valueInBaseCurrency: value.toNumber() }; } @@ -626,7 +672,7 @@ export class PortfolioService { const emergencyFundInCash = emergencyFund .minus( this.getEmergencyFundPositionsValueInBaseCurrency({ - activities: orders + holdings }) ) .toNumber(); @@ -643,7 +689,7 @@ export class PortfolioService { holdings[userCurrency] = { ...emergencyFundCashPositions[userCurrency], investment: emergencyFundInCash, - value: emergencyFundInCash + valueInBaseCurrency: emergencyFundInCash }; } @@ -654,7 +700,7 @@ export class PortfolioService { balanceInBaseCurrency: cashDetails.balanceInBaseCurrency, emergencyFundPositionsValueInBaseCurrency: this.getEmergencyFundPositionsValueInBaseCurrency({ - activities: orders + holdings }) }); @@ -740,6 +786,7 @@ export class PortfolioService { name: order.SymbolProfile?.name, quantity: new Big(order.quantity), symbol: order.SymbolProfile.symbol, + tags: order.tags, type: order.type, unitPrice: new Big(order.unitPrice) })); @@ -756,9 +803,8 @@ export class PortfolioService { const transactionPoints = portfolioCalculator.getTransactionPoints(); const portfolioStart = parseDate(transactionPoints[0].date); - const currentPositions = await portfolioCalculator.getCurrentPositions( - portfolioStart - ); + const currentPositions = + await portfolioCalculator.getCurrentPositions(portfolioStart); const position = currentPositions.positions.find( (item) => item.symbol === aSymbol @@ -897,9 +943,9 @@ export class PortfolioService { ) }; } else { - const currentData = await this.dataProviderService.getQuotes([ - { dataSource: DataSource.YAHOO, symbol: aSymbol } - ]); + const currentData = await this.dataProviderService.getQuotes({ + items: [{ dataSource: DataSource.YAHOO, symbol: aSymbol }] + }); const marketPrice = currentData[aSymbol]?.marketPrice; let historicalData = await this.dataProviderService.getHistorical( @@ -992,23 +1038,22 @@ export class PortfolioService { const portfolioStart = parseDate(transactionPoints[0].date); const startDate = this.getStartDate(dateRange, portfolioStart); - const currentPositions = await portfolioCalculator.getCurrentPositions( - startDate - ); + const currentPositions = + await portfolioCalculator.getCurrentPositions(startDate); const positions = currentPositions.positions.filter( (item) => !item.quantity.eq(0) ); - const dataGatheringItem = positions.map((position) => { + const dataGatheringItems = positions.map(({ dataSource, symbol }) => { return { - dataSource: position.dataSource, - symbol: position.symbol + dataSource, + symbol }; }); const [dataProviderResponses, symbolProfiles] = await Promise.all([ - this.dataProviderService.getQuotes(dataGatheringItem), + this.dataProviderService.getQuotes({ items: dataGatheringItems }), this.symbolProfileService.getSymbolProfiles( positions.map(({ dataSource, symbol }) => { return { dataSource, symbol }; @@ -1184,9 +1229,8 @@ export class PortfolioService { portfolioCalculator.setTransactionPoints(transactionPoints); const portfolioStart = parseDate(transactionPoints[0].date); - const currentPositions = await portfolioCalculator.getCurrentPositions( - portfolioStart - ); + const currentPositions = + await portfolioCalculator.getCurrentPositions(portfolioStart); const positions = currentPositions.positions.filter( (item) => !item.quantity.eq(0) @@ -1276,7 +1320,7 @@ export class PortfolioService { if (cashPositions[account.currency]) { cashPositions[account.currency].investment += convertedBalance; - cashPositions[account.currency].value += convertedBalance; + cashPositions[account.currency].valueInBaseCurrency += convertedBalance; } else { cashPositions[account.currency] = this.getInitialCashPosition({ balance: convertedBalance, @@ -1288,43 +1332,15 @@ export class PortfolioService { for (const symbol of Object.keys(cashPositions)) { // Calculate allocations for each currency cashPositions[symbol].allocationInPercentage = value.gt(0) - ? new Big(cashPositions[symbol].value).div(value).toNumber() + ? new Big(cashPositions[symbol].valueInBaseCurrency) + .div(value) + .toNumber() : 0; } return cashPositions; } - private getDividend({ - activities, - date = new Date(0), - userCurrency - }: { - activities: OrderWithAccount[]; - date?: Date; - userCurrency: string; - }) { - return activities - .filter((activity) => { - // Filter out all activities before given date (drafts) and type dividend - return ( - isBefore(date, new Date(activity.date)) && - activity.type === TypeOfOrder.DIVIDEND - ); - }) - .map(({ quantity, SymbolProfile, unitPrice }) => { - return this.exchangeRateDataService.toCurrency( - new Big(quantity).mul(unitPrice).toNumber(), - SymbolProfile.currency, - userCurrency - ); - }) - .reduce( - (previous, current) => new Big(previous).plus(current), - new Big(0) - ); - } - private getDividendsByGroup({ dividends, groupBy @@ -1388,13 +1404,13 @@ export class PortfolioService { } private getEmergencyFundPositionsValueInBaseCurrency({ - activities + holdings }: { - activities: Activity[]; + holdings: PortfolioDetails['holdings']; }) { - const emergencyFundOrders = activities.filter((activity) => { + const emergencyFundHoldings = Object.values(holdings).filter(({ tags }) => { return ( - activity.tags?.some(({ id }) => { + tags?.some(({ id }) => { return id === EMERGENCY_FUND_TAG_ID; }) ?? false ); @@ -1402,18 +1418,9 @@ export class PortfolioService { let valueInBaseCurrencyOfEmergencyFundPositions = new Big(0); - for (const order of emergencyFundOrders) { - if (order.type === 'BUY') { - valueInBaseCurrencyOfEmergencyFundPositions = - valueInBaseCurrencyOfEmergencyFundPositions.plus( - order.valueInBaseCurrency - ); - } else if (order.type === 'SELL') { - valueInBaseCurrencyOfEmergencyFundPositions = - valueInBaseCurrencyOfEmergencyFundPositions.minus( - order.valueInBaseCurrency - ); - } + for (const { valueInBaseCurrency } of emergencyFundHoldings) { + valueInBaseCurrencyOfEmergencyFundPositions = + valueInBaseCurrencyOfEmergencyFundPositions.plus(valueInBaseCurrency); } return valueInBaseCurrencyOfEmergencyFundPositions.toNumber(); @@ -1472,51 +1479,12 @@ export class PortfolioService { quantity: 0, sectors: [], symbol: currency, + tags: [], transactionCount: 0, - value: balance + valueInBaseCurrency: balance }; } - private getItems(activities: OrderWithAccount[], date = new Date(0)) { - return activities - .filter((activity) => { - // Filter out all activities before given date (drafts) and type item - return ( - isBefore(date, new Date(activity.date)) && - activity.type === TypeOfOrder.ITEM - ); - }) - .map(({ quantity, SymbolProfile, unitPrice }) => { - return this.exchangeRateDataService.toCurrency( - new Big(quantity).mul(unitPrice).toNumber(), - SymbolProfile.currency, - this.request.user.Settings.settings.baseCurrency - ); - }) - .reduce( - (previous, current) => new Big(previous).plus(current), - new Big(0) - ); - } - - private getLiabilities(activities: OrderWithAccount[]) { - return activities - .filter(({ type }) => { - return type === TypeOfOrder.LIABILITY; - }) - .map(({ quantity, SymbolProfile, unitPrice }) => { - return this.exchangeRateDataService.toCurrency( - new Big(quantity).mul(unitPrice).toNumber(), - SymbolProfile.currency, - this.request.user.Settings.settings.baseCurrency - ); - }) - .reduce( - (previous, current) => new Big(previous).plus(current), - new Big(0) - ); - } - private getStartDate(aDateRange: DateRange, portfolioStart: Date) { switch (aDateRange) { case '1d': @@ -1605,9 +1573,10 @@ export class PortfolioService { return account?.isExcluded ?? false; }); - const dividend = this.getDividend({ + const dividend = this.getSumOfActivityType({ activities, - userCurrency + userCurrency, + activityType: 'DIVIDEND' }).toNumber(); const emergencyFund = new Big( Math.max( @@ -1617,20 +1586,49 @@ export class PortfolioService { ); const fees = this.getFees({ activities, userCurrency }).toNumber(); const firstOrderDate = activities[0]?.date; - const items = this.getItems(activities).toNumber(); - const liabilities = this.getLiabilities(activities).toNumber(); + const interest = this.getSumOfActivityType({ + activities, + userCurrency, + activityType: 'INTEREST' + }).toNumber(); + const items = this.getSumOfActivityType({ + activities, + userCurrency, + activityType: 'ITEM' + }).toNumber(); + const liabilities = this.getSumOfActivityType({ + activities, + userCurrency, + activityType: 'LIABILITY' + }).toNumber(); - const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY'); - const totalSell = this.getTotalByType(activities, userCurrency, 'SELL'); + const totalBuy = this.getSumOfActivityType({ + activities, + userCurrency, + activityType: 'BUY' + }).toNumber(); + const totalSell = this.getSumOfActivityType({ + activities, + userCurrency, + activityType: 'SELL' + }).toNumber(); const cash = new Big(balanceInBaseCurrency) .minus(emergencyFund) .plus(emergencyFundPositionsValueInBaseCurrency) .toNumber(); const committedFunds = new Big(totalBuy).minus(totalSell); - const totalOfExcludedActivities = new Big( - this.getTotalByType(excludedActivities, userCurrency, 'BUY') - ).minus(this.getTotalByType(excludedActivities, userCurrency, 'SELL')); + const totalOfExcludedActivities = this.getSumOfActivityType({ + userCurrency, + activities: excludedActivities, + activityType: 'BUY' + }).minus( + this.getSumOfActivityType({ + userCurrency, + activities: excludedActivities, + activityType: 'SELL' + }) + ); const cashDetailsWithExcludedAccounts = await this.accountService.getCashDetails({ @@ -1677,19 +1675,62 @@ export class PortfolioService { excludedAccountsAndActivities, fees, firstOrderDate, + interest, items, liabilities, netWorth, totalBuy, totalSell, committedFunds: committedFunds.toNumber(), - emergencyFund: emergencyFund.toNumber(), + emergencyFund: { + assets: emergencyFundPositionsValueInBaseCurrency, + cash: emergencyFund + .minus(emergencyFundPositionsValueInBaseCurrency) + .toNumber(), + total: emergencyFund.toNumber() + }, + fireWealth: new Big(performanceInformation.performance.currentValue) + .minus(emergencyFundPositionsValueInBaseCurrency) + .toNumber(), ordersCount: activities.filter(({ type }) => { return type === 'BUY' || type === 'SELL'; }).length }; } + private getSumOfActivityType({ + activities, + activityType, + date = new Date(0), + userCurrency + }: { + activities: OrderWithAccount[]; + activityType: ActivityType; + date?: Date; + userCurrency: string; + }) { + return activities + .filter((activity) => { + // Filter out all activities before given date (drafts) and + // activity type + return ( + isBefore(date, new Date(activity.date)) && + activity.type === activityType + ); + }) + .map(({ quantity, SymbolProfile, unitPrice }) => { + return this.exchangeRateDataService.toCurrency( + new Big(quantity).mul(unitPrice).toNumber(), + SymbolProfile.currency, + userCurrency + ); + }) + .reduce( + (previous, current) => new Big(previous).plus(current), + new Big(0) + ); + } + private async getTransactionPoints({ filters, includeDrafts = false, @@ -1706,7 +1747,7 @@ export class PortfolioService { portfolioOrders: PortfolioOrder[]; }> { const userCurrency = - this.request.user?.Settings?.settings.baseCurrency ?? this.baseCurrency; + this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY; const orders = await this.orderService.getOrders({ filters, @@ -1735,6 +1776,7 @@ export class PortfolioService { name: order.SymbolProfile?.name, quantity: new Big(order.quantity), symbol: order.SymbolProfile.symbol, + tags: order.tags, type: order.type, unitPrice: new Big( this.exchangeRateDataService.toCurrency( @@ -1760,6 +1802,21 @@ export class PortfolioService { }; } + private getUserCurrency(aUser: UserWithSettings) { + return ( + aUser.Settings?.settings.baseCurrency ?? + this.request.user?.Settings?.settings.baseCurrency ?? + DEFAULT_CURRENCY + ); + } + + private async getUserId(aImpersonationId: string, aUserId: string) { + const impersonationUserId = + await this.impersonationService.validateImpersonationId(aImpersonationId); + + return impersonationUserId || aUserId; + } + private async getValueOfAccountsAndPlatforms({ filters = [], orders, @@ -1775,12 +1832,12 @@ export class PortfolioService { userId: string; withExcludedAccounts?: boolean; }) { - const ordersOfTypeItem = await this.orderService.getOrders({ + const ordersOfTypeItemOrLiability = await this.orderService.getOrders({ filters, userCurrency, userId, withExcludedAccounts, - types: ['ITEM'] + types: ['ITEM', 'LIABILITY'] }); const accounts: PortfolioDetails['accounts'] = {}; @@ -1820,13 +1877,14 @@ export class PortfolioService { return accountId === account.id; }); - const ordersOfTypeItemByAccount = ordersOfTypeItem.filter( - ({ accountId }) => { + const ordersOfTypeItemOrLiabilityByAccount = + ordersOfTypeItemOrLiability.filter(({ accountId }) => { return accountId === account.id; - } - ); + }); - ordersByAccount = ordersByAccount.concat(ordersOfTypeItemByAccount); + ordersByAccount = ordersByAccount.concat( + ordersOfTypeItemOrLiabilityByAccount + ); accounts[account.id] = { balance: account.balance, @@ -1866,7 +1924,7 @@ export class PortfolioService { order.unitPrice ?? 0); - if (order.type === 'SELL') { + if (order.type === 'LIABILITY' || order.type === 'SELL') { currentValueOfSymbolInBaseCurrency *= -1; } @@ -1902,38 +1960,4 @@ export class PortfolioService { return { accounts, platforms }; } - - private getTotalByType( - orders: OrderWithAccount[], - currency: string, - type: TypeOfOrder - ) { - return orders - .filter( - (order) => !isAfter(order.date, endOfToday()) && order.type === type - ) - .map((order) => { - return this.exchangeRateDataService.toCurrency( - order.quantity * order.unitPrice, - order.SymbolProfile.currency, - currency - ); - }) - .reduce((previous, current) => previous + current, 0); - } - - private getUserCurrency(aUser: UserWithSettings) { - return ( - aUser.Settings?.settings.baseCurrency ?? - this.request.user?.Settings?.settings.baseCurrency ?? - this.baseCurrency - ); - } - - private async getUserId(aImpersonationId: string, aUserId: string) { - const impersonationUserId = - await this.impersonationService.validateImpersonationId(aImpersonationId); - - return impersonationUserId || aUserId; - } } 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 new file mode 100644 index 000000000..194da0bc8 --- /dev/null +++ b/apps/api/src/app/redis-cache/interfaces/redis-cache.interface.ts @@ -0,0 +1,7 @@ +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 new file mode 100644 index 000000000..2ad5df485 --- /dev/null +++ b/apps/api/src/app/redis-cache/interfaces/redis-store.interface.ts @@ -0,0 +1,8 @@ +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 41f49c499..46ed6dc50 100644 --- a/apps/api/src/app/redis-cache/redis-cache.module.ts +++ b/apps/api/src/app/redis-cache/redis-cache.module.ts @@ -1,7 +1,9 @@ import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { CacheManagerOptions, CacheModule, Module } from '@nestjs/common'; +import { CacheModule } from '@nestjs/cache-manager'; +import { Module } from '@nestjs/common'; import * as redisStore from 'cache-manager-redis-store'; +import type { RedisClientOptions } from 'redis'; import { RedisCacheService } from './redis-cache.service'; @@ -11,7 +13,7 @@ import { RedisCacheService } from './redis-cache.service'; imports: [ConfigurationModule], inject: [ConfigurationService], useFactory: async (configurationService: ConfigurationService) => { - return { + return { host: configurationService.get('REDIS_HOST'), max: configurationService.get('MAX_ITEM_IN_CACHE'), password: configurationService.get('REDIS_PASSWORD'), 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 fb75460ed..1e8243144 100644 --- a/apps/api/src/app/redis-cache/redis-cache.service.ts +++ b/apps/api/src/app/redis-cache/redis-cache.service.ts @@ -1,21 +1,30 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { UniqueAsset } from '@ghostfolio/common/interfaces'; -import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common'; -import { Cache } from 'cache-manager'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Inject, Injectable, Logger } from '@nestjs/common'; + +import type { RedisCache } from './interfaces/redis-cache.interface'; @Injectable() export class RedisCacheService { public constructor( - @Inject(CACHE_MANAGER) private readonly cache: Cache, + @Inject(CACHE_MANAGER) private readonly cache: RedisCache, private readonly configurationService: ConfigurationService - ) {} + ) { + const client = cache.store.getClient(); + + client.on('error', (error) => { + Logger.error(error, 'RedisCacheService'); + }); + } public async get(key: string): Promise { return await this.cache.get(key); } public getQuoteKey({ dataSource, symbol }: UniqueAsset) { - return `quote-${dataSource}-${symbol}`; + return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`; } public async remove(key: string) { @@ -27,8 +36,10 @@ export class RedisCacheService { } public async set(key: string, value: string, ttlInSeconds?: number) { - await this.cache.set(key, value, { - ttl: ttlInSeconds ?? this.configurationService.get('CACHE_TTL') - }); + await this.cache.set( + key, + value, + ttlInSeconds ?? this.configurationService.get('CACHE_TTL') + ); } } diff --git a/apps/api/src/app/sitemap/sitemap.controller.ts b/apps/api/src/app/sitemap/sitemap.controller.ts new file mode 100644 index 000000000..cd28c06db --- /dev/null +++ b/apps/api/src/app/sitemap/sitemap.controller.ts @@ -0,0 +1,36 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { + DATE_FORMAT, + getYesterday, + interpolate +} from '@ghostfolio/common/helper'; +import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common'; +import { format } from 'date-fns'; +import { Response } from 'express'; + +@Controller('sitemap.xml') +export class SitemapController { + public sitemapXml = ''; + + public constructor() { + try { + this.sitemapXml = fs.readFileSync( + path.join(__dirname, 'assets', 'sitemap.xml'), + 'utf8' + ); + } catch {} + } + + @Get() + @Version(VERSION_NEUTRAL) + public async flushCache(@Res() response: Response): Promise { + response.setHeader('content-type', 'application/xml'); + response.send( + interpolate(this.sitemapXml, { + currentDate: format(getYesterday(), DATE_FORMAT) + }) + ); + } +} diff --git a/apps/api/src/app/sitemap/sitemap.module.ts b/apps/api/src/app/sitemap/sitemap.module.ts new file mode 100644 index 000000000..2fe7358d4 --- /dev/null +++ b/apps/api/src/app/sitemap/sitemap.module.ts @@ -0,0 +1,24 @@ +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.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 { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; +import { Module } from '@nestjs/common'; + +import { SitemapController } from './sitemap.controller'; + +@Module({ + controllers: [SitemapController], + imports: [ + ConfigurationModule, + DataGatheringModule, + DataProviderModule, + ExchangeRateDataModule, + PrismaModule, + RedisCacheModule, + SymbolProfileModule + ] +}) +export class SitemapModule {} diff --git a/apps/api/src/app/subscription/subscription.service.ts b/apps/api/src/app/subscription/subscription.service.ts index c3e01851d..d94dd68ad 100644 --- a/apps/api/src/app/subscription/subscription.service.ts +++ b/apps/api/src/app/subscription/subscription.service.ts @@ -93,9 +93,8 @@ export class SubscriptionService { public async createSubscriptionViaStripe(aCheckoutSessionId: string) { try { - const session = await this.stripe.checkout.sessions.retrieve( - aCheckoutSessionId - ); + const session = + await this.stripe.checkout.sessions.retrieve(aCheckoutSessionId); await this.createSubscription({ price: session.amount_total / 100, diff --git a/apps/api/src/app/symbol/symbol.controller.ts b/apps/api/src/app/symbol/symbol.controller.ts index da73382a6..ad9042991 100644 --- a/apps/api/src/app/symbol/symbol.controller.ts +++ b/apps/api/src/app/symbol/symbol.controller.ts @@ -15,6 +15,7 @@ import { import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; import { DataSource } from '@prisma/client'; +import { parseISO } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { isDate, isEmpty } from 'lodash'; @@ -93,7 +94,7 @@ export class SymbolController { @Param('dateString') dateString: string, @Param('symbol') symbol: string ): Promise { - const date = new Date(dateString); + const date = parseISO(dateString); if (!isDate(date)) { throw new HttpException( diff --git a/apps/api/src/app/symbol/symbol.service.ts b/apps/api/src/app/symbol/symbol.service.ts index bc626a97f..5eacbb1b0 100644 --- a/apps/api/src/app/symbol/symbol.service.ts +++ b/apps/api/src/app/symbol/symbol.service.ts @@ -27,9 +27,9 @@ export class SymbolService { dataGatheringItem: IDataGatheringItem; includeHistoricalData?: number; }): Promise { - const quotes = await this.dataProviderService.getQuotes([ - dataGatheringItem - ]); + const quotes = await this.dataProviderService.getQuotes({ + items: [dataGatheringItem] + }); const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {}; if (dataGatheringItem.dataSource && marketPrice >= 0) { diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 39c0571b2..a2710bfd5 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -4,7 +4,11 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service'; -import { PROPERTY_IS_READ_ONLY_MODE, locale } from '@ghostfolio/common/config'; +import { + DEFAULT_CURRENCY, + PROPERTY_IS_READ_ONLY_MODE, + locale +} from '@ghostfolio/common/config'; import { User as IUser, UserSettings } from '@ghostfolio/common/interfaces'; import { getPermissions, @@ -14,24 +18,23 @@ import { import { UserWithSettings } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; import { Prisma, Role, User } from '@prisma/client'; -import { sortBy } from 'lodash'; +import { differenceInDays } from 'date-fns'; +import { sortBy, without } from 'lodash'; const crypto = require('crypto'); @Injectable() export class UserService { - public static DEFAULT_CURRENCY = 'USD'; - - private baseCurrency: string; - public constructor( private readonly configurationService: ConfigurationService, private readonly prismaService: PrismaService, private readonly propertyService: PropertyService, private readonly subscriptionService: SubscriptionService, private readonly tagService: TagService - ) { - this.baseCurrency = this.configurationService.get('BASE_CURRENCY'); + ) {} + + public async count(args?: Prisma.UserCountArgs) { + return this.prismaService.user.count(args); } public async getUser( @@ -123,7 +126,7 @@ export class UserService { id, provider, role, - Settings, + Settings: Settings as UserWithSettings['Settings'], thirdPartyId, updatedAt, activityCount: Analytics?.activityCount @@ -144,8 +147,7 @@ export class UserService { // Set default value for base currency if (!(user.Settings.settings as UserSettings)?.baseCurrency) { - (user.Settings.settings as UserSettings).baseCurrency = - UserService.DEFAULT_CURRENCY; + (user.Settings.settings as UserSettings).baseCurrency = DEFAULT_CURRENCY; } // Set default value for date range @@ -165,11 +167,34 @@ export class UserService { user.subscription = this.subscriptionService.getSubscription(Subscription); - if ( - Analytics?.activityCount % 10 === 0 && - user.subscription?.type === 'Basic' - ) { - currentPermissions.push(permissions.enableSubscriptionInterstitial); + if (user.subscription?.type === 'Basic') { + const daysSinceRegistration = differenceInDays( + new Date(), + user.createdAt + ); + let frequency = 20; + + if (daysSinceRegistration > 180) { + frequency = 3; + } else if (daysSinceRegistration > 60) { + frequency = 5; + } else if (daysSinceRegistration > 30) { + frequency = 10; + } else if (daysSinceRegistration > 15) { + frequency = 15; + } + + if (Analytics?.activityCount % frequency === 1) { + currentPermissions.push(permissions.enableSubscriptionInterstitial); + } + + currentPermissions = without( + currentPermissions, + permissions.createAccess + ); + + // Reset benchmark + user.Settings.settings.benchmark = undefined; } if (user.subscription?.type === 'Premium') { @@ -247,7 +272,7 @@ export class UserService { ...data, Account: { create: { - currency: this.baseCurrency, + currency: DEFAULT_CURRENCY, isDefault: true, name: 'Default Account' } @@ -255,7 +280,7 @@ export class UserService { Settings: { create: { settings: { - currency: this.baseCurrency + currency: DEFAULT_CURRENCY } } } diff --git a/apps/api/src/assets/countries/asia-pacific-markets.json b/apps/api/src/assets/countries/asia-pacific-markets.json new file mode 100644 index 000000000..adbb0750e --- /dev/null +++ b/apps/api/src/assets/countries/asia-pacific-markets.json @@ -0,0 +1 @@ +["AU", "HK", "NZ", "SG"] diff --git a/apps/api/src/assets/countries/europe-markets.json b/apps/api/src/assets/countries/europe-markets.json new file mode 100644 index 000000000..26eb2176c --- /dev/null +++ b/apps/api/src/assets/countries/europe-markets.json @@ -0,0 +1,19 @@ +[ + "AT", + "BE", + "CH", + "DE", + "DK", + "ES", + "FI", + "FR", + "GB", + "IE", + "IL", + "IT", + "LU", + "NL", + "NO", + "PT", + "SE" +] diff --git a/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json b/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json index 413425f68..7b1f42e31 100644 --- a/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json +++ b/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json @@ -51,7 +51,9 @@ "3FT": "ThreeFold Token", "3ULL": "3ULL Coin", "3XD": "3DChain", + "420CHAN": "420chan", "4ART": "4ART Coin", + "4CHAN": "4Chan", "4JNET": "4JNET", "77G": "GraphenTech", "7E": "7ELEVEN", @@ -60,6 +62,7 @@ "8BT": "8 Circuit Studios", "8PAY": "8Pay", "8X8": "8X8 Protocol", + "9GAG": "9GAG", "A5T": "Alpha5", "AAA": "Moon Rabbit", "AAB": "AAX Token", @@ -101,6 +104,7 @@ "ACN": "AvonCoin", "ACOIN": "ACoin", "ACP": "Anarchists Prime", + "ACQ": "Acquire.Fi", "ACS": "Access Protocol", "ACT": "Achain", "ACTIN": "Actinium", @@ -180,7 +184,7 @@ "AGX": "Agricoin", "AHOO": "Ahoolee", "AHT": "AhaToken", - "AI": "Multiverse", + "AI": "AiDoge", "AIB": "AdvancedInternetBlock", "AIBB": "AiBB", "AIBK": "AIB Utility Token", @@ -213,6 +217,7 @@ "AKA": "Akroma", "AKITA": "Akita Inu", "AKN": "Akoin", + "AKNC": "Aave KNC v1", "AKRO": "Akropolis", "AKT": "Akash Network", "AKTIO": "AKTIO Coin", @@ -237,12 +242,14 @@ "ALIC": "AliCoin", "ALICE": "My Neighbor Alice", "ALIEN": "AlienCoin", + "ALINK": "Aave LINK v1", "ALIS": "ALISmedia", "ALITA": "Alita Network", "ALIX": "AlinX", "ALKI": "Alkimi", "ALLBI": "ALL BEST ICO", "ALLEY": "NFT Alley", + "ALLIN": "All in", "ALN": "Aluna", "ALOHA": "Aloha", "ALP": "Alphacon", @@ -410,12 +417,14 @@ "ARIX": "Arix", "ARK": "ARK", "ARKER": "Arker", + "ARKM": "Arkham", "ARKN": "Ark Rivals", "ARM": "Armory Coin", "ARMOR": "ARMOR", "ARMR": "ARMR", "ARMS": "2Acoin", "ARNA": "ARNA Panacea", + "ARNM": "Arenum", "ARNO": "ARNO", "ARNX": "Aeron", "ARNXM": "Armor NXM", @@ -472,6 +481,7 @@ "ASTO": "Altered State Token", "ASTON": "Aston", "ASTR": "Astar", + "ASTRAFER": "Astrafer", "ASTRAL": "Astral", "ASTRO": "AstroSwap", "ASTROC": "Astroport Classic", @@ -531,6 +541,7 @@ "AURY": "Aurory", "AUSCM": "Auric Network", "AUSD": "Appeal dollar", + "AUSDC": "Aave USDC v1", "AUT": "Autoria", "AUTHORSHIP": "Authorship", "AUTO": "Auto", @@ -612,6 +623,7 @@ "BACK": "DollarBack", "BACOIN": "BACoin", "BACON": "BaconDAO (BACON)", + "BAD": "Bad Idea AI", "BADGER": "Badger DAO", "BAG": "BondAppetit", "BAGS": "Basis Gold Share", @@ -662,6 +674,7 @@ "BBCT": "TraDove B2BCoin", "BBDT": "BBD Token", "BBF": "Bubblefong", + "BBFT": "Block Busters Tech Token", "BBG": "BigBang", "BBGC": "BigBang Game", "BBI": "BelugaPay", @@ -725,6 +738,7 @@ "BDX": "Beldex", "BDY": "Buddy DAO", "BEACH": "BeachCoin", + "BEAI": "BeNFT Solutions", "BEAM": "Beam", "BEAN": "BeanCash", "BEAST": "CryptoBeast", @@ -806,6 +820,7 @@ "BIDR": "Binance IDR Stable Coin", "BIFI": "Beefy.Finance", "BIFIF": "BiFi", + "BIG": "Big Eyes", "BIGHAN": "BighanCoin", "BIGSB": "BigShortBets", "BIGUP": "BigUp", @@ -1090,6 +1105,7 @@ "BRNK": "Brank", "BRNX": "Bronix", "BRO": "Bitradio", + "BROCK": "Bitrock", "BRONZ": "BitBronze", "BRT": "Bikerush", "BRTR": "Barter", @@ -1226,7 +1242,7 @@ "BULL": "Bullieverse", "BULLC": "BuySell", "BULLION": "BullionFX", - "BULLS": "BullshitCoin", + "BULLS": "Bull Coin", "BULLSH": "Bullshit Inu", "BUMN": "BUMooN", "BUMP": "Bumper", @@ -1277,6 +1293,7 @@ "BZKY": "Bizkey", "BZL": "BZLCoin", "BZNT": "Bezant", + "BZR": "Bazaars", "BZRX": "bZx Protocol", "BZX": "Bitcoin Zero", "BZZ": "Swarmv", @@ -1319,8 +1336,10 @@ "CAP": "BottleCaps", "CAPD": "Capdax", "CAPP": "Cappasity", + "CAPRICOIN": "CapriCoin", "CAPS": "Ternoa", "CAPT": "Bitcoin Captain", + "CAPTAINPLANET": "Captain Planet", "CAR": "CarBlock", "CARAT": "Carats Token", "CARBON": "Carboncoin", @@ -1478,6 +1497,7 @@ "CHECKR": "CheckerChain", "CHECOIN": "CheCoin", "CHEDDA": "Chedda", + "CHEEL": "Cheelee", "CHEESE": "CHEESE", "CHEESUS": "Cheesus", "CHEQ": "CHEQD Network", @@ -1520,7 +1540,8 @@ "CHX": "Own", "CHY": "Concern Poverty Chain", "CHZ": "Chiliz", - "CIC": "CIChain", + "CIC": "Crazy Internet Coin", + "CICHAIN": "CIChain", "CIF": "Crypto Improvement Fund", "CIM": "COINCOME", "CIN": "CinderCoin", @@ -1630,7 +1651,6 @@ "COB": "Cobinhood", "COC": "Coin of the champions", "COCK": "Shibacock", - "COCOS": "COCOS BCX", "CODEO": "Codeo Token", "CODEX": "CODEX Finance", "CODI": "Codi Finance", @@ -1659,7 +1679,7 @@ "COLX": "ColossusCoinXT", "COM": "Coliseum", "COMB": "Combo", - "COMBO": "Furucombo", + "COMBO": "COMBO", "COMFI": "CompliFi", "COMM": "Community Coin", "COMMUNITYCOIN": "Community Coin", @@ -1672,7 +1692,6 @@ "CONI": "CoinBene", "CONS": "ConSpiracy Coin", "CONSENTIUM": "Consentium", - "CONT": "Contentos", "CONUN": "CONUN", "CONV": "Convergence", "COOK": "Cook", @@ -1683,17 +1702,19 @@ "COPS": "Cops Finance", "COR": "Corion", "CORAL": "CoralPay", - "CORE": "Coreum", + "CORE": "Core", "COREDAO": "coreDAO", "COREG": "Core Group Asset", + "COREUM": "Coreum", "CORGI": "Corgi Inu", "CORN": "CORN", "CORX": "CorionX", - "COS": "COS", + "COS": "Contentos", "COSHI": "CoShi Inu", "COSM": "CosmoChain", "COSMIC": "CosmicSwap", "COSP": "Cosplay Token", + "COSS": "COS", "COSX": "Cosmecoin", "COT": "CoTrader", "COTI": "COTI", @@ -1729,7 +1750,7 @@ "CPOOL": "Clearpool", "CPROP": "CPROP", "CPRX": "Crypto Perx", - "CPS": "CapriCoin", + "CPS": "Cryptostone", "CPT": "Cryptaur", "CPU": "CPUcoin", "CPX": "Apex Token", @@ -1796,6 +1817,7 @@ "CRTS": "Cratos", "CRU": "Crust Network", "CRV": "Curve DAO Token", + "CRVUSD": "crvUSD", "CRW": "Crown Coin", "CRWD": "CRWD Network", "CRWNY": "Crowny Token", @@ -1843,7 +1865,7 @@ "CTLX": "Cash Telex", "CTN": "Continuum Finance", "CTO": "Crypto", - "CTP": "Captain Planet", + "CTP": "Ctomorrow Platform", "CTPL": "Cultiplan", "CTPT": "Contents Protocol", "CTR": "Creator Platform", @@ -2007,6 +2029,7 @@ "DBC": "DeepBrain Chain", "DBCCOIN": "Datablockchain", "DBD": "Day By Day", + "DBEAR": "DBear Coin", "DBET": "Decent.bet", "DBIC": "DubaiCoin", "DBIX": "DubaiCoin", @@ -2058,6 +2081,7 @@ "DEEP": "DeepCloud AI", "DEEPG": "Deep Gold", "DEEX": "DEEX", + "DEEZ": "DEEZ NUTS", "DEFI": "Defi", "DEFI5": "DEFI Top 5 Tokens Index", "DEFIL": "DeFIL", @@ -2162,11 +2186,12 @@ "DIEM": "Facebook Diem", "DIESEL": "Diesel", "DIFX": "Digital Financial Exchange", - "DIG": "Dignity", + "DIG": "DIEGO", "DIGG": "DIGG", "DIGIC": "DigiCube", "DIGIF": "DigiFel", "DIGITAL": "Digital Reserve Currency", + "DIGNITY": "Dignity", "DIGS": "Diggits", "DIKO": "Arkadiko", "DILI": "D Community", @@ -2246,6 +2271,7 @@ "DOGBOSS": "Dog Boss", "DOGDEFI": "DogDeFiCoin", "DOGE": "Dogecoin", + "DOGE20": "Doge 2.0", "DOGEBNB": "DogeBNB", "DOGEC": "DogeCash", "DOGECEO": "Doge CEO", @@ -2539,7 +2565,7 @@ "ELONGT": "Elon GOAT", "ELONONE": "AstroElon", "ELP": "Ellerium", - "ELS": "Elysium", + "ELS": "Ethlas", "ELT": "Element Black", "ELTC2": "eLTC", "ELTCOIN": "ELTCOIN", @@ -2548,6 +2574,7 @@ "ELVN": "11Minutes", "ELX": "Energy Ledger", "ELY": "Elysian", + "ELYSIUM": "Elysium", "EM": "Eminer", "EMANATE": "EMANATE", "EMAR": "EmaratCoin", @@ -2559,6 +2586,7 @@ "EMC2": "Einsteinium", "EMD": "Emerald", "EMIGR": "EmiratesGoldCoin", + "EML": "EML Protocol", "EMN.CUR": "Eastman Chemical", "EMON": "Ethermon", "EMOT": "Sentigraph.io", @@ -2692,6 +2720,7 @@ "ETHD": "Ethereum Dark", "ETHER": "Etherparty", "ETHERDELTA": "EtherDelta", + "ETHERKING": "Ether Kingdoms Token", "ETHERNITY": "Ethernity Chain", "ETHF": "EthereumFair", "ETHIX": "EthicHub", @@ -2709,6 +2738,7 @@ "ETHSHIB": "Eth Shiba", "ETHV": "Ethverse", "ETHW": "Ethereum PoW", + "ETHX": "Stader ETHx", "ETHY": "Ethereum Yield", "ETI": "EtherInc", "ETK": "Energi Token", @@ -2722,7 +2752,7 @@ "ETR": "Electric Token", "ETRNT": "Eternal Trusts", "ETS": "ETH Share", - "ETSC": "​Ether star blockchain", + "ETSC": "Ether star blockchain", "ETT": "EncryptoTel", "ETY": "Ethereum Cloud", "ETZ": "EtherZero", @@ -2773,6 +2803,7 @@ "EXB": "ExaByte (EXB)", "EXC": "Eximchain", "EXCC": "ExchangeCoin", + "EXCHANGEN": "ExchangeN", "EXCL": "Exclusive Coin", "EXE": "ExeCoin", "EXFI": "Flare Finance", @@ -2781,7 +2812,7 @@ "EXLT": "ExtraLovers", "EXM": "EXMO Coin", "EXMR": "EXMR FDN", - "EXN": "ExchangeN", + "EXN": "Exeno", "EXO": "Exosis", "EXP": "Expanse", "EXRD": "Radix", @@ -2814,6 +2845,7 @@ "FAIR": "FairCoin", "FAIRC": "Faireum Token", "FAIRG": "FairGame", + "FAKE": "FAKE COIN", "FAKT": "Medifakt", "FALCONS": "Falcon Swaps", "FAME": "Fame MMA", @@ -2860,6 +2892,7 @@ "FDO": "Firdaos", "FDR": "French Digital Reserve", "FDT": "Frutti Dino", + "FDUSD": "First Digital USD", "FDX": "fidentiaX", "FDZ": "Friendz", "FEAR": "Fear", @@ -2870,6 +2903,7 @@ "FEN": "First Ever NFT", "FENOMY": "Fenomy", "FER": "Ferro", + "FERC": "FairERC20", "FERMA": "Ferma", "FESS": "Fesschain", "FET": "Fetch.AI", @@ -2931,7 +2965,7 @@ "FLASH": "Flashstake", "FLASHC": "FLASH coin", "FLC": "FlowChainCoin", - "FLD": "FLUID", + "FLD": "FluidAI", "FLDC": "Folding Coin", "FLDT": "FairyLand", "FLETA": "FLETA", @@ -3091,6 +3125,7 @@ "FUEL": "Jetfuel Finance", "FUJIN": "Fujinto", "FUKU": "Furukuru", + "FUMO": "Alien Milady Fumo", "FUN": "FUN Token", "FUNC": "FunCoin", "FUND": "Unification", @@ -3101,6 +3136,7 @@ "FUNDZ": "FundFantasy", "FUNK": "Cypherfunks Coin", "FUR": "Furio", + "FURU": "Furucombo", "FURY": "Engines of Fury", "FUS": "Fus", "FUSE": "Fuse Network Token", @@ -3118,6 +3154,7 @@ "FXP": "FXPay", "FXS": "Frax Share", "FXT": "FuzeX", + "FXY": "Floxypay", "FYN": "Affyn", "FYP": "FlypMe", "FYZ": "Fyooz", @@ -3172,6 +3209,7 @@ "GAT": "GATCOIN", "GATE": "GATENet", "GATEWAY": "Gateway Protocol", + "GAYPEPE": "Gay Pepe", "GAZE": "GazeTV", "GB": "GoldBlocks", "GBA": "Geeba", @@ -3222,6 +3260,7 @@ "GEMZ": "Gemz Social", "GEN": "DAOstack", "GENE": "Genopets", + "GENIE": "The Genie", "GENIX": "Genix", "GENS": "Genshiro", "GENSTAKE": "Genstake", @@ -3261,6 +3300,7 @@ "GHCOLD": "Galaxy Heroes Coin", "GHD": "Giftedhands", "GHNY": "Grizzly Honey", + "GHO": "GHO", "GHOST": "GhostbyMcAfee", "GHOSTCOIN": "GhostCoin", "GHOSTM": "GhostMarket", @@ -3274,6 +3314,7 @@ "GIFT": "GiftNet", "GIG": "GigaCoin", "GIGA": "GigaSwap", + "GIGX": "GigXCoin", "GIM": "Gimli", "GIMMER": "Gimmer", "GIN": "GINcoin", @@ -3385,6 +3426,7 @@ "GOVT": "The Government Network", "GOZ": "Göztepe S.K. Fan Token", "GP": "Wizards And Dragons", + "GPBP": "Genius Playboy Billionaire Philanthropist", "GPKR": "Gold Poker", "GPL": "Gold Pressed Latinum", "GPPT": "Pluto Project Coin", @@ -3501,7 +3543,8 @@ "HALF": "0.5X Long Bitcoin Token", "HALFSHIT": "0.5X Long Shitcoin Index Token", "HALLO": "Halloween Coin", - "HALO": "Halo Platform", + "HALO": "Halo Coin", + "HALOPLATFORM": "Halo Platform", "HAM": "Hamster", "HAMS": "HamsterCoin", "HANA": "Hanacoin", @@ -3598,6 +3641,7 @@ "HILL": "President Clinton", "HINA": "Hina Inu", "HINT": "Hintchain", + "HIPPO": "HIPPO", "HIRE": "HireMatch", "HIT": "HitChain", "HITBTC": "HitBTC Token", @@ -3634,6 +3678,7 @@ "HNTR": "Hunter", "HNY": "Honey", "HNZO": "Hanzo Inu", + "HOBO": "HOBO THE BEAR", "HOD": "HoDooi.com", "HODL": "HOdlcoin", "HOGE": "Hoge Finance", @@ -3839,7 +3884,7 @@ "IMPCN": "Brain Space", "IMPER": "Impermax", "IMPS": "Impulse Coin", - "IMPT": "Ether Kingdoms Token", + "IMPT": "IMPT", "IMPULSE": "IMPULSE by FDR", "IMS": "Independent Money System", "IMST": "Imsmart", @@ -4001,6 +4046,7 @@ "JAM": "Tune.Fm", "JANE": "JaneCoin", "JAR": "Jarvis+", + "JARED": "Jared From Subway", "JASMY": "JasmyCoin", "JBS": "JumBucks Coin", "JBX": "Juicebox", @@ -4163,9 +4209,10 @@ "KIN": "Kin", "KIND": "Kind Ads", "KINE": "Kine Protocol", - "KING": "King Finance", + "KING": "KING", "KING93": "King93", "KINGDOMQUEST": "Kingdom Quest", + "KINGF": "King Finance", "KINGSHIB": "King Shiba", "KINGSWAP": "KingSwap", "KINT": "Kintsugi", @@ -4175,6 +4222,7 @@ "KISC": "Kaiser", "KISHIMOTO": "Kishimoto Inu", "KISHU": "Kishu Inu", + "KITA": "KITA INU", "KITSU": "Kitsune Inu", "KITTY": "Kitty Inu", "KKO": "Kineko", @@ -4267,10 +4315,12 @@ "KUBO": "KUBO", "KUBOS": "KubosCoin", "KUE": "Kuende", + "KUJI": "Kujira", "KUMA": "Kuma Inu", "KUNCI": "Kunci Coin", "KUR": "Kuro", "KURT": "Kurrent", + "KUSA": "Kusa Inu", "KUSD": "Kowala", "KUSH": "KushCoin", "KUV": "Kuverit", @@ -4280,6 +4330,7 @@ "KVT": "Kinesis Velocity Token", "KWATT": "4New", "KWD": "KIWI DEFI", + "KWENTA": "Kwenta", "KWH": "KWHCoin", "KWIK": "KwikSwap", "KWS": "Knight War Spirits", @@ -4299,7 +4350,9 @@ "LABX": "Stakinglab", "LACCOIN": "LocalAgro", "LACE": "Lovelace World", + "LADYS": "Milady Meme Coin", "LAEEB": "LaEeb", + "LAELAPS": "Laelaps", "LAIKA": "Laika Protocol", "LALA": "LaLa World", "LAMB": "Lambda", @@ -4455,13 +4508,14 @@ "LLAND": "Lyfe Land", "LLG": "Loligo", "LLION": "Lydian Lion", - "LM": "LM Token", + "LM": "LeisureMeta", "LMAO": "LMAO Finance", "LMC": "LomoCoin", "LMCH": "Latamcash", "LMCSWAP": "LimoCoin SWAP", "LMR": "Lumerin", "LMT": "Lympo Market Token", + "LMTOKEN": "LM Token", "LMXC": "LimonX", "LMY": "Lunch Money", "LN": "LINK", @@ -4530,6 +4584,7 @@ "LRG": "Largo Coin", "LRN": "Loopring [NEO]", "LSD": "LightSpeedCoin", + "LSETH": "Liquid Staked ETH", "LSK": "Lisk", "LSP": "Lumenswap", "LSS": "Lossless", @@ -4626,6 +4681,7 @@ "MAEP": "Maester Protocol", "MAG": "Magnet", "MAGIC": "Magic", + "MAGICF": "MagicFox", "MAHA": "MahaDAO", "MAI": "Mindsync", "MAID": "MaidSafe Coin", @@ -4639,6 +4695,7 @@ "MANDOX": "MandoX", "MANGA": "Manga Token", "MANNA": "Manna", + "MANTLE": "Mantle", "MAP": "MAP Protocol", "MAPC": "MapCoin", "MAPE": "Mecha Morphing", @@ -4672,6 +4729,7 @@ "MATIC": "Polygon", "MATPAD": "MaticPad", "MATTER": "AntiMatter", + "MAV": "Maverick Protocol", "MAX": "MaxCoin", "MAXR": "Max Revive", "MAY": "Theresa May Coin", @@ -4776,6 +4834,7 @@ "MESA": "MetaVisa", "MESG": "MESG", "MESH": "MeshBox", + "MESSI": "MESSI COIN", "MET": "Metronome", "META": "Metadium", "METAC": "Metacoin", @@ -4881,6 +4940,7 @@ "MIODIO": "MIODIOCOIN", "MIOTA": "IOTA", "MIR": "Mirror Protocol", + "MIRACLE": "MIRACLE", "MIRC": "MIR COIN", "MIS": "Mithril Share", "MISA": "Sangkara", @@ -4938,7 +4998,6 @@ "MNRB": "MoneyRebel", "MNS": "Monnos", "MNST": "MoonStarter", - "MNT": "microNFT", "MNTC": "Manet Coin", "MNTG": "Monetas", "MNTL": "AssetMantle", @@ -4967,6 +5026,7 @@ "MOF": "Molecular Future (TRC20)", "MOFI": "MobiFi", "MOFOLD": "Molecular Future (ERC20)", + "MOG": "Mog Coin", "MOGU": "Mogu", "MOGX": "Mogu", "MOI": "MyOwnItem", @@ -4989,9 +5049,11 @@ "MONEYIMT": "MoneyToken", "MONF": "Monfter", "MONG": "MongCoin", + "MONG20": "Mongoose 2.0", "MONI": "Monsta Infinite", "MONK": "Monkey Project", "MONKEY": "Monkey", + "MONKEYS": "Monkeys Token", "MONO": "MonoX", "MONONOKEINU": "Mononoke Inu", "MONS": "Monsters Clan", @@ -5011,11 +5073,13 @@ "MOONSHOT": "Moonshot", "MOOO": "Hashtagger", "MOOV": "dotmoovs", + "MOOX": "Moox Protocol", "MOPS": "Mops", "MORA": "Meliora", "MORE": "More Coin", "MOS": "MOS Coin", "MOT": "Olympus Labs", + "MOTG": "MetaOctagon", "MOTI": "Motion", "MOTO": "Motocoin", "MOV": "MovieCoin", @@ -5076,6 +5140,7 @@ "MSWAP": "MoneySwap", "MT": "MyToken", "MTA": "Meta", + "MTB": "MetaBridge", "MTBC": "Metabolic", "MTC": "MEDICAL TOKEN CURRENCY", "MTCMN": "MTC Mesh", @@ -5108,6 +5173,7 @@ "MUE": "MonetaryUnit", "MULTI": "Multichain", "MULTIBOT": "Multibot", + "MULTIV": "Multiverse", "MUN": "MUNcoin", "MUNCH": "Munch Token", "MUSD": "mStable USD", @@ -5648,6 +5714,7 @@ "OZP": "OZAPHYRE", "P202": "Project 202", "P2PS": "P2P Solutions Foundation", + "PAAL": "PAAL AI", "PAC": "PAC Protocol", "PACOCA": "Pacoca", "PAD": "NearPad", @@ -5736,6 +5803,7 @@ "PEARL": "Pearl Finance", "PEC": "PeaceCoin", "PEEL": "Meta Apes", + "PEEPA": "Peepa", "PEEPS": "The People’s Coin", "PEG": "PegNet", "PEGS": "PegShares", @@ -5748,6 +5816,7 @@ "PEOPLE": "ConstitutionDAO", "PEOS": "pEOS", "PEPE": "Pepe", + "PEPE20": "Pepe 2.0", "PEPECASH": "Pepe Cash", "PEPPER": "Pepper Token", "PEPS": "PEPS Coin", @@ -5822,6 +5891,7 @@ "PINK": "PinkCoin", "PINKX": "PantherCoin", "PINMO": "Pinmo", + "PINO": "Pinocchu", "PINU": "Piccolo Inu", "PIO": "Pioneershares", "PIPI": "Pippi Finance", @@ -5885,6 +5955,7 @@ "PLS": "Pulsechain", "PLSD": "PulseDogecoin", "PLSPAD": "PulsePad", + "PLSX": "PulseX", "PLT": "Poollotto.finance", "PLTC": "PlatonCoin", "PLTX": "PlutusX", @@ -5911,7 +5982,6 @@ "PNK": "Kleros", "PNL": "True PNL", "PNODE": "Pinknode", - "PNP": "LogisticsX", "PNT": "pNetwork Token", "PNX": "PhantomX", "PNY": "Peony Coin", @@ -5927,6 +5997,7 @@ "POINTS": "Cryptsy Points", "POK": "Pokmonsters", "POKEM": "Pokemonio", + "POKEMON": "Pokemon", "POKER": "PokerCoin", "POKT": "Pocket Network", "POL": "Pool-X", @@ -6010,6 +6081,7 @@ "PRIME": "Echelon Prime", "PRIMECHAIN": "PrimeChain", "PRINT": "Printer.Finance", + "PRINTERIUM": "Printerium", "PRINTS": "FingerprintsDAO", "PRISM": "Prism", "PRIX": "Privatix", @@ -6033,7 +6105,7 @@ "PROTON": "Proton", "PROUD": "PROUD Money", "PROXI": "PROXI", - "PRP": "Papyrus", + "PRP": "Pepe Prime", "PRPS": "Purpose", "PRPT": "Purple Token", "PRQ": "PARSIQ", @@ -6042,7 +6114,7 @@ "PRTG": "Pre-Retogeum", "PRV": "PrivacySwap", "PRVS": "Previse", - "PRX": "Printerium", + "PRX": "Parex", "PRXY": "Proxy", "PRY": "PRIMARY", "PSB": "Planet Sandbox", @@ -6120,6 +6192,7 @@ "PYRAM": "Pyram Token", "PYRK": "Pyrk", "PYT": "Payther", + "PYUSD": "PayPal USD", "PZM": "Prizm", "Q1S": "Quantum1Net", "Q2C": "QubitCoin", @@ -6178,6 +6251,7 @@ "QUA": "Quantum Tech", "QUACK": "Rich Quack", "QUAM": "Quam Network", + "QUANT": "Quant Finance", "QUARASHI": "Quarashi Network", "QUARTZ": "Sandclock", "QUASA": "Quasacoin", @@ -6201,7 +6275,7 @@ "RAC": "RAcoin", "RACA": "Radio Caca", "RACEFI": "RaceFi", - "RAD": "Radicle", + "RAD": "Radworks", "RADAR": "DappRadar", "RADI": "RadicalCoin", "RADIO": "RadioShack", @@ -6220,7 +6294,7 @@ "RAM": "Ramifi Protocol", "RAMP": "RAMP", "RANKER": "RankerDao", - "RAP": "Rapture", + "RAP": "Philosoraptor", "RAPDOGE": "RapDoge", "RARE": "SuperRare", "RARI": "Rarible", @@ -6277,6 +6351,7 @@ "REA": "Realisto", "REAL": "RealLink", "REALM": "Realm", + "REALMS": "Realms of Ethernity", "REALPLATFORM": "REAL", "REALY": "Realy Metaverse", "REAP": "ReapChain", @@ -6287,6 +6362,7 @@ "RED": "RED TOKEN", "REDC": "RedCab", "REDCO": "Redcoin", + "REDDIT": "Reddit", "REDI": "REDi", "REDLANG": "RED", "REDLC": "Redlight Chain", @@ -6324,7 +6400,7 @@ "REST": "Restore", "RET": "RealTract", "RETAIL": "Retail.Global", - "RETH": "Realms of Ethernity", + "RETH": "Rocket Pool ETH", "RETH2": "rETH2", "RETIRE": "Retire Token", "REU": "REUCOIN", @@ -6351,6 +6427,7 @@ "RGP": "Rigel Protocol", "RGT": "Rari Governance Token", "RHEA": "Rhea", + "RHINO": "RHINO", "RHOC": "RChain", "RHP": "Rhypton Club", "RIC": "Riecoin", @@ -6490,6 +6567,7 @@ "RWE": "Real-World Evidence", "RWN": "Rowan Token", "RWS": "Robonomics Web Services", + "RXD": "Radiant", "RXT": "RIMAUNANGIS", "RYC": "RoyalCoin", "RYCN": "RoyalCoin 2.0", @@ -6564,6 +6642,7 @@ "SBTC": "Super Bitcoin", "SC": "Siacoin", "SCA": "SiaClassic", + "SCAM": "Scam Coin", "SCAP": "SafeCapital", "SCAR": "Velhalla", "SCASH": "SpaceCash", @@ -6624,6 +6703,7 @@ "SEER": "SEER", "SEI": "Sei", "SEL": "SelenCoin", + "SELF": "SELFCrypto", "SEM": "Semux", "SEN": "Sentaro", "SENATE": "SENATE", @@ -6665,6 +6745,7 @@ "SGE": "Society of Galactic Exploration", "SGLY": "Singularity", "SGN": "Signals Network", + "SGO": "SafuuGO", "SGOLD": "SpaceGold", "SGP": "SGPay", "SGR": "Sogur Currency", @@ -6684,6 +6765,7 @@ "SHEESH": "Sheesh it is bussin bussin", "SHEESHA": "Sheesha Finance", "SHELL": "Shell Token", + "SHERA": "Shera Tokens", "SHFL": "SHUFFLE!", "SHFT": "Shyft Network", "SHI": "Shirtum", @@ -6719,6 +6801,8 @@ "SHR": "ShareToken", "SHREK": "ShrekCoin", "SHROOM": "Shroom.Finance", + "SHROOMFOX": "Magic Shroom", + "SHS": "SHEESH", "SHX": "Stronghold Token", "SI": "Siren", "SIB": "SibCoin", @@ -7018,9 +7102,11 @@ "STEN": "Steneum Coin", "STEP": "Step Finance", "STEPH": "Step Hero", + "STEPR": "Step", "STEPS": "Steps", "STERLINGCOIN": "SterlingCoin", "STETH": "Staked Ether", + "STEWIE": "Stewie Coin", "STEX": "STEX", "STF": "Structure Finance", "STFX": "STFX", @@ -7055,7 +7141,7 @@ "STR": "Sourceless", "STRAKS": "Straks", "STRAX": "Stratis", - "STRAY": "Animal Token", + "STRAY": "Stray Dog", "STREAM": "STREAMIT COIN", "STRIP": "Stripto", "STRK": "Strike", @@ -7361,6 +7447,7 @@ "TOM": "TOM Finance", "TOMAHAWKCOIN": "Tomahawkcoin", "TOMB": "Tomb", + "TOMI": "tomiNet", "TOMO": "TomoChain", "TOMOE": "TomoChain ERC20", "TOMS": "TomTomCoin", @@ -7385,6 +7472,7 @@ "TOTM": "Totem", "TOWER": "Tower", "TOWN": "Town Star", + "TOX": "INTOverse", "TOZ": "Tozex", "TP": "Token Swap", "TPAD": "TrustPad", @@ -7600,6 +7688,7 @@ "UNITY": "SuperNET", "UNIVRS": "Universe", "UNIX": "UniX", + "UNLEASH": "UnleashClub", "UNN": "UNION Protocol Governance Token", "UNO": "Unobtanium", "UNORE": "UnoRe", @@ -7673,6 +7762,7 @@ "UTT": "United Traders Token", "UTU": "UTU Protocol", "UUU": "U Network", + "UWU": "uwu", "UZUMAKI": "Uzumaki Inu", "VAB": "Vabble", "VADER": "Vader Protocol", @@ -7695,6 +7785,7 @@ "VCF": "Valencia CF Fan Token", "VCG": "VCGamers", "VCK": "28VCK", + "VCORE": "VCORE", "VDG": "VeriDocGlobal", "VDL": "Vidulum", "VDO": "VidioCoin", @@ -7710,6 +7801,7 @@ "VEIL": "VEIL", "VELA": "Vela Token", "VELO": "Velo", + "VELOD": "Velodrome Finance", "VELOX": "Velox", "VELOXPROJECT": "Velox", "VEMP": "vEmpire DDAO", @@ -7782,6 +7874,7 @@ "VNT": "VNT Chain", "VNTW": "Value Network Token", "VNX": "VisionX", + "VNXAU": "VNX Gold", "VNXLU": "VNX Exchange", "VOCO": "Provoco", "VODKA": "Vodka Token", @@ -7902,7 +7995,8 @@ "WEC": "Whole Earth Coin", "WEGEN": "WeGen Platform", "WELD": "Weld", - "WELL": "Well", + "WELL": "Moonwell", + "WELLTOKEN": "Well", "WELT": "Fabwelt", "WELUPS": "Welups Blockchain", "WEMIX": "WEMIX", @@ -7958,6 +8052,7 @@ "WIX": "Wixlar", "WIZ": "WIZ Protocol", "WKD": "Wakanda Inu", + "WLD": "Worldcoin", "WLF": "Wolfs Group", "WLITI": "wLITI", "WLK": "Wolk", @@ -7983,6 +8078,7 @@ "WNZ": "Winerz", "WOA": "Wrapped Origin Axie", "WOD": "World of Defish", + "WOID": "WORLD ID", "WOJ": "Wojak Finance", "WOLF": "Insanity Coin", "WOLFILAND": "Wolfiland", @@ -8000,6 +8096,7 @@ "WOOFY": "Woofy", "WOOL": "Wolf Game Wool", "WOONK": "Woonkly", + "WOOO": "wooonen", "WOOP": "Woonkly Power", "WOP": "WorldPay", "WORLD": "World Token", @@ -8010,6 +8107,7 @@ "WOZX": "Efforce", "WPC": "WePiggy Coin", "WPE": "OPES (Wrapped PE)", + "WPLS": "Wrapped Pulse", "WPP": "Green Energy Token", "WPR": "WePower", "WQT": "Work Quest", @@ -8049,6 +8147,7 @@ "WZEC": "Wrapped Zcash", "WZENIQ": "Wrapped Zeniq (ETH)", "WZRD": "Wizardia", + "X": "AI-X", "X2": "X2Coin", "X2Y2": "X2Y2", "X42": "X42 Protocol", @@ -8096,7 +8195,7 @@ "XCI": "Cannabis Industry Coin", "XCLR": "ClearCoin", "XCM": "CoinMetro", - "XCN": "Chain", + "XCN": "Onyxcoin", "XCO": "XCoin", "XCONSOL": "X-Consoles", "XCP": "CounterParty", @@ -8365,6 +8464,7 @@ "YUANG": "Yuang Coin", "YUCJ": "Yu Coin", "YUCT": "Yucreat", + "YUDI": "Yudi", "YUM": "Yumerium", "YUMMY": "Yummy", "YUP": "Crowdholding", diff --git a/apps/api/src/assets/cryptocurrencies/custom.json b/apps/api/src/assets/cryptocurrencies/custom.json index 2ccad3881..1215114bb 100644 --- a/apps/api/src/assets/cryptocurrencies/custom.json +++ b/apps/api/src/assets/cryptocurrencies/custom.json @@ -1,4 +1,5 @@ { + "CYBER24781": "CyberConnect", "LUNA1": "Terra", "LUNA2": "Terra", "SGB1": "Songbird", diff --git a/apps/api/src/assets/sitemap.xml b/apps/api/src/assets/sitemap.xml new file mode 100644 index 000000000..5590f1909 --- /dev/null +++ b/apps/api/src/assets/sitemap.xml @@ -0,0 +1,871 @@ + + + + https://ghostfol.io/de + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/blog + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/blog/2021/07/hallo-ghostfolio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/features + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/haeufig-gestellte-fragen + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/maerkte + daily + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/open + daily + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/preise + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/registrierung + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-altoo + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-copilot-money + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-delta + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-divvydiary + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-exirio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-folishare + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-getquin + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-gospatz + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-justetf + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-kubera + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-markets.sh + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-maybe-finance + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-monse + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-parqet + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-plannix + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-portfolio-dividend-tracker + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-portseido + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-projectionlab + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-seeking-alpha + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sharesight + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-simple-portfolio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-snowball-analytics + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sumio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-utluna + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-yeekatee + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ueber-uns + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ueber-uns/changelog + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ueber-uns/datenschutzbestimmungen + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ueber-uns/lizenz + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ueber-uns/oss-friends + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/about + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/about/changelog + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/about/license + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/about/oss-friends + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog/2021/07/hello-ghostfolio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog/2022/01/ghostfolio-first-months-in-open-source + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog/2022/07/ghostfolio-meets-internet-identity + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog/2022/07/how-do-i-get-my-finances-in-order + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog/2022/08/500-stars-on-github + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog/2022/10/hacktoberfest-2022 + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog/2022/11/black-friday-2022 + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog/2022/12/the-importance-of-tracking-your-personal-finances + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog/2023/02/ghostfolio-meets-umbrel + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog/2023/07/exploring-the-path-to-fire + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog/2023/08/ghostfolio-joins-oss-friends + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog/2023/09/ghostfolio-2 + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/faq + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/features + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/markets + daily + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/open + daily + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/pricing + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/register + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-copilot-money + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-delta + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-divvydiary + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-getquin + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-gospatz + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-justetf + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-kubera + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-markets.sh + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-maybe-finance + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-monse + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-parqet + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-plannix + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-portfolio-dividend-tracker + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-portseido + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-projectionlab + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-seeking-alpha + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sharesight + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-simple-portfolio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-snowball-analytics + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-utluna + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-yeekatee + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/es + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/es/funcionalidades + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/es/mercados + daily + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/es/open + daily + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/es/precios + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/es/preguntas-mas-frecuentes + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/es/recursos + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/es/registro + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/es/sobre + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/es/sobre/changelog + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/es/sobre/licencia + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/es/sobre/oss-friends + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/es/sobre/politica-de-privacidad + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/fr + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/fr/a-propos + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/fr/a-propos/changelog + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/fr/a-propos/licence + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/fr/a-propos/oss-friends + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/fr/a-propos/politique-de-confidentialite + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/fr/enregistrement + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/fr/fonctionnalites + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/fr/foire-aux-questions + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/fr/marches + daily + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/fr/open + daily + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/fr/prix + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/fr/ressources + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/domande-piu-frequenti + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/funzionalita + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/informazioni-su + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/informazioni-su/changelog + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/informazioni-su/informativa-sulla-privacy + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/informazioni-su/licenza + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/informazioni-su/oss-friends + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/iscrizione + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/mercati + daily + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/open + daily + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/prezzi + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-altoo + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-copilot-money + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-delta + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-divvydiary + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-exirio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-folishare + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-getquin + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-gospatz + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-justetf + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-kubera + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-markets.sh + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-maybe-finance + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-monse + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-parqet + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-plannix + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-portfolio-dividend-tracker + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-portseido + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-projectionlab + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-seeking-alpha + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sharesight + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-simple-portfolio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-snowball-analytics + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sumio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-utluna + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-yeekatee + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-altoo + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-copilot-money + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-delta + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-divvydiary + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-exirio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-folishare + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-getquin + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-gospatz + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-justetf + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-kubera + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-markets.sh + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-maybe-finance + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-monse + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-parqet + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-plannix + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-portfolio-dividend-tracker + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-portseido + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-projectionlab + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-seeking-alpha + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sharesight + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-simple-portfolio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-snowball-analytics + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sumio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-utluna + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-yeekatee + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/functionaliteiten + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/markten + daily + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/open + daily + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/over + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/over/changelog + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/over/licentie + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/over/oss-friends + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/over/privacybeleid + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/prijzen + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/registratie + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/veelgestelde-vragen + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pt + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pt/blog + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pt/funcionalidades + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pt/mercados + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pt/open + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pt/perguntas-mais-frequentes + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pt/precos + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pt/recursos + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pt/registo + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pt/sobre + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pt/sobre/changelog + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pt/sobre/licenca + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pt/sobre/oss-friends + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pt/sobre/politica-de-privacidade + ${currentDate}T00:00:00+00:00 + + diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 45e7a987d..016f82473 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -7,6 +7,7 @@ import helmet from 'helmet'; import { AppModule } from './app/app.module'; import { environment } from './environments/environment'; +import { HtmlTemplateMiddleware } from './middlewares/html-template.middleware'; async function bootstrap() { const configApp = await NestFactory.create(AppModule); @@ -23,7 +24,7 @@ async function bootstrap() { defaultVersion: '1', type: VersioningType.URI }); - app.setGlobalPrefix('api'); + app.setGlobalPrefix('api', { exclude: ['sitemap.xml'] }); app.useGlobalPipes( new ValidationPipe({ forbidNonWhitelisted: true, @@ -40,6 +41,7 @@ async function bootstrap() { helmet({ contentSecurityPolicy: { directives: { + connectSrc: ["'self'", 'https://js.stripe.com'], // Allow connections to Stripe frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers @@ -51,7 +53,8 @@ async function bootstrap() { ); } - const BASE_CURRENCY = configService.get('BASE_CURRENCY'); + app.use(HtmlTemplateMiddleware); + const HOST = configService.get('HOST') || '0.0.0.0'; const PORT = configService.get('PORT') || 3333; @@ -59,15 +62,6 @@ async function bootstrap() { logLogo(); Logger.log(`Listening at http://${HOST}:${PORT}`); Logger.log(''); - - if (BASE_CURRENCY) { - Logger.warn( - `The environment variable "BASE_CURRENCY" is deprecated and will be removed in Ghostfolio 2.0.` - ); - Logger.warn( - 'Please use the currency converter in the activity dialog instead.' - ); - } }); } diff --git a/apps/api/src/middlewares/html-template.middleware.ts b/apps/api/src/middlewares/html-template.middleware.ts new file mode 100644 index 000000000..ceb563fd6 --- /dev/null +++ b/apps/api/src/middlewares/html-template.middleware.ts @@ -0,0 +1,136 @@ +import * as fs from 'fs'; +import { join } from 'path'; + +import { environment } from '@ghostfolio/api/environments/environment'; +import { + DEFAULT_LANGUAGE_CODE, + DEFAULT_ROOT_URL, + SUPPORTED_LANGUAGE_CODES +} from '@ghostfolio/common/config'; +import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper'; +import { format } from 'date-fns'; +import { NextFunction, Request, Response } from 'express'; + +const descriptions = { + de: 'Mit dem Finanz-Dashboard Ghostfolio können Sie Ihr Vermögen in Form von Aktien, ETFs oder Kryptowährungen verteilt über mehrere Finanzinstitute überwachen.', + en: 'Ghostfolio is a personal finance dashboard to keep track of your assets like stocks, ETFs or cryptocurrencies across multiple platforms.', + es: 'Ghostfolio es un dashboard de finanzas personales para hacer un seguimiento de tus activos como acciones, ETFs o criptodivisas a través de múltiples plataformas.', + fr: 'Ghostfolio est un dashboard de finances personnelles qui permet de suivre vos actifs comme les actions, les ETF ou les crypto-monnaies sur plusieurs plateformes.', + it: 'Ghostfolio è un dashboard di finanza personale per tenere traccia delle vostre attività come azioni, ETF o criptovalute su più piattaforme.', + nl: 'Ghostfolio is een persoonlijk financieel dashboard om uw activa zoals aandelen, ETF’s of cryptocurrencies over meerdere platforms bij te houden.', + pt: 'Ghostfolio é um dashboard de finanças pessoais para acompanhar os seus activos como acções, ETFs ou criptomoedas em múltiplas plataformas.' +}; + +const title = 'Ghostfolio – Open Source Wealth Management Software'; +const titleShort = 'Ghostfolio'; + +let indexHtmlMap: { [languageCode: string]: string } = {}; + +try { + indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce( + (map, languageCode) => ({ + ...map, + [languageCode]: fs.readFileSync( + join(__dirname, '..', 'client', languageCode, 'index.html'), + 'utf8' + ) + }), + {} + ); +} catch {} + +const locales = { + '/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt': { + featureGraphicPath: 'assets/images/blog/ghostfolio-x-sackgeld.png', + title: `Ghostfolio auf Sackgeld.com vorgestellt - ${titleShort}` + }, + '/en/blog/2022/08/500-stars-on-github': { + featureGraphicPath: 'assets/images/blog/500-stars-on-github.jpg', + title: `500 Stars - ${titleShort}` + }, + '/en/blog/2022/10/hacktoberfest-2022': { + featureGraphicPath: 'assets/images/blog/hacktoberfest-2022.png', + title: `Hacktoberfest 2022 - ${titleShort}` + }, + '/en/blog/2022/12/the-importance-of-tracking-your-personal-finances': { + featureGraphicPath: 'assets/images/blog/20221226.jpg', + title: `The importance of tracking your personal finances - ${titleShort}` + }, + '/en/blog/2023/02/ghostfolio-meets-umbrel': { + featureGraphicPath: 'assets/images/blog/ghostfolio-x-umbrel.png', + title: `Ghostfolio meets Umbrel - ${titleShort}` + }, + '/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github': { + featureGraphicPath: 'assets/images/blog/1000-stars-on-github.jpg', + title: `Ghostfolio reaches 1’000 Stars on GitHub - ${titleShort}` + }, + '/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio': { + featureGraphicPath: 'assets/images/blog/20230520.jpg', + title: `Unlock your Financial Potential with Ghostfolio - ${titleShort}` + }, + '/en/blog/2023/07/exploring-the-path-to-fire': { + featureGraphicPath: 'assets/images/blog/20230701.jpg', + title: `Exploring the Path to FIRE - ${titleShort}` + }, + '/en/blog/2023/08/ghostfolio-joins-oss-friends': { + featureGraphicPath: 'assets/images/blog/ghostfolio-joins-oss-friends.png', + title: `Ghostfolio joins OSS Friends - ${titleShort}` + }, + '/en/blog/2023/09/ghostfolio-2': { + featureGraphicPath: 'assets/images/blog/ghostfolio-2.jpg', + title: `Announcing Ghostfolio 2.0 - ${titleShort}` + } +}; + +const isFileRequest = (filename: string) => { + if (filename === '/assets/LICENSE') { + return true; + } else if ( + filename.includes('auth/ey') || + filename.includes( + 'personal-finance-tools/open-source-alternative-to-markets.sh' + ) + ) { + return false; + } + + return filename.split('.').pop() !== filename; +}; + +export const HtmlTemplateMiddleware = async ( + request: Request, + response: Response, + next: NextFunction +) => { + const path = request.originalUrl.replace(/\/$/, ''); + let languageCode = path.substr(1, 2); + + if (!SUPPORTED_LANGUAGE_CODES.includes(languageCode)) { + languageCode = DEFAULT_LANGUAGE_CODE; + } + + const currentDate = format(new Date(), DATE_FORMAT); + const rootUrl = process.env.ROOT_URL || DEFAULT_ROOT_URL; + + if ( + path.startsWith('/api/') || + isFileRequest(path) || + !environment.production + ) { + // Skip + next(); + } else { + const indexHtml = interpolate(indexHtmlMap[languageCode], { + currentDate, + languageCode, + path, + rootUrl, + description: descriptions[languageCode], + featureGraphicPath: + locales[path]?.featureGraphicPath ?? 'assets/cover.png', + title: locales[path]?.title ?? title + }); + + return response.send(indexHtml); + } +}; diff --git a/apps/api/src/services/account-balance/account-balance.module.ts b/apps/api/src/services/account-balance/account-balance.module.ts new file mode 100644 index 000000000..53c695b5f --- /dev/null +++ b/apps/api/src/services/account-balance/account-balance.module.ts @@ -0,0 +1,10 @@ +import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { Module } from '@nestjs/common'; + +@Module({ + exports: [AccountBalanceService], + imports: [PrismaModule], + providers: [AccountBalanceService] +}) +export class AccountBalanceModule {} diff --git a/apps/api/src/services/account-balance/account-balance.service.ts b/apps/api/src/services/account-balance/account-balance.service.ts new file mode 100644 index 000000000..9cd2d31ac --- /dev/null +++ b/apps/api/src/services/account-balance/account-balance.service.ts @@ -0,0 +1,16 @@ +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { Injectable } from '@nestjs/common'; +import { AccountBalance, Prisma } from '@prisma/client'; + +@Injectable() +export class AccountBalanceService { + public constructor(private readonly prismaService: PrismaService) {} + + public async createAccountBalance( + data: Prisma.AccountBalanceCreateInput + ): Promise { + return this.prismaService.accountBalance.create({ + data + }); + } +} diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index e522aeccd..40a04f5a0 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -1,4 +1,5 @@ import { Environment } from '@ghostfolio/api/services/interfaces/environment.interface'; +import { DEFAULT_ROOT_URL } from '@ghostfolio/common/config'; import { Injectable } from '@nestjs/common'; import { DataSource } from '@prisma/client'; import { bool, cleanEnv, host, json, num, port, str } from 'envalid'; @@ -11,10 +12,6 @@ export class ConfigurationService { this.environmentConfiguration = cleanEnv(process.env, { ACCESS_TOKEN_SALT: str(), ALPHA_VANTAGE_API_KEY: str({ default: '' }), - BASE_CURRENCY: str({ - choices: ['AUD', 'CAD', 'CNY', 'EUR', 'GBP', 'JPY', 'RUB', 'USD'], - default: 'USD' - }), BETTER_UPTIME_API_KEY: str({ default: '' }), CACHE_QUOTES_TTL: num({ default: 1 }), CACHE_TTL: num({ default: 1 }), @@ -46,7 +43,7 @@ export class ConfigurationService { REDIS_HOST: str({ default: 'localhost' }), REDIS_PASSWORD: str({ default: '' }), REDIS_PORT: port({ default: 6379 }), - ROOT_URL: str({ default: 'http://localhost:4200' }), + ROOT_URL: str({ default: DEFAULT_ROOT_URL }), STRIPE_PUBLIC_KEY: str({ default: '' }), STRIPE_SECRET_KEY: str({ default: '' }), TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }), diff --git a/apps/api/src/services/cron.service.ts b/apps/api/src/services/cron.service.ts index 72043b36b..e3597f049 100644 --- a/apps/api/src/services/cron.service.ts +++ b/apps/api/src/services/cron.service.ts @@ -2,6 +2,7 @@ import { GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS_OPTIONS } from '@ghostfolio/common/config'; +import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { Injectable } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; @@ -48,7 +49,7 @@ export class CronService { name: GATHER_ASSET_PROFILE_PROCESS, opts: { ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, - jobId: `${dataSource}-${symbol}` + jobId: getAssetProfileIdentifier({ dataSource, symbol }) } }; }) diff --git a/apps/api/src/services/data-gathering/data-gathering.module.ts b/apps/api/src/services/data-gathering/data-gathering.module.ts index 50131bbe8..673364f09 100644 --- a/apps/api/src/services/data-gathering/data-gathering.module.ts +++ b/apps/api/src/services/data-gathering/data-gathering.module.ts @@ -5,6 +5,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data- 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 { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config'; import { BullModule } from '@nestjs/bull'; @@ -28,6 +29,7 @@ import { DataGatheringProcessor } from './data-gathering.processor'; ExchangeRateDataModule, MarketDataModule, PrismaModule, + PropertyModule, SymbolProfileModule ], providers: [DataGatheringProcessor, DataGatheringService], diff --git a/apps/api/src/services/data-gathering/data-gathering.processor.ts b/apps/api/src/services/data-gathering/data-gathering.processor.ts index c517e0f15..a3ab0e513 100644 --- a/apps/api/src/services/data-gathering/data-gathering.processor.ts +++ b/apps/api/src/services/data-gathering/data-gathering.processor.ts @@ -13,6 +13,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { Job } from 'bull'; import { + addDays, format, getDate, getMonth, @@ -101,15 +102,7 @@ export class DataGatheringProcessor { }); } - // Count month one up for iteration - currentDate = new Date( - Date.UTC( - getYear(currentDate), - getMonth(currentDate), - getDate(currentDate) + 1, - 0 - ) - ); + currentDate = addDays(currentDate, 1); } await this.marketDataService.updateMany({ data }); diff --git a/apps/api/src/services/data-gathering/data-gathering.service.ts b/apps/api/src/services/data-gathering/data-gathering.service.ts index fc77bdc60..16ca505de 100644 --- a/apps/api/src/services/data-gathering/data-gathering.service.ts +++ b/apps/api/src/services/data-gathering/data-gathering.service.ts @@ -4,14 +4,20 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; 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 { DATA_GATHERING_QUEUE, GATHER_HISTORICAL_MARKET_DATA_PROCESS, - GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS + GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS, + PROPERTY_BENCHMARKS } from '@ghostfolio/common/config'; -import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper'; -import { UniqueAsset } from '@ghostfolio/common/interfaces'; +import { + DATE_FORMAT, + getAssetProfileIdentifier, + resetHours +} from '@ghostfolio/common/helper'; +import { BenchmarkProperty, UniqueAsset } from '@ghostfolio/common/interfaces'; import { InjectQueue } from '@nestjs/bull'; import { Inject, Injectable, Logger } from '@nestjs/common'; import { DataSource } from '@prisma/client'; @@ -30,6 +36,7 @@ export class DataGatheringService { private readonly exchangeRateDataService: ExchangeRateDataService, private readonly marketDataService: MarketDataService, private readonly prismaService: PrismaService, + private readonly propertyService: PropertyService, private readonly symbolProfileService: SymbolProfileService ) {} @@ -120,12 +127,10 @@ export class DataGatheringService { uniqueAssets = await this.getUniqueAssets(); } - const assetProfiles = await this.dataProviderService.getAssetProfiles( - uniqueAssets - ); - const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( - uniqueAssets - ); + const assetProfiles = + await this.dataProviderService.getAssetProfiles(uniqueAssets); + const symbolProfiles = + await this.symbolProfileService.getSymbolProfiles(uniqueAssets); for (const [symbol, assetProfile] of Object.entries(assetProfiles)) { const symbolMapping = symbolProfiles.find((symbolProfile) => { @@ -140,7 +145,9 @@ export class DataGatheringService { }); } catch (error) { Logger.error( - `Failed to enhance data for symbol ${symbol} by ${dataEnhancer.getName()}`, + `Failed to enhance data for ${symbol} (${ + assetProfile.dataSource + }) by ${dataEnhancer.getName()}`, error, 'DataGatheringService' ); @@ -221,7 +228,10 @@ export class DataGatheringService { name: GATHER_HISTORICAL_MARKET_DATA_PROCESS, opts: { ...GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS, - jobId: `${dataSource}-${symbol}-${format(date, DATE_FORMAT)}` + jobId: `${getAssetProfileIdentifier({ + dataSource, + symbol + })}-${format(date, DATE_FORMAT)}` } }; }) @@ -248,6 +258,10 @@ export class DataGatheringService { }); } + private getEarliestDate(aStartDate: Date) { + return min([aStartDate, subYears(new Date(), 10)]); + } + private async getSymbols7D(): Promise { const startDate = subDays(resetHours(new Date()), 7); @@ -314,6 +328,14 @@ export class DataGatheringService { } private async getSymbolsMax(): Promise { + const benchmarkAssetProfileIdMap: { [key: string]: boolean } = {}; + ( + ((await this.propertyService.getByKey( + PROPERTY_BENCHMARKS + )) as BenchmarkProperty[]) ?? [] + ).forEach(({ symbolProfileId }) => { + benchmarkAssetProfileIdMap[symbolProfileId] = true; + }); const startDate = ( await this.prismaService.order.findFirst({ @@ -327,7 +349,7 @@ export class DataGatheringService { return { dataSource, symbol, - date: min([startDate, subYears(new Date(), 10)]) + date: this.getEarliestDate(startDate) }; }); @@ -336,6 +358,7 @@ export class DataGatheringService { orderBy: [{ symbol: 'asc' }], select: { dataSource: true, + id: true, Order: { orderBy: [{ date: 'asc' }], select: { date: true }, @@ -357,9 +380,15 @@ export class DataGatheringService { ); }) .map((symbolProfile) => { + let date = symbolProfile.Order?.[0]?.date ?? startDate; + + if (benchmarkAssetProfileIdMap[symbolProfile.id]) { + date = this.getEarliestDate(startDate); + } + return { ...symbolProfile, - date: symbolProfile.Order?.[0]?.date ?? startDate + date }; }); diff --git a/apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts b/apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts index 4713a4c5f..973fc5df2 100644 --- a/apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts +++ b/apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts @@ -9,6 +9,7 @@ import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { Granularity } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; import { DataSource, SymbolProfile } from '@prisma/client'; +import * as Alphavantage from 'alphavantage'; import { format, isAfter, isBefore, parse } from 'date-fns'; import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces'; @@ -20,7 +21,7 @@ export class AlphaVantageService implements DataProviderInterface { public constructor( private readonly configurationService: ConfigurationService ) { - this.alphaVantage = require('alphavantage')({ + this.alphaVantage = Alphavantage({ key: this.configurationService.get('ALPHA_VANTAGE_API_KEY') }); } @@ -126,6 +127,9 @@ export class AlphaVantageService implements DataProviderInterface { return { items: result?.bestMatches?.map((bestMatch) => { return { + assetClass: undefined, + assetSubClass: undefined, + currency: bestMatch['8. currency'], dataSource: this.getName(), name: bestMatch['2. name'], symbol: bestMatch['1. symbol'] 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 35083e810..4360822f0 100644 --- a/apps/api/src/services/data-provider/coingecko/coingecko.service.ts +++ b/apps/api/src/services/data-provider/coingecko/coingecko.service.ts @@ -1,10 +1,13 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { IDataProviderHistoricalResponse, IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces'; +import { + DEFAULT_CURRENCY, + DEFAULT_REQUEST_TIMEOUT +} from '@ghostfolio/common/config'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import { Granularity } from '@ghostfolio/common/types'; @@ -15,19 +18,14 @@ import { DataSource, SymbolProfile } from '@prisma/client'; -import bent from 'bent'; import { format, fromUnixTime, getUnixTime } from 'date-fns'; +import got from 'got'; @Injectable() export class CoinGeckoService implements DataProviderInterface { - private baseCurrency: string; private readonly URL = 'https://api.coingecko.com/api/v3'; - public constructor( - private readonly configurationService: ConfigurationService - ) { - this.baseCurrency = this.configurationService.get('BASE_CURRENCY'); - } + public constructor() {} public canHandle(symbol: string) { return true; @@ -39,14 +37,22 @@ export class CoinGeckoService implements DataProviderInterface { const response: Partial = { assetClass: AssetClass.CASH, assetSubClass: AssetSubClass.CRYPTOCURRENCY, - currency: this.baseCurrency, + currency: DEFAULT_CURRENCY, dataSource: this.getName(), symbol: aSymbol }; try { - const get = bent(`${this.URL}/coins/${aSymbol}`, 'GET', 'json', 200); - const { name } = await get(); + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const { name } = await got(`${this.URL}/coins/${aSymbol}`, { + // @ts-ignore + signal: abortController.signal + }).json(); response.name = name; } catch (error) { @@ -79,17 +85,23 @@ export class CoinGeckoService implements DataProviderInterface { [symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; }> { try { - const get = bent( + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const { prices } = await got( `${ this.URL - }/coins/${aSymbol}/market_chart/range?vs_currency=${this.baseCurrency.toLowerCase()}&from=${getUnixTime( + }/coins/${aSymbol}/market_chart/range?vs_currency=${DEFAULT_CURRENCY.toLowerCase()}&from=${getUnixTime( from )}&to=${getUnixTime(to)}`, - 'GET', - 'json', - 200 - ); - const { prices } = await get(); + { + // @ts-ignore + signal: abortController.signal + } + ).json(); const result: { [symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; @@ -132,23 +144,29 @@ export class CoinGeckoService implements DataProviderInterface { } try { - const get = bent( + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const response = await got( `${this.URL}/simple/price?ids=${aSymbols.join( ',' - )}&vs_currencies=${this.baseCurrency.toLowerCase()}`, - 'GET', - 'json', - 200 - ); - const response = await get(); + )}&vs_currencies=${DEFAULT_CURRENCY.toLowerCase()}`, + { + // @ts-ignore + signal: abortController.signal + } + ).json(); for (const symbol in response) { if (Object.prototype.hasOwnProperty.call(response, symbol)) { results[symbol] = { - currency: this.baseCurrency, + currency: DEFAULT_CURRENCY, dataProviderInfo: this.getDataProviderInfo(), dataSource: DataSource.COINGECKO, - marketPrice: response[symbol][this.baseCurrency.toLowerCase()], + marketPrice: response[symbol][DEFAULT_CURRENCY.toLowerCase()], marketState: 'open' }; } @@ -174,8 +192,16 @@ export class CoinGeckoService implements DataProviderInterface { let items: LookupItem[] = []; try { - const get = bent(`${this.URL}/search?query=${query}`, 'GET', 'json', 200); - const { coins } = await get(); + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const { coins } = await got(`${this.URL}/search?query=${query}`, { + // @ts-ignore + signal: abortController.signal + }).json(); items = coins.map(({ id: symbol, name }) => { return { @@ -183,7 +209,7 @@ export class CoinGeckoService implements DataProviderInterface { symbol, assetClass: AssetClass.CASH, assetSubClass: AssetSubClass.CRYPTOCURRENCY, - currency: this.baseCurrency, + currency: DEFAULT_CURRENCY, dataSource: this.getName() }; }); diff --git a/apps/api/src/services/data-provider/data-enhancer/data-enhancer.module.ts b/apps/api/src/services/data-provider/data-enhancer/data-enhancer.module.ts index a658ef448..069309508 100644 --- a/apps/api/src/services/data-provider/data-enhancer/data-enhancer.module.ts +++ b/apps/api/src/services/data-provider/data-enhancer/data-enhancer.module.ts @@ -4,14 +4,18 @@ import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-p import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service'; import { Module } from '@nestjs/common'; +import { DataEnhancerService } from './data-enhancer.service'; + @Module({ exports: [ - 'DataEnhancers', + DataEnhancerService, TrackinsightDataEnhancerService, - YahooFinanceDataEnhancerService + YahooFinanceDataEnhancerService, + 'DataEnhancers' ], imports: [ConfigurationModule, CryptocurrencyModule], providers: [ + DataEnhancerService, TrackinsightDataEnhancerService, YahooFinanceDataEnhancerService, { diff --git a/apps/api/src/services/data-provider/data-enhancer/data-enhancer.service.ts b/apps/api/src/services/data-provider/data-enhancer/data-enhancer.service.ts new file mode 100644 index 000000000..e5038c7c6 --- /dev/null +++ b/apps/api/src/services/data-provider/data-enhancer/data-enhancer.service.ts @@ -0,0 +1,44 @@ +import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; +import { HttpException, Inject, Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +@Injectable() +export class DataEnhancerService { + public constructor( + @Inject('DataEnhancers') + private readonly dataEnhancers: DataEnhancerInterface[] + ) {} + + public async enhance(aName: string) { + const dataEnhancer = this.dataEnhancers.find((dataEnhancer) => { + return dataEnhancer.getName() === aName; + }); + + if (!dataEnhancer) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + try { + const assetProfile = await dataEnhancer.enhance({ + response: { + assetClass: 'EQUITY', + assetSubClass: 'ETF' + }, + symbol: dataEnhancer.getTestSymbol() + }); + + if ( + (assetProfile.countries as unknown as Prisma.JsonArray)?.length > 0 && + (assetProfile.sectors as unknown as Prisma.JsonArray)?.length > 0 + ) { + return true; + } + } catch {} + + return false; + } +} diff --git a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts index ec68dd2eb..36eb22dad 100644 --- a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts +++ b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts @@ -1,15 +1,14 @@ import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; +import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config'; import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Injectable } from '@nestjs/common'; import { SymbolProfile } from '@prisma/client'; -import bent from 'bent'; - -const getJSON = bent('json'); +import got from 'got'; @Injectable() export class TrackinsightDataEnhancerService implements DataEnhancerInterface { - private static baseUrl = 'https://data.trackinsight.com'; + private static baseUrl = 'https://www.trackinsight.com/data-api'; private static countries = require('countries-list/dist/countries.json'); private static countriesMapping = { 'Russian Federation': 'Russia' @@ -34,27 +33,83 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { return response; } - const profile = await getJSON( - `${TrackinsightDataEnhancerService.baseUrl}/data-api/funds/${symbol}.json` - ).catch(() => { - return {}; - }); + let abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const profile = await got( + `${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol}.json`, + { + // @ts-ignore + signal: abortController.signal + } + ) + .json() + .catch(() => { + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + return got( + `${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol.split( + '.' + )?.[0]}.json`, + { + // @ts-ignore + signal: abortController.signal + } + ) + .json() + .catch(() => { + return {}; + }); + }); - const isin = profile.isin?.split(';')?.[0]; + const isin = profile?.isin?.split(';')?.[0]; if (isin) { response.isin = isin; } - const holdings = await getJSON( - `${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json` - ).catch(() => { - return getJSON( - `${TrackinsightDataEnhancerService.baseUrl}/holdings/${ - symbol.split('.')?.[0] - }.json` - ); - }); + abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const holdings = await got( + `${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`, + { + // @ts-ignore + signal: abortController.signal + } + ) + .json() + .catch(() => { + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + return got( + `${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol.split( + '.' + )?.[0]}.json`, + { + // @ts-ignore + signal: abortController.signal + } + ) + .json() + .catch(() => { + return {}; + }); + }); if (holdings?.weight < 0.95) { // Skip if data is inaccurate @@ -112,4 +167,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { public getName() { return 'TRACKINSIGHT'; } + + public getTestSymbol() { + return 'QQQ'; + } } diff --git a/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.spec.ts b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.spec.ts index 951a623d0..8a8ab1f08 100644 --- a/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.spec.ts +++ b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.spec.ts @@ -1,4 +1,3 @@ -import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { YahooFinanceDataEnhancerService } from './yahoo-finance.service'; @@ -26,16 +25,13 @@ jest.mock( ); describe('YahooFinanceDataEnhancerService', () => { - let configurationService: ConfigurationService; let cryptocurrencyService: CryptocurrencyService; let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService; beforeAll(async () => { - configurationService = new ConfigurationService(); cryptocurrencyService = new CryptocurrencyService(); yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService( - configurationService, cryptocurrencyService ); }); diff --git a/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts index 1c8b96292..8731e709c 100644 --- a/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts +++ b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts @@ -1,13 +1,13 @@ -import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; -import { UNKNOWN_KEY } from '@ghostfolio/common/config'; +import { DEFAULT_CURRENCY, UNKNOWN_KEY } from '@ghostfolio/common/config'; import { isCurrency } from '@ghostfolio/common/helper'; import { Injectable, Logger } from '@nestjs/common'; import { AssetClass, AssetSubClass, DataSource, + Prisma, SymbolProfile } from '@prisma/client'; import { countries } from 'countries-list'; @@ -16,23 +16,18 @@ import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-ifa @Injectable() export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { - private baseCurrency: string; - public constructor( - private readonly configurationService: ConfigurationService, private readonly cryptocurrencyService: CryptocurrencyService - ) { - this.baseCurrency = this.configurationService.get('BASE_CURRENCY'); - } + ) {} public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) { let symbol = aYahooFinanceSymbol.replace( - new RegExp(`-${this.baseCurrency}$`), - this.baseCurrency + new RegExp(`-${DEFAULT_CURRENCY}$`), + DEFAULT_CURRENCY ); - if (symbol.includes('=X') && !symbol.includes(this.baseCurrency)) { - symbol = `${this.baseCurrency}${symbol}`; + if (symbol.includes('=X') && !symbol.includes(DEFAULT_CURRENCY)) { + symbol = `${DEFAULT_CURRENCY}${symbol}`; } return symbol.replace('=X', ''); @@ -47,21 +42,18 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { */ public convertToYahooFinanceSymbol(aSymbol: string) { if ( - aSymbol.includes(this.baseCurrency) && - aSymbol.length > this.baseCurrency.length + aSymbol.includes(DEFAULT_CURRENCY) && + aSymbol.length > DEFAULT_CURRENCY.length ) { if ( isCurrency( - aSymbol.substring(0, aSymbol.length - this.baseCurrency.length) + aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length) ) ) { return `${aSymbol}=X`; } else if ( this.cryptocurrencyService.isCryptocurrency( - aSymbol.replace( - new RegExp(`-${this.baseCurrency}$`), - this.baseCurrency - ) + aSymbol.replace(new RegExp(`-${DEFAULT_CURRENCY}$`), DEFAULT_CURRENCY) ) ) { // Add a dash before the last three characters @@ -69,8 +61,8 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { // DOGEUSD -> DOGE-USD // SOL1USD -> SOL1-USD return aSymbol.replace( - new RegExp(`-?${this.baseCurrency}$`), - `-${this.baseCurrency}` + new RegExp(`-?${DEFAULT_CURRENCY}$`), + `-${DEFAULT_CURRENCY}` ); } } @@ -99,15 +91,14 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { yahooSymbol = quotes[0].symbol; } - const { countries, sectors, url } = await this.getAssetProfile( - yahooSymbol - ); + const { countries, sectors, url } = + await this.getAssetProfile(yahooSymbol); - if (countries) { + if ((countries as unknown as Prisma.JsonArray)?.length > 0) { response.countries = countries; } - if (sectors) { + if ((sectors as unknown as Prisma.JsonArray)?.length > 0) { response.sectors = sectors; } @@ -135,6 +126,8 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { let name = longName; if (name) { + name = name.replace('&', '&'); + name = name.replace('Amundi Index Solutions - ', ''); name = name.replace('iShares ETF (CH) - ', ''); name = name.replace('iShares III Public Limited Company - ', ''); @@ -232,6 +225,10 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { return DataSource.YAHOO; } + public getTestSymbol() { + return 'AAPL'; + } + public parseAssetClass({ quoteType, shortName diff --git a/apps/api/src/services/data-provider/data-provider.service.ts b/apps/api/src/services/data-provider/data-provider.service.ts index b4f4e10b5..557699495 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -3,7 +3,6 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.in import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { - IDataGatheringItem, IDataProviderHistoricalResponse, IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces'; @@ -12,6 +11,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PROPERTY_DATA_SOURCE_MAPPING } from '@ghostfolio/common/config'; import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper'; +import { UniqueAsset } from '@ghostfolio/common/interfaces'; import type { Granularity, UserWithSettings } from '@ghostfolio/common/types'; import { Inject, Injectable, Logger } from '@nestjs/common'; import { DataSource, MarketData, SymbolProfile } from '@prisma/client'; @@ -45,12 +45,15 @@ export class DataProviderService { const dataProvider = this.getDataProvider(dataSource); const symbol = dataProvider.getTestSymbol(); - const quotes = await this.getQuotes([ - { - dataSource, - symbol - } - ]); + const quotes = await this.getQuotes({ + items: [ + { + dataSource, + symbol + } + ], + useCache: false + }); if (quotes[symbol]?.marketPrice > 0) { return true; @@ -59,14 +62,16 @@ export class DataProviderService { return false; } - public async getAssetProfiles(items: IDataGatheringItem[]): Promise<{ + public async getAssetProfiles(items: UniqueAsset[]): Promise<{ [symbol: string]: Partial; }> { const response: { [symbol: string]: Partial; } = {}; - const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource); + const itemsGroupedByDataSource = groupBy(items, ({ dataSource }) => { + return dataSource; + }); const promises = []; @@ -127,7 +132,7 @@ export class DataProviderService { } public async getHistorical( - aItems: IDataGatheringItem[], + aItems: UniqueAsset[], aGranularity: Granularity = 'month', from: Date, to: Date @@ -155,11 +160,11 @@ export class DataProviderService { )}'` : ''; - const dataSources = aItems.map((item) => { - return item.dataSource; + const dataSources = aItems.map(({ dataSource }) => { + return dataSource; }); - const symbols = aItems.map((item) => { - return item.symbol; + const symbols = aItems.map(({ symbol }) => { + return symbol; }); try { @@ -192,7 +197,7 @@ export class DataProviderService { } public async getHistoricalRaw( - aDataGatheringItems: IDataGatheringItem[], + aDataGatheringItems: UniqueAsset[], from: Date, to: Date ): Promise<{ @@ -229,7 +234,13 @@ export class DataProviderService { return result; } - public async getQuotes(items: IDataGatheringItem[]): Promise<{ + public async getQuotes({ + items, + useCache = true + }: { + items: UniqueAsset[]; + useCache?: boolean; + }): Promise<{ [symbol: string]: IDataProviderResponse; }> { const response: { @@ -238,23 +249,24 @@ export class DataProviderService { const startTimeTotal = performance.now(); // Get items from cache - const itemsToFetch: IDataGatheringItem[] = []; + const itemsToFetch: UniqueAsset[] = []; for (const { dataSource, symbol } of items) { - const quoteString = await this.redisCacheService.get( - this.redisCacheService.getQuoteKey({ dataSource, symbol }) - ); + if (useCache) { + const quoteString = await this.redisCacheService.get( + this.redisCacheService.getQuoteKey({ dataSource, symbol }) + ); - if (quoteString) { - try { - const cachedDataProviderResponse = JSON.parse(quoteString); - response[symbol] = cachedDataProviderResponse; - } catch {} + if (quoteString) { + try { + const cachedDataProviderResponse = JSON.parse(quoteString); + response[symbol] = cachedDataProviderResponse; + continue; + } catch {} + } } - if (!quoteString) { - itemsToFetch.push({ dataSource, symbol }); - } + itemsToFetch.push({ dataSource, symbol }); } const numberOfItemsInCache = Object.keys(response)?.length; 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 961f960da..307f6127a 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 @@ -5,6 +5,10 @@ import { IDataProviderHistoricalResponse, IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces'; +import { + DEFAULT_CURRENCY, + DEFAULT_REQUEST_TIMEOUT +} from '@ghostfolio/common/config'; import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper'; import { Granularity } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; @@ -14,21 +18,19 @@ import { DataSource, SymbolProfile } from '@prisma/client'; -import bent from 'bent'; import Big from 'big.js'; import { format, isToday } from 'date-fns'; +import got from 'got'; @Injectable() export class EodHistoricalDataService implements DataProviderInterface { private apiKey: string; - private baseCurrency: string; private readonly URL = 'https://eodhistoricaldata.com/api'; public constructor( private readonly configurationService: ConfigurationService ) { this.apiKey = this.configurationService.get('EOD_HISTORICAL_DATA_API_KEY'); - this.baseCurrency = this.configurationService.get('BASE_CURRENCY'); } public canHandle(symbol: string) { @@ -76,19 +78,24 @@ export class EodHistoricalDataService implements DataProviderInterface { const symbol = this.convertToEodSymbol(aSymbol); try { - const get = bent( + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const response = await got( `${this.URL}/eod/${symbol}?api_token=${ this.apiKey }&fmt=json&from=${format(from, DATE_FORMAT)}&to=${format( to, DATE_FORMAT )}&period={aGranularity}`, - 'GET', - 'json', - 200 - ); - - const response = await get(); + { + // @ts-ignore + signal: abortController.signal + } + ).json(); return response.reduce( (result, historicalItem, index, array) => { @@ -136,16 +143,21 @@ export class EodHistoricalDataService implements DataProviderInterface { } try { - const get = bent( + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const realTimeResponse = await got( `${this.URL}/real-time/${symbols[0]}?api_token=${ this.apiKey }&fmt=json&s=${symbols.join(',')}`, - 'GET', - 'json', - 200 - ); - - const realTimeResponse = await get(); + { + // @ts-ignore + signal: abortController.signal + } + ).json(); const quotes = symbols.length === 1 ? [realTimeResponse] : realTimeResponse; @@ -174,7 +186,7 @@ export class EodHistoricalDataService implements DataProviderInterface { })?.currency; result[this.convertFromEodSymbol(code)] = { - currency: currency ?? this.baseCurrency, + currency: currency ?? DEFAULT_CURRENCY, dataSource: DataSource.EOD_HISTORICAL_DATA, marketPrice: close, marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed' @@ -185,24 +197,24 @@ export class EodHistoricalDataService implements DataProviderInterface { {} ); - if (response[`${this.baseCurrency}GBP`]) { - response[`${this.baseCurrency}GBp`] = { - ...response[`${this.baseCurrency}GBP`], - currency: `${this.baseCurrency}GBp`, + if (response[`${DEFAULT_CURRENCY}GBP`]) { + response[`${DEFAULT_CURRENCY}GBp`] = { + ...response[`${DEFAULT_CURRENCY}GBP`], + currency: `${DEFAULT_CURRENCY}GBp`, marketPrice: this.getConvertedValue({ - symbol: `${this.baseCurrency}GBp`, - value: response[`${this.baseCurrency}GBP`].marketPrice + symbol: `${DEFAULT_CURRENCY}GBp`, + value: response[`${DEFAULT_CURRENCY}GBP`].marketPrice }) }; } - if (response[`${this.baseCurrency}ILS`]) { - response[`${this.baseCurrency}ILA`] = { - ...response[`${this.baseCurrency}ILS`], - currency: `${this.baseCurrency}ILA`, + if (response[`${DEFAULT_CURRENCY}ILS`]) { + response[`${DEFAULT_CURRENCY}ILA`] = { + ...response[`${DEFAULT_CURRENCY}ILS`], + currency: `${DEFAULT_CURRENCY}ILA`, marketPrice: this.getConvertedValue({ - symbol: `${this.baseCurrency}ILA`, - value: response[`${this.baseCurrency}ILS`].marketPrice + symbol: `${DEFAULT_CURRENCY}ILA`, + value: response[`${DEFAULT_CURRENCY}ILS`].marketPrice }) }; } @@ -271,7 +283,7 @@ export class EodHistoricalDataService implements DataProviderInterface { if (symbol.endsWith('.FOREX')) { symbol = symbol.replace('GBX', 'GBp'); symbol = symbol.replace('.FOREX', ''); - symbol = `${this.baseCurrency}${symbol}`; + symbol = `${DEFAULT_CURRENCY}${symbol}`; } return symbol; @@ -284,17 +296,17 @@ export class EodHistoricalDataService implements DataProviderInterface { */ private convertToEodSymbol(aSymbol: string) { if ( - aSymbol.startsWith(this.baseCurrency) && - aSymbol.length > this.baseCurrency.length + aSymbol.startsWith(DEFAULT_CURRENCY) && + aSymbol.length > DEFAULT_CURRENCY.length ) { if ( isCurrency( - aSymbol.substring(0, aSymbol.length - this.baseCurrency.length) + aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length) ) ) { return `${aSymbol .replace('GBp', 'GBX') - .replace(this.baseCurrency, '')}.FOREX`; + .replace(DEFAULT_CURRENCY, '')}.FOREX`; } } @@ -308,10 +320,10 @@ export class EodHistoricalDataService implements DataProviderInterface { symbol: string; value: number; }) { - if (symbol === `${this.baseCurrency}GBp`) { + if (symbol === `${DEFAULT_CURRENCY}GBp`) { // Convert GPB to GBp (pence) return new Big(value).mul(100).toNumber(); - } else if (symbol === `${this.baseCurrency}ILA`) { + } else if (symbol === `${DEFAULT_CURRENCY}ILA`) { // Convert ILS to ILA return new Big(value).mul(100).toNumber(); } @@ -329,13 +341,19 @@ export class EodHistoricalDataService implements DataProviderInterface { let searchResult = []; try { - const get = bent( + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const response = await got( `${this.URL}/search/${aQuery}?api_token=${this.apiKey}`, - 'GET', - 'json', - 200 - ); - const response = await get(); + { + // @ts-ignore + signal: abortController.signal + } + ).json(); searchResult = response.map( ({ Code, Currency, Exchange, ISIN: isin, Name: name, Type }) => { 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 ed65c3f65..4fd1d4ebd 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 @@ -5,18 +5,21 @@ import { IDataProviderHistoricalResponse, IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces'; +import { + DEFAULT_CURRENCY, + DEFAULT_REQUEST_TIMEOUT +} from '@ghostfolio/common/config'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import { Granularity } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; import { DataSource, SymbolProfile } from '@prisma/client'; -import bent from 'bent'; import { format, isAfter, isBefore, isSameDay } from 'date-fns'; +import got from 'got'; @Injectable() export class FinancialModelingPrepService implements DataProviderInterface { private apiKey: string; - private baseCurrency: string; private readonly URL = 'https://financialmodelingprep.com/api/v3'; public constructor( @@ -25,7 +28,6 @@ export class FinancialModelingPrepService implements DataProviderInterface { this.apiKey = this.configurationService.get( 'FINANCIAL_MODELING_PREP_API_KEY' ); - this.baseCurrency = this.configurationService.get('BASE_CURRENCY'); } public canHandle(symbol: string) { @@ -64,13 +66,19 @@ export class FinancialModelingPrepService implements DataProviderInterface { [symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; }> { try { - const get = bent( + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const { historical } = await got( `${this.URL}/historical-price-full/${aSymbol}?apikey=${this.apiKey}`, - 'GET', - 'json', - 200 - ); - const { historical } = await get(); + { + // @ts-ignore + signal: abortController.signal + } + ).json(); const result: { [symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; @@ -115,17 +123,23 @@ export class FinancialModelingPrepService implements DataProviderInterface { } try { - const get = bent( + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const response = await got( `${this.URL}/quote/${aSymbols.join(',')}?apikey=${this.apiKey}`, - 'GET', - 'json', - 200 - ); - const response = await get(); + { + // @ts-ignore + signal: abortController.signal + } + ).json(); for (const { price, symbol } of response) { results[symbol] = { - currency: this.baseCurrency, + currency: DEFAULT_CURRENCY, dataProviderInfo: this.getDataProviderInfo(), dataSource: DataSource.FINANCIAL_MODELING_PREP, marketPrice: price, @@ -153,13 +167,19 @@ export class FinancialModelingPrepService implements DataProviderInterface { let items: LookupItem[] = []; try { - const get = bent( + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const result = await got( `${this.URL}/search?query=${query}&apikey=${this.apiKey}`, - 'GET', - 'json', - 200 - ); - const result = await get(); + { + // @ts-ignore + signal: abortController.signal + } + ).json(); items = result.map(({ currency, name, symbol }) => { return { diff --git a/apps/api/src/services/data-provider/interfaces/data-enhancer.interface.ts b/apps/api/src/services/data-provider/interfaces/data-enhancer.interface.ts index 4e5ce8cba..9c6db9196 100644 --- a/apps/api/src/services/data-provider/interfaces/data-enhancer.interface.ts +++ b/apps/api/src/services/data-provider/interfaces/data-enhancer.interface.ts @@ -10,4 +10,6 @@ export interface DataEnhancerInterface { }): Promise>; getName(): string; + + getTestSymbol(): string; } 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 7b3933532..5c84a9c92 100644 --- a/apps/api/src/services/data-provider/manual/manual.service.ts +++ b/apps/api/src/services/data-provider/manual/manual.service.ts @@ -6,6 +6,7 @@ import { } from '@ghostfolio/api/services/interfaces/interfaces'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config'; import { DATE_FORMAT, extractNumberFromString, @@ -14,10 +15,10 @@ import { import { Granularity } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; import { DataSource, SymbolProfile } from '@prisma/client'; -import bent from 'bent'; import * as cheerio from 'cheerio'; import { isUUID } from 'class-validator'; import { addDays, format, isBefore } from 'date-fns'; +import got from 'got'; @Injectable() export class ManualService implements DataProviderInterface { @@ -95,10 +96,19 @@ export class ManualService implements DataProviderInterface { return {}; } - const get = bent(url, 'GET', 'string', 200, headers); + const abortController = new AbortController(); - const html = await get(); - const $ = cheerio.load(html); + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const { body } = await got(url, { + headers, + // @ts-ignore + signal: abortController.signal + }); + + const $ = cheerio.load(body); const value = extractNumberFromString($(selector).text()); diff --git a/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts b/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts index 053391a8a..7743d7805 100644 --- a/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts +++ b/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts @@ -5,13 +5,16 @@ import { IDataProviderHistoricalResponse, IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces'; -import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config'; +import { + DEFAULT_REQUEST_TIMEOUT, + ghostfolioFearAndGreedIndexSymbol +} from '@ghostfolio/common/config'; import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import { Granularity } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; import { DataSource, SymbolProfile } from '@prisma/client'; -import bent from 'bent'; import { format } from 'date-fns'; +import got from 'got'; @Injectable() export class RapidApiService implements DataProviderInterface { @@ -135,19 +138,25 @@ export class RapidApiService implements DataProviderInterface { oneYearAgo: { value: number; valueText: string }; }> { try { - const get = bent( + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const { fgi } = await got( `https://fear-and-greed-index.p.rapidapi.com/v1/fgi`, - 'GET', - 'json', - 200, { - useQueryString: true, - 'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com', - 'x-rapidapi-key': this.configurationService.get('RAPID_API_API_KEY') + headers: { + useQueryString: 'true', + 'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com', + 'x-rapidapi-key': this.configurationService.get('RAPID_API_API_KEY') + }, + // @ts-ignore + signal: abortController.signal } - ); + ).json(); - const { fgi } = await get(); return fgi; } catch (error) { Logger.error(error, 'RapidApiService'); 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 a525b4685..c7c0ebbc8 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 @@ -1,5 +1,4 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; @@ -7,6 +6,7 @@ import { IDataProviderHistoricalResponse, IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces'; +import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { Granularity } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; @@ -18,15 +18,10 @@ import { Quote } from 'yahoo-finance2/dist/esm/src/modules/quote'; @Injectable() export class YahooFinanceService implements DataProviderInterface { - private baseCurrency: string; - public constructor( - private readonly configurationService: ConfigurationService, private readonly cryptocurrencyService: CryptocurrencyService, private readonly yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService - ) { - this.baseCurrency = this.configurationService.get('BASE_CURRENCY'); - } + ) {} public canHandle(symbol: string) { return true; @@ -212,50 +207,50 @@ export class YahooFinanceService implements DataProviderInterface { }; if ( - symbol === `${this.baseCurrency}GBP` && - yahooFinanceSymbols.includes(`${this.baseCurrency}GBp=X`) + symbol === `${DEFAULT_CURRENCY}GBP` && + yahooFinanceSymbols.includes(`${DEFAULT_CURRENCY}GBp=X`) ) { // Convert GPB to GBp (pence) - response[`${this.baseCurrency}GBp`] = { + response[`${DEFAULT_CURRENCY}GBp`] = { ...response[symbol], currency: 'GBp', marketPrice: this.getConvertedValue({ - symbol: `${this.baseCurrency}GBp`, + symbol: `${DEFAULT_CURRENCY}GBp`, value: response[symbol].marketPrice }) }; } else if ( - symbol === `${this.baseCurrency}ILS` && - yahooFinanceSymbols.includes(`${this.baseCurrency}ILA=X`) + symbol === `${DEFAULT_CURRENCY}ILS` && + yahooFinanceSymbols.includes(`${DEFAULT_CURRENCY}ILA=X`) ) { // Convert ILS to ILA - response[`${this.baseCurrency}ILA`] = { + response[`${DEFAULT_CURRENCY}ILA`] = { ...response[symbol], currency: 'ILA', marketPrice: this.getConvertedValue({ - symbol: `${this.baseCurrency}ILA`, + symbol: `${DEFAULT_CURRENCY}ILA`, value: response[symbol].marketPrice }) }; } else if ( - symbol === `${this.baseCurrency}ZAR` && - yahooFinanceSymbols.includes(`${this.baseCurrency}ZAc=X`) + symbol === `${DEFAULT_CURRENCY}ZAR` && + yahooFinanceSymbols.includes(`${DEFAULT_CURRENCY}ZAc=X`) ) { // Convert ZAR to ZAc (cents) - response[`${this.baseCurrency}ZAc`] = { + response[`${DEFAULT_CURRENCY}ZAc`] = { ...response[symbol], currency: 'ZAc', marketPrice: this.getConvertedValue({ - symbol: `${this.baseCurrency}ZAc`, + symbol: `${DEFAULT_CURRENCY}ZAc`, value: response[symbol].marketPrice }) }; } } - if (yahooFinanceSymbols.includes(`${this.baseCurrency}USX=X`)) { + if (yahooFinanceSymbols.includes(`${DEFAULT_CURRENCY}USX=X`)) { // Convert USD to USX (cent) - response[`${this.baseCurrency}USX`] = { + response[`${DEFAULT_CURRENCY}USX`] = { currency: 'USX', dataSource: this.getName(), marketPrice: new Big(1).mul(100).toNumber(), @@ -303,8 +298,8 @@ export class YahooFinanceService implements DataProviderInterface { (quoteType === 'CRYPTOCURRENCY' && this.cryptocurrencyService.isCryptocurrency( symbol.replace( - new RegExp(`-${this.baseCurrency}$`), - this.baseCurrency + new RegExp(`-${DEFAULT_CURRENCY}$`), + DEFAULT_CURRENCY ) )) || quoteTypes.includes(quoteType) @@ -314,7 +309,7 @@ export class YahooFinanceService implements DataProviderInterface { if (quoteType === 'CRYPTOCURRENCY') { // Only allow cryptocurrencies in base currency to avoid having redundancy in the database. // Transactions need to be converted manually to the base currency before - return symbol.includes(this.baseCurrency); + return symbol.includes(DEFAULT_CURRENCY); } else if (quoteType === 'FUTURE') { // Allow GC=F, but not MGC=F return symbol.length === 4; @@ -373,13 +368,13 @@ export class YahooFinanceService implements DataProviderInterface { symbol: string; value: number; }) { - if (symbol === `${this.baseCurrency}GBp`) { + if (symbol === `${DEFAULT_CURRENCY}GBp`) { // Convert GPB to GBp (pence) return new Big(value).mul(100).toNumber(); - } else if (symbol === `${this.baseCurrency}ILA`) { + } else if (symbol === `${DEFAULT_CURRENCY}ILA`) { // Convert ILS to ILA return new Big(value).mul(100).toNumber(); - } else if (symbol === `${this.baseCurrency}ZAc`) { + } else if (symbol === `${DEFAULT_CURRENCY}ZAc`) { // Convert ZAR to ZAc (cents) return new Big(value).mul(100).toNumber(); } 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 448933b42..376aa1a6a 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,10 +1,12 @@ -import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; 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'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; -import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config'; +import { + DEFAULT_CURRENCY, + PROPERTY_CURRENCIES +} from '@ghostfolio/common/config'; import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import { Injectable, Logger } from '@nestjs/common'; import { format, isToday } from 'date-fns'; @@ -12,13 +14,11 @@ import { isNumber, uniq } from 'lodash'; @Injectable() export class ExchangeRateDataService { - private baseCurrency: string; private currencies: string[] = []; private currencyPairs: IDataGatheringItem[] = []; private exchangeRates: { [currencyPair: string]: number } = {}; public constructor( - private readonly configurationService: ConfigurationService, private readonly dataProviderService: DataProviderService, private readonly marketDataService: MarketDataService, private readonly prismaService: PrismaService, @@ -26,15 +26,23 @@ export class ExchangeRateDataService { ) {} public getCurrencies() { - return this.currencies?.length > 0 ? this.currencies : [this.baseCurrency]; + return this.currencies?.length > 0 ? this.currencies : [DEFAULT_CURRENCY]; } public getCurrencyPairs() { return this.currencyPairs; } + public hasCurrencyPair(currency1: string, currency2: string) { + return this.currencyPairs.some(({ symbol }) => { + return ( + symbol === `${currency1}${currency2}` || + symbol === `${currency2}${currency1}` + ); + }); + } + public async initialize() { - this.baseCurrency = this.configurationService.get('BASE_CURRENCY'); this.currencies = await this.prepareCurrencies(); this.currencyPairs = []; this.exchangeRates = {}; @@ -64,11 +72,11 @@ export class ExchangeRateDataService { if (Object.keys(result).length !== this.currencyPairs.length) { // Load currencies directly from data provider as a fallback // if historical data is not fully available - const quotes = await this.dataProviderService.getQuotes( - this.currencyPairs.map(({ dataSource, symbol }) => { + const quotes = await this.dataProviderService.getQuotes({ + items: this.currencyPairs.map(({ dataSource, symbol }) => { return { dataSource, symbol }; }) - ); + }); for (const symbol of Object.keys(quotes)) { if (isNumber(quotes[symbol].marketPrice)) { @@ -104,9 +112,9 @@ export class ExchangeRateDataService { if (!this.exchangeRates[symbol]) { // Not found, calculate indirectly via base currency this.exchangeRates[symbol] = - resultExtended[`${currency1}${this.baseCurrency}`]?.[date] + resultExtended[`${currency1}${DEFAULT_CURRENCY}`]?.[date] ?.marketPrice * - resultExtended[`${this.baseCurrency}${currency2}`]?.[date] + resultExtended[`${DEFAULT_CURRENCY}${currency2}`]?.[date] ?.marketPrice; // Calculate the opposite direction @@ -125,17 +133,18 @@ export class ExchangeRateDataService { return 0; } - let factor = 1; + let factor: number; - if (aFromCurrency !== aToCurrency) { + if (aFromCurrency === aToCurrency) { + factor = 1; + } else { if (this.exchangeRates[`${aFromCurrency}${aToCurrency}`]) { factor = this.exchangeRates[`${aFromCurrency}${aToCurrency}`]; } else { // Calculate indirectly via base currency const factor1 = - this.exchangeRates[`${aFromCurrency}${this.baseCurrency}`]; - const factor2 = - this.exchangeRates[`${this.baseCurrency}${aToCurrency}`]; + this.exchangeRates[`${aFromCurrency}${DEFAULT_CURRENCY}`]; + const factor2 = this.exchangeRates[`${DEFAULT_CURRENCY}${aToCurrency}`]; factor = factor1 * factor2; @@ -171,7 +180,9 @@ export class ExchangeRateDataService { let factor: number; - if (aFromCurrency !== aToCurrency) { + if (aFromCurrency === aToCurrency) { + factor = 1; + } else { const dataSource = this.dataProviderService.getDataSourceForExchangeRates(); const symbol = `${aFromCurrency}${aToCurrency}`; @@ -191,28 +202,28 @@ export class ExchangeRateDataService { let marketPriceBaseCurrencyToCurrency: number; try { - if (this.baseCurrency === aFromCurrency) { + if (aFromCurrency === DEFAULT_CURRENCY) { marketPriceBaseCurrencyFromCurrency = 1; } else { marketPriceBaseCurrencyFromCurrency = ( await this.marketDataService.get({ dataSource, date: aDate, - symbol: `${this.baseCurrency}${aFromCurrency}` + symbol: `${DEFAULT_CURRENCY}${aFromCurrency}` }) )?.marketPrice; } } catch {} try { - if (this.baseCurrency === aToCurrency) { + if (aToCurrency === DEFAULT_CURRENCY) { marketPriceBaseCurrencyToCurrency = 1; } else { marketPriceBaseCurrencyToCurrency = ( await this.marketDataService.get({ dataSource, date: aDate, - symbol: `${this.baseCurrency}${aToCurrency}` + symbol: `${DEFAULT_CURRENCY}${aToCurrency}` }) )?.marketPrice; } @@ -282,14 +293,14 @@ export class ExchangeRateDataService { private prepareCurrencyPairs(aCurrencies: string[]) { return aCurrencies .filter((currency) => { - return currency !== this.baseCurrency; + return currency !== DEFAULT_CURRENCY; }) .map((currency) => { return { - currency1: this.baseCurrency, + currency1: DEFAULT_CURRENCY, currency2: currency, dataSource: this.dataProviderService.getDataSourceForExchangeRates(), - symbol: `${this.baseCurrency}${currency}` + symbol: `${DEFAULT_CURRENCY}${currency}` }; }); } diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index 88fa4c3f5..b437668ab 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -3,7 +3,6 @@ import { CleanedEnvAccessors } from 'envalid'; export interface Environment extends CleanedEnvAccessors { ACCESS_TOKEN_SALT: string; ALPHA_VANTAGE_API_KEY: string; - BASE_CURRENCY: string; BETTER_UPTIME_API_KEY: string; CACHE_QUOTES_TTL: number; CACHE_TTL: number; diff --git a/apps/api/src/services/twitter-bot/twitter-bot.service.ts b/apps/api/src/services/twitter-bot/twitter-bot.service.ts index 218dd291f..d3e7fb91c 100644 --- a/apps/api/src/services/twitter-bot/twitter-bot.service.ts +++ b/apps/api/src/services/twitter-bot/twitter-bot.service.ts @@ -65,9 +65,8 @@ export class TwitterBotService { status += benchmarkListing; } - const { data: createdTweet } = await this.twitterClient.v2.tweet( - status - ); + const { data: createdTweet } = + await this.twitterClient.v2.tweet(status); Logger.log( `Fear & Greed Index has been tweeted: https://twitter.com/ghostfolio_/status/${createdTweet.id}`, diff --git a/apps/api/tsconfig.app.json b/apps/api/tsconfig.app.json index 44e62fa9f..a0c17b4fa 100644 --- a/apps/api/tsconfig.app.json +++ b/apps/api/tsconfig.app.json @@ -4,7 +4,7 @@ "outDir": "../../dist/out-tsc", "types": ["node"], "emitDecoratorMetadata": true, - "target": "es2015" + "target": "es2021" }, "exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"], "include": ["**/*.ts"] diff --git a/apps/client/project.json b/apps/client/project.json index a4c8830a7..76da6bd1a 100644 --- a/apps/client/project.json +++ b/apps/client/project.json @@ -11,60 +11,15 @@ "prefix": "gf", "targets": { "build": { - "executor": "@angular-devkit/build-angular:browser", + "executor": "@nx/angular:webpack-browser", "options": { + "localize": true, "outputPath": "dist/apps/client", "index": "apps/client/src/index.html", "main": "apps/client/src/main.ts", "polyfills": "apps/client/src/polyfills.ts", "tsConfig": "apps/client/tsconfig.app.json", - "assets": [ - { - "glob": "assetlinks.json", - "input": "apps/client/src/assets", - "output": "./../.well-known" - }, - { - "glob": "CHANGELOG.md", - "input": "", - "output": "./../assets" - }, - { - "glob": "LICENSE", - "input": "", - "output": "./../assets" - }, - { - "glob": "robots.txt", - "input": "apps/client/src/assets", - "output": "./../" - }, - { - "glob": "sitemap.xml", - "input": "apps/client/src/assets", - "output": "./../" - }, - { - "glob": "site.webmanifest", - "input": "apps/client/src/assets", - "output": "./../" - }, - { - "glob": "**/*", - "input": "node_modules/ionicons/dist/ionicons", - "output": "./../ionicons" - }, - { - "glob": "**/*.js", - "input": "node_modules/ionicons/dist/", - "output": "./../" - }, - { - "glob": "**/*", - "input": "apps/client/src/assets", - "output": "./../assets/" - } - ], + "assets": [], "styles": [ "apps/client/src/styles/theme.scss", "apps/client/src/styles.scss" @@ -139,8 +94,51 @@ "outputs": ["{options.outputPath}"], "defaultConfiguration": "" }, + "copy-assets": { + "executor": "nx:run-commands", + "options": { + "commands": [ + { + "command": "mkdir -p dist/apps/client" + }, + { + "command": "cp -r apps/client/src/assets dist/apps/client" + }, + { + "command": "cp -r apps/client/src/assets/.well-known dist/apps/client" + }, + { + "command": "cp apps/client/src/assets/favicon.ico dist/apps/client" + }, + { + "command": "cp apps/client/src/assets/index.html dist/apps/client" + }, + { + "command": "cp apps/client/src/assets/robots.txt dist/apps/client" + }, + { + "command": "cp apps/client/src/assets/site.webmanifest dist/apps/client" + }, + { + "command": "cp node_modules/ionicons/dist/index.js dist/apps/client" + }, + { + "command": "cp node_modules/ionicons/dist/ionicons.js dist/apps/client" + }, + { + "command": "cp -r node_modules/ionicons/dist/ionicons dist/apps/client/ionicons" + }, + { + "command": "cp CHANGELOG.md dist/apps/client/assets" + }, + { + "command": "cp LICENSE dist/apps/client/assets" + } + ] + } + }, "serve": { - "executor": "@angular-devkit/build-angular:dev-server", + "executor": "@nx/angular:webpack-dev-server", "options": { "browserTarget": "client:build", "proxyConfig": "apps/client/proxy.conf.json" diff --git a/apps/client/src/app/app-routing.module.ts b/apps/client/src/app/app-routing.module.ts index e9eed48a2..f82bad864 100644 --- a/apps/client/src/app/app-routing.module.ts +++ b/apps/client/src/app/app-routing.module.ts @@ -4,25 +4,29 @@ import { PageTitleStrategy } from '@ghostfolio/client/services/page-title.strate import { ModulePreloadService } from './core/module-preload.service'; +export const paths = { + about: $localize`about`, + faq: $localize`faq`, + features: $localize`features`, + license: $localize`license`, + markets: $localize`markets`, + pricing: $localize`pricing`, + privacyPolicy: $localize`privacy-policy`, + register: $localize`register`, + resources: $localize`resources` +}; + const routes: Routes = [ - ...[ - 'about', - ///// - 'a-propos', - 'informazioni-su', - 'over', - 'sobre', - 'ueber-uns' - ].map((path) => ({ - path, + { + path: paths.about, loadChildren: () => import('./pages/about/about-page.module').then((m) => m.AboutPageModule) - })), + }, { path: 'account', loadChildren: () => - import('./pages/account/account-page.module').then( - (m) => m.AccountPageModule + import('./pages/user-account/user-account-page.module').then( + (m) => m.UserAccountPageModule ) }, { @@ -42,64 +46,40 @@ const routes: Routes = [ loadChildren: () => import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule) }, - ...['blog'].map((path) => ({ - path, + { + path: 'blog', loadChildren: () => import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule) - })), + }, { path: 'demo', loadChildren: () => import('./pages/demo/demo-page.module').then((m) => m.DemoPageModule) }, - ...[ - 'faq', - ///// - 'domande-piu-frequenti', - 'foire-aux-questions', - 'haeufig-gestellte-fragen', - 'perguntas-mais-frequentes', - 'preguntas-mas-frecuentes', - 'vaak-gestelde-vragen' - ].map((path) => ({ - path, + { + path: paths.faq, loadChildren: () => import('./pages/faq/faq-page.module').then((m) => m.FaqPageModule) - })), - ...[ - 'features', - ///// - 'fonctionnalites', - 'funcionalidades', - 'funzionalita', - 'kenmerken' - ].map((path) => ({ - path, + }, + { + path: paths.features, loadChildren: () => import('./pages/features/features-page.module').then( (m) => m.FeaturesPageModule ) - })), + }, { path: 'home', loadChildren: () => import('./pages/home/home-page.module').then((m) => m.HomePageModule) }, - ...[ - 'markets', - ///// - 'maerkte', - 'marches', - 'markten', - 'mercados', - 'mercati' - ].map((path) => ({ - path, + { + path: paths.markets, loadChildren: () => import('./pages/markets/markets-page.module').then( (m) => m.MarketsPageModule ) - })), + }, { path: 'open', loadChildren: () => @@ -119,53 +99,27 @@ const routes: Routes = [ (m) => m.PortfolioPageModule ) }, - ...[ - 'pricing', - ///// - 'precios', - 'precos', - 'preise', - 'prezzi', - 'prijzen', - 'prix' - ].map((path) => ({ - path, + { + path: paths.pricing, loadChildren: () => import('./pages/pricing/pricing-page.module').then( (m) => m.PricingPageModule ) - })), - ...[ - 'register', - ///// - 'enregistrement', - 'iscrizione', - 'registo', - 'registratie', - 'registrierung', - 'registro' - ].map((path) => ({ - path, + }, + { + path: paths.register, loadChildren: () => import('./pages/register/register-page.module').then( (m) => m.RegisterPageModule ) - })), - ...[ - 'resources', - ///// - 'bronnen', - 'recursos', - 'ressourcen', - 'ressources', - 'risorse' - ].map((path) => ({ - path, + }, + { + path: paths.resources, loadChildren: () => import('./pages/resources/resources-page.module').then( (m) => m.ResourcesPageModule ) - })), + }, { path: 'start', loadChildren: () => diff --git a/apps/client/src/app/app.component.html b/apps/client/src/app/app.component.html index 3eafe89ad..b8dfbdb9c 100644 --- a/apps/client/src/app/app.component.html +++ b/apps/client/src/app/app.component.html @@ -1,37 +1,26 @@
- -
- -
-
-
+
+ + + + +
-