diff --git a/.env.dev b/.env.dev new file mode 100644 index 000000000..c4c8a0d35 --- /dev/null +++ b/.env.dev @@ -0,0 +1,25 @@ +COMPOSE_PROJECT_NAME=ghostfolio-development + +# CACHE +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= + +# POSTGRES +POSTGRES_DB=ghostfolio-db +POSTGRES_USER=user +POSTGRES_PASSWORD= + +# VARIOUS +ACCESS_TOKEN_SALT= +DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer +JWT_SECRET_KEY= + +# DEVELOPMENT + +# Nx 18 enables using plugins to infer targets by default +# This is disabled for existing workspaces to maintain compatibility +# For more info, see: https://nx.dev/concepts/inferred-tasks +NX_ADD_PLUGINS=false + +NX_NATIVE_COMMAND_RUNNER=false diff --git a/.env.example b/.env.example index 8df547e37..766894992 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -COMPOSE_PROJECT_NAME=ghostfolio-development +COMPOSE_PROJECT_NAME=ghostfolio # CACHE REDIS_HOST=localhost @@ -10,6 +10,7 @@ POSTGRES_DB=ghostfolio-db POSTGRES_USER=user POSTGRES_PASSWORD= +# VARIOUS ACCESS_TOKEN_SALT= DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer JWT_SECRET_KEY= diff --git a/.eslintrc.json b/.eslintrc.json index 33e0aafa1..1b7628ebf 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -24,12 +24,18 @@ { "files": ["*.ts", "*.tsx"], "extends": ["plugin:@nx/typescript"], - "rules": {} + "rules": { + "@typescript-eslint/no-extra-semi": "error", + "no-extra-semi": "off" + } }, { "files": ["*.js", "*.jsx"], "extends": ["plugin:@nx/javascript"], - "rules": {} + "rules": { + "@typescript-eslint/no-extra-semi": "error", + "no-extra-semi": "off" + } }, { "files": ["*.ts"], diff --git a/.github/workflows/build-code.yml b/.github/workflows/build-code.yml index e1f994749..5c072d760 100644 --- a/.github/workflows/build-code.yml +++ b/.github/workflows/build-code.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: node_version: - - 18 + - 20 steps: - name: Checkout code uses: actions/checkout@v4 @@ -24,16 +24,16 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ matrix.node_version }} - cache: 'yarn' + cache: 'npm' - name: Install dependencies - run: yarn install --frozen-lockfile + run: npm ci - name: Check formatting - run: yarn format:check + run: npm run format:check - name: Execute tests - run: yarn test + run: npm test - name: Build application - run: yarn build:production + run: npm run build:production diff --git a/.gitignore b/.gitignore index 1339a53b7..d7e5e5eb1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,8 +5,8 @@ /tmp # dependencies -/.yarn /node_modules +npm-debug.log # IDEs and editors /.idea @@ -28,15 +28,14 @@ .env .env.prod .nx/cache +.nx/workspace-data /.sass-cache /connect.lock /coverage /dist /libpeerconnection.log -npm-debug.log testem.log /typings -yarn-error.log # System Files .DS_Store diff --git a/.nvmrc b/.nvmrc index 3f430af82..9a2a0e219 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v18 +v20 diff --git a/.prettierignore b/.prettierignore index a40959061..27e509393 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,5 @@ /.nx/cache +/.nx/workspace-data /apps/client/src/polyfills.ts /dist /test/import diff --git a/.prettierrc b/.prettierrc index 8ea3ca4d7..9a579e2ea 100644 --- a/.prettierrc +++ b/.prettierrc @@ -12,6 +12,12 @@ "importOrder": ["^@ghostfolio/(.*)$", "", "^[./]"], "importOrderSeparation": true, "overrides": [ + { + "files": "*.html", + "options": { + "parser": "angular" + } + }, { "files": "*.ts", "options": { diff --git a/.yarnrc b/.yarnrc deleted file mode 100644 index 788570fcd..000000000 --- a/.yarnrc +++ /dev/null @@ -1 +0,0 @@ -network-timeout 600000 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fe813c57..30dc3b6e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,822 @@ 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). -## Unreleased +## 2.106.0-beta.2 - 2024-08-26 + +### Changed + +- Reworked the portfolio calculator +- Exposed the maximum of chart data items as an environment variable (`MAX_CHART_ITEMS`) + +### Fixed + +- Fixed an issue in the view mode toggle of the holdings tab on the home page (experimental) + +## 2.105.0 - 2024-08-21 + +### Added + +- Added support to deactivate rules in the _X-ray_ section (experimental) + +### Changed + +- Improved the language localization for German (`de`) + +### Fixed + +- Fixed the currency conversion for fees and values in the dividend import by applying the correct rate based on the activity date +- Fixed the currency conversion for fees and values in the activities service by applying the correct rate based on the activity date + +## 2.104.1 - 2024-08-17 + +### Fixed + +- Fixed an issue with the clone functionality of an activity caused by a changed date format + +## 2.104.0 - 2024-08-17 + +### Added + +- Set up a notification service for alert and confirmation dialogs + +### Changed + +- Refactored the dark theme CSS selector +- Improved the language localization for German (`de`) +- Upgraded `date-fns` from version `2.29.3` to `3.6.0` +- Upgraded `zone.js` from version `0.14.7` to `0.14.10` + +### Fixed + +- Removed `read_only: true` from the `docker-compose.yml` file to allow `prisma` to run migrations + +## 2.103.0 - 2024-08-10 + +### Changed + +- Improved the color assignment in the chart of the holdings tab on the home page (experimental) +- Enabled Catalan (`ca`) as an option in the user settings (experimental) +- Enabled Polish (`pl`) as an option in the user settings (experimental) +- Improved the language localization for Portuguese (`pt`) +- Optimized the docker image layers to reduce the image size +- Updated the binary targets of `debian-openssl` for `prisma` +- Upgraded `prisma` from version `5.17.0` to `5.18.0` + +## 2.102.0 - 2024-08-07 + +### Added + +- Added support to clone an activity from the account detail dialog (experimental) +- Added support to edit an activity from the account detail dialog (experimental) +- Added support to clone an activity from the holding detail dialog (experimental) +- Added support to edit an activity from the holding detail dialog (experimental) + +### Changed + +- Improved the caching of the benchmarks in the markets overview by returning cached data and recalculating in the background when it expires +- Improved the language localization for German (`de`) +- Improved the language localization for Polish (`pl`) +- Upgraded `Nx` from version `19.5.1` to `19.5.6` + +### Fixed + +- Fixed the cache flush endpoint response + +## 2.101.0 - 2024-08-03 + +### Changed + +- Hardened container security by switching to a non-root user, setting the filesystem to read-only, and dropping unnecessary capabilities + +## 2.100.0 - 2024-08-03 + +### Added + +- Added support to manage tags of holdings in the holding detail dialog + +### Changed + +- Improved the color assignment in the chart of the holdings tab on the home page (experimental) +- Persisted the view mode of the holdings tab on the home page (experimental) +- Improved the language localization for Catalan (`ca`) +- Improved the language localization for Spanish (`es`) + +## 2.99.0 - 2024-07-29 + +### Changed + +- Migrated the usage of `yarn` to `npm` +- Upgraded `storybook` from version `7.0.9` to `8.2.5` +- Downgraded `marked` from version `13.0.0` to `12.0.2` + +## 2.98.0 - 2024-07-27 + +### Added + +- Set up the language localization for Catalan (`ca`) + +### Changed + +- Improved the account selector of the create or update activity dialog +- Improved the handling of the numerical precision in the value component +- Skipped derived currencies in the get quotes functionality of the data provider service +- Improved the language localization for Spanish (`es`) +- Upgraded `angular` from version `18.0.4` to `18.1.1` +- Upgraded `Nx` from version `19.4.3` to `19.5.1` +- Upgraded `prisma` from version `5.16.1` to `5.17.0` + +### Fixed + +- Fixed the dividend import from a data provider for holdings without an account +- Fixed an issue in the public page related to a non-existent access + +## 2.97.0 - 2024-07-20 + +### Added + +- Added _selfh.st_ to the _As seen in_ section on the landing page + +### Changed + +- Improved the numerical precision in the holding detail dialog +- Improved the handling of the numerical precision in the value component +- Optimized the 7d data gathering by prioritizing the currencies +- Improved the language localization for German (`de`) +- Upgraded `Node.js` from version `18` to `20` (`Dockerfile`) +- Upgraded `Nx` from version `19.4.0` to `19.4.3` +- Upgraded `prettier` from version `3.3.1` to `3.3.3` + +### Fixed + +- Fixed the table sorting of the holdings tab on the home page + +## 2.96.0 - 2024-07-13 + +### Changed + +- Improved the chart of the holdings tab on the home page (experimental) +- Separated the icon purposes in the `site.webmanifest` + +### Fixed + +- Fixed an issue in the portfolio summary with the currency conversion of fees +- Fixed an issue in the the search for a holding +- Removed the show condition of the experimental features setting in the user settings + +## 2.95.0 - 2024-07-12 + +### Added + +- Added a chart to the holdings tab of the home page (experimental) + +## 2.94.0 - 2024-07-09 + +### Changed + +- Improved the language localization for German (`de`) + +### Fixed + +- Fixed a pagination issue in the activities endpoint by adding `id` as a secondary sort criterion to `date` to ensure consistent ordering + +## 2.93.0 - 2024-07-07 + +### Added + +- Added the _Crypto Coins Heatmap_ to the resources section +- Added the _Stock Heatmap_ to the resources section +- Extended the content of the _Self-Hosting_ section by the platforms concept on the Frequently Asked Questions (FAQ) page + +### Changed + +- Improved the allocations by ETF holding on the allocations page for the impersonation mode (experimental) +- Improved the detection of REST APIs (`JSON`) used via the scraper configuration +- Improved the usability to delete an asset profile of type currency in the historical market data table and the asset profile details dialog of the admin control +- Refreshed the cryptocurrencies list +- Refactored the thresholds of the rules in the _X-ray_ section +- Removed the obsolete `version` from the `docker-compose` files +- Upgraded `Nx` from version `19.2.2` to `19.4.0` + +## 2.92.0 - 2024-06-30 + +### Added + +- Added support for bulk deletion of asset profiles from the market data table in the admin control panel + +### Changed + +- Added support for derived currencies in the currency validation +- Added support for automatic deletion of unused asset profiles when deleting activities +- Improved the caching of the benchmarks in the markets overview (only cache if needed) +- Upgraded `prisma` from version `5.15.0` to `5.16.1` + +### Fixed + +- Fixed an issue with the all time high in the benchmarks of the markets overview + +## 2.91.0 - 2024-06-26 + +### Added + +- Added a benchmarks preset to the historical market data table of the admin control panel + +### Changed + +- Upgraded `angular` from version `18.0.2` to `18.0.4` + +### Fixed + +- Fixed the dialog position (center) on mobile +- Fixed the horizontal overflow in the historical market data table of the admin control panel +- Changed the mechanism of the `INTRADAY` data gathering to persist data only if the market state is `OPEN` +- Fixed the creation of activities with `MANUAL` data source (with no historical market data) + +## 2.90.0 - 2024-06-22 + +### Added + +- Added a dialog for the benchmarks in the markets overview +- Extended the asset profile details dialog of the admin control for currencies +- Extended the content of the _Self-Hosting_ section by the mobile app question on the Frequently Asked Questions (FAQ) page + +### Changed + +- Moved the indicator for active filters from experimental to general availability +- Improved the error handling in the biometric authentication registration +- Improved the language localization for German (`de`) +- Set up SSL for local development +- Upgraded the _Stripe_ dependencies +- Upgraded `marked` from version `9.1.6` to `13.0.0` +- Upgraded `ngx-device-detector` from version `5.0.1` to `8.0.0` +- Upgraded `ngx-markdown` from version `17.1.1` to `18.0.0` +- Upgraded `zone.js` from version `0.14.5` to `0.14.7` + +## 2.89.0 - 2024-06-14 + +### Added + +- Extended the historical market data table with currencies preset by date and activities count in the admin control panel + +### Changed + +- Improved the date validation in the create, import and update activities endpoints +- Improved the language localization for German (`de`) + +## 2.88.0 - 2024-06-11 + +### Added + +- Set the image source label in `Dockerfile` + +### Changed + +- Improved the style of the blog post list +- Migrated the `@ghostfolio/client` components to control flow +- Improved the language localization for German (`de`) +- Upgraded `angular` from version `17.3.10` to `18.0.2` +- Upgraded `Nx` from version `19.0.5` to `19.2.2` + +## 2.87.0 - 2024-06-08 + +### Changed + +- Improved the portfolio summary +- Improved the allocations by ETF holding on the allocations page (experimental) +- Improved the error handling in the `HttpResponseInterceptor` +- Improved the language localization for German (`de`) +- Upgraded `prisma` from version `5.14.0` to `5.15.0` + +### Fixed + +- Fixed an issue in the _FIRE_ calculator + +## 2.86.0 - 2024-06-07 + +### Added + +- Introduced the allocations by ETF holding on the allocations page (experimental) + +### Changed + +- Upgraded `prettier` from version `3.2.5` to `3.3.1` + +## 2.85.0 - 2024-06-06 + +### Added + +- Added the ability to close a user account + +### Changed + +- Improved the language localization for German (`de`) +- Upgraded `ng-extract-i18n-merge` from version `2.10.0` to `2.12.0` + +### Fixed + +- Fixed an issue with the default locale in the value component + +## 2.84.0 - 2024-06-01 + +### Added + +- Added the data provider information to the asset profile details dialog of the admin control +- Added the cascading on delete for various relations in the database schema + +### Fixed + +- Fixed an issue with the initial annual interest rate in the _FIRE_ calculator +- Fixed the state handling in the currency selector +- Fixed the deletion of an asset profile with symbol profile overrides in the asset profile details dialog of the admin control + +## 2.83.0 - 2024-05-30 + +### Changed + +- Upgraded `@nestjs/passport` from version `10.0.0` to `10.0.3` +- Upgraded `angular` from version `17.3.5` to `17.3.10` +- Upgraded `class-validator` from version `0.14.0` to `0.14.1` +- Upgraded `countup.js` from version `2.3.2` to `2.8.0` +- Upgraded `Nx` from version `19.0.2` to `19.0.5` +- Upgraded `passport` from version `0.6.0` to `0.7.0` +- Upgraded `passport-jwt` from version `4.0.0` to `4.0.1` +- Upgraded `prisma` from version `5.13.0` to `5.14.0` +- Upgraded `yahoo-finance2` from version `2.11.2` to `2.11.3` + +## 2.82.0 - 2024-05-22 + +### Changed + +- Improved the usability of the create or update activity dialog by preselecting the (only) account +- Improved the usability of the date range selector in the assistant +- Refactored the holding detail dialog to a standalone component +- Refreshed the cryptocurrencies list +- Refactored various pages to standalone components +- Upgraded `@internationalized/number` from version `3.5.0` to `3.5.2` +- Upgraded `body-parser` from version `1.20.1` to `1.20.2` +- Upgraded `zone.js` from version `0.14.4` to `0.14.5` + +## 2.81.0 - 2024-05-12 + +### Added + +- Added an indicator for active filters (experimental) + +### Changed + +- Improved the delete all activities functionality on the portfolio activities page to work with the filters of the assistant +- Improved the language localization for German (`de`) +- Improved the language localization for Türkçe (`tr`) +- Upgraded `Nx` from version `18.3.3` to `19.0.2` + +### Fixed + +- Fixed the position detail dialog close functionality + +## 2.80.0 - 2024-05-08 + +### Added + +- Added the absolute change column to the holdings table on the home page + +### Changed + +- Increased the spacing around the floating action buttons (FAB) +- Set the icon column of the activities table to stick at the beginning +- Set the icon column of the holdings table to stick at the beginning +- Increased the number of attempts of queue jobs from `10` to `12` (fail later) +- Upgraded `ionicons` from version `7.3.0` to `7.4.0` + +### Fixed + +- Fixed the position detail dialog open functionality when searching for a holding in the assistant + +## 2.79.0 - 2024-05-04 + +### Changed + +- Moved the holdings table to the holdings tab of the home page +- Improved the performance labels (with and without currency effects) in the position detail dialog +- Optimized the calculations of the portfolio details endpoint + +### Fixed + +- Fixed an issue with the benchmarks in the markets overview +- Fixed an issue with the _Fear & Greed Index_ (market mood) in the markets overview + +## 2.78.0 - 2024-05-02 + +### Added + +- Added a form validation against the DTO in the create or update access dialog +- Added a form validation against the DTO in the asset profile details dialog of the admin control +- Added a form validation against the DTO in the platform management of the admin control panel +- Added a form validation against the DTO in the tag management of the admin control panel + +### Changed + +- Set the performance column of the holdings table to stick at the end +- Skipped the caching in the portfolio calculator if there are active filters (experimental) +- Improved the `INACTIVE` user role + +### Fixed + +- Fixed an issue in the calculation of the portfolio summary caused by future liabilities +- Fixed a division by zero error in the dividend yield calculation (experimental) + +## 2.77.1 - 2024-04-27 + +### Added + +- Extended the content of the _Self-Hosting_ section by the custom asset instructions on the Frequently Asked Questions (FAQ) page +- Added the caching to the portfolio calculator (experimental) + +### Changed + +- Migrated the `@ghostfolio/ui` components to control flow +- Updated the browserslist database +- Upgraded `prisma` from version `5.12.1` to `5.13.0` + +### Fixed + +- Fixed the form submit in the asset profile details dialog of the admin control due to the `url` validation +- Fixed the historical market data gathering for asset profiles with `MANUAL` data source + +## 2.76.0 - 2024-04-23 + +### Changed + +- Changed `CASH` to `LIQUIDITY` in the asset class enum + +## 2.75.1 - 2024-04-21 + +### Added + +- Added `accountId` and `date` as a unique constraint to the `AccountBalance` database schema + +### Changed + +- Improved the chart in the account detail dialog +- Improved the account balance management + +### Fixed + +- Fixed an issue with `totalValueInBaseCurrency` in the value redaction interceptor for the impersonation mode + +## 2.74.0 - 2024-04-20 + +### Added + +- Added the date range support to the portfolio holdings page +- Added support to create an account balance + +### Changed + +- Removed the date range support in the activities table on the portfolio activities page (experimental) +- Improved the language localization for German (`de`) +- Upgraded `angular` from version `17.3.3` to `17.3.5` +- Upgraded `Nx` from version `18.2.3` to `18.3.3` + +### Fixed + +- Fixed gaps in the portfolio performance charts by considering `BUY` and `SELL` activities + +## 2.73.0 - 2024-04-17 + +### Added + +- Added a form validation against the DTO in the create or update account dialog +- Added a form validation against the DTO in the create or update activity dialog + +### Changed + +- Moved the dividend calculations into the portfolio calculator +- Moved the fee calculations into the portfolio calculator +- Moved the interest calculations into the portfolio calculator +- Moved the liability calculations into the portfolio calculator +- Moved the (wealth) item calculations into the portfolio calculator +- Let queue jobs for asset profile data gathering fail by throwing an error +- Let queue jobs for historical market data gathering fail by throwing an error +- Upgraded `yahoo-finance2` from version `2.11.1` to `2.11.2` + +## 2.72.0 - 2024-04-13 + +### Added + +- Added support to immediately execute a queue job from the admin control panel +- Added a priority column to the queue jobs view in the admin control panel + +### Changed + +- Adapted the priorities of queue jobs +- Upgraded `angular` from version `17.2.4` to `17.3.3` +- Upgraded `Nx` from version `18.1.2` to `18.2.3` +- Upgraded `prisma` from version `5.11.0` to `5.12.1` +- Upgraded `yahoo-finance2` from version `2.11.0` to `2.11.1` + +### Fixed + +- Fixed an issue in the public page + +## 2.71.0 - 2024-04-07 + +### Added + +- Added the dividend yield to the position detail dialog (experimental) +- Added support to override the asset class of an asset profile in the asset profile details dialog of the admin control +- Added support to override the asset sub class of an asset profile in the asset profile details dialog of the admin control +- Added support to override the url of an asset profile in the asset profile details dialog of the admin control +- Added the asset profile icon to the asset profile details dialog of the admin control +- Added the platform icon to the create or update platform dialog of the admin control +- Extended the rules in the _X-ray_ section by a `key` +- Added `currency` to the `Order` database schema as a preparation to set a custom currency +- Extended the content of the _Self-Hosting_ section by the data providers on the Frequently Asked Questions (FAQ) page + +### Changed + +- Optimized the calculation of allocations by market +- Improved the url validation in the create and update platform endpoint +- Improved the language localization for German (`de`) + +### Fixed + +- Fixed the missing tags in the portfolio calculations + +## 2.70.0 - 2024-04-02 + +### Added + +- Set up the language localization for Chinese (`zh`) +- Added `init: true` to the `docker-compose` files (`docker-compose.yml` and `docker-compose.build.yml`) to avoid zombie processes +- Set up _Webpack Bundle Analyzer_ + +### Changed + +- Disabled the option to update the cash balance of an account if date is not today +- Improved the usability of the date range support by specific years (`2023`, `2022`, `2021`, etc.) in the assistant (experimental) +- Introduced a factory for the portfolio calculations to support different algorithms in future + +### Fixed + +- Fixed the duplicated tags in the position detail dialog +- Removed `Tini` from the docker image + +## 2.69.0 - 2024-03-30 + +### Added + +- Added the date range support in the activities table on the portfolio activities page (experimental) +- Extended the date range support by specific years (`2021`, `2022`, `2023`, etc.) in the assistant (experimental) +- Set up `Tini` to avoid zombie processes and perform signal forwarding in docker image + +### Changed + +- Improved the usability to delete an asset profile in the historical market data table and the asset profile details dialog of the admin control + +### Fixed + +- Added missing dates to edit historical market data in the asset profile details dialog of the admin control panel + +## 2.68.0 - 2024-03-29 + +### Added + +- Extended the export functionality by the user account’s currency +- Added support to override the name of an asset profile in the asset profile details dialog of the admin control + +### Changed + +- Optimized the portfolio calculations + +### Fixed + +- Fixed the chart tooltip of the benchmark comparator +- Fixed an issue with names in the activities table on the portfolio activities page while using symbol profile overrides + +## 2.67.0 - 2024-03-26 + +### Added + +- Added support for the cryptocurrency _Toncoin_ (`TON11419-USD`) + +### Changed + +- Replaced `Math.random()` with `crypto.randomBytes()` for generating cryptographically secure random strings +- Upgraded `ionicons` from version `7.1.0` to `7.3.0` +- Upgraded `yahoo-finance2` from version `2.10.0` to `2.11.0` +- Upgraded `zone.js` from version `0.14.3` to `0.14.4` + +## 2.66.3 - 2024-03-23 + +### Added + +- Extended the content of the _SaaS_ and _Self-Hosting_ sections by the backup strategy on the Frequently Asked Questions (FAQ) page +- Added an index for `dataSource` / `symbol` to the market data database table + +### Changed + +- Improved the chart tooltip of the benchmark comparator by adding the benchmark name +- Upgraded `angular` from version `17.1.3` to `17.2.4` +- Upgraded `Nx` from version `18.0.4` to `18.1.2` + +### Fixed + +- Fixed the missing portfolio performance chart in the _Presenter View_ / _Zen Mode_ + +## 2.65.0 - 2024-03-19 + +### Added + +- Added the symbol and ISIN number to the position detail dialog +- Added support to delete an asset profile in the asset profile details dialog of the admin control + +### Changed + +- Moved the support to grant private access with permissions from experimental to general availability +- Set the meta theme color dynamically to respect the appearance (dark mode) +- Improved the usability to edit market data in the admin control panel + +## 2.64.0 - 2024-03-16 + +### Added + +- Added a toggle to switch between active and closed holdings on the portfolio holdings page +- Added support to update the cash balance of an account when adding a fee activity +- Added support to update the cash balance of an account when adding an interest activity +- Extended the content of the _General_ section by the product roadmap on the Frequently Asked Questions (FAQ) page + +### Changed + +- Improved the usability of the platform management in the admin control panel +- Improved the usability of the tag management in the admin control panel +- Improved the exception handling of various rules in the _X-ray_ section +- Increased the timeout to load benchmarks +- Upgraded `prisma` from version `5.10.2` to `5.11.0` + +### Fixed + +- Fixed an issue in the dividend calculation of the portfolio holdings +- Fixed the date conversion of the import of historical market data in the admin control panel + +## 2.63.2 - 2024-03-12 + +### Added + +- Extended the content of the _Self-Hosting_ section by available home server systems on the Frequently Asked Questions (FAQ) page +- Added support for the cryptocurrency _Real Smurf Cat_ (`SMURFCAT-USD`) + +### Changed + +- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `8.3` to `9.0` +- Upgraded `countries-list` from version `2.6.1` to `3.1.0` +- Upgraded `yahoo-finance2` from version `2.9.1` to `2.10.0` + +### Fixed + +- Fixed an issue in the performance calculation caused by multiple `SELL` activities on the same day +- Fixed an issue in the calculation on the allocations page caused by liabilities +- Fixed an issue with the currency in the request to get quotes from _EOD Historical Data_ + +## 2.62.0 - 2024-03-09 + +### Changed + +- Optimized the calculation of the accounts table +- Optimized the calculation of the portfolio holdings +- Integrated dividend into the transaction point concept in the portfolio service +- Removed the environment variable `WEB_AUTH_RP_ID` + +### Fixed + +- Fixed an issue in the calculation of the portfolio summary caused by future liabilities +- Fixed an issue with removing a linked account from a (wealth) item activity + +## 2.61.1 - 2024-03-06 + +### Fixed + +- Fixed an issue in the account value calculation caused by liabilities + +## 2.61.0 - 2024-03-04 + +### Changed + +- Optimized the calculation of the portfolio summary + +### Fixed + +- Fixed the activities import (query parameter handling) + +## 2.60.0 - 2024-03-02 + +### Added + +- Added support for the cryptocurrency _Uniswap_ (`UNI7083-USD`) + +### Changed + +- Improved the usability of the benchmarks in the markets overview +- Integrated (wealth) items into the transaction point concept in the portfolio service +- Refreshed the cryptocurrencies list + +### Fixed + +- Fixed a missing value in the activities table on mobile +- Fixed a missing value on the public page +- Displayed the button to fetch the current market price only if the activity is from today + +## 2.59.0 - 2024-02-29 + +### Added + +- Added an index for `isExcluded` to the account database table +- Extended the content of the _Self-Hosting_ section on the Frequently Asked Questions (FAQ) page + +### Changed + +- Improved the activities import by `isin` in the _Yahoo Finance_ service + +### Fixed + +- Fixed an issue with the exchange rate calculation of (wealth) items in accounts + +## 2.58.0 - 2024-02-27 + +### Changed + +- Improved the handling of activities without account + +### Fixed + +- Fixed the query to filter activities of excluded accounts +- Improved the asset profile validation in the activities import + +## 2.57.0 - 2024-02-25 + +### Changed + +- Moved the break down of the performance into asset and currency on the analysis page from experimental to general availability +- Restructured the `copy-assets` `Nx` target + +### Fixed + +- Changed the performances of the _Top 3_ and _Bottom 3_ performers on the analysis page to take the currency effects into account + +## 2.56.0 - 2024-02-24 + +### Changed + +- Switched the performance calculations to take the currency effects into account +- Removed the `isDefault` flag from the `Account` database schema +- Exposed the database index of _Redis_ as an environment variable (`REDIS_DB`) +- Improved the language localization for German (`de`) +- Upgraded `prisma` from version `5.9.1` to `5.10.2` + +### Fixed + +- Added the missing default currency to the prepare currencies function in the exchange rate data service + +## 2.55.0 - 2024-02-22 + +### Added + +- Added indexes for `alias`, `granteeUserId` and `userId` to the access database table +- Added indexes for `currency`, `name` and `userId` to the account database table +- Added indexes for `accountId`, `date` and `updatedAt` to the account balance database table +- Added an index for `userId` to the auth device database table +- Added indexes for `marketPrice` and `state` to the market data database table +- Added indexes for `date`, `isDraft` and `userId` to the order database table +- Added an index for `name` to the platform database table +- Added indexes for `assetClass`, `currency`, `dataSource`, `isin`, `name` and `symbol` to the symbol profile database table +- Added an index for `userId` to the subscription database table +- Added an index for `name` to the tag database table +- Added indexes for `accessToken`, `createdAt`, `provider`, `role` and `thirdPartyId` to the user database table + +### Changed + +- Improved the validation for `currency` in various endpoints +- Harmonized the setting of a default locale in various components +- Set the parser to `angular` in the `prettier` options + +## 2.54.0 - 2024-02-19 + +### Added + +- Added an index for `id` to the account database table +- Added indexes for `dataSource` and `date` to the market data database table +- Added an index for `accountId` to the order database table + +## 2.53.1 - 2024-02-18 ### Added - Added an accounts tab to the position detail dialog +- Added `INACTIVE` as a new user role ### Changed +- Improved the usability of the holdings table +- Refactored the query to filter activities of excluded accounts +- Eliminated the search request to get quotes in the _EOD Historical Data_ service - Improved the language localization for German (`de`) - Upgraded `ng-extract-i18n-merge` from version `2.9.1` to `2.10.0` @@ -4153,7 +4961,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added the attribute `precision` in the value component +- Added the attribute `precision` to the value component ### Fixed diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 10323e640..0c76a2924 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,5 +1,54 @@ # Ghostfolio Development Guide +## Development Environment + +### Prerequisites + +- [Docker](https://www.docker.com/products/docker-desktop) +- [Node.js](https://nodejs.org/en/download) (version 20+) +- Create a local copy of this Git repository (clone) +- Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`) + +### Setup + +1. Run `npm install` +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 `npm run database:setup` to initialize the database schema +1. Run `git config core.hooksPath ./git-hooks/` to setup git hooks +1. Start the [server](#start-server) and the [client](#start-client) +1. Open https://localhost:4200/en in your browser +1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`) + +### Start Server + +#### Debug + +Run `npm run watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com) + +#### Serve + +Run `npm run start:server` + +### Start Client + +Run `npm run start:client` and open https://localhost:4200/en in your browser + +### Start _Storybook_ + +Run `npm run start:storybook` + +### Migrate Database + +With the following command you can keep your database schema in sync: + +```bash +npm run database:push +``` + +## Testing + +Run `npm test` + ## Experimental Features New functionality can be enabled using a feature flag switch from the user settings. @@ -10,7 +59,7 @@ Remove permission in `UserService` using `without()` ### Frontend -Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template +Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template ## Git @@ -30,26 +79,26 @@ Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template #### Upgrade -1. Run `yarn nx migrate latest` -1. Make sure `package.json` changes make sense and then run `yarn install` -1. Run `yarn nx migrate --run-migrations` (Run `YARN_NODE_LINKER="node-modules" NX_MIGRATE_SKIP_INSTALL=1 yarn nx migrate --run-migrations` due to https://github.com/nrwl/nx/issues/16338) +1. Run `npx nx migrate latest` +1. Make sure `package.json` changes make sense and then run `npm install` +1. Run `npx nx migrate --run-migrations` ### Prisma #### Access database via GUI -Run `yarn database:gui` +Run `npm run database:gui` https://www.prisma.io/studio #### Synchronize schema with database for prototyping -Run `yarn database:push` +Run `npm run database:push` https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push #### Create schema migration -Run `yarn prisma migrate dev --name added_job_title` +Run `npm run prisma migrate dev --name added_job_title` https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate diff --git a/Dockerfile b/Dockerfile index 1a557b8c4..e6c38f273 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,25 +1,25 @@ -FROM --platform=$BUILDPLATFORM node:18-slim as builder +FROM --platform=$BUILDPLATFORM node:20-slim AS builder # Build application and add additional files WORKDIR /ghostfolio +RUN apt-get update && apt-get install -y --no-install-suggests \ + g++ \ + git \ + make \ + openssl \ + python3 \ + && rm -rf /var/lib/apt/lists/* + # Only add basic files without the application itself to avoid rebuilding # layers when files (package.json etc.) have not changed COPY ./CHANGELOG.md CHANGELOG.md COPY ./LICENSE LICENSE COPY ./package.json package.json -COPY ./yarn.lock yarn.lock -COPY ./.yarnrc .yarnrc +COPY ./package-lock.json package-lock.json COPY ./prisma/schema.prisma prisma/schema.prisma -RUN apt update && apt install -y \ - g++ \ - git \ - make \ - openssl \ - python3 \ - && rm -rf /var/lib/apt/lists/* -RUN yarn install +RUN npm install # See https://github.com/nrwl/nx/issues/6586 for further details COPY ./decorate-angular-cli.js decorate-angular-cli.js @@ -33,30 +33,35 @@ COPY ./tsconfig.base.json tsconfig.base.json COPY ./libs libs COPY ./apps apps -RUN yarn build:production +RUN npm run build:production # Prepare the dist image with additional node_modules WORKDIR /ghostfolio/dist/apps/api # package.json was generated by the build process, however the original -# yarn.lock needs to be used to ensure the same versions -COPY ./yarn.lock /ghostfolio/dist/apps/api/yarn.lock +# package-lock.json needs to be used to ensure the same versions +COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json -RUN yarn +RUN npm install COPY prisma /ghostfolio/dist/apps/api/prisma # Overwrite the generated package.json with the original one to ensure having # all the scripts COPY package.json /ghostfolio/dist/apps/api -RUN yarn database:generate-typings +RUN npm run database:generate-typings # Image to run, copy everything needed from builder -FROM node:18-slim -RUN apt update && apt install -y \ - curl \ - openssl \ - && rm -rf /var/lib/apt/lists/* +FROM node:20-slim +LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio" +ENV NODE_ENV=production + +RUN apt-get update && apt-get install -y --no-install-suggests \ + curl \ + openssl \ + && rm -rf /var/lib/apt/lists/* -COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps +COPY --chown=node:node --from=builder /ghostfolio/dist/apps /ghostfolio/apps +COPY --chown=node:node ./docker/entrypoint.sh /ghostfolio/entrypoint.sh WORKDIR /ghostfolio/apps/api EXPOSE ${PORT:-3333} -CMD [ "yarn", "start:production" ] +USER node +CMD [ "/ghostfolio/entrypoint.sh" ] diff --git a/README.md b/README.md index 0d4bf3cfb..47316881f 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,12 @@ **Open Source Wealth Management Software** [**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) | -[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**Twitter**](https://twitter.com/ghostfolio_) +[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://x.com/ghostfolio_) [![Shield: Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee)](https://www.buymeacoffee.com/ghostfolio) [![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-orange.svg)](#contributing) [![Shield: License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) -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. @@ -49,7 +47,7 @@ Ghostfolio is for you if you are... - ✅ Create, update and delete transactions - ✅ Multi account management -- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max` +- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `WTD`, `MTD`, `YTD`, `1Y`, `5Y`, `Max` - ✅ Various charts - ✅ Static analysis to identify potential risks in your portfolio - ✅ Import and export transactions @@ -73,7 +71,7 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https:// ### Frontend -The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com). +The frontend is built with [Angular](https://angular.dev) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com). ## Self-hosting @@ -87,22 +85,23 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c ### Supported Environment Variables -| Name | Default Value | Description | -| ------------------------ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens | -| `API_KEY_COINGECKO_DEMO` |   | The _CoinGecko_ Demo API key | -| `API_KEY_COINGECKO_PRO` |   | The _CoinGecko_ Pro API | -| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` | -| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on | -| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) | -| `PORT` | `3333` | The port where the Ghostfolio application will run on | -| `POSTGRES_DB` | | The name of the _PostgreSQL_ database | -| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database | -| `POSTGRES_USER` | | The user of the _PostgreSQL_ database | -| `REDIS_HOST` | | The host where _Redis_ is running | -| `REDIS_PASSWORD` | | The password of _Redis_ | -| `REDIS_PORT` | | The port where _Redis_ is running | -| `REQUEST_TIMEOUT` | `2000` | The timeout of network requests to data providers in milliseconds | +| Name | Type | Default Value | Description | +| ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens | +| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key | +| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key | +| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` | +| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on | +| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) | +| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on | +| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database | +| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database | +| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database | +| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ | +| `REDIS_HOST` | `string` | | The host where _Redis_ is running | +| `REDIS_PASSWORD` | `string` | | The password of _Redis_ | +| `REDIS_PORT` | `number` | | The port where _Redis_ is running | +| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | ### Run with Docker Compose @@ -143,57 +142,11 @@ docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d ### Home Server Systems (Community) -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). +Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), [Home Assistant](https://github.com/lildude/ha-addon-ghostfolio), [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 -### Prerequisites - -- [Docker](https://www.docker.com/products/docker-desktop) -- [Node.js](https://nodejs.org/en/download) (version 18+) -- [Yarn](https://yarnpkg.com/en/docs/install) -- Create a local copy of this Git repository (clone) -- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`) - -### Setup - -1. Run `yarn install` -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. Run `git config core.hooksPath ./git-hooks/` to setup git hooks -1. Start the server and the client (see [_Development_](#Development)) -1. Open http://localhost:4200/en in your browser -1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`) - -### Start Server - -#### Debug - -Run `yarn watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com) - -#### Serve - -Run `yarn start:server` - -### Start Client - -Run `yarn start:client` and open http://localhost:4200/en in your browser - -### Start _Storybook_ - -Run `yarn start:storybook` - -### Migrate Database - -With the following command you can keep your database schema in sync: - -```bash -yarn database:push -``` - -## Testing - -Run `yarn test` +For detailed information on the environment setup and development process, please refer to [DEVELOPMENT.md](./DEVELOPMENT.md). ## Public API @@ -205,7 +158,7 @@ Set the header for each request as follows: "Authorization": "Bearer eyJh..." ``` -You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ accessToken: }`) +You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ "accessToken": "" }`) Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/` or `curl -s http://localhost:3333/api/v1/auth/anonymous/`. @@ -234,18 +187,18 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/ { + const account = await this.accountService.account({ + id_userId: { + id: data.accountId, + userId: this.request.user.id + } + }); + + if (!account) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.accountBalanceService.createOrUpdateAccountBalance({ + accountId: account.id, + balance: data.balance, + date: data.date, + userId: account.userId + }); + } + @HasPermission(permissions.deleteAccountBalance) @Delete(':id') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @@ -32,10 +65,11 @@ export class AccountBalanceController { @Param('id') id: string ): Promise { const accountBalance = await this.accountBalanceService.accountBalance({ - id + id, + userId: this.request.user.id }); - if (!accountBalance || accountBalance.userId !== this.request.user.id) { + if (!accountBalance) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), StatusCodes.FORBIDDEN @@ -43,7 +77,8 @@ export class AccountBalanceController { } return this.accountBalanceService.deleteAccountBalance({ - id + id: accountBalance.id, + userId: accountBalance.userId }); } } diff --git a/apps/api/src/app/account-balance/account-balance.module.ts b/apps/api/src/app/account-balance/account-balance.module.ts index 1fba60fce..02323acc9 100644 --- a/apps/api/src/app/account-balance/account-balance.module.ts +++ b/apps/api/src/app/account-balance/account-balance.module.ts @@ -1,3 +1,4 @@ +import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; @@ -10,6 +11,6 @@ import { AccountBalanceService } from './account-balance.service'; controllers: [AccountBalanceController], exports: [AccountBalanceService], imports: [ExchangeRateDataModule, PrismaModule], - providers: [AccountBalanceService] + providers: [AccountBalanceService, AccountService] }) export class AccountBalanceModule {} diff --git a/apps/api/src/app/account-balance/account-balance.service.ts b/apps/api/src/app/account-balance/account-balance.service.ts index 8a9d7b83e..65393cec8 100644 --- a/apps/api/src/app/account-balance/account-balance.service.ts +++ b/apps/api/src/app/account-balance/account-balance.service.ts @@ -1,14 +1,21 @@ +import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { resetHours } from '@ghostfolio/common/helper'; import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces'; import { UserWithSettings } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { AccountBalance, Prisma } from '@prisma/client'; +import { parseISO } from 'date-fns'; + +import { CreateAccountBalanceDto } from './create-account-balance.dto'; @Injectable() export class AccountBalanceService { public constructor( + private readonly eventEmitter: EventEmitter2, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly prismaService: PrismaService ) {} @@ -24,20 +31,63 @@ export class AccountBalanceService { }); } - public async createAccountBalance( - data: Prisma.AccountBalanceCreateInput - ): Promise { - return this.prismaService.accountBalance.create({ - data + public async createOrUpdateAccountBalance({ + accountId, + balance, + date, + userId + }: CreateAccountBalanceDto & { + userId: string; + }): Promise { + const accountBalance = await this.prismaService.accountBalance.upsert({ + create: { + Account: { + connect: { + id_userId: { + userId, + id: accountId + } + } + }, + date: resetHours(parseISO(date)), + value: balance + }, + update: { + value: balance + }, + where: { + accountId_date: { + accountId, + date: resetHours(parseISO(date)) + } + } }); + + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId + }) + ); + + return accountBalance; } public async deleteAccountBalance( where: Prisma.AccountBalanceWhereUniqueInput ): Promise { - return this.prismaService.accountBalance.delete({ + const accountBalance = await this.prismaService.accountBalance.delete({ where }); + + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: where.userId + }) + ); + + return accountBalance; } public async getAccountBalances({ diff --git a/apps/api/src/app/account-balance/create-account-balance.dto.ts b/apps/api/src/app/account-balance/create-account-balance.dto.ts new file mode 100644 index 000000000..28e939b82 --- /dev/null +++ b/apps/api/src/app/account-balance/create-account-balance.dto.ts @@ -0,0 +1,12 @@ +import { IsISO8601, IsNumber, IsUUID } from 'class-validator'; + +export class CreateAccountBalanceDto { + @IsUUID() + accountId: string; + + @IsNumber() + balance: number; + + @IsISO8601() + date: string; +} diff --git a/apps/api/src/app/account/account.controller.ts b/apps/api/src/app/account/account.controller.ts index 4bee65966..594a733f7 100644 --- a/apps/api/src/app/account/account.controller.ts +++ b/apps/api/src/app/account/account.controller.ts @@ -2,7 +2,7 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/accou import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; -import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor'; +import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { @@ -63,7 +63,7 @@ export class AccountController { { Order: true } ); - if (account?.isDefault || account?.Order.length > 0) { + if (!account || account?.Order.length > 0) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), StatusCodes.FORBIDDEN diff --git a/apps/api/src/app/account/account.module.ts b/apps/api/src/app/account/account.module.ts index 75be85f5c..1c2d20216 100644 --- a/apps/api/src/app/account/account.module.ts +++ b/apps/api/src/app/account/account.module.ts @@ -1,9 +1,7 @@ import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module'; import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; -import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; -import { UserModule } from '@ghostfolio/api/app/user/user.module'; +import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; -import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; @@ -19,13 +17,11 @@ import { AccountService } from './account.service'; imports: [ AccountBalanceModule, ConfigurationModule, - DataProviderModule, ExchangeRateDataModule, ImpersonationModule, PortfolioModule, PrismaModule, - RedisCacheModule, - UserModule + RedactValuesInResponseModule ], providers: [AccountService] }) diff --git a/apps/api/src/app/account/account.service.ts b/apps/api/src/app/account/account.service.ts index 238e3d429..37876dde0 100644 --- a/apps/api/src/app/account/account.service.ts +++ b/apps/api/src/app/account/account.service.ts @@ -1,11 +1,15 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; +import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { Filter } from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { Account, Order, Platform, Prisma } from '@prisma/client'; -import Big from 'big.js'; +import { Big } from 'big.js'; +import { format } from 'date-fns'; import { groupBy } from 'lodash'; import { CashDetails } from './interfaces/cash-details.interface'; @@ -14,6 +18,7 @@ import { CashDetails } from './interfaces/cash-details.interface'; export class AccountService { public constructor( private readonly accountBalanceService: AccountBalanceService, + private readonly eventEmitter: EventEmitter2, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly prismaService: PrismaService ) {} @@ -21,10 +26,8 @@ export class AccountService { public async account({ id_userId }: Prisma.AccountWhereUniqueInput): Promise { - const { id, userId } = id_userId; - const [account] = await this.accounts({ - where: { id, userId } + where: id_userId }); return account; @@ -87,17 +90,20 @@ export class AccountService { data }); - await this.prismaService.accountBalance.create({ - data: { - Account: { - connect: { - id_userId: { id: account.id, userId: aUserId } - } - }, - value: data.balance - } + await this.accountBalanceService.createOrUpdateAccountBalance({ + accountId: account.id, + balance: data.balance, + date: format(new Date(), DATE_FORMAT), + userId: aUserId }); + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: account.userId + }) + ); + return account; } @@ -105,9 +111,18 @@ export class AccountService { where: Prisma.AccountWhereUniqueInput, aUserId: string ): Promise { - return this.prismaService.account.delete({ + const account = await this.prismaService.account.delete({ where }); + + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: account.userId + }) + ); + + return account; } public async getAccounts(aUserId: string): Promise { @@ -159,8 +174,8 @@ export class AccountService { ACCOUNT: filtersByAccount, ASSET_CLASS: filtersByAssetClass, TAG: filtersByTag - } = groupBy(filters, (filter) => { - return filter.type; + } = groupBy(filters, ({ type }) => { + return type; }); if (filtersByAccount?.length > 0) { @@ -198,21 +213,26 @@ export class AccountService { ): Promise { const { data, where } = params; - await this.prismaService.accountBalance.create({ - data: { - Account: { - connect: { - id_userId: where.id_userId - } - }, - value: data.balance - } + await this.accountBalanceService.createOrUpdateAccountBalance({ + accountId: data.id, + balance: data.balance, + date: format(new Date(), DATE_FORMAT), + userId: aUserId }); - return this.prismaService.account.update({ + const account = await this.prismaService.account.update({ data, where }); + + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: account.userId + }) + ); + + return account; } public async updateAccountBalance({ @@ -244,17 +264,11 @@ export class AccountService { ); if (amountInCurrencyOfAccount) { - await this.accountBalanceService.createAccountBalance({ - date, - Account: { - connect: { - id_userId: { - userId, - id: accountId - } - } - }, - value: new Big(balance).plus(amountInCurrencyOfAccount).toNumber() + await this.accountBalanceService.createOrUpdateAccountBalance({ + accountId, + userId, + balance: new Big(balance).plus(amountInCurrencyOfAccount).toNumber(), + date: date.toISOString() }); } } diff --git a/apps/api/src/app/account/create-account.dto.ts b/apps/api/src/app/account/create-account.dto.ts index a6e07ad82..f3c88316f 100644 --- a/apps/api/src/app/account/create-account.dto.ts +++ b/apps/api/src/app/account/create-account.dto.ts @@ -1,3 +1,5 @@ +import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code'; + import { Transform, TransformFnParams } from 'class-transformer'; import { IsBoolean, @@ -19,7 +21,7 @@ export class CreateAccountDto { ) comment?: string; - @IsString() + @IsCurrencyCode() currency: string; @IsOptional() diff --git a/apps/api/src/app/account/update-account.dto.ts b/apps/api/src/app/account/update-account.dto.ts index 16d55386d..6b87af71b 100644 --- a/apps/api/src/app/account/update-account.dto.ts +++ b/apps/api/src/app/account/update-account.dto.ts @@ -1,3 +1,5 @@ +import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code'; + import { Transform, TransformFnParams } from 'class-transformer'; import { IsBoolean, @@ -19,7 +21,7 @@ export class UpdateAccountDto { ) comment?: string; - @IsString() + @IsCurrencyCode() currency: string; @IsString() diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 40ba85759..69e6955c1 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -1,19 +1,18 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; -import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { + DATA_GATHERING_QUEUE_PRIORITY_HIGH, + DATA_GATHERING_QUEUE_PRIORITY_MEDIUM, GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS_OPTIONS } from '@ghostfolio/common/config'; -import { - getAssetProfileIdentifier, - resetHours -} from '@ghostfolio/common/helper'; +import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { AdminData, AdminMarketData, @@ -82,10 +81,11 @@ export class AdminController { @Post('gather/max') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async gatherMax(): Promise { - const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); + const assetProfileIdentifiers = + await this.dataGatheringService.getAllAssetProfileIdentifiers(); await this.dataGatheringService.addJobsToQueue( - uniqueAssets.map(({ dataSource, symbol }) => { + assetProfileIdentifiers.map(({ dataSource, symbol }) => { return { data: { dataSource, @@ -94,7 +94,8 @@ export class AdminController { name: GATHER_ASSET_PROFILE_PROCESS, opts: { ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, - jobId: getAssetProfileIdentifier({ dataSource, symbol }) + jobId: getAssetProfileIdentifier({ dataSource, symbol }), + priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM } }; }) @@ -107,10 +108,11 @@ export class AdminController { @Post('gather/profile-data') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async gatherProfileData(): Promise { - const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); + const assetProfileIdentifiers = + await this.dataGatheringService.getAllAssetProfileIdentifiers(); await this.dataGatheringService.addJobsToQueue( - uniqueAssets.map(({ dataSource, symbol }) => { + assetProfileIdentifiers.map(({ dataSource, symbol }) => { return { data: { dataSource, @@ -119,7 +121,8 @@ export class AdminController { name: GATHER_ASSET_PROFILE_PROCESS, opts: { ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, - jobId: getAssetProfileIdentifier({ dataSource, symbol }) + jobId: getAssetProfileIdentifier({ dataSource, symbol }), + priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM } }; }) @@ -141,7 +144,8 @@ export class AdminController { name: GATHER_ASSET_PROFILE_PROCESS, opts: { ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, - jobId: getAssetProfileIdentifier({ dataSource, symbol }) + jobId: getAssetProfileIdentifier({ dataSource, symbol }), + priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH } }); } @@ -339,6 +343,6 @@ export class AdminController { @Param('key') key: string, @Body() data: PropertyDto ) { - return await this.adminService.putSetting(key, data.value); + return this.adminService.putSetting(key, data.value); } } diff --git a/apps/api/src/app/admin/admin.module.ts b/apps/api/src/app/admin/admin.module.ts index d0557156a..f8cf8e8ac 100644 --- a/apps/api/src/app/admin/admin.module.ts +++ b/apps/api/src/app/admin/admin.module.ts @@ -1,4 +1,7 @@ +import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module'; +import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; +import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; @@ -18,16 +21,19 @@ import { QueueModule } from './queue/queue.module'; @Module({ imports: [ ApiModule, + BenchmarkModule, ConfigurationModule, DataGatheringModule, DataProviderModule, ExchangeRateDataModule, MarketDataModule, + OrderModule, PrismaModule, PropertyModule, QueueModule, SubscriptionModule, - SymbolProfileModule + SymbolProfileModule, + TransformDataSourceInRequestModule ], controllers: [AdminController], providers: [AdminService], diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index 320601667..50b781f54 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -1,3 +1,5 @@ +import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service'; +import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { environment } from '@ghostfolio/api/environments/environment'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; @@ -13,21 +15,25 @@ import { PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_USER_SIGNUP_ENABLED } from '@ghostfolio/common/config'; +import { isCurrency, getCurrencyFromSymbol } from '@ghostfolio/common/helper'; import { AdminData, AdminMarketData, AdminMarketDataDetails, AdminMarketDataItem, - Filter, - UniqueAsset + AssetProfileIdentifier, + EnhancedSymbolProfile, + Filter } from '@ghostfolio/common/interfaces'; import { MarketDataPreset } from '@ghostfolio/common/types'; -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { + AssetClass, AssetSubClass, DataSource, Prisma, + PrismaClient, Property, SymbolProfile } from '@prisma/client'; @@ -37,10 +43,12 @@ import { groupBy } from 'lodash'; @Injectable() export class AdminService { public constructor( + private readonly benchmarkService: BenchmarkService, private readonly configurationService: ConfigurationService, private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly marketDataService: MarketDataService, + private readonly orderService: OrderService, private readonly prismaService: PrismaService, private readonly propertyService: PropertyService, private readonly subscriptionService: SubscriptionService, @@ -51,7 +59,9 @@ export class AdminService { currency, dataSource, symbol - }: UniqueAsset & { currency?: string }): Promise { + }: AssetProfileIdentifier & { currency?: string }): Promise< + SymbolProfile | never + > { try { if (dataSource === 'MANUAL') { return this.symbolProfileService.add({ @@ -71,7 +81,7 @@ export class AdminService { ); } - return await this.symbolProfileService.add( + return this.symbolProfileService.add( assetProfiles[symbol] as Prisma.SymbolProfileCreateInput ); } catch (error) { @@ -88,7 +98,10 @@ export class AdminService { } } - public async deleteProfileData({ dataSource, symbol }: UniqueAsset) { + public async deleteProfileData({ + dataSource, + symbol + }: AssetProfileIdentifier) { await this.marketDataService.deleteMany({ dataSource, symbol }); await this.symbolProfileService.delete({ dataSource, symbol }); } @@ -146,7 +159,16 @@ export class AdminService { [{ symbol: 'asc' }]; const where: Prisma.SymbolProfileWhereInput = {}; - if (presetId === 'CURRENCIES') { + if (presetId === 'BENCHMARKS') { + const benchmarkAssetProfiles = + await this.benchmarkService.getBenchmarkAssetProfiles(); + + where.id = { + in: benchmarkAssetProfiles.map(({ id }) => { + return id; + }) + }; + } else if (presetId === 'CURRENCIES') { return this.getMarketDataForCurrencies(); } else if ( presetId === 'ETF_WITHOUT_COUNTRIES' || @@ -196,101 +218,129 @@ export class AdminService { } } - let [assetProfiles, count] = await Promise.all([ - this.prismaService.symbolProfile.findMany({ - orderBy, - skip, - take, - where, - select: { - _count: { - select: { Order: true } - }, - assetClass: true, - assetSubClass: true, - comment: true, - countries: true, - currency: true, - dataSource: true, - name: true, - Order: { - orderBy: [{ date: 'asc' }], - select: { date: true }, - take: 1 - }, - scraperConfiguration: true, - sectors: true, - symbol: true - } - }), - this.prismaService.symbolProfile.count({ where }) - ]); + const extendedPrismaClient = this.getExtendedPrismaClient(); - let marketData: AdminMarketDataItem[] = assetProfiles.map( - ({ - _count, - assetClass, - assetSubClass, - comment, - countries, - currency, - dataSource, - name, - 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; + try { + let [assetProfiles, count] = await Promise.all([ + extendedPrismaClient.symbolProfile.findMany({ + orderBy, + skip, + take, + where, + select: { + _count: { + select: { Order: true } + }, + assetClass: true, + assetSubClass: true, + comment: true, + countries: true, + currency: true, + dataSource: true, + id: true, + isUsedByUsersWithSubscription: true, + name: true, + Order: { + orderBy: [{ date: 'asc' }], + select: { date: true }, + take: 1 + }, + scraperConfiguration: true, + sectors: true, + symbol: true + } + }), + this.prismaService.symbolProfile.count({ where }) + ]); - return { - assetClass, - assetSubClass, - comment, - currency, - countriesCount, - dataSource, - name, - symbol, - marketDataItemCount, - sectorsCount, - activitiesCount: _count.Order, - date: Order?.[0]?.date - }; - } - ); + let marketData: AdminMarketDataItem[] = await Promise.all( + assetProfiles.map( + async ({ + _count, + assetClass, + assetSubClass, + comment, + countries, + currency, + dataSource, + id, + isUsedByUsersWithSubscription, + name, + 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, + currency, + countriesCount, + dataSource, + id, + name, + symbol, + marketDataItemCount, + sectorsCount, + activitiesCount: _count.Order, + date: Order?.[0]?.date, + isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription + }; + } + ) + ); - 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; - }); + 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; } - count = marketData.length; - } + return { + count, + marketData + }; + } finally { + await extendedPrismaClient.$disconnect(); - return { - count, - marketData - }; + Logger.debug('Disconnect extended prisma client', 'AdminService'); + } } public async getMarketDataBySymbol({ dataSource, symbol - }: UniqueAsset): Promise { + }: AssetProfileIdentifier): Promise { + let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0; + let currency: EnhancedSymbolProfile['currency'] = '-'; + let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity']; + + if (isCurrency(getCurrencyFromSymbol(symbol))) { + currency = getCurrencyFromSymbol(symbol); + ({ activitiesCount, dateOfFirstActivity } = + await this.orderService.getStatisticsByCurrency(currency)); + } + const [[assetProfile], marketData] = await Promise.all([ this.symbolProfileService.getSymbolProfiles([ { @@ -309,11 +359,20 @@ export class AdminService { }) ]); + if (assetProfile) { + assetProfile.dataProviderInfo = this.dataProviderService + .getDataProvider(assetProfile.dataSource) + .getDataProviderInfo(); + } + return { marketData, assetProfile: assetProfile ?? { - symbol, - currency: '-' + activitiesCount, + currency, + dataSource, + dateOfFirstActivity, + symbol } }; } @@ -325,25 +384,45 @@ export class AdminService { countries, currency, dataSource, + holdings, name, scraperConfiguration, sectors, symbol, - symbolMapping - }: Prisma.SymbolProfileUpdateInput & UniqueAsset) { - await this.symbolProfileService.updateSymbolProfile({ - assetClass, - assetSubClass, + symbolMapping, + url + }: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) { + const symbolProfileOverrides = { + assetClass: assetClass as AssetClass, + assetSubClass: assetSubClass as AssetSubClass, + name: name as string, + url: url as string + }; + + const updatedSymbolProfile: AssetProfileIdentifier & + Prisma.SymbolProfileUpdateInput = { comment, countries, currency, dataSource, - name, + holdings, scraperConfiguration, sectors, symbol, - symbolMapping - }); + symbolMapping, + ...(dataSource === 'MANUAL' + ? { assetClass, assetSubClass, name, url } + : { + SymbolProfileOverrides: { + upsert: { + create: symbolProfileOverrides, + update: symbolProfileOverrides + } + } + }) + }; + + await this.symbolProfileService.updateSymbolProfile(updatedSymbolProfile); const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([ { @@ -373,35 +452,97 @@ export class AdminService { return response; } + private getExtendedPrismaClient() { + Logger.debug('Connect extended prisma client', 'AdminService'); + + const symbolProfileExtension = Prisma.defineExtension((client) => { + return client.$extends({ + result: { + symbolProfile: { + isUsedByUsersWithSubscription: { + compute: async ({ id }) => { + const { _count } = + await this.prismaService.symbolProfile.findUnique({ + select: { + _count: { + select: { + Order: { + where: { + User: { + Subscription: { + some: { + expiresAt: { + gt: new Date() + } + } + } + } + } + } + } + } + }, + where: { + id + } + }); + + return _count.Order > 0; + } + } + } + } + }); + }); + + return new PrismaClient().$extends(symbolProfileExtension); + } + 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; + const marketDataPromise: Promise[] = + this.exchangeRateDataService + .getCurrencyPairs() + .map(async ({ dataSource, symbol }) => { + let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0; + let currency: EnhancedSymbolProfile['currency'] = '-'; + let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity']; + + if (isCurrency(getCurrencyFromSymbol(symbol))) { + currency = getCurrencyFromSymbol(symbol); + ({ activitiesCount, dateOfFirstActivity } = + await this.orderService.getStatisticsByCurrency(currency)); + } - return { - dataSource, - marketDataItemCount, - symbol, - assetClass: 'CASH', - countriesCount: 0, - currency: symbol.replace(DEFAULT_CURRENCY, ''), - name: symbol, - sectorsCount: 0 - }; - }); + const marketDataItemCount = + marketDataItems.find((marketDataItem) => { + return ( + marketDataItem.dataSource === dataSource && + marketDataItem.symbol === symbol + ); + })?._count ?? 0; + + return { + activitiesCount, + currency, + dataSource, + marketDataItemCount, + symbol, + assetClass: AssetClass.LIQUIDITY, + assetSubClass: AssetSubClass.CASH, + countriesCount: 0, + date: dateOfFirstActivity, + id: undefined, + name: symbol, + sectorsCount: 0 + }; + }); + const marketData = await Promise.all(marketDataPromise); return { marketData, count: marketData.length }; } @@ -440,13 +581,14 @@ export class AdminService { }, createdAt: true, id: true, + role: true, Subscription: true }, take: 30 }); return usersWithAnalytics.map( - ({ _count, Analytics, createdAt, id, Subscription }) => { + ({ _count, Analytics, createdAt, id, role, Subscription }) => { const daysSinceRegistration = differenceInDays(new Date(), createdAt) + 1; const engagement = Analytics @@ -466,6 +608,7 @@ export class AdminService { createdAt, engagement, id, + role, subscription, accountCount: _count.Account || 0, country: Analytics?.country, diff --git a/apps/api/src/app/admin/queue/queue.controller.ts b/apps/api/src/app/admin/queue/queue.controller.ts index 89bd851bc..978cb9721 100644 --- a/apps/api/src/app/admin/queue/queue.controller.ts +++ b/apps/api/src/app/admin/queue/queue.controller.ts @@ -46,4 +46,11 @@ export class QueueController { public async deleteJob(@Param('id') id: string): Promise { return this.queueService.deleteJob(id); } + + @Get('job/:id/execute') + @HasPermission(permissions.accessAdminControl) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async executeJob(@Param('id') id: string): Promise { + return this.queueService.executeJob(id); + } } diff --git a/apps/api/src/app/admin/queue/queue.service.ts b/apps/api/src/app/admin/queue/queue.service.ts index c5143e870..abae3cad1 100644 --- a/apps/api/src/app/admin/queue/queue.service.ts +++ b/apps/api/src/app/admin/queue/queue.service.ts @@ -32,6 +32,10 @@ export class QueueService { } } + public async executeJob(aId: string) { + return (await this.dataGatheringQueue.getJob(aId))?.promote(); + } + public async getJobs({ limit = 1000, status = QUEUE_JOB_STATUS_LIST @@ -54,6 +58,7 @@ export class QueueService { finishedOn: job.finishedOn, id: job.id, name: job.name, + opts: job.opts, stacktrace: job.stacktrace, state: await job.getState(), timestamp: job.timestamp diff --git a/apps/api/src/app/admin/update-asset-profile.dto.ts b/apps/api/src/app/admin/update-asset-profile.dto.ts index 624acff9f..8c9ae220b 100644 --- a/apps/api/src/app/admin/update-asset-profile.dto.ts +++ b/apps/api/src/app/admin/update-asset-profile.dto.ts @@ -1,10 +1,13 @@ +import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code'; + import { AssetClass, AssetSubClass, Prisma } from '@prisma/client'; import { IsArray, IsEnum, IsObject, IsOptional, - IsString + IsString, + IsUrl } from 'class-validator'; export class UpdateAssetProfileDto { @@ -24,7 +27,7 @@ export class UpdateAssetProfileDto { @IsOptional() countries?: Prisma.InputJsonArray; - @IsString() + @IsCurrencyCode() @IsOptional() currency?: string; @@ -45,4 +48,11 @@ export class UpdateAssetProfileDto { symbolMapping?: { [dataProvider: string]: string; }; + + @IsOptional() + @IsUrl({ + protocols: ['https'], + require_protocol: true + }) + url?: string; } diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 506a76a8a..ca19d63bc 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -1,3 +1,4 @@ +import { EventsModule } from '@ghostfolio/api/events/events.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { CronService } from '@ghostfolio/api/services/cron.service'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; @@ -14,6 +15,7 @@ import { import { BullModule } from '@nestjs/bull'; import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { EventEmitterModule } from '@nestjs/event-emitter'; import { ScheduleModule } from '@nestjs/schedule'; import { ServeStaticModule } from '@nestjs/serve-static'; import { StatusCodes } from 'http-status-codes'; @@ -23,6 +25,7 @@ import { AccessModule } from './access/access.module'; import { AccountModule } from './account/account.module'; import { AdminModule } from './admin/admin.module'; import { AppController } from './app.controller'; +import { AssetModule } from './asset/asset.module'; import { AuthDeviceModule } from './auth-device/auth-device.module'; import { AuthModule } from './auth/auth.module'; import { BenchmarkModule } from './benchmark/benchmark.module'; @@ -44,15 +47,18 @@ import { TagModule } from './tag/tag.module'; import { UserModule } from './user/user.module'; @Module({ + controllers: [AppController], imports: [ AdminModule, AccessModule, AccountModule, + AssetModule, AuthDeviceModule, AuthModule, BenchmarkModule, BullModule.forRoot({ redis: { + db: parseInt(process.env.REDIS_DB ?? '0', 10), host: process.env.REDIS_HOST, port: parseInt(process.env.REDIS_PORT ?? '6379', 10), password: process.env.REDIS_PASSWORD @@ -63,6 +69,8 @@ import { UserModule } from './user/user.module'; ConfigurationModule, DataGatheringModule, DataProviderModule, + EventEmitterModule.forRoot(), + EventsModule, ExchangeRateModule, ExchangeRateDataModule, ExportModule, @@ -108,7 +116,6 @@ import { UserModule } from './user/user.module'; TwitterBotModule, UserModule ], - controllers: [AppController], providers: [CronService] }) export class AppModule {} diff --git a/apps/api/src/app/asset/asset.controller.ts b/apps/api/src/app/asset/asset.controller.ts new file mode 100644 index 000000000..828320f82 --- /dev/null +++ b/apps/api/src/app/asset/asset.controller.ts @@ -0,0 +1,29 @@ +import { AdminService } from '@ghostfolio/api/app/admin/admin.service'; +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; +import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; +import type { AdminMarketDataDetails } from '@ghostfolio/common/interfaces'; + +import { Controller, Get, Param, UseInterceptors } from '@nestjs/common'; +import { DataSource } from '@prisma/client'; +import { pick } from 'lodash'; + +@Controller('asset') +export class AssetController { + public constructor(private readonly adminService: AdminService) {} + + @Get(':dataSource/:symbol') + @UseInterceptors(TransformDataSourceInRequestInterceptor) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async getAsset( + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + const { assetProfile, marketData } = + await this.adminService.getMarketDataBySymbol({ dataSource, symbol }); + + return { + marketData, + assetProfile: pick(assetProfile, ['dataSource', 'name', 'symbol']) + }; + } +} diff --git a/apps/api/src/app/asset/asset.module.ts b/apps/api/src/app/asset/asset.module.ts new file mode 100644 index 000000000..168585ed8 --- /dev/null +++ b/apps/api/src/app/asset/asset.module.ts @@ -0,0 +1,17 @@ +import { AdminModule } from '@ghostfolio/api/app/admin/admin.module'; +import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; +import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; + +import { Module } from '@nestjs/common'; + +import { AssetController } from './asset.controller'; + +@Module({ + controllers: [AssetController], + imports: [ + AdminModule, + TransformDataSourceInRequestModule, + TransformDataSourceInResponseModule + ] +}) +export class AssetModule {} diff --git a/apps/api/src/app/auth-device/auth-device.module.ts b/apps/api/src/app/auth-device/auth-device.module.ts index 89689e5d2..515efa155 100644 --- a/apps/api/src/app/auth-device/auth-device.module.ts +++ b/apps/api/src/app/auth-device/auth-device.module.ts @@ -1,6 +1,5 @@ import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; -import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { Module } from '@nestjs/common'; @@ -9,7 +8,6 @@ import { JwtModule } from '@nestjs/jwt'; @Module({ controllers: [AuthDeviceController], imports: [ - ConfigurationModule, JwtModule.register({ secret: process.env.JWT_SECRET_KEY, signOptions: { expiresIn: '180 days' } diff --git a/apps/api/src/app/auth-device/auth-device.service.ts b/apps/api/src/app/auth-device/auth-device.service.ts index b24df0ae6..59208a1f3 100644 --- a/apps/api/src/app/auth-device/auth-device.service.ts +++ b/apps/api/src/app/auth-device/auth-device.service.ts @@ -1,4 +1,3 @@ -import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { Injectable } from '@nestjs/common'; @@ -6,10 +5,7 @@ import { AuthDevice, Prisma } from '@prisma/client'; @Injectable() export class AuthDeviceService { - public constructor( - private readonly configurationService: ConfigurationService, - private readonly prismaService: PrismaService - ) {} + public constructor(private readonly prismaService: PrismaService) {} public async authDevice( where: Prisma.AuthDeviceWhereUniqueInput diff --git a/apps/api/src/app/auth/google.strategy.ts b/apps/api/src/app/auth/google.strategy.ts index 4abc1105a..4d024603b 100644 --- a/apps/api/src/app/auth/google.strategy.ts +++ b/apps/api/src/app/auth/google.strategy.ts @@ -3,7 +3,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con import { Injectable, Logger } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Provider } from '@prisma/client'; -import { Strategy } from 'passport-google-oauth20'; +import { Profile, Strategy } from 'passport-google-oauth20'; import { AuthService } from './auth.service'; @@ -11,7 +11,7 @@ import { AuthService } from './auth.service'; export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { public constructor( private readonly authService: AuthService, - readonly configurationService: ConfigurationService + private readonly configurationService: ConfigurationService ) { super({ callbackURL: `${configurationService.get( @@ -20,7 +20,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { clientID: configurationService.get('GOOGLE_CLIENT_ID'), clientSecret: configurationService.get('GOOGLE_SECRET'), passReqToCallback: true, - scope: ['email', 'profile'] + scope: ['profile'] }); } @@ -28,20 +28,17 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { request: any, token: string, refreshToken: string, - profile, + profile: Profile, done: Function, done2: Function ) { try { - const jwt: string = await this.authService.validateOAuthLogin({ + const jwt = await this.authService.validateOAuthLogin({ provider: Provider.GOOGLE, thirdPartyId: profile.id }); - const user = { - jwt - }; - done(null, user); + done(null, { jwt }); } catch (error) { Logger.error(error, 'GoogleStrategy'); done(error, false); diff --git a/apps/api/src/app/auth/jwt.strategy.ts b/apps/api/src/app/auth/jwt.strategy.ts index c7ce38986..a8ad8fd08 100644 --- a/apps/api/src/app/auth/jwt.strategy.ts +++ b/apps/api/src/app/auth/jwt.strategy.ts @@ -2,10 +2,12 @@ import { UserService } from '@ghostfolio/api/app/user/user.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config'; +import { hasRole } from '@ghostfolio/common/permissions'; -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { HttpException, Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import * as countriesAndTimezones from 'countries-and-timezones'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { ExtractJwt, Strategy } from 'passport-jwt'; @Injectable() @@ -29,6 +31,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { if (user) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + if (hasRole(user, 'INACTIVE')) { + throw new HttpException( + getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + StatusCodes.TOO_MANY_REQUESTS + ); + } + const country = countriesAndTimezones.getCountryForTimezone(timezone)?.id; @@ -45,10 +54,20 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { return user; } else { - throw ''; + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + } catch (error) { + if (error?.getStatus() === StatusCodes.TOO_MANY_REQUESTS) { + throw error; + } else { + throw new HttpException( + getReasonPhrase(StatusCodes.UNAUTHORIZED), + StatusCodes.UNAUTHORIZED + ); } - } catch (err) { - throw new UnauthorizedException('unauthorized', err.message); } } } diff --git a/apps/api/src/app/auth/web-auth.service.ts b/apps/api/src/app/auth/web-auth.service.ts index a6e76ffbb..961bbe9a7 100644 --- a/apps/api/src/app/auth/web-auth.service.ts +++ b/apps/api/src/app/auth/web-auth.service.ts @@ -41,7 +41,7 @@ export class WebAuthService { ) {} get rpID() { - return this.configurationService.get('WEB_AUTH_RP_ID'); + return new URL(this.configurationService.get('ROOT_URL')).hostname; } get expectedOrigin() { diff --git a/apps/api/src/app/benchmark/benchmark.controller.ts b/apps/api/src/app/benchmark/benchmark.controller.ts index d3b91c6df..66c268b9b 100644 --- a/apps/api/src/app/benchmark/benchmark.controller.ts +++ b/apps/api/src/app/benchmark/benchmark.controller.ts @@ -1,14 +1,15 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; -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 { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; +import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; +import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import type { + AssetProfileIdentifier, BenchmarkMarketDataDetails, - BenchmarkResponse, - UniqueAsset + BenchmarkResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; -import type { RequestWithUser } from '@ghostfolio/common/types'; +import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; import { Body, @@ -19,6 +20,7 @@ import { Inject, Param, Post, + Query, UseGuards, UseInterceptors } from '@nestjs/common'; @@ -39,7 +41,9 @@ export class BenchmarkController { @HasPermission(permissions.accessAdminControl) @Post() @UseGuards(AuthGuard('jwt'), HasPermissionGuard) - public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) { + public async addBenchmark( + @Body() { dataSource, symbol }: AssetProfileIdentifier + ) { try { const benchmark = await this.benchmarkService.addBenchmark({ dataSource, @@ -103,16 +107,21 @@ export class BenchmarkController { @Get(':dataSource/:symbol/:startDateString') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseInterceptors(TransformDataSourceInRequestInterceptor) - public async getBenchmarkMarketDataBySymbol( + public async getBenchmarkMarketDataForUser( @Param('dataSource') dataSource: DataSource, @Param('startDateString') startDateString: string, - @Param('symbol') symbol: string + @Param('symbol') symbol: string, + @Query('range') dateRange: DateRange = 'max' ): Promise { - const startDate = new Date(startDateString); + const { endDate, startDate } = getIntervalFromDateRange( + dateRange, + new Date(startDateString) + ); const userCurrency = this.request.user.Settings.settings.baseCurrency; - return this.benchmarkService.getMarketDataBySymbol({ + return this.benchmarkService.getMarketDataForUser({ dataSource, + endDate, startDate, symbol, userCurrency diff --git a/apps/api/src/app/benchmark/benchmark.module.ts b/apps/api/src/app/benchmark/benchmark.module.ts index 7371588d1..4c5f4d58e 100644 --- a/apps/api/src/app/benchmark/benchmark.module.ts +++ b/apps/api/src/app/benchmark/benchmark.module.ts @@ -1,5 +1,7 @@ import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; +import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; +import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; @@ -25,7 +27,9 @@ import { BenchmarkService } from './benchmark.service'; PropertyModule, RedisCacheModule, SymbolModule, - SymbolProfileModule + SymbolProfileModule, + TransformDataSourceInRequestModule, + TransformDataSourceInResponseModule ], providers: [BenchmarkService] }) diff --git a/apps/api/src/app/benchmark/benchmark.service.spec.ts b/apps/api/src/app/benchmark/benchmark.service.spec.ts index 42a29e6d1..5371fcdc0 100644 --- a/apps/api/src/app/benchmark/benchmark.service.spec.ts +++ b/apps/api/src/app/benchmark/benchmark.service.spec.ts @@ -12,6 +12,7 @@ describe('BenchmarkService', () => { null, null, null, + null, null ); }); diff --git a/apps/api/src/app/benchmark/benchmark.service.ts b/apps/api/src/app/benchmark/benchmark.service.ts index b820430f6..169ea8cad 100644 --- a/apps/api/src/app/benchmark/benchmark.service.ts +++ b/apps/api/src/app/benchmark/benchmark.service.ts @@ -1,41 +1,51 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; +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 { 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 { - MAX_CHART_ITEMS, - PROPERTY_BENCHMARKS -} from '@ghostfolio/common/config'; +import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config'; import { DATE_FORMAT, calculateBenchmarkTrend, - parseDate + parseDate, + resetHours } from '@ghostfolio/common/helper'; import { + AssetProfileIdentifier, Benchmark, BenchmarkMarketDataDetails, BenchmarkProperty, - BenchmarkResponse, - UniqueAsset + BenchmarkResponse } from '@ghostfolio/common/interfaces'; import { BenchmarkTrend } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; import { SymbolProfile } from '@prisma/client'; -import Big from 'big.js'; -import { format, isSameDay, subDays } from 'date-fns'; +import { Big } from 'big.js'; +import { + addHours, + differenceInDays, + eachDayOfInterval, + format, + isAfter, + isSameDay, + subDays +} from 'date-fns'; import { isNumber, last, uniqBy } from 'lodash'; import ms from 'ms'; +import { BenchmarkValue } from './interfaces/benchmark-value.interface'; + @Injectable() export class BenchmarkService { private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS'; public constructor( + private readonly configurationService: ConfigurationService, private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly marketDataService: MarketDataService, @@ -54,7 +64,10 @@ export class BenchmarkService { return 0; } - public async getBenchmarkTrends({ dataSource, symbol }: UniqueAsset) { + public async getBenchmarkTrends({ + dataSource, + symbol + }: AssetProfileIdentifier) { const historicalData = await this.marketDataService.marketDataItems({ orderBy: { date: 'desc' @@ -82,92 +95,28 @@ export class BenchmarkService { enableSharing = false, useCache = true } = {}): Promise { - let benchmarks: BenchmarkResponse['benchmarks']; - if (useCache) { try { - benchmarks = JSON.parse( - await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS) + const cachedBenchmarkValue = await this.redisCacheService.get( + this.CACHE_KEY_BENCHMARKS ); - if (benchmarks) { - return benchmarks; - } - } catch {} - } - - const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({ - enableSharing - }); - - const promisesAllTimeHighs: Promise<{ date: Date; marketPrice: number }>[] = - []; - const promisesBenchmarkTrends: Promise<{ - trend50d: BenchmarkTrend; - trend200d: BenchmarkTrend; - }>[] = []; - - const quotes = await this.dataProviderService.getQuotes({ - items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => { - return { dataSource, symbol }; - }) - }); - - for (const { dataSource, symbol } of benchmarkAssetProfiles) { - promisesAllTimeHighs.push( - this.marketDataService.getMax({ dataSource, symbol }) - ); - promisesBenchmarkTrends.push( - this.getBenchmarkTrends({ dataSource, symbol }) - ); - } + const { benchmarks, expiration }: BenchmarkValue = + JSON.parse(cachedBenchmarkValue); - const [allTimeHighs, benchmarkTrends] = await Promise.all([ - Promise.all(promisesAllTimeHighs), - Promise.all(promisesBenchmarkTrends) - ]); - let storeInCache = true; + Logger.debug('Fetched benchmarks from cache', 'BenchmarkService'); - benchmarks = allTimeHighs.map((allTimeHigh, index) => { - const { marketPrice } = - quotes[benchmarkAssetProfiles[index].symbol] ?? {}; - - let performancePercentFromAllTimeHigh = 0; - - if (allTimeHigh?.marketPrice && marketPrice) { - performancePercentFromAllTimeHigh = this.calculateChangeInPercentage( - allTimeHigh.marketPrice, - marketPrice - ); - } else { - storeInCache = false; - } - - return { - marketCondition: this.getMarketCondition( - performancePercentFromAllTimeHigh - ), - name: benchmarkAssetProfiles[index].name, - performances: { - allTimeHigh: { - date: allTimeHigh?.date, - performancePercent: performancePercentFromAllTimeHigh - } - }, - trend50d: benchmarkTrends[index].trend50d, - trend200d: benchmarkTrends[index].trend200d - }; - }); + if (isAfter(new Date(), new Date(expiration))) { + this.calculateAndCacheBenchmarks({ + enableSharing + }); + } - if (storeInCache) { - await this.redisCacheService.set( - this.CACHE_KEY_BENCHMARKS, - JSON.stringify(benchmarks), - ms('4 hours') / 1000 - ); + return benchmarks; + } catch {} } - return benchmarks; + return this.calculateAndCacheBenchmarks({ enableSharing }); } public async getBenchmarkAssetProfiles({ @@ -204,17 +153,35 @@ export class BenchmarkService { .sort((a, b) => a.name.localeCompare(b.name)); } - public async getMarketDataBySymbol({ + public async getMarketDataForUser({ dataSource, + endDate = new Date(), startDate, symbol, userCurrency }: { + endDate?: Date; startDate: Date; userCurrency: string; - } & UniqueAsset): Promise { + } & AssetProfileIdentifier): Promise { const marketData: { date: string; value: number }[] = []; + const days = differenceInDays(endDate, startDate) + 1; + const dates = eachDayOfInterval( + { + start: startDate, + end: endDate + }, + { + step: Math.round( + days / + Math.min(days, this.configurationService.get('MAX_CHART_ITEMS')) + ) + } + ).map((date) => { + return resetHours(date); + }); + const [currentSymbolItem, marketDataItems] = await Promise.all([ this.symbolService.get({ dataGatheringItem: { @@ -230,7 +197,7 @@ export class BenchmarkService { dataSource, symbol, date: { - gte: startDate + in: dates } } }) @@ -264,17 +231,7 @@ export class BenchmarkService { return { marketData }; } - const step = Math.round( - marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS) - ); - - let i = 0; - for (let marketDataItem of marketDataItems) { - if (i % step !== 0) { - continue; - } - const exchangeRate = exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[ format(marketDataItem.date, DATE_FORMAT) @@ -297,15 +254,15 @@ export class BenchmarkService { }); } - const includesToday = isSameDay( + const includesEndDate = isSameDay( parseDate(last(marketData).date), - new Date() + endDate ); - if (currentSymbolItem?.marketPrice && !includesToday) { + if (currentSymbolItem?.marketPrice && !includesEndDate) { const exchangeRate = exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[ - format(new Date(), DATE_FORMAT) + format(endDate, DATE_FORMAT) ]; const exchangeRateFactor = @@ -314,7 +271,7 @@ export class BenchmarkService { : 1; marketData.push({ - date: format(new Date(), DATE_FORMAT), + date: format(endDate, DATE_FORMAT), value: this.calculateChangeInPercentage( marketPriceAtStartDate, @@ -331,7 +288,7 @@ export class BenchmarkService { public async addBenchmark({ dataSource, symbol - }: UniqueAsset): Promise> { + }: AssetProfileIdentifier): Promise> { const assetProfile = await this.prismaService.symbolProfile.findFirst({ where: { dataSource, @@ -368,7 +325,7 @@ export class BenchmarkService { public async deleteBenchmark({ dataSource, symbol - }: UniqueAsset): Promise> { + }: AssetProfileIdentifier): Promise> { const assetProfile = await this.prismaService.symbolProfile.findFirst({ where: { dataSource, @@ -402,10 +359,101 @@ export class BenchmarkService { }; } + private async calculateAndCacheBenchmarks({ + enableSharing = false + }): Promise { + Logger.debug('Calculate benchmarks', 'BenchmarkService'); + + const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({ + enableSharing + }); + + const promisesAllTimeHighs: Promise<{ date: Date; marketPrice: number }>[] = + []; + const promisesBenchmarkTrends: Promise<{ + trend50d: BenchmarkTrend; + trend200d: BenchmarkTrend; + }>[] = []; + + const quotes = await this.dataProviderService.getQuotes({ + items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => { + return { dataSource, symbol }; + }), + requestTimeout: ms('30 seconds'), + useCache: false + }); + + for (const { dataSource, symbol } of benchmarkAssetProfiles) { + promisesAllTimeHighs.push( + this.marketDataService.getMax({ dataSource, symbol }) + ); + promisesBenchmarkTrends.push( + this.getBenchmarkTrends({ dataSource, symbol }) + ); + } + + const [allTimeHighs, benchmarkTrends] = await Promise.all([ + Promise.all(promisesAllTimeHighs), + Promise.all(promisesBenchmarkTrends) + ]); + let storeInCache = true; + + const benchmarks = allTimeHighs.map((allTimeHigh, index) => { + const { marketPrice } = + quotes[benchmarkAssetProfiles[index].symbol] ?? {}; + + let performancePercentFromAllTimeHigh = 0; + + if (allTimeHigh?.marketPrice && marketPrice) { + performancePercentFromAllTimeHigh = this.calculateChangeInPercentage( + allTimeHigh.marketPrice, + marketPrice + ); + } else { + storeInCache = false; + } + + return { + dataSource: benchmarkAssetProfiles[index].dataSource, + marketCondition: this.getMarketCondition( + performancePercentFromAllTimeHigh + ), + name: benchmarkAssetProfiles[index].name, + performances: { + allTimeHigh: { + date: allTimeHigh?.date, + performancePercent: + performancePercentFromAllTimeHigh >= 0 + ? 0 + : performancePercentFromAllTimeHigh + } + }, + symbol: benchmarkAssetProfiles[index].symbol, + trend50d: benchmarkTrends[index].trend50d, + trend200d: benchmarkTrends[index].trend200d + }; + }); + + if (storeInCache) { + const expiration = addHours(new Date(), 2); + + await this.redisCacheService.set( + this.CACHE_KEY_BENCHMARKS, + JSON.stringify({ + benchmarks, + expiration: expiration.getTime() + }), + ms('12 hours') / 1000 + ); + } + + return benchmarks; + } + private getMarketCondition( aPerformanceInPercent: number ): Benchmark['marketCondition'] { - if (aPerformanceInPercent === 0) { + if (aPerformanceInPercent >= 0) { return 'ALL_TIME_HIGH'; } else if (aPerformanceInPercent <= -0.2) { return 'BEAR_MARKET'; diff --git a/apps/api/src/app/benchmark/interfaces/benchmark-value.interface.ts b/apps/api/src/app/benchmark/interfaces/benchmark-value.interface.ts new file mode 100644 index 000000000..eda302f90 --- /dev/null +++ b/apps/api/src/app/benchmark/interfaces/benchmark-value.interface.ts @@ -0,0 +1,6 @@ +import { BenchmarkResponse } from '@ghostfolio/common/interfaces'; + +export interface BenchmarkValue { + benchmarks: BenchmarkResponse['benchmarks']; + expiration: number; +} diff --git a/apps/api/src/app/cache/cache.controller.ts b/apps/api/src/app/cache/cache.controller.ts index edfd16c49..4d34a2eff 100644 --- a/apps/api/src/app/cache/cache.controller.ts +++ b/apps/api/src/app/cache/cache.controller.ts @@ -14,6 +14,6 @@ export class CacheController { @Post('flush') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async flushCache(): Promise { - return this.redisCacheService.reset(); + await this.redisCacheService.reset(); } } diff --git a/apps/api/src/app/cache/cache.module.ts b/apps/api/src/app/cache/cache.module.ts index 7a82e4366..d435c72a6 100644 --- a/apps/api/src/app/cache/cache.module.ts +++ b/apps/api/src/app/cache/cache.module.ts @@ -1,10 +1,4 @@ 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'; @@ -12,14 +6,6 @@ import { CacheController } from './cache.controller'; @Module({ controllers: [CacheController], - imports: [ - ConfigurationModule, - DataGatheringModule, - DataProviderModule, - ExchangeRateDataModule, - PrismaModule, - RedisCacheModule, - SymbolProfileModule - ] + imports: [RedisCacheModule] }) export class CacheModule {} diff --git a/apps/api/src/app/export/export.module.ts b/apps/api/src/app/export/export.module.ts index 9bc5722a7..048c60359 100644 --- a/apps/api/src/app/export/export.module.ts +++ b/apps/api/src/app/export/export.module.ts @@ -1,10 +1,6 @@ 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 { ApiModule } from '@ghostfolio/api/services/api/api.module'; -import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; -import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; -import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { Module } from '@nestjs/common'; @@ -12,15 +8,7 @@ import { ExportController } from './export.controller'; import { ExportService } from './export.service'; @Module({ - imports: [ - AccountModule, - ApiModule, - ConfigurationModule, - DataGatheringModule, - DataProviderModule, - OrderModule, - RedisCacheModule - ], + imports: [AccountModule, ApiModule, OrderModule], controllers: [ExportController], providers: [ExportService] }) diff --git a/apps/api/src/app/export/export.service.ts b/apps/api/src/app/export/export.service.ts index 90090119e..1ff18ce9c 100644 --- a/apps/api/src/app/export/export.service.ts +++ b/apps/api/src/app/export/export.service.ts @@ -95,7 +95,10 @@ export class ExportService { : SymbolProfile.symbol }; } - ) + ), + user: { + settings: { currency: userCurrency } + } }; } } diff --git a/apps/api/src/app/health/health.controller.ts b/apps/api/src/app/health/health.controller.ts index d899df410..31bdb2e8f 100644 --- a/apps/api/src/app/health/health.controller.ts +++ b/apps/api/src/app/health/health.controller.ts @@ -1,4 +1,4 @@ -import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { Controller, diff --git a/apps/api/src/app/health/health.module.ts b/apps/api/src/app/health/health.module.ts index bce798d16..6ed464401 100644 --- a/apps/api/src/app/health/health.module.ts +++ b/apps/api/src/app/health/health.module.ts @@ -1,4 +1,4 @@ -import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.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'; @@ -9,7 +9,11 @@ import { HealthService } from './health.service'; @Module({ controllers: [HealthController], - imports: [ConfigurationModule, DataEnhancerModule, DataProviderModule], + imports: [ + DataEnhancerModule, + DataProviderModule, + TransformDataSourceInRequestModule + ], providers: [HealthService] }) export class HealthModule {} diff --git a/apps/api/src/app/import/import.controller.ts b/apps/api/src/app/import/import.controller.ts index 3a79a7409..b276a3c3d 100644 --- a/apps/api/src/app/import/import.controller.ts +++ b/apps/api/src/app/import/import.controller.ts @@ -1,7 +1,7 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; -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 { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; +import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ImportResponse } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; @@ -43,8 +43,10 @@ export class ImportController { @UseInterceptors(TransformDataSourceInResponseInterceptor) public async import( @Body() importData: ImportDataDto, - @Query('dryRun') isDryRun?: boolean + @Query('dryRun') isDryRunParam = 'false' ): Promise { + const isDryRun = isDryRunParam === 'true'; + if ( !hasPermission(this.request.user.permissions, permissions.createAccount) ) { diff --git a/apps/api/src/app/import/import.module.ts b/apps/api/src/app/import/import.module.ts index ae8318044..47a4b5db3 100644 --- a/apps/api/src/app/import/import.module.ts +++ b/apps/api/src/app/import/import.module.ts @@ -4,6 +4,8 @@ import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module'; import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; +import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; +import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { 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'; @@ -30,7 +32,9 @@ import { ImportService } from './import.service'; PortfolioModule, PrismaModule, RedisCacheModule, - SymbolProfileModule + SymbolProfileModule, + TransformDataSourceInRequestModule, + TransformDataSourceInResponseModule ], providers: [ImportService] }) diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index e33bab347..30ab87069 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -13,12 +13,13 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/da 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 { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config'; import { DATE_FORMAT, getAssetProfileIdentifier, parseDate } from '@ghostfolio/common/helper'; -import { UniqueAsset } from '@ghostfolio/common/interfaces'; +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { AccountWithPlatform, OrderWithAccount, @@ -27,7 +28,7 @@ import { import { Injectable } from '@nestjs/common'; import { DataSource, Prisma, SymbolProfile } from '@prisma/client'; -import Big from 'big.js'; +import { Big } from 'big.js'; import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns'; import { uniqBy } from 'lodash'; import { v4 as uuidv4 } from 'uuid'; @@ -50,7 +51,7 @@ export class ImportService { dataSource, symbol, userCurrency - }: UniqueAsset & { userCurrency: string }): Promise { + }: AssetProfileIdentifier & { userCurrency: string }): Promise { try { const { firstBuyDate, historicalData, orders } = await this.portfolioService.getPosition(dataSource, undefined, symbol); @@ -71,65 +72,74 @@ export class ImportService { }) ]); - const accounts = orders.map((order) => { - return order.Account; - }); + const accounts = orders + .filter(({ Account }) => { + return !!Account; + }) + .map(({ Account }) => { + return Account; + }); const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined; - return Object.entries(dividends).map(([dateString, { marketPrice }]) => { - const quantity = - historicalData.find((historicalDataItem) => { - return historicalDataItem.date === dateString; - })?.quantity ?? 0; - - const value = new Big(quantity).mul(marketPrice).toNumber(); - - const date = parseDate(dateString); - const isDuplicate = orders.some((activity) => { - return ( - activity.accountId === Account?.id && - activity.SymbolProfile.currency === assetProfile.currency && - activity.SymbolProfile.dataSource === assetProfile.dataSource && - isSameSecond(activity.date, date) && - activity.quantity === quantity && - activity.SymbolProfile.symbol === assetProfile.symbol && - activity.type === 'DIVIDEND' && - activity.unitPrice === marketPrice - ); - }); + return await Promise.all( + Object.entries(dividends).map(async ([dateString, { marketPrice }]) => { + const quantity = + historicalData.find((historicalDataItem) => { + return historicalDataItem.date === dateString; + })?.quantity ?? 0; + + const value = new Big(quantity).mul(marketPrice).toNumber(); + + const date = parseDate(dateString); + const isDuplicate = orders.some((activity) => { + return ( + activity.accountId === Account?.id && + activity.SymbolProfile.currency === assetProfile.currency && + activity.SymbolProfile.dataSource === assetProfile.dataSource && + isSameSecond(activity.date, date) && + activity.quantity === quantity && + activity.SymbolProfile.symbol === assetProfile.symbol && + activity.type === 'DIVIDEND' && + activity.unitPrice === marketPrice + ); + }); - const error: ActivityError = isDuplicate - ? { code: 'IS_DUPLICATE' } - : undefined; + const error: ActivityError = isDuplicate + ? { code: 'IS_DUPLICATE' } + : undefined; - return { - Account, - date, - error, - quantity, - value, - accountId: Account?.id, - accountUserId: undefined, - comment: undefined, - createdAt: undefined, - fee: 0, - feeInBaseCurrency: 0, - id: assetProfile.id, - isDraft: false, - SymbolProfile: (assetProfile), - symbolProfileId: assetProfile.id, - type: 'DIVIDEND', - unitPrice: marketPrice, - updatedAt: undefined, - userId: Account?.userId, - valueInBaseCurrency: this.exchangeRateDataService.toCurrency( + return { + Account, + date, + error, + quantity, value, - assetProfile.currency, - userCurrency - ) - }; - }); + accountId: Account?.id, + accountUserId: undefined, + comment: undefined, + currency: undefined, + createdAt: undefined, + fee: 0, + feeInBaseCurrency: 0, + id: assetProfile.id, + isDraft: false, + SymbolProfile: assetProfile, + symbolProfileId: assetProfile.id, + type: 'DIVIDEND', + unitPrice: marketPrice, + updatedAt: undefined, + userId: Account?.userId, + valueInBaseCurrency: + await this.exchangeRateDataService.toCurrencyAtDate( + value, + assetProfile.currency, + userCurrency, + date + ) + }; + }) + ); } catch { return []; } @@ -261,6 +271,7 @@ export class ImportService { { accountId, comment, + currency, date, error, fee, @@ -285,11 +296,11 @@ export class ImportService { assetSubClass, countries, createdAt, - currency, dataSource, figi, figiComposite, figiShareClass, + holdings, id, isin, name, @@ -342,6 +353,7 @@ export class ImportService { if (isDryRun) { order = { comment, + currency, date, fee, quantity, @@ -357,11 +369,11 @@ export class ImportService { assetSubClass, countries, createdAt, - currency, dataSource, figi, figiComposite, figiShareClass, + holdings, id, isin, name, @@ -371,6 +383,7 @@ export class ImportService { symbolMapping, updatedAt, url, + currency: assetProfile.currency, comment: assetProfile.comment }, Account: validatedAccount, @@ -394,9 +407,9 @@ export class ImportService { SymbolProfile: { connectOrCreate: { create: { - currency, dataSource, - symbol + symbol, + currency: assetProfile.currency }, where: { dataSource_symbol: { @@ -410,6 +423,11 @@ export class ImportService { User: { connect: { id: user.id } }, userId: user.id }); + + if (order.SymbolProfile?.symbol) { + // Update symbol that may have been assigned in createOrder() + assetProfile.symbol = order.SymbolProfile.symbol; + } } const value = new Big(quantity).mul(unitPrice).toNumber(); @@ -418,18 +436,21 @@ export class ImportService { ...order, error, value, - feeInBaseCurrency: this.exchangeRateDataService.toCurrency( + feeInBaseCurrency: await this.exchangeRateDataService.toCurrencyAtDate( fee, - currency, - userCurrency + assetProfile.currency, + userCurrency, + date ), // @ts-ignore SymbolProfile: assetProfile, - valueInBaseCurrency: this.exchangeRateDataService.toCurrency( - value, - currency, - userCurrency - ) + valueInBaseCurrency: + await this.exchangeRateDataService.toCurrencyAtDate( + value, + assetProfile.currency, + userCurrency, + date + ) }); } @@ -446,15 +467,16 @@ export class ImportService { }); }); - this.dataGatheringService.gatherSymbols( - uniqueActivities.map(({ date, SymbolProfile }) => { + this.dataGatheringService.gatherSymbols({ + dataGatheringItems: uniqueActivities.map(({ date, SymbolProfile }) => { return { date, dataSource: SymbolProfile.dataSource, symbol: SymbolProfile.symbol }; - }) - ); + }), + priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH + }); } return activities; @@ -521,22 +543,15 @@ export class ImportService { currency, dataSource, symbol, - assetClass: null, - assetSubClass: null, - comment: null, - countries: null, + activitiesCount: undefined, + assetClass: undefined, + assetSubClass: undefined, + countries: undefined, createdAt: undefined, - figi: null, - figiComposite: null, - figiShareClass: null, + holdings: undefined, id: undefined, - isin: null, - name: null, - scraperConfiguration: null, - sectors: null, - symbolMapping: null, - updatedAt: undefined, - url: null + sectors: undefined, + updatedAt: undefined } }; } @@ -570,17 +585,10 @@ export class ImportService { [assetProfileIdentifier: string]: Partial; } = {}; - const uniqueActivitiesDto = uniqBy( - activitiesDto, - ({ dataSource, symbol }) => { - return getAssetProfileIdentifier({ dataSource, symbol }); - } - ); - for (const [ index, { currency, dataSource, symbol, type } - ] of uniqueActivitiesDto.entries()) { + ] of activitiesDto.entries()) { if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) { throw new Error( `activities.${index}.dataSource ("${dataSource}") is not valid` @@ -602,37 +610,33 @@ export class ImportService { } } - const assetProfile = { - currency, - ...( - await this.dataProviderService.getAssetProfiles([ - { dataSource, symbol } - ]) - )?.[symbol] - }; + if (!assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]) { + const assetProfile = { + currency, + ...( + await this.dataProviderService.getAssetProfiles([ + { dataSource, symbol } + ]) + )?.[symbol] + }; - if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') { - if (!assetProfile?.name) { - throw new Error( - `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` - ); - } + if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') { + if (!assetProfile?.name) { + throw new Error( + `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` + ); + } - if ( - assetProfile.currency !== currency && - !this.exchangeRateDataService.hasCurrencyPair( - currency, - assetProfile.currency - ) - ) { - throw new Error( - `activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}" and no exchange rate is available from "${currency}" to "${assetProfile.currency}"` - ); + if (assetProfile.currency !== currency) { + throw new Error( + `activities.${index}.currency ("${currency}") does not match with currency of ${assetProfile.symbol} ("${assetProfile.currency}")` + ); + } } - } - assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] = - assetProfile; + assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] = + assetProfile; + } } return assetProfiles; diff --git a/apps/api/src/app/info/info.controller.ts b/apps/api/src/app/info/info.controller.ts index 4890a444e..67d4101a3 100644 --- a/apps/api/src/app/info/info.controller.ts +++ b/apps/api/src/app/info/info.controller.ts @@ -1,4 +1,4 @@ -import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; +import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { InfoItem } from '@ghostfolio/common/interfaces'; import { Controller, Get, UseInterceptors } from '@nestjs/common'; diff --git a/apps/api/src/app/info/info.module.ts b/apps/api/src/app/info/info.module.ts index c28ef5869..473a966ad 100644 --- a/apps/api/src/app/info/info.module.ts +++ b/apps/api/src/app/info/info.module.ts @@ -2,11 +2,11 @@ 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 { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; -import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; @@ -34,6 +34,7 @@ import { InfoService } from './info.service'; RedisCacheModule, SymbolProfileModule, TagModule, + TransformDataSourceInResponseModule, UserModule ], providers: [InfoService] diff --git a/apps/api/src/app/logo/logo.controller.ts b/apps/api/src/app/logo/logo.controller.ts index 2ef65936e..0982a793f 100644 --- a/apps/api/src/app/logo/logo.controller.ts +++ b/apps/api/src/app/logo/logo.controller.ts @@ -1,4 +1,4 @@ -import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { Controller, diff --git a/apps/api/src/app/logo/logo.module.ts b/apps/api/src/app/logo/logo.module.ts index fe1a46ef6..1f59df1c8 100644 --- a/apps/api/src/app/logo/logo.module.ts +++ b/apps/api/src/app/logo/logo.module.ts @@ -1,3 +1,4 @@ +import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; @@ -8,7 +9,11 @@ import { LogoService } from './logo.service'; @Module({ controllers: [LogoController], - imports: [ConfigurationModule, SymbolProfileModule], + imports: [ + ConfigurationModule, + SymbolProfileModule, + TransformDataSourceInRequestModule + ], providers: [LogoService] }) export class LogoModule {} diff --git a/apps/api/src/app/logo/logo.service.ts b/apps/api/src/app/logo/logo.service.ts index a944900a0..908921a19 100644 --- a/apps/api/src/app/logo/logo.service.ts +++ b/apps/api/src/app/logo/logo.service.ts @@ -1,6 +1,6 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; -import { UniqueAsset } from '@ghostfolio/common/interfaces'; +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { HttpException, Injectable } from '@nestjs/common'; import { DataSource } from '@prisma/client'; @@ -17,7 +17,7 @@ export class LogoService { public async getLogoByDataSourceAndSymbol({ dataSource, symbol - }: UniqueAsset) { + }: AssetProfileIdentifier) { if (!DataSource[dataSource]) { throw new HttpException( getReasonPhrase(StatusCodes.NOT_FOUND), diff --git a/apps/api/src/app/order/create-order.dto.ts b/apps/api/src/app/order/create-order.dto.ts index f25a7ee12..6f52e7032 100644 --- a/apps/api/src/app/order/create-order.dto.ts +++ b/apps/api/src/app/order/create-order.dto.ts @@ -1,3 +1,6 @@ +import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code'; +import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970'; + import { AssetClass, AssetSubClass, @@ -14,7 +17,8 @@ import { IsNumber, IsOptional, IsString, - Min + Min, + Validate } from 'class-validator'; import { isString } from 'lodash'; @@ -38,14 +42,19 @@ export class CreateOrderDto { ) comment?: string; - @IsString() + @IsCurrencyCode() currency: string; + @IsCurrencyCode() + @IsOptional() + customCurrency?: string; + @IsOptional() @IsEnum(DataSource, { each: true }) dataSource?: DataSource; @IsISO8601() + @Validate(IsAfter1970Constraint) date: string; @IsNumber() diff --git a/apps/api/src/app/order/interfaces/activities.interface.ts b/apps/api/src/app/order/interfaces/activities.interface.ts index 7c612d464..b16d10b7d 100644 --- a/apps/api/src/app/order/interfaces/activities.interface.ts +++ b/apps/api/src/app/order/interfaces/activities.interface.ts @@ -1,13 +1,19 @@ -import { OrderWithAccount } from '@ghostfolio/common/types'; +import { EnhancedSymbolProfile } from '@ghostfolio/common/interfaces'; +import { AccountWithPlatform } from '@ghostfolio/common/types'; + +import { Order, Tag } from '@prisma/client'; export interface Activities { activities: Activity[]; count: number; } -export interface Activity extends OrderWithAccount { +export interface Activity extends Order { + Account?: AccountWithPlatform; error?: ActivityError; feeInBaseCurrency: number; + SymbolProfile?: EnhancedSymbolProfile; + tags?: Tag[]; updateAccountBalance?: boolean; value: number; valueInBaseCurrency: number; diff --git a/apps/api/src/app/order/order.controller.ts b/apps/api/src/app/order/order.controller.ts index dbcf6dedb..7a9cf3d17 100644 --- a/apps/api/src/app/order/order.controller.ts +++ b/apps/api/src/app/order/order.controller.ts @@ -1,14 +1,18 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; -import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor'; -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 { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; +import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { 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'; -import type { RequestWithUser } from '@ghostfolio/common/types'; +import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; +import { + DATA_GATHERING_QUEUE_PRIORITY_HIGH, + HEADER_KEY_IMPERSONATION +} from '@ghostfolio/common/config'; +import { permissions } from '@ghostfolio/common/permissions'; +import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; import { Body, @@ -32,7 +36,7 @@ import { parseISO } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { CreateOrderDto } from './create-order.dto'; -import { Activities } from './interfaces/activities.interface'; +import { Activities, Activity } from './interfaces/activities.interface'; import { OrderService } from './order.service'; import { UpdateOrderDto } from './update-order.dto'; @@ -49,22 +53,33 @@ export class OrderController { @Delete() @HasPermission(permissions.deleteOrder) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) - public async deleteOrders(): Promise { + public async deleteOrders( + @Query('accounts') filterByAccounts?: string, + @Query('assetClasses') filterByAssetClasses?: string, + @Query('tags') filterByTags?: string + ): Promise { + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAccounts, + filterByAssetClasses, + filterByTags + }); + return this.orderService.deleteOrders({ + filters, userId: this.request.user.id }); } @Delete(':id') + @HasPermission(permissions.deleteOrder) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async deleteOrder(@Param('id') id: string): Promise { - const order = await this.orderService.order({ id }); + const order = await this.orderService.order({ + id, + userId: this.request.user.id + }); - if ( - !hasPermission(this.request.user.permissions, permissions.deleteOrder) || - !order || - order.userId !== this.request.user.id - ) { + if (!order) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), StatusCodes.FORBIDDEN @@ -84,12 +99,20 @@ export class OrderController { @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, @Query('accounts') filterByAccounts?: string, @Query('assetClasses') filterByAssetClasses?: string, + @Query('range') dateRange?: DateRange, @Query('skip') skip?: number, @Query('sortColumn') sortColumn?: string, @Query('sortDirection') sortDirection?: Prisma.SortOrder, @Query('tags') filterByTags?: string, @Query('take') take?: number ): Promise { + let endDate: Date; + let startDate: Date; + + if (dateRange) { + ({ endDate, startDate } = getIntervalFromDateRange(dateRange)); + } + const filters = this.apiService.buildFiltersFromQueryParams({ filterByAccounts, filterByAssetClasses, @@ -101,9 +124,11 @@ export class OrderController { const userCurrency = this.request.user.Settings.settings.baseCurrency; const { activities, count } = await this.orderService.getOrders({ + endDate, filters, sortColumn, sortDirection, + startDate, userCurrency, includeDrafts: true, skip: isNaN(skip) ? undefined : skip, @@ -115,18 +140,59 @@ export class OrderController { return { activities, count }; } + @Get(':id') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(RedactValuesInResponseInterceptor) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async getOrderById( + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, + @Param('id') id: string + ): Promise { + const impersonationUserId = + await this.impersonationService.validateImpersonationId(impersonationId); + const userCurrency = this.request.user.Settings.settings.baseCurrency; + + const { activities } = await this.orderService.getOrders({ + userCurrency, + userId: impersonationUserId || this.request.user.id, + withExcludedAccounts: true + }); + + const activity = activities.find((activity) => { + return activity.id === id; + }); + + if (!activity) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + return activity; + } + @HasPermission(permissions.createOrder) @Post() @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async createOrder(@Body() data: CreateOrderDto): Promise { + const currency = data.currency; + const customCurrency = data.customCurrency; + + if (customCurrency) { + data.currency = customCurrency; + + delete data.customCurrency; + } + const order = await this.orderService.createOrder({ ...data, date: parseISO(data.date), SymbolProfile: { connectOrCreate: { create: { - currency: data.currency, + currency, dataSource: data.dataSource, symbol: data.symbol }, @@ -145,13 +211,16 @@ export class OrderController { if (data.dataSource && !order.isDraft) { // Gather symbol data in the background, if data source is set // (not MANUAL) and not draft - this.dataGatheringService.gatherSymbols([ - { - dataSource: data.dataSource, - date: order.date, - symbol: data.symbol - } - ]); + this.dataGatheringService.gatherSymbols({ + dataGatheringItems: [ + { + dataSource: data.dataSource, + date: order.date, + symbol: data.symbol + } + ], + priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH + }); } return order; @@ -176,8 +245,16 @@ export class OrderController { const date = parseISO(data.date); const accountId = data.accountId; + const customCurrency = data.customCurrency; + delete data.accountId; + if (customCurrency) { + data.currency = customCurrency; + + delete data.customCurrency; + } + return this.orderService.updateOrder({ data: { ...data, diff --git a/apps/api/src/app/order/order.module.ts b/apps/api/src/app/order/order.module.ts index d125bb62b..55b4cce82 100644 --- a/apps/api/src/app/order/order.module.ts +++ b/apps/api/src/app/order/order.module.ts @@ -2,9 +2,10 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/accou 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 { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module'; +import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; +import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; -import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; @@ -23,15 +24,16 @@ import { OrderService } from './order.service'; imports: [ ApiModule, CacheModule, - ConfigurationModule, DataGatheringModule, DataProviderModule, ExchangeRateDataModule, ImpersonationModule, PrismaModule, + RedactValuesInResponseModule, RedisCacheModule, SymbolProfileModule, - UserModule + TransformDataSourceInRequestModule, + TransformDataSourceInResponseModule ], providers: [AccountBalanceService, AccountService, OrderService] }) diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index 5a6beb0a4..f66380e1f 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -1,17 +1,24 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { + DATA_GATHERING_QUEUE_PRIORITY_HIGH, 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 { + AssetProfileIdentifier, + EnhancedSymbolProfile, + Filter +} from '@ghostfolio/common/interfaces'; import { OrderWithAccount } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { AssetClass, AssetSubClass, @@ -19,11 +26,11 @@ import { Order, Prisma, Tag, - Type as TypeOfOrder + Type as ActivityType } from '@prisma/client'; -import Big from 'big.js'; +import { Big } from 'big.js'; import { endOfToday, isAfter } from 'date-fns'; -import { groupBy } from 'lodash'; +import { groupBy, uniqBy } from 'lodash'; import { v4 as uuidv4 } from 'uuid'; import { Activities } from './interfaces/activities.interface'; @@ -33,11 +40,45 @@ export class OrderService { public constructor( private readonly accountService: AccountService, private readonly dataGatheringService: DataGatheringService, + private readonly eventEmitter: EventEmitter2, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly prismaService: PrismaService, private readonly symbolProfileService: SymbolProfileService ) {} + public async assignTags({ + dataSource, + symbol, + tags, + userId + }: { tags: Tag[]; userId: string } & AssetProfileIdentifier) { + const orders = await this.prismaService.order.findMany({ + where: { + userId, + SymbolProfile: { + dataSource, + symbol + } + } + }); + + return Promise.all( + orders.map(({ id }) => + this.prismaService.order.update({ + data: { + tags: { + // The set operation replaces all existing connections with the provided ones + set: tags.map(({ id }) => { + return { id }; + }) + } + }, + where: { id } + }) + ) + ); + } + public async createOrder( data: Prisma.OrderCreateInput & { accountId?: string; @@ -65,20 +106,13 @@ export class OrderService { } const accountId = data.accountId; - let currency = data.currency; const tags = data.tags ?? []; const updateAccountBalance = data.updateAccountBalance ?? false; const userId = data.userId; - if ( - data.type === 'FEE' || - data.type === 'INTEREST' || - data.type === 'ITEM' || - data.type === 'LIABILITY' - ) { + if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) { const assetClass = data.assetClass; const assetSubClass = data.assetSubClass; - currency = data.SymbolProfile.connectOrCreate.create.currency; const dataSource: DataSource = 'MANUAL'; const id = uuidv4(); const name = data.SymbolProfile.connectOrCreate.create.symbol; @@ -86,7 +120,6 @@ export class OrderService { data.id = id; data.SymbolProfile.connectOrCreate.create.assetClass = assetClass; data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass; - data.SymbolProfile.connectOrCreate.create.currency = currency; data.SymbolProfile.connectOrCreate.create.dataSource = dataSource; data.SymbolProfile.connectOrCreate.create.name = name; data.SymbolProfile.connectOrCreate.create.symbol = id; @@ -108,7 +141,8 @@ export class OrderService { jobId: getAssetProfileIdentifier({ dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, symbol: data.SymbolProfile.connectOrCreate.create.symbol - }) + }), + priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH } }); } @@ -121,7 +155,6 @@ export class OrderService { delete data.comment; } - delete data.currency; delete data.dataSource; delete data.symbol; delete data.tags; @@ -130,13 +163,9 @@ 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 isDraft = ['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type) + ? false + : isAfter(data.date as Date, endOfToday()); const order = await this.prismaService.order.create({ data: { @@ -148,7 +177,8 @@ export class OrderService { return { id }; }) } - } + }, + include: { SymbolProfile: true } }); if (updateAccountBalance === true) { @@ -157,19 +187,26 @@ export class OrderService { .plus(data.fee) .toNumber(); - if (data.type === 'BUY') { + if (['BUY', 'FEE'].includes(data.type)) { amount = new Big(amount).mul(-1).toNumber(); } await this.accountService.updateAccountBalance({ accountId, amount, - currency, userId, + currency: data.SymbolProfile.connectOrCreate.create.currency, date: data.date as Date }); } + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: order.userId + }) + ); + return order; } @@ -180,62 +217,142 @@ export class OrderService { where }); + const [symbolProfile] = + await this.symbolProfileService.getSymbolProfilesByIds([ + order.symbolProfileId + ]); + if ( - order.type === 'FEE' || - order.type === 'INTEREST' || - order.type === 'ITEM' || - order.type === 'LIABILITY' + ['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(order.type) || + symbolProfile.activitiesCount === 0 ) { await this.symbolProfileService.deleteById(order.symbolProfileId); } + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: order.userId + }) + ); + return order; } - public async deleteOrders(where: Prisma.OrderWhereInput): Promise { + public async deleteOrders({ + filters, + userId + }: { + filters?: Filter[]; + userId: string; + }): Promise { + const { activities } = await this.getOrders({ + filters, + userId, + includeDrafts: true, + userCurrency: undefined, + withExcludedAccounts: true + }); + const { count } = await this.prismaService.order.deleteMany({ - where + where: { + id: { + in: activities.map(({ id }) => { + return id; + }) + } + } }); + const symbolProfiles = + await this.symbolProfileService.getSymbolProfilesByIds( + activities.map(({ symbolProfileId }) => { + return symbolProfileId; + }) + ); + + for (const { activitiesCount, id } of symbolProfiles) { + if (activitiesCount === 0) { + await this.symbolProfileService.deleteById(id); + } + } + + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ userId }) + ); + return count; } + public async getLatestOrder({ dataSource, symbol }: AssetProfileIdentifier) { + return this.prismaService.order.findFirst({ + orderBy: { + date: 'desc' + }, + where: { + SymbolProfile: { dataSource, symbol } + } + }); + } + public async getOrders({ + endDate, filters, includeDrafts = false, skip, sortColumn, sortDirection, + startDate, take = Number.MAX_SAFE_INTEGER, types, userCurrency, userId, withExcludedAccounts = false }: { + endDate?: Date; filters?: Filter[]; includeDrafts?: boolean; skip?: number; sortColumn?: string; sortDirection?: Prisma.SortOrder; + startDate?: Date; take?: number; - types?: TypeOfOrder[]; + types?: ActivityType[]; userCurrency: string; userId: string; withExcludedAccounts?: boolean; }): Promise { let orderBy: Prisma.Enumerable = [ - { date: 'asc' } + { date: 'asc' }, + { id: 'asc' } ]; const where: Prisma.OrderWhereInput = { userId }; + if (endDate || startDate) { + where.AND = []; + + if (endDate) { + where.AND.push({ date: { lte: endDate } }); + } + + if (startDate) { + where.AND.push({ date: { gt: startDate } }); + } + } + const { ACCOUNT: filtersByAccount, ASSET_CLASS: filtersByAssetClass, TAG: filtersByTag - } = groupBy(filters, (filter) => { - return filter.type; + } = groupBy(filters, ({ type }) => { + return type; }); + const searchQuery = filters?.find(({ type }) => { + return type === 'SEARCH_QUERY'; + })?.id; + if (filtersByAccount?.length > 0) { where.accountId = { in: filtersByAccount.map(({ id }) => { @@ -277,6 +394,30 @@ export class OrderService { }; } + if (searchQuery) { + const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [ + { id: { mode: 'insensitive', startsWith: searchQuery } }, + { isin: { mode: 'insensitive', startsWith: searchQuery } }, + { name: { mode: 'insensitive', startsWith: searchQuery } }, + { symbol: { mode: 'insensitive', startsWith: searchQuery } } + ]; + + if (where.SymbolProfile) { + where.SymbolProfile = { + AND: [ + where.SymbolProfile, + { + OR: searchQueryWhereInput + } + ] + }; + } else { + where.SymbolProfile = { + OR: searchQueryWhereInput + }; + } + } + if (filtersByTag?.length > 0) { where.tags = { some: { @@ -288,17 +429,18 @@ export class OrderService { } if (sortColumn) { - orderBy = [{ [sortColumn]: sortDirection }]; + orderBy = [{ [sortColumn]: sortDirection }, { id: sortDirection }]; } if (types) { - where.OR = types.map((type) => { - return { - type: { - equals: type - } - }; - }); + where.type = { in: types }; + } + + if (withExcludedAccounts === false) { + where.OR = [ + { Account: null }, + { Account: { NOT: { isExcluded: true } } } + ]; } const [orders, count] = await Promise.all([ @@ -322,36 +464,98 @@ export class OrderService { this.prismaService.order.count({ where }) ]); - const activities = orders - .filter((order) => { - return ( - withExcludedAccounts || - !order.Account || - order.Account?.isExcluded === false - ); - }) - .map((order) => { + const assetProfileIdentifiers = uniqBy( + orders.map(({ SymbolProfile }) => { + return { + dataSource: SymbolProfile.dataSource, + symbol: SymbolProfile.symbol + }; + }), + ({ dataSource, symbol }) => { + return getAssetProfileIdentifier({ + dataSource, + symbol + }); + } + ); + + const assetProfiles = await this.symbolProfileService.getSymbolProfiles( + assetProfileIdentifiers + ); + + const activities = await Promise.all( + orders.map(async (order) => { + const assetProfile = assetProfiles.find(({ dataSource, symbol }) => { + return ( + dataSource === order.SymbolProfile.dataSource && + symbol === order.SymbolProfile.symbol + ); + }); + const value = new Big(order.quantity).mul(order.unitPrice).toNumber(); return { ...order, value, - feeInBaseCurrency: this.exchangeRateDataService.toCurrency( - order.fee, - order.SymbolProfile.currency, - userCurrency - ), - valueInBaseCurrency: this.exchangeRateDataService.toCurrency( - value, - order.SymbolProfile.currency, - userCurrency - ) + feeInBaseCurrency: + await this.exchangeRateDataService.toCurrencyAtDate( + order.fee, + order.SymbolProfile.currency, + userCurrency, + order.date + ), + SymbolProfile: assetProfile, + valueInBaseCurrency: + await this.exchangeRateDataService.toCurrencyAtDate( + value, + order.SymbolProfile.currency, + userCurrency, + order.date + ) }; - }); + }) + ); return { activities, count }; } + public async getOrdersForPortfolioCalculator({ + filters, + userCurrency, + userId + }: { + filters?: Filter[]; + userCurrency: string; + userId: string; + }) { + return this.getOrders({ + filters, + userCurrency, + userId, + withExcludedAccounts: false // TODO + }); + } + + public async getStatisticsByCurrency( + currency: EnhancedSymbolProfile['currency'] + ): Promise<{ + activitiesCount: EnhancedSymbolProfile['activitiesCount']; + dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity']; + }> { + const { _count, _min } = await this.prismaService.order.aggregate({ + _count: true, + _min: { + date: true + }, + where: { SymbolProfile: { currency } } + }); + + return { + activitiesCount: _count as number, + dateOfFirstActivity: _min.date + }; + } + public async order( orderWhereUniqueInput: Prisma.OrderWhereUniqueInput ): Promise { @@ -371,13 +575,10 @@ export class OrderService { dataSource?: DataSource; symbol?: string; tags?: Tag[]; + type?: ActivityType; }; where: Prisma.OrderWhereUniqueInput; }): Promise { - if (data.Account.connect.id_userId.id === null) { - delete data.Account; - } - if (!data.comment) { data.comment = null; } @@ -386,13 +587,12 @@ export class OrderService { let isDraft = false; - if ( - data.type === 'FEE' || - data.type === 'INTEREST' || - data.type === 'ITEM' || - data.type === 'LIABILITY' - ) { + if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) { delete data.SymbolProfile.connect; + + if (data.Account?.connect?.id_userId?.id === null) { + data.Account = { disconnect: true }; + } } else { delete data.SymbolProfile.update; @@ -400,19 +600,22 @@ export class OrderService { if (!isDraft) { // Gather symbol data of order in the background, if not draft - this.dataGatheringService.gatherSymbols([ - { - dataSource: data.SymbolProfile.connect.dataSource_symbol.dataSource, - date: data.date, - symbol: data.SymbolProfile.connect.dataSource_symbol.symbol - } - ]); + this.dataGatheringService.gatherSymbols({ + dataGatheringItems: [ + { + dataSource: + data.SymbolProfile.connect.dataSource_symbol.dataSource, + date: data.date, + symbol: data.SymbolProfile.connect.dataSource_symbol.symbol + } + ], + priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH + }); } } delete data.assetClass; delete data.assetSubClass; - delete data.currency; delete data.dataSource; delete data.symbol; delete data.tags; @@ -423,7 +626,7 @@ export class OrderService { where }); - return this.prismaService.order.update({ + const order = await this.prismaService.order.update({ data: { ...data, isDraft, @@ -435,6 +638,15 @@ export class OrderService { }, where }); + + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: order.userId + }) + ); + + return order; } private async orders(params: { diff --git a/apps/api/src/app/order/update-order.dto.ts b/apps/api/src/app/order/update-order.dto.ts index 3123d5665..eabd1f418 100644 --- a/apps/api/src/app/order/update-order.dto.ts +++ b/apps/api/src/app/order/update-order.dto.ts @@ -1,3 +1,6 @@ +import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code'; +import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970'; + import { AssetClass, AssetSubClass, @@ -13,7 +16,8 @@ import { IsNumber, IsOptional, IsString, - Min + Min, + Validate } from 'class-validator'; import { isString } from 'lodash'; @@ -37,13 +41,18 @@ export class UpdateOrderDto { ) comment?: string; - @IsString() + @IsCurrencyCode() currency: string; + @IsCurrencyCode() + @IsOptional() + customCurrency?: string; + @IsString() dataSource: DataSource; @IsISO8601() + @Validate(IsAfter1970Constraint) date: string; @IsNumber() diff --git a/apps/api/src/app/platform/create-platform.dto.ts b/apps/api/src/app/platform/create-platform.dto.ts index a61f21743..941354c11 100644 --- a/apps/api/src/app/platform/create-platform.dto.ts +++ b/apps/api/src/app/platform/create-platform.dto.ts @@ -1,9 +1,12 @@ -import { IsString } from 'class-validator'; +import { IsString, IsUrl } from 'class-validator'; export class CreatePlatformDto { @IsString() name: string; - @IsString() + @IsUrl({ + protocols: ['https'], + require_protocol: true + }) url: string; } diff --git a/apps/api/src/app/platform/update-platform.dto.ts b/apps/api/src/app/platform/update-platform.dto.ts index ec6f2687c..4c4f907af 100644 --- a/apps/api/src/app/platform/update-platform.dto.ts +++ b/apps/api/src/app/platform/update-platform.dto.ts @@ -1,4 +1,4 @@ -import { IsString } from 'class-validator'; +import { IsString, IsUrl } from 'class-validator'; export class UpdatePlatformDto { @IsString() @@ -7,6 +7,9 @@ export class UpdatePlatformDto { @IsString() name: string; - @IsString() + @IsUrl({ + protocols: ['https'], + require_protocol: true + }) url: string; } diff --git a/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts new file mode 100644 index 000000000..eb49b7cdb --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts @@ -0,0 +1,34 @@ +import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; +import { + AssetProfileIdentifier, + SymbolMetrics +} from '@ghostfolio/common/interfaces'; +import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; + +export class MWRPortfolioCalculator extends PortfolioCalculator { + protected calculateOverallPerformance( + positions: TimelinePosition[] + ): PortfolioSnapshot { + throw new Error('Method not implemented.'); + } + + protected getSymbolMetrics({ + dataSource, + end, + exchangeRates, + marketSymbolMap, + start, + step = 1, + symbol + }: { + end: Date; + exchangeRates: { [dateString: string]: number }; + marketSymbolMap: { + [date: string]: { [symbol: string]: Big }; + }; + start: Date; + step?: number; + } & AssetProfileIdentifier): SymbolMetrics { + throw new Error('Method not implemented.'); + } +} diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts new file mode 100644 index 000000000..d458be708 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts @@ -0,0 +1,31 @@ +export const activityDummyData = { + accountId: undefined, + accountUserId: undefined, + comment: undefined, + createdAt: new Date(), + currency: undefined, + feeInBaseCurrency: undefined, + id: undefined, + isDraft: false, + symbolProfileId: undefined, + updatedAt: new Date(), + userId: undefined, + value: undefined, + valueInBaseCurrency: undefined +}; + +export const symbolProfileDummyData = { + activitiesCount: undefined, + assetClass: undefined, + assetSubClass: undefined, + countries: [], + createdAt: undefined, + holdings: [], + id: undefined, + sectors: [], + updatedAt: undefined +}; + +export const userDummyData = { + id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' +}; diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts new file mode 100644 index 000000000..b531ffc9d --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts @@ -0,0 +1,72 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces'; + +import { Injectable } from '@nestjs/common'; + +import { MWRPortfolioCalculator } from './mwr/portfolio-calculator'; +import { PortfolioCalculator } from './portfolio-calculator'; +import { TWRPortfolioCalculator } from './twr/portfolio-calculator'; + +export enum PerformanceCalculationType { + MWR = 'MWR', // Money-Weighted Rate of Return + TWR = 'TWR' // Time-Weighted Rate of Return +} + +@Injectable() +export class PortfolioCalculatorFactory { + public constructor( + private readonly configurationService: ConfigurationService, + private readonly currentRateService: CurrentRateService, + private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly redisCacheService: RedisCacheService + ) {} + + public createCalculator({ + accountBalanceItems = [], + activities, + calculationType, + currency, + filters = [], + userId + }: { + accountBalanceItems?: HistoricalDataItem[]; + activities: Activity[]; + calculationType: PerformanceCalculationType; + currency: string; + filters?: Filter[]; + userId: string; + }): PortfolioCalculator { + switch (calculationType) { + case PerformanceCalculationType.MWR: + return new MWRPortfolioCalculator({ + accountBalanceItems, + activities, + currency, + filters, + userId, + configurationService: this.configurationService, + currentRateService: this.currentRateService, + exchangeRateDataService: this.exchangeRateDataService, + redisCacheService: this.redisCacheService + }); + case PerformanceCalculationType.TWR: + return new TWRPortfolioCalculator({ + accountBalanceItems, + activities, + currency, + currentRateService: this.currentRateService, + filters, + userId, + configurationService: this.configurationService, + exchangeRateDataService: this.exchangeRateDataService, + redisCacheService: this.redisCacheService + }); + default: + throw new Error('Invalid calculation type'); + } + } +} diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts new file mode 100644 index 000000000..99f71ef0e --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -0,0 +1,1046 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface'; +import { TransactionPointSymbol } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point-symbol.interface'; +import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; +import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; +import { + DATE_FORMAT, + getSum, + parseDate, + resetHours +} from '@ghostfolio/common/helper'; +import { + AssetProfileIdentifier, + DataProviderInfo, + Filter, + HistoricalDataItem, + InvestmentItem, + ResponseError, + SymbolMetrics +} from '@ghostfolio/common/interfaces'; +import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; +import { GroupBy } from '@ghostfolio/common/types'; + +import { Logger } from '@nestjs/common'; +import { Big } from 'big.js'; +import { plainToClass } from 'class-transformer'; +import { + differenceInDays, + eachDayOfInterval, + endOfDay, + format, + isAfter, + isBefore, + min, + subDays +} from 'date-fns'; +import { first, isNumber, last, sortBy, sum, uniq, uniqBy } from 'lodash'; + +export abstract class PortfolioCalculator { + protected static readonly ENABLE_LOGGING = false; + + protected accountBalanceItems: HistoricalDataItem[]; + protected activities: PortfolioOrder[]; + + private configurationService: ConfigurationService; + private currency: string; + private currentRateService: CurrentRateService; + private dataProviderInfos: DataProviderInfo[]; + private endDate: Date; + private exchangeRateDataService: ExchangeRateDataService; + private filters: Filter[]; + private redisCacheService: RedisCacheService; + private snapshot: PortfolioSnapshot; + private snapshotPromise: Promise; + private startDate: Date; + private transactionPoints: TransactionPoint[]; + private userId: string; + + public constructor({ + accountBalanceItems, + activities, + configurationService, + currency, + currentRateService, + exchangeRateDataService, + filters, + redisCacheService, + userId + }: { + accountBalanceItems: HistoricalDataItem[]; + activities: Activity[]; + configurationService: ConfigurationService; + currency: string; + currentRateService: CurrentRateService; + exchangeRateDataService: ExchangeRateDataService; + filters: Filter[]; + redisCacheService: RedisCacheService; + userId: string; + }) { + this.accountBalanceItems = accountBalanceItems; + this.configurationService = configurationService; + this.currency = currency; + this.currentRateService = currentRateService; + this.exchangeRateDataService = exchangeRateDataService; + this.filters = filters; + + let dateOfFirstActivity = new Date(); + + this.activities = activities + .map( + ({ + date, + fee, + quantity, + SymbolProfile, + tags = [], + type, + unitPrice + }) => { + if (isBefore(date, dateOfFirstActivity)) { + dateOfFirstActivity = date; + } + + if (isAfter(date, new Date())) { + // Adapt date to today if activity is in future (e.g. liability) + // to include it in the interval + date = endOfDay(new Date()); + } + + return { + SymbolProfile, + tags, + type, + date: format(date, DATE_FORMAT), + fee: new Big(fee), + quantity: new Big(quantity), + unitPrice: new Big(unitPrice) + }; + } + ) + .sort((a, b) => { + return a.date?.localeCompare(b.date); + }); + + this.redisCacheService = redisCacheService; + this.userId = userId; + + const { endDate, startDate } = getIntervalFromDateRange( + 'max', + subDays(dateOfFirstActivity, 1) + ); + + this.endDate = endDate; + this.startDate = startDate; + + this.computeTransactionPoints(); + + this.snapshotPromise = this.initialize(); + } + + protected abstract calculateOverallPerformance( + positions: TimelinePosition[] + ): PortfolioSnapshot; + + private async computeSnapshot(): Promise { + const lastTransactionPoint = last(this.transactionPoints); + + const transactionPoints = this.transactionPoints?.filter(({ date }) => { + return isBefore(parseDate(date), this.endDate); + }); + + if (!transactionPoints.length) { + return { + currentValueInBaseCurrency: new Big(0), + hasErrors: false, + historicalData: [], + positions: [], + totalFeesWithCurrencyEffect: new Big(0), + totalInterestWithCurrencyEffect: new Big(0), + totalInvestment: new Big(0), + totalInvestmentWithCurrencyEffect: new Big(0), + totalLiabilitiesWithCurrencyEffect: new Big(0), + totalValuablesWithCurrencyEffect: new Big(0) + }; + } + + const currencies: { [symbol: string]: string } = {}; + const dataGatheringItems: IDataGatheringItem[] = []; + let firstIndex = transactionPoints.length; + let firstTransactionPoint: TransactionPoint = null; + let totalInterestWithCurrencyEffect = new Big(0); + let totalLiabilitiesWithCurrencyEffect = new Big(0); + let totalValuablesWithCurrencyEffect = new Big(0); + + for (const { currency, dataSource, symbol } of transactionPoints[ + firstIndex - 1 + ].items) { + dataGatheringItems.push({ + dataSource, + symbol + }); + + currencies[symbol] = currency; + } + + for (let i = 0; i < transactionPoints.length; i++) { + if ( + !isBefore(parseDate(transactionPoints[i].date), this.startDate) && + firstTransactionPoint === null + ) { + firstTransactionPoint = transactionPoints[i]; + firstIndex = i; + } + } + + let exchangeRatesByCurrency = + await this.exchangeRateDataService.getExchangeRatesByCurrency({ + currencies: uniq(Object.values(currencies)), + endDate: endOfDay(this.endDate), + startDate: this.startDate, + targetCurrency: this.currency + }); + + const { + dataProviderInfos, + errors: currentRateErrors, + values: marketSymbols + } = await this.currentRateService.getValues({ + dataGatheringItems, + dateQuery: { + gte: this.startDate, + lt: this.endDate + } + }); + + this.dataProviderInfos = dataProviderInfos; + + const marketSymbolMap: { + [date: string]: { [symbol: string]: Big }; + } = {}; + + for (const marketSymbol of marketSymbols) { + const date = format(marketSymbol.date, DATE_FORMAT); + + if (!marketSymbolMap[date]) { + marketSymbolMap[date] = {}; + } + + if (marketSymbol.marketPrice) { + marketSymbolMap[date][marketSymbol.symbol] = new Big( + marketSymbol.marketPrice + ); + } + } + + const endDateString = format(this.endDate, DATE_FORMAT); + + const daysInMarket = differenceInDays(this.endDate, this.startDate); + + let chartDateMap = this.getChartDateMap({ + endDate: this.endDate, + startDate: this.startDate, + step: Math.round( + daysInMarket / + Math.min( + daysInMarket, + this.configurationService.get('MAX_CHART_ITEMS') + ) + ) + }); + + const chartDates = sortBy(Object.keys(chartDateMap), (chartDate) => { + return chartDate; + }); + + if (firstIndex > 0) { + firstIndex--; + } + + const positions: TimelinePosition[] = []; + let hasAnySymbolMetricsErrors = false; + + const errors: ResponseError['errors'] = []; + + const accumulatedValuesByDate: { + [date: string]: { + investmentValueWithCurrencyEffect: Big; + totalAccountBalanceWithCurrencyEffect: Big; + totalCurrentValue: Big; + totalCurrentValueWithCurrencyEffect: Big; + totalInvestmentValue: Big; + totalInvestmentValueWithCurrencyEffect: Big; + totalNetPerformanceValue: Big; + totalNetPerformanceValueWithCurrencyEffect: Big; + totalTimeWeightedInvestmentValue: Big; + totalTimeWeightedInvestmentValueWithCurrencyEffect: Big; + }; + } = {}; + + const valuesBySymbol: { + [symbol: string]: { + currentValues: { [date: string]: Big }; + currentValuesWithCurrencyEffect: { [date: string]: Big }; + investmentValuesAccumulated: { [date: string]: Big }; + investmentValuesAccumulatedWithCurrencyEffect: { [date: string]: Big }; + investmentValuesWithCurrencyEffect: { [date: string]: Big }; + netPerformanceValues: { [date: string]: Big }; + netPerformanceValuesWithCurrencyEffect: { [date: string]: Big }; + timeWeightedInvestmentValues: { [date: string]: Big }; + timeWeightedInvestmentValuesWithCurrencyEffect: { [date: string]: Big }; + }; + } = {}; + + for (const item of lastTransactionPoint.items) { + const feeInBaseCurrency = item.fee.mul( + exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[ + lastTransactionPoint.date + ] ?? 1 + ); + + const marketPriceInBaseCurrency = ( + marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice + ).mul( + exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[ + endDateString + ] + ); + + const { + currentValues, + currentValuesWithCurrencyEffect, + grossPerformance, + grossPerformancePercentage, + grossPerformancePercentageWithCurrencyEffect, + grossPerformanceWithCurrencyEffect, + hasErrors, + investmentValuesAccumulated, + investmentValuesAccumulatedWithCurrencyEffect, + investmentValuesWithCurrencyEffect, + netPerformance, + netPerformancePercentage, + netPerformancePercentageWithCurrencyEffectMap, + netPerformanceValues, + netPerformanceValuesWithCurrencyEffect, + netPerformanceWithCurrencyEffectMap, + timeWeightedInvestment, + timeWeightedInvestmentValues, + timeWeightedInvestmentValuesWithCurrencyEffect, + timeWeightedInvestmentWithCurrencyEffect, + totalDividend, + totalDividendInBaseCurrency, + totalInterestInBaseCurrency, + totalInvestment, + totalInvestmentWithCurrencyEffect, + totalLiabilitiesInBaseCurrency, + totalValuablesInBaseCurrency + } = this.getSymbolMetrics({ + chartDateMap, + marketSymbolMap, + dataSource: item.dataSource, + end: this.endDate, + exchangeRates: + exchangeRatesByCurrency[`${item.currency}${this.currency}`], + start: this.startDate, + symbol: item.symbol + }); + + hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors; + + valuesBySymbol[item.symbol] = { + currentValues, + currentValuesWithCurrencyEffect, + investmentValuesAccumulated, + investmentValuesAccumulatedWithCurrencyEffect, + investmentValuesWithCurrencyEffect, + netPerformanceValues, + netPerformanceValuesWithCurrencyEffect, + timeWeightedInvestmentValues, + timeWeightedInvestmentValuesWithCurrencyEffect + }; + + positions.push({ + feeInBaseCurrency, + timeWeightedInvestment, + timeWeightedInvestmentWithCurrencyEffect, + dividend: totalDividend, + dividendInBaseCurrency: totalDividendInBaseCurrency, + averagePrice: item.averagePrice, + currency: item.currency, + dataSource: item.dataSource, + fee: item.fee, + firstBuyDate: item.firstBuyDate, + grossPerformance: !hasErrors ? (grossPerformance ?? null) : null, + grossPerformancePercentage: !hasErrors + ? (grossPerformancePercentage ?? null) + : null, + grossPerformancePercentageWithCurrencyEffect: !hasErrors + ? (grossPerformancePercentageWithCurrencyEffect ?? null) + : null, + grossPerformanceWithCurrencyEffect: !hasErrors + ? (grossPerformanceWithCurrencyEffect ?? null) + : null, + investment: totalInvestment, + investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect, + marketPrice: + marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? null, + marketPriceInBaseCurrency: + marketPriceInBaseCurrency?.toNumber() ?? null, + netPerformance: !hasErrors ? (netPerformance ?? null) : null, + netPerformancePercentage: !hasErrors + ? (netPerformancePercentage ?? null) + : null, + netPerformancePercentageWithCurrencyEffectMap: !hasErrors + ? (netPerformancePercentageWithCurrencyEffectMap ?? null) + : null, + netPerformanceWithCurrencyEffectMap: !hasErrors + ? (netPerformanceWithCurrencyEffectMap ?? null) + : null, + quantity: item.quantity, + symbol: item.symbol, + tags: item.tags, + transactionCount: item.transactionCount, + valueInBaseCurrency: new Big(marketPriceInBaseCurrency).mul( + item.quantity + ) + }); + + totalInterestWithCurrencyEffect = totalInterestWithCurrencyEffect.plus( + totalInterestInBaseCurrency + ); + + totalLiabilitiesWithCurrencyEffect = + totalLiabilitiesWithCurrencyEffect.plus(totalLiabilitiesInBaseCurrency); + + totalValuablesWithCurrencyEffect = totalValuablesWithCurrencyEffect.plus( + totalValuablesInBaseCurrency + ); + + if ( + (hasErrors || + currentRateErrors.find(({ dataSource, symbol }) => { + return dataSource === item.dataSource && symbol === item.symbol; + })) && + item.investment.gt(0) + ) { + errors.push({ dataSource: item.dataSource, symbol: item.symbol }); + } + } + + let lastDate = chartDates[0]; + + for (const dateString of chartDates) { + for (const symbol of Object.keys(valuesBySymbol)) { + const symbolValues = valuesBySymbol[symbol]; + + const currentValue = + symbolValues.currentValues?.[dateString] ?? new Big(0); + + const currentValueWithCurrencyEffect = + symbolValues.currentValuesWithCurrencyEffect?.[dateString] ?? + new Big(0); + + const investmentValueAccumulated = + symbolValues.investmentValuesAccumulated?.[dateString] ?? new Big(0); + + const investmentValueAccumulatedWithCurrencyEffect = + symbolValues.investmentValuesAccumulatedWithCurrencyEffect?.[ + dateString + ] ?? new Big(0); + + const investmentValueWithCurrencyEffect = + symbolValues.investmentValuesWithCurrencyEffect?.[dateString] ?? + new Big(0); + + const netPerformanceValue = + symbolValues.netPerformanceValues?.[dateString] ?? new Big(0); + + const netPerformanceValueWithCurrencyEffect = + symbolValues.netPerformanceValuesWithCurrencyEffect?.[dateString] ?? + new Big(0); + + const timeWeightedInvestmentValue = + symbolValues.timeWeightedInvestmentValues?.[dateString] ?? new Big(0); + + const timeWeightedInvestmentValueWithCurrencyEffect = + symbolValues.timeWeightedInvestmentValuesWithCurrencyEffect?.[ + dateString + ] ?? new Big(0); + + accumulatedValuesByDate[dateString] = { + investmentValueWithCurrencyEffect: ( + accumulatedValuesByDate[dateString] + ?.investmentValueWithCurrencyEffect ?? new Big(0) + ).add(investmentValueWithCurrencyEffect), + totalAccountBalanceWithCurrencyEffect: this.accountBalanceItems.some( + ({ date }) => { + return date === dateString; + } + ) + ? new Big( + this.accountBalanceItems.find(({ date }) => { + return date === dateString; + }).value + ) + : (accumulatedValuesByDate[lastDate] + ?.totalAccountBalanceWithCurrencyEffect ?? new Big(0)), + totalCurrentValue: ( + accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0) + ).add(currentValue), + totalCurrentValueWithCurrencyEffect: ( + accumulatedValuesByDate[dateString] + ?.totalCurrentValueWithCurrencyEffect ?? new Big(0) + ).add(currentValueWithCurrencyEffect), + totalInvestmentValue: ( + accumulatedValuesByDate[dateString]?.totalInvestmentValue ?? + new Big(0) + ).add(investmentValueAccumulated), + totalInvestmentValueWithCurrencyEffect: ( + accumulatedValuesByDate[dateString] + ?.totalInvestmentValueWithCurrencyEffect ?? new Big(0) + ).add(investmentValueAccumulatedWithCurrencyEffect), + totalNetPerformanceValue: ( + accumulatedValuesByDate[dateString]?.totalNetPerformanceValue ?? + new Big(0) + ).add(netPerformanceValue), + totalNetPerformanceValueWithCurrencyEffect: ( + accumulatedValuesByDate[dateString] + ?.totalNetPerformanceValueWithCurrencyEffect ?? new Big(0) + ).add(netPerformanceValueWithCurrencyEffect), + totalTimeWeightedInvestmentValue: ( + accumulatedValuesByDate[dateString] + ?.totalTimeWeightedInvestmentValue ?? new Big(0) + ).add(timeWeightedInvestmentValue), + totalTimeWeightedInvestmentValueWithCurrencyEffect: ( + accumulatedValuesByDate[dateString] + ?.totalTimeWeightedInvestmentValueWithCurrencyEffect ?? new Big(0) + ).add(timeWeightedInvestmentValueWithCurrencyEffect) + }; + } + + lastDate = dateString; + } + + const historicalData: HistoricalDataItem[] = Object.entries( + accumulatedValuesByDate + ).map(([date, values]) => { + const { + investmentValueWithCurrencyEffect, + totalAccountBalanceWithCurrencyEffect, + totalCurrentValue, + totalCurrentValueWithCurrencyEffect, + totalInvestmentValue, + totalInvestmentValueWithCurrencyEffect, + totalNetPerformanceValue, + totalNetPerformanceValueWithCurrencyEffect, + totalTimeWeightedInvestmentValue, + totalTimeWeightedInvestmentValueWithCurrencyEffect + } = values; + + const netPerformanceInPercentage = totalTimeWeightedInvestmentValue.eq(0) + ? 0 + : totalNetPerformanceValue + .div(totalTimeWeightedInvestmentValue) + .toNumber(); + + const netPerformanceInPercentageWithCurrencyEffect = + totalTimeWeightedInvestmentValueWithCurrencyEffect.eq(0) + ? 0 + : totalNetPerformanceValueWithCurrencyEffect + .div(totalTimeWeightedInvestmentValueWithCurrencyEffect) + .toNumber(); + + return { + date, + netPerformanceInPercentage, + netPerformanceInPercentageWithCurrencyEffect, + investmentValueWithCurrencyEffect: + investmentValueWithCurrencyEffect.toNumber(), + netPerformance: totalNetPerformanceValue.toNumber(), + netPerformanceWithCurrencyEffect: + totalNetPerformanceValueWithCurrencyEffect.toNumber(), + // TODO: Add valuables + netWorth: totalCurrentValueWithCurrencyEffect + .plus(totalAccountBalanceWithCurrencyEffect) + .toNumber(), + totalAccountBalance: totalAccountBalanceWithCurrencyEffect.toNumber(), + totalInvestment: totalInvestmentValue.toNumber(), + totalInvestmentValueWithCurrencyEffect: + totalInvestmentValueWithCurrencyEffect.toNumber(), + value: totalCurrentValue.toNumber(), + valueWithCurrencyEffect: totalCurrentValueWithCurrencyEffect.toNumber() + }; + }); + + const overall = this.calculateOverallPerformance(positions); + + return { + ...overall, + errors, + historicalData, + positions, + totalInterestWithCurrencyEffect, + totalLiabilitiesWithCurrencyEffect, + totalValuablesWithCurrencyEffect, + hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors + }; + } + + public getDataProviderInfos() { + return this.dataProviderInfos; + } + + public async getDividendInBaseCurrency() { + await this.snapshotPromise; + + return getSum( + this.snapshot.positions.map(({ dividendInBaseCurrency }) => { + return dividendInBaseCurrency; + }) + ); + } + + public async getFeesInBaseCurrency() { + await this.snapshotPromise; + + return this.snapshot.totalFeesWithCurrencyEffect; + } + + public async getInterestInBaseCurrency() { + await this.snapshotPromise; + + return this.snapshot.totalInterestWithCurrencyEffect; + } + + public getInvestments(): { date: string; investment: Big }[] { + if (this.transactionPoints.length === 0) { + return []; + } + + return this.transactionPoints.map((transactionPoint) => { + return { + date: transactionPoint.date, + investment: transactionPoint.items.reduce( + (investment, transactionPointSymbol) => + investment.plus(transactionPointSymbol.investment), + new Big(0) + ) + }; + }); + } + + public getInvestmentsByGroup({ + data, + groupBy + }: { + data: HistoricalDataItem[]; + groupBy: GroupBy; + }): InvestmentItem[] { + const groupedData: { [dateGroup: string]: Big } = {}; + + for (const { date, investmentValueWithCurrencyEffect } of data) { + const dateGroup = + groupBy === 'month' ? date.substring(0, 7) : date.substring(0, 4); + groupedData[dateGroup] = (groupedData[dateGroup] ?? new Big(0)).plus( + investmentValueWithCurrencyEffect + ); + } + + return Object.keys(groupedData).map((dateGroup) => ({ + date: groupBy === 'month' ? `${dateGroup}-01` : `${dateGroup}-01-01`, + investment: groupedData[dateGroup].toNumber() + })); + } + + public async getLiabilitiesInBaseCurrency() { + await this.snapshotPromise; + + return this.snapshot.totalLiabilitiesWithCurrencyEffect; + } + + public async getPerformance({ end, start }) { + await this.snapshotPromise; + + const { historicalData } = this.snapshot; + + const chart: HistoricalDataItem[] = []; + + let netPerformanceAtStartDate: number; + let netPerformanceWithCurrencyEffectAtStartDate: number; + let totalInvestmentValuesWithCurrencyEffect: number[] = []; + + for (let historicalDataItem of historicalData) { + const date = resetHours(parseDate(historicalDataItem.date)); + + if (!isBefore(date, start) && !isAfter(date, end)) { + if (!isNumber(netPerformanceAtStartDate)) { + netPerformanceAtStartDate = historicalDataItem.netPerformance; + + netPerformanceWithCurrencyEffectAtStartDate = + historicalDataItem.netPerformanceWithCurrencyEffect; + } + + const netPerformanceSinceStartDate = + historicalDataItem.netPerformance - netPerformanceAtStartDate; + + const netPerformanceWithCurrencyEffectSinceStartDate = + historicalDataItem.netPerformanceWithCurrencyEffect - + netPerformanceWithCurrencyEffectAtStartDate; + + if (historicalDataItem.totalInvestmentValueWithCurrencyEffect > 0) { + totalInvestmentValuesWithCurrencyEffect.push( + historicalDataItem.totalInvestmentValueWithCurrencyEffect + ); + } + + const timeWeightedInvestmentValue = + totalInvestmentValuesWithCurrencyEffect.length > 0 + ? sum(totalInvestmentValuesWithCurrencyEffect) / + totalInvestmentValuesWithCurrencyEffect.length + : 0; + + chart.push({ + ...historicalDataItem, + netPerformance: + historicalDataItem.netPerformance - netPerformanceAtStartDate, + netPerformanceWithCurrencyEffect: + netPerformanceWithCurrencyEffectSinceStartDate, + netPerformanceInPercentage: + timeWeightedInvestmentValue === 0 + ? 0 + : netPerformanceSinceStartDate / timeWeightedInvestmentValue, + netPerformanceInPercentageWithCurrencyEffect: + timeWeightedInvestmentValue === 0 + ? 0 + : netPerformanceWithCurrencyEffectSinceStartDate / + timeWeightedInvestmentValue, + // TODO: Add net worth with valuables + // netWorth: totalCurrentValueWithCurrencyEffect + // .plus(totalAccountBalanceWithCurrencyEffect) + // .toNumber() + netWorth: 0 + }); + } + } + + return { chart }; + } + + public async getSnapshot() { + await this.snapshotPromise; + + return this.snapshot; + } + + public getStartDate() { + let firstAccountBalanceDate: Date; + let firstActivityDate: Date; + + try { + const firstAccountBalanceDateString = first( + this.accountBalanceItems + )?.date; + firstAccountBalanceDate = firstAccountBalanceDateString + ? parseDate(firstAccountBalanceDateString) + : new Date(); + } catch (error) { + firstAccountBalanceDate = new Date(); + } + + try { + const firstActivityDateString = this.transactionPoints[0].date; + firstActivityDate = firstActivityDateString + ? parseDate(firstActivityDateString) + : new Date(); + } catch (error) { + firstActivityDate = new Date(); + } + + return min([firstAccountBalanceDate, firstActivityDate]); + } + + protected abstract getSymbolMetrics({ + chartDateMap, + dataSource, + end, + exchangeRates, + marketSymbolMap, + start, + symbol + }: { + chartDateMap: { [date: string]: boolean }; + end: Date; + exchangeRates: { [dateString: string]: number }; + marketSymbolMap: { + [date: string]: { [symbol: string]: Big }; + }; + start: Date; + } & AssetProfileIdentifier): SymbolMetrics; + + public getTransactionPoints() { + return this.transactionPoints; + } + + public async getValuablesInBaseCurrency() { + await this.snapshotPromise; + + return this.snapshot.totalValuablesWithCurrencyEffect; + } + + private getChartDateMap({ + endDate, + startDate, + step + }: { + endDate: Date; + startDate: Date; + step: number; + }) { + // Create a map of all relevant chart dates: + // 1. Add transaction point dates + let chartDateMap = this.transactionPoints.reduce((result, { date }) => { + result[date] = true; + return result; + }, {}); + + // 2. Add dates between transactions respecting the specified step size + for (let date of eachDayOfInterval( + { end: endDate, start: startDate }, + { step } + )) { + chartDateMap[format(date, DATE_FORMAT)] = true; + } + + if (step > 1) { + // Reduce the step size of last 90 days + for (let date of eachDayOfInterval( + { end: endDate, start: subDays(endDate, 90) }, + { step: 3 } + )) { + chartDateMap[format(date, DATE_FORMAT)] = true; + } + + // Reduce the step size of last 30 days + for (let date of eachDayOfInterval( + { end: endDate, start: subDays(endDate, 30) }, + { step: 1 } + )) { + chartDateMap[format(date, DATE_FORMAT)] = true; + } + } + + // Make sure the end date is present + chartDateMap[format(endDate, DATE_FORMAT)] = true; + + // Make sure some key dates are present + for (let dateRange of ['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd']) { + const { endDate: dateRangeEnd, startDate: dateRangeStart } = + getIntervalFromDateRange(dateRange); + + if ( + !isBefore(dateRangeStart, startDate) && + !isAfter(dateRangeStart, endDate) + ) { + chartDateMap[format(dateRangeStart, DATE_FORMAT)] = true; + } + + if ( + !isBefore(dateRangeEnd, startDate) && + !isAfter(dateRangeEnd, endDate) + ) { + chartDateMap[format(dateRangeEnd, DATE_FORMAT)] = true; + } + } + + return chartDateMap; + } + + private computeTransactionPoints() { + this.transactionPoints = []; + const symbols: { [symbol: string]: TransactionPointSymbol } = {}; + + let lastDate: string = null; + let lastTransactionPoint: TransactionPoint = null; + + for (const { + fee, + date, + quantity, + SymbolProfile, + tags, + type, + unitPrice + } of this.activities) { + let currentTransactionPointItem: TransactionPointSymbol; + const oldAccumulatedSymbol = symbols[SymbolProfile.symbol]; + + const factor = getFactor(type); + + if (oldAccumulatedSymbol) { + let investment = oldAccumulatedSymbol.investment; + + const newQuantity = quantity + .mul(factor) + .plus(oldAccumulatedSymbol.quantity); + + if (type === 'BUY') { + investment = oldAccumulatedSymbol.investment.plus( + quantity.mul(unitPrice) + ); + } else if (type === 'SELL') { + investment = oldAccumulatedSymbol.investment.minus( + quantity.mul(oldAccumulatedSymbol.averagePrice) + ); + } + + currentTransactionPointItem = { + investment, + averagePrice: newQuantity.gt(0) + ? investment.div(newQuantity) + : new Big(0), + currency: SymbolProfile.currency, + dataSource: SymbolProfile.dataSource, + dividend: new Big(0), + fee: oldAccumulatedSymbol.fee.plus(fee), + firstBuyDate: oldAccumulatedSymbol.firstBuyDate, + quantity: newQuantity, + symbol: SymbolProfile.symbol, + tags: oldAccumulatedSymbol.tags.concat(tags), + transactionCount: oldAccumulatedSymbol.transactionCount + 1 + }; + } else { + currentTransactionPointItem = { + fee, + tags, + averagePrice: unitPrice, + currency: SymbolProfile.currency, + dataSource: SymbolProfile.dataSource, + dividend: new Big(0), + firstBuyDate: date, + investment: unitPrice.mul(quantity).mul(factor), + quantity: quantity.mul(factor), + symbol: SymbolProfile.symbol, + transactionCount: 1 + }; + } + + currentTransactionPointItem.tags = uniqBy( + currentTransactionPointItem.tags, + 'id' + ); + + symbols[SymbolProfile.symbol] = currentTransactionPointItem; + + const items = lastTransactionPoint?.items ?? []; + + const newItems = items.filter(({ symbol }) => { + return symbol !== SymbolProfile.symbol; + }); + + newItems.push(currentTransactionPointItem); + + newItems.sort((a, b) => { + return a.symbol?.localeCompare(b.symbol); + }); + + let fees = new Big(0); + + if (type === 'FEE') { + fees = fee; + } + + let interest = new Big(0); + + if (type === 'INTEREST') { + interest = quantity.mul(unitPrice); + } + + let liabilities = new Big(0); + + if (type === 'LIABILITY') { + liabilities = quantity.mul(unitPrice); + } + + let valuables = new Big(0); + + if (type === 'ITEM') { + valuables = quantity.mul(unitPrice); + } + + if (lastDate !== date || lastTransactionPoint === null) { + lastTransactionPoint = { + date, + fees, + interest, + liabilities, + valuables, + items: newItems + }; + + this.transactionPoints.push(lastTransactionPoint); + } else { + lastTransactionPoint.fees = lastTransactionPoint.fees.plus(fees); + lastTransactionPoint.interest = + lastTransactionPoint.interest.plus(interest); + lastTransactionPoint.items = newItems; + lastTransactionPoint.liabilities = + lastTransactionPoint.liabilities.plus(liabilities); + lastTransactionPoint.valuables = + lastTransactionPoint.valuables.plus(valuables); + } + + lastDate = date; + } + } + + private async initialize() { + const startTimeTotal = performance.now(); + + const cachedSnapshot = await this.redisCacheService.get( + this.redisCacheService.getPortfolioSnapshotKey({ + filters: this.filters, + userId: this.userId + }) + ); + + if (cachedSnapshot) { + this.snapshot = plainToClass( + PortfolioSnapshot, + JSON.parse(cachedSnapshot) + ); + + Logger.debug( + `Fetched portfolio snapshot from cache in ${( + (performance.now() - startTimeTotal) / + 1000 + ).toFixed(3)} seconds`, + 'PortfolioCalculator' + ); + } else { + this.snapshot = await this.computeSnapshot(); + + this.redisCacheService.set( + this.redisCacheService.getPortfolioSnapshotKey({ + filters: this.filters, + userId: this.userId + }), + JSON.stringify(this.snapshot), + this.configurationService.get('CACHE_QUOTES_TTL') + ); + + Logger.debug( + `Computed portfolio snapshot in ${( + (performance.now() - startTimeTotal) / + 1000 + ).toFixed(3)} seconds`, + 'PortfolioCalculator' + ); + } + } +} diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts new file mode 100644 index 000000000..1ac86faf0 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts @@ -0,0 +1,207 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { + PortfolioCalculatorFactory, + PerformanceCalculationType +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { parseDate } from '@ghostfolio/common/helper'; + +import { Big } from 'big.js'; +import { last } from 'lodash'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + redisCacheService = new RedisCacheService(null, null); + + factory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with BALN.SW buy and sell in two activities', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2021-11-22'), + fee: 1.55, + quantity: 2, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + type: 'BUY', + unitPrice: 142.9 + }, + { + ...activityDummyData, + date: new Date('2021-11-30'), + fee: 1.65, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + type: 'SELL', + unitPrice: 136.6 + }, + { + ...activityDummyData, + date: new Date('2021-11-30'), + fee: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + type: 'SELL', + unitPrice: 136.6 + } + ]; + + const portfolioCalculator = factory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.getSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('0'), + errors: [], + hasErrors: false, + positions: [ + { + averagePrice: new Big('0'), + currency: 'CHF', + dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('3.2'), + feeInBaseCurrency: new Big('3.2'), + firstBuyDate: '2021-11-22', + grossPerformance: new Big('-12.6'), + grossPerformancePercentage: new Big('-0.04408677396780965649'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '-0.04408677396780965649' + ), + grossPerformanceWithCurrencyEffect: new Big('-12.6'), + investment: new Big('0'), + investmentWithCurrencyEffect: new Big('0'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('-0.0552834149755073478') + }, + netPerformanceWithCurrencyEffectMap: { + max: new Big('-15.8') + }, + marketPrice: 148.9, + marketPriceInBaseCurrency: 148.9, + quantity: new Big('0'), + symbol: 'BALN.SW', + tags: [], + timeWeightedInvestment: new Big('285.80000000000000396627'), + timeWeightedInvestmentWithCurrencyEffect: new Big( + '285.80000000000000396627' + ), + transactionCount: 3, + valueInBaseCurrency: new Big('0') + } + ], + totalFeesWithCurrencyEffect: new Big('3.2'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('0'), + totalInvestmentWithCurrencyEffect: new Big('0'), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') + }); + + expect(last(portfolioSnapshot.historicalData)).toMatchObject( + expect.objectContaining({ + netPerformance: -15.8, + netPerformanceInPercentage: -0.05528341497550734703, + netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703, + netPerformanceWithCurrencyEffect: -15.8, + totalInvestmentValueWithCurrencyEffect: 0 + }) + ); + + expect(investments).toEqual([ + { date: '2021-11-22', investment: new Big('285.8') }, + { date: '2021-11-30', investment: new Big('0') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2021-11-01', investment: 0 }, + { date: '2021-12-01', investment: 0 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts new file mode 100644 index 000000000..54cea8dae --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts @@ -0,0 +1,192 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { + PerformanceCalculationType, + PortfolioCalculatorFactory +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { parseDate } from '@ghostfolio/common/helper'; + +import { Big } from 'big.js'; +import { last } from 'lodash'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + redisCacheService = new RedisCacheService(null, null); + + factory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with BALN.SW buy and sell', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2021-11-22'), + fee: 1.55, + quantity: 2, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + type: 'BUY', + unitPrice: 142.9 + }, + { + ...activityDummyData, + date: new Date('2021-11-30'), + fee: 1.65, + quantity: 2, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + type: 'SELL', + unitPrice: 136.6 + } + ]; + + const portfolioCalculator = factory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.getSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('0'), + errors: [], + hasErrors: false, + positions: [ + { + averagePrice: new Big('0'), + currency: 'CHF', + dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('3.2'), + feeInBaseCurrency: new Big('3.2'), + firstBuyDate: '2021-11-22', + grossPerformance: new Big('-12.6'), + grossPerformancePercentage: new Big('-0.0440867739678096571'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '-0.0440867739678096571' + ), + grossPerformanceWithCurrencyEffect: new Big('-12.6'), + investment: new Big('0'), + investmentWithCurrencyEffect: new Big('0'), + netPerformance: new Big('-15.8'), + netPerformancePercentage: new Big('-0.0552834149755073478'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('-0.0552834149755073478') + }, + netPerformanceWithCurrencyEffectMap: { + max: new Big('-15.8') + }, + marketPrice: 148.9, + marketPriceInBaseCurrency: 148.9, + quantity: new Big('0'), + symbol: 'BALN.SW', + tags: [], + timeWeightedInvestment: new Big('285.8'), + timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'), + transactionCount: 2, + valueInBaseCurrency: new Big('0') + } + ], + totalFeesWithCurrencyEffect: new Big('3.2'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('0'), + totalInvestmentWithCurrencyEffect: new Big('0'), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') + }); + + expect(last(portfolioSnapshot.historicalData)).toMatchObject( + expect.objectContaining({ + netPerformance: -15.8, + netPerformanceInPercentage: -0.05528341497550734703, + netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703, + netPerformanceWithCurrencyEffect: -15.8, + totalInvestmentValueWithCurrencyEffect: 0 + }) + ); + + expect(investments).toEqual([ + { date: '2021-11-22', investment: new Big('285.8') }, + { date: '2021-11-30', investment: new Big('0') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2021-11-01', investment: 0 }, + { date: '2021-12-01', investment: 0 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts new file mode 100644 index 000000000..e638073eb --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts @@ -0,0 +1,182 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { + PortfolioCalculatorFactory, + PerformanceCalculationType +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { parseDate } from '@ghostfolio/common/helper'; + +import { Big } from 'big.js'; +import { last } from 'lodash'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + redisCacheService = new RedisCacheService(null, null); + + factory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with BALN.SW buy', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2021-11-30'), + fee: 1.55, + quantity: 2, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + type: 'BUY', + unitPrice: 136.6 + } + ]; + + const portfolioCalculator = factory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.getSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('297.8'), + errors: [], + hasErrors: false, + positions: [ + { + averagePrice: new Big('136.6'), + currency: 'CHF', + dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('1.55'), + feeInBaseCurrency: new Big('1.55'), + firstBuyDate: '2021-11-30', + grossPerformance: new Big('24.6'), + grossPerformancePercentage: new Big('0.09004392386530014641'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.09004392386530014641' + ), + grossPerformanceWithCurrencyEffect: new Big('24.6'), + investment: new Big('273.2'), + investmentWithCurrencyEffect: new Big('273.2'), + netPerformance: new Big('23.05'), + netPerformancePercentage: new Big('0.08437042459736456808'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('0.08437042459736456808') + }, + netPerformanceWithCurrencyEffectMap: { + '1d': new Big('10.00'), // 2 * (148.9 - 143.9) -> no fees in this time period + '1y': new Big('23.05'), // 2 * (148.9 - 136.6) - 1.55 + '5y': new Big('23.05'), // 2 * (148.9 - 136.6) - 1.55 + max: new Big('23.05'), // 2 * (148.9 - 136.6) - 1.55 + mtd: new Big('24.60'), // 2 * (148.9 - 136.6) -> no fees in this time period + wtd: new Big('13.80'), // 2 * (148.9 - 142.0) -> no fees in this time period + ytd: new Big('23.05') // 2 * (148.9 - 136.6) - 1.55 + }, + marketPrice: 148.9, + marketPriceInBaseCurrency: 148.9, + quantity: new Big('2'), + symbol: 'BALN.SW', + tags: [], + timeWeightedInvestment: new Big('273.2'), + timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'), + transactionCount: 1, + valueInBaseCurrency: new Big('297.8') + } + ], + totalFeesWithCurrencyEffect: new Big('1.55'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('273.2'), + totalInvestmentWithCurrencyEffect: new Big('273.2'), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') + }); + + expect(last(portfolioSnapshot.historicalData)).toMatchObject( + expect.objectContaining({ + netPerformance: 23.05, + netPerformanceInPercentage: 0.08437042459736457, + netPerformanceInPercentageWithCurrencyEffect: 0.08437042459736457, + netPerformanceWithCurrencyEffect: 23.05, + totalInvestmentValueWithCurrencyEffect: 273.2 + }) + ); + + expect(investments).toEqual([ + { date: '2021-11-30', investment: new Big('273.2') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2021-11-01', investment: 273.2 }, + { date: '2021-12-01', investment: 0 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts similarity index 51% rename from apps/api/src/app/portfolio/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts rename to apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts index b6ee34622..cc64a540b 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts @@ -1,12 +1,24 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { + PortfolioCalculatorFactory, + PerformanceCalculationType +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; import { parseDate } from '@ghostfolio/common/helper'; -import Big from 'big.js'; - -import { CurrentRateServiceMock } from './current-rate.service.mock'; -import { PortfolioCalculator } from './portfolio-calculator'; +import { Big } from 'big.js'; +import { last } from 'lodash'; jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { return { @@ -17,6 +29,15 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { }; }); +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + jest.mock( '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', () => { @@ -30,11 +51,16 @@ jest.mock( ); describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + let redisCacheService: RedisCacheService; beforeEach(() => { - currentRateService = new CurrentRateService(null, null); + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); exchangeRateDataService = new ExchangeRateDataService( null, @@ -42,120 +68,135 @@ describe('PortfolioCalculator', () => { null, null ); + + redisCacheService = new RedisCacheService(null, null); + + factory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + redisCacheService + ); }); - describe('get current positions', () => { + // TODO + describe.skip('get current positions', () => { it.only('with BTCUSD buy and sell partially', async () => { - const portfolioCalculator = new PortfolioCalculator({ - currentRateService, - exchangeRateDataService, - currency: 'CHF', - orders: [ - { + jest.useFakeTimers().setSystemTime(parseDate('2018-01-01').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2015-01-01'), + fee: 0, + quantity: 2, + SymbolProfile: { + ...symbolProfileDummyData, currency: 'USD', - date: '2015-01-01', dataSource: 'YAHOO', - fee: new Big(0), name: 'Bitcoin USD', - quantity: new Big(2), - symbol: 'BTCUSD', - type: 'BUY', - unitPrice: new Big(320.43) + symbol: 'BTCUSD' }, - { + type: 'BUY', + unitPrice: 320.43 + }, + { + ...activityDummyData, + date: new Date('2017-12-31'), + fee: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, currency: 'USD', - date: '2017-12-31', dataSource: 'YAHOO', - fee: new Big(0), name: 'Bitcoin USD', - quantity: new Big(1), - symbol: 'BTCUSD', - type: 'SELL', - unitPrice: new Big(14156.4) - } - ] + symbol: 'BTCUSD' + }, + type: 'SELL', + unitPrice: 14156.4 + } + ]; + + const portfolioCalculator = factory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'CHF', + userId: userDummyData.id }); - portfolioCalculator.computeTransactionPoints(); - - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => parseDate('2018-01-01').getTime()); - - const chartData = await portfolioCalculator.getChartData({ - start: parseDate('2015-01-01') - }); - - const currentPositions = await portfolioCalculator.getCurrentPositions( - parseDate('2015-01-01') - ); + const portfolioSnapshot = await portfolioCalculator.getSnapshot(); const investments = portfolioCalculator.getInvestments(); const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ - data: chartData, + data: portfolioSnapshot.historicalData, groupBy: 'month' }); - spy.mockRestore(); - - expect(currentPositions).toEqual({ - currentValue: new Big('13298.425356'), + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('13298.425356'), errors: [], - grossPerformance: new Big('27172.74'), - grossPerformancePercentage: new Big('42.41978276196153750666'), - grossPerformancePercentageWithCurrencyEffect: new Big( - '41.6401219622042072686' - ), grossPerformanceWithCurrencyEffect: new Big('26516.208701400000064086'), hasErrors: false, - netPerformance: new Big('27172.74'), - netPerformancePercentage: new Big('42.41978276196153750666'), - netPerformancePercentageWithCurrencyEffect: new Big( - '41.6401219622042072686' - ), - netPerformanceWithCurrencyEffect: new Big('26516.208701400000064086'), positions: [ { averagePrice: new Big('320.43'), currency: 'USD', dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), fee: new Big('0'), + feeInBaseCurrency: new Big('0'), firstBuyDate: '2015-01-01', - grossPerformance: new Big('27172.74'), - grossPerformancePercentage: new Big('42.41978276196153750666'), + grossPerformance: new Big('27172.74').mul(0.97373), + grossPerformancePercentage: new Big('0.4241983590271396608571'), grossPerformancePercentageWithCurrencyEffect: new Big( - '41.6401219622042072686' + '0.4164017412624815597008' ), grossPerformanceWithCurrencyEffect: new Big( '26516.208701400000064086' ), - investment: new Big('320.43'), + investment: new Big('320.43').mul(0.97373), investmentWithCurrencyEffect: new Big('318.542667299999967957'), marketPrice: 13657.2, marketPriceInBaseCurrency: 13298.425356, - netPerformance: new Big('27172.74'), - netPerformancePercentage: new Big('42.41978276196153750666'), - netPerformancePercentageWithCurrencyEffect: new Big( - '41.6401219622042072686' - ), - netPerformanceWithCurrencyEffect: new Big( - '26516.208701400000064086' - ), + netPerformance: new Big('27172.74').mul(0.97373), + netPerformancePercentage: new Big('0.4241983590271396608571'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('0.417188277288666871633') + }, + netPerformanceWithCurrencyEffectMap: { + max: new Big('26516.208701400000064086') + }, quantity: new Big('1'), symbol: 'BTCUSD', - tags: undefined, - timeWeightedInvestment: new Big('640.56763686131386861314'), + tags: [], + timeWeightedInvestment: new Big('623.73914366102470265325'), timeWeightedInvestmentWithCurrencyEffect: new Big( - '636.79469348020066587024' + '636.79389574611155533947' ), - transactionCount: 2 + transactionCount: 2, + valueInBaseCurrency: new Big('13298.425356') } ], - totalInvestment: new Big('320.43'), - totalInvestmentWithCurrencyEffect: new Big('318.542667299999967957') + totalFeesWithCurrencyEffect: new Big('0'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('320.43').mul(0.97373), + totalInvestmentWithCurrencyEffect: new Big('318.542667299999967957'), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') }); + expect(last(portfolioSnapshot.historicalData)).toMatchObject( + expect.objectContaining({ + netPerformance: new Big('27172.74').mul(0.97373).toNumber(), + netPerformanceInPercentage: 42.41983590271396609433, + netPerformanceInPercentageWithCurrencyEffect: 41.64017412624815597854, + netPerformanceWithCurrencyEffect: 26516.208701400000064086, + totalInvestmentValueWithCurrencyEffect: 318.542667299999967957 + }) + ); + expect(investments).toEqual([ { date: '2015-01-01', investment: new Big('640.86') }, { date: '2017-12-31', investment: new Big('320.43') } diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts new file mode 100644 index 000000000..4f4c05b13 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts @@ -0,0 +1,154 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { + PortfolioCalculatorFactory, + PerformanceCalculationType +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { parseDate } from '@ghostfolio/common/helper'; + +import { Big } from 'big.js'; +import { last } from 'lodash'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + redisCacheService = new RedisCacheService(null, null); + + factory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + redisCacheService + ); + }); + + describe('compute portfolio snapshot', () => { + it.only('with fee activity', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2021-09-01'), + fee: 49, + quantity: 0, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'MANUAL', + name: 'Account Opening Fee', + symbol: '2c463fb3-af07-486e-adb0-8301b3d72141' + }, + type: 'FEE', + unitPrice: 0 + } + ]; + + const portfolioCalculator = factory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'USD', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.getSnapshot(); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('0'), + errors: [], + hasErrors: true, + positions: [ + { + averagePrice: new Big('0'), + currency: 'USD', + dataSource: 'MANUAL', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('49'), + feeInBaseCurrency: new Big('49'), + firstBuyDate: '2021-09-01', + grossPerformance: null, + grossPerformancePercentage: null, + grossPerformancePercentageWithCurrencyEffect: null, + grossPerformanceWithCurrencyEffect: null, + investment: new Big('0'), + investmentWithCurrencyEffect: new Big('0'), + marketPrice: null, + marketPriceInBaseCurrency: 0, + netPerformance: null, + netPerformancePercentage: null, + netPerformancePercentageWithCurrencyEffectMap: null, + netPerformanceWithCurrencyEffectMap: null, + quantity: new Big('0'), + symbol: '2c463fb3-af07-486e-adb0-8301b3d72141', + tags: [], + timeWeightedInvestment: new Big('0'), + timeWeightedInvestmentWithCurrencyEffect: new Big('0'), + transactionCount: 1, + valueInBaseCurrency: new Big('0') + } + ], + totalFeesWithCurrencyEffect: new Big('49'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('0'), + totalInvestmentWithCurrencyEffect: new Big('0'), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') + }); + + expect(last(portfolioSnapshot.historicalData)).toMatchObject( + expect.objectContaining({ + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + totalInvestmentValueWithCurrencyEffect: 0 + }) + ); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts new file mode 100644 index 000000000..c7cf7e2b3 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts @@ -0,0 +1,212 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { + PortfolioCalculatorFactory, + PerformanceCalculationType +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; + +import { Big } from 'big.js'; +import { last } from 'lodash'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + ExchangeRateDataService: jest.fn().mockImplementation(() => { + return ExchangeRateDataServiceMock; + }) + }; + } +); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + redisCacheService = new RedisCacheService(null, null); + + factory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with GOOGL buy', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2023-01-03'), + fee: 1, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Alphabet Inc.', + symbol: 'GOOGL' + }, + type: 'BUY', + unitPrice: 89.12 + } + ]; + + const portfolioCalculator = factory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.getSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('103.10483'), + errors: [], + hasErrors: false, + positions: [ + { + averagePrice: new Big('89.12'), + currency: 'USD', + dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('1'), + feeInBaseCurrency: new Big('0.9238'), + firstBuyDate: '2023-01-03', + grossPerformance: new Big('27.33').mul(0.8854), + grossPerformancePercentage: new Big('0.3066651705565529623'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.25235044599563974109' + ), + grossPerformanceWithCurrencyEffect: new Big('20.775774'), + investment: new Big('89.12').mul(0.8854), + investmentWithCurrencyEffect: new Big('82.329056'), + netPerformance: new Big('26.33').mul(0.8854), + netPerformancePercentage: new Big('0.29544434470377019749'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('0.24112962014285697628') + }, + netPerformanceWithCurrencyEffectMap: { + max: new Big('19.851974') + }, + marketPrice: 116.45, + marketPriceInBaseCurrency: 103.10483, + quantity: new Big('1'), + symbol: 'GOOGL', + tags: [], + timeWeightedInvestment: new Big('89.12').mul(0.8854), + timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'), + transactionCount: 1, + valueInBaseCurrency: new Big('103.10483') + } + ], + totalFeesWithCurrencyEffect: new Big('0.9238'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('89.12').mul(0.8854), + totalInvestmentWithCurrencyEffect: new Big('82.329056'), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') + }); + + expect(last(portfolioSnapshot.historicalData)).toMatchObject( + expect.objectContaining({ + netPerformance: new Big('26.33').mul(0.8854).toNumber(), + netPerformanceInPercentage: 0.29544434470377019749, + netPerformanceInPercentageWithCurrencyEffect: 0.24112962014285697628, + netPerformanceWithCurrencyEffect: 19.851974, + totalInvestmentValueWithCurrencyEffect: 82.329056 + }) + ); + + expect(investments).toEqual([ + { date: '2023-01-03', investment: new Big('89.12') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2023-01-01', investment: 82.329056 }, + { + date: '2023-02-01', + investment: 0 + }, + { + date: '2023-03-01', + investment: 0 + }, + { + date: '2023-04-01', + investment: 0 + }, + { + date: '2023-05-01', + investment: 0 + }, + { + date: '2023-06-01', + investment: 0 + }, + { + date: '2023-07-01', + investment: 0 + } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts new file mode 100644 index 000000000..a0e62af57 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts @@ -0,0 +1,154 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { + PortfolioCalculatorFactory, + PerformanceCalculationType +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { parseDate } from '@ghostfolio/common/helper'; + +import { Big } from 'big.js'; +import { last } from 'lodash'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + redisCacheService = new RedisCacheService(null, null); + + factory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + redisCacheService + ); + }); + + describe('compute portfolio snapshot', () => { + it.only('with item activity', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2022-01-31').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2022-01-01'), + fee: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'MANUAL', + name: 'Penthouse Apartment', + symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde' + }, + type: 'ITEM', + unitPrice: 500000 + } + ]; + + const portfolioCalculator = factory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'USD', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.getSnapshot(); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('0'), + errors: [], + hasErrors: true, + positions: [ + { + averagePrice: new Big('500000'), + currency: 'USD', + dataSource: 'MANUAL', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('0'), + feeInBaseCurrency: new Big('0'), + firstBuyDate: '2022-01-01', + grossPerformance: null, + grossPerformancePercentage: null, + grossPerformancePercentageWithCurrencyEffect: null, + grossPerformanceWithCurrencyEffect: null, + investment: new Big('0'), + investmentWithCurrencyEffect: new Big('0'), + marketPrice: null, + marketPriceInBaseCurrency: 500000, + netPerformance: null, + netPerformancePercentage: null, + netPerformancePercentageWithCurrencyEffectMap: null, + netPerformanceWithCurrencyEffectMap: null, + quantity: new Big('0'), + symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde', + tags: [], + timeWeightedInvestment: new Big('0'), + timeWeightedInvestmentWithCurrencyEffect: new Big('0'), + transactionCount: 1, + valueInBaseCurrency: new Big('0') + } + ], + totalFeesWithCurrencyEffect: new Big('0'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('0'), + totalInvestmentWithCurrencyEffect: new Big('0'), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') + }); + + expect(last(portfolioSnapshot.historicalData)).toMatchObject( + expect.objectContaining({ + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + totalInvestmentValueWithCurrencyEffect: 0 + }) + ); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts new file mode 100644 index 000000000..0df8dee48 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts @@ -0,0 +1,103 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { + PortfolioCalculatorFactory, + PerformanceCalculationType +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { parseDate } from '@ghostfolio/common/helper'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + redisCacheService = new RedisCacheService(null, null); + + factory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + redisCacheService + ); + }); + + describe('compute portfolio snapshot', () => { + it.only('with liability activity', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2022-01-31').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2023-01-01'), // Date in future + fee: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'MANUAL', + name: 'Loan', + symbol: '55196015-1365-4560-aa60-8751ae6d18f8' + }, + type: 'LIABILITY', + unitPrice: 3000 + } + ]; + + const portfolioCalculator = factory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'USD', + userId: userDummyData.id + }); + + const liabilitiesInBaseCurrency = + await portfolioCalculator.getLiabilitiesInBaseCurrency(); + + expect(liabilitiesInBaseCurrency).toEqual(new Big(3000)); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts new file mode 100644 index 000000000..8a1c5a517 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts @@ -0,0 +1,165 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { + PerformanceCalculationType, + PortfolioCalculatorFactory +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; + +import { Big } from 'big.js'; +import { last } from 'lodash'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + ExchangeRateDataService: jest.fn().mockImplementation(() => { + return ExchangeRateDataServiceMock; + }) + }; + } +); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + redisCacheService = new RedisCacheService(null, null); + + factory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with MSFT buy', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2021-09-16'), + fee: 19, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + type: 'BUY', + unitPrice: 298.58 + }, + { + ...activityDummyData, + date: new Date('2021-11-16'), + fee: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + type: 'DIVIDEND', + unitPrice: 0.62 + } + ]; + + const portfolioCalculator = factory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'USD', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.getSnapshot(); + + expect(portfolioSnapshot).toMatchObject({ + errors: [], + hasErrors: false, + positions: [ + { + averagePrice: new Big('298.58'), + currency: 'USD', + dataSource: 'YAHOO', + dividend: new Big('0.62'), + dividendInBaseCurrency: new Big('0.62'), + fee: new Big('19'), + firstBuyDate: '2021-09-16', + investment: new Big('298.58'), + investmentWithCurrencyEffect: new Big('298.58'), + marketPrice: 331.83, + marketPriceInBaseCurrency: 331.83, + quantity: new Big('1'), + symbol: 'MSFT', + tags: [], + transactionCount: 2 + } + ], + totalFeesWithCurrencyEffect: new Big('19'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('298.58'), + totalInvestmentWithCurrencyEffect: new Big('298.58'), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') + }); + + expect(last(portfolioSnapshot.historicalData)).toMatchObject( + expect.objectContaining({ + totalInvestmentValueWithCurrencyEffect: 298.58 + }) + ); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts new file mode 100644 index 000000000..a25e31dd3 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts @@ -0,0 +1,103 @@ +import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { + PerformanceCalculationType, + PortfolioCalculatorFactory +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { parseDate } from '@ghostfolio/common/helper'; + +import { Big } from 'big.js'; +import { subDays } from 'date-fns'; +import { last } from 'lodash'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + redisCacheService = new RedisCacheService(null, null); + + factory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it('with no orders', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); + + const portfolioCalculator = factory.createCalculator({ + activities: [], + calculationType: PerformanceCalculationType.TWR, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.getSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big(0), + hasErrors: false, + historicalData: [], + positions: [], + totalFeesWithCurrencyEffect: new Big('0'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big(0), + totalInvestmentWithCurrencyEffect: new Big(0), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') + }); + + expect(investments).toEqual([]); + + expect(investmentsByMonth).toEqual([]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts new file mode 100644 index 000000000..967f8cd1f --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts @@ -0,0 +1,194 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { + PerformanceCalculationType, + PortfolioCalculatorFactory +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { parseDate } from '@ghostfolio/common/helper'; + +import { Big } from 'big.js'; +import { last } from 'lodash'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + redisCacheService = new RedisCacheService(null, null); + + factory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with NOVN.SW buy and sell partially', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2022-03-07'), + fee: 1.3, + quantity: 2, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Novartis AG', + symbol: 'NOVN.SW' + }, + type: 'BUY', + unitPrice: 75.8 + }, + { + ...activityDummyData, + date: new Date('2022-04-08'), + fee: 2.95, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Novartis AG', + symbol: 'NOVN.SW' + }, + type: 'SELL', + unitPrice: 85.73 + } + ]; + + const portfolioCalculator = factory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.getSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('87.8'), + errors: [], + hasErrors: false, + positions: [ + { + averagePrice: new Big('75.80'), + currency: 'CHF', + dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('4.25'), + feeInBaseCurrency: new Big('4.25'), + firstBuyDate: '2022-03-07', + grossPerformance: new Big('21.93'), + grossPerformancePercentage: new Big('0.15113417083448194384'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.15113417083448194384' + ), + grossPerformanceWithCurrencyEffect: new Big('21.93'), + investment: new Big('75.80'), + investmentWithCurrencyEffect: new Big('75.80'), + netPerformance: new Big('17.68'), + netPerformancePercentage: new Big('0.12184460284330327256'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('0.12348284960422163588') + }, + netPerformanceWithCurrencyEffectMap: { + max: new Big('17.68') + }, + marketPrice: 87.8, + marketPriceInBaseCurrency: 87.8, + quantity: new Big('1'), + symbol: 'NOVN.SW', + tags: [], + timeWeightedInvestment: new Big('145.10285714285714285714'), + timeWeightedInvestmentWithCurrencyEffect: new Big( + '145.10285714285714285714' + ), + transactionCount: 2, + valueInBaseCurrency: new Big('87.8') + } + ], + totalFeesWithCurrencyEffect: new Big('4.25'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('75.80'), + totalInvestmentWithCurrencyEffect: new Big('75.80'), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') + }); + + expect(last(portfolioSnapshot.historicalData)).toMatchObject( + expect.objectContaining({ + netPerformance: 17.68, + netPerformanceInPercentage: 0.12184460284330327256, + netPerformanceInPercentageWithCurrencyEffect: 0.12184460284330327256, + netPerformanceWithCurrencyEffect: 17.68, + totalInvestmentValueWithCurrencyEffect: 75.8 + }) + ); + + expect(investments).toEqual([ + { date: '2022-03-07', investment: new Big('151.6') }, + { date: '2022-04-08', investment: new Big('75.8') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2022-03-01', investment: 151.6 }, + { date: '2022-04-01', investment: -75.8 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts new file mode 100644 index 000000000..068246eb6 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts @@ -0,0 +1,241 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { + PerformanceCalculationType, + PortfolioCalculatorFactory +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { parseDate } from '@ghostfolio/common/helper'; + +import { Big } from 'big.js'; +import { last } from 'lodash'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + redisCacheService = new RedisCacheService(null, null); + + factory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with NOVN.SW buy and sell', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2022-03-07'), + fee: 0, + quantity: 2, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Novartis AG', + symbol: 'NOVN.SW' + }, + type: 'BUY', + unitPrice: 75.8 + }, + { + ...activityDummyData, + date: new Date('2022-04-08'), + fee: 0, + quantity: 2, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Novartis AG', + symbol: 'NOVN.SW' + }, + type: 'SELL', + unitPrice: 85.73 + } + ]; + + const portfolioCalculator = factory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.getSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + expect(portfolioSnapshot.historicalData[0]).toEqual({ + date: '2022-03-06', + investmentValueWithCurrencyEffect: 0, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + netWorth: 0, + totalAccountBalance: 0, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0, + value: 0, + valueWithCurrencyEffect: 0 + }); + + expect(portfolioSnapshot.historicalData[1]).toEqual({ + date: '2022-03-07', + investmentValueWithCurrencyEffect: 151.6, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + netWorth: 151.6, + totalAccountBalance: 0, + totalInvestment: 151.6, + totalInvestmentValueWithCurrencyEffect: 151.6, + value: 151.6, + valueWithCurrencyEffect: 151.6 + }); + + expect( + portfolioSnapshot.historicalData[ + portfolioSnapshot.historicalData.length - 1 + ] + ).toEqual({ + date: '2022-04-11', + investmentValueWithCurrencyEffect: 0, + netPerformance: 19.86, + netPerformanceInPercentage: 0.13100263852242744, + netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744, + netPerformanceWithCurrencyEffect: 19.86, + netWorth: 0, + totalAccountBalance: 0, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0, + value: 0, + valueWithCurrencyEffect: 0 + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('0'), + errors: [], + hasErrors: false, + positions: [ + { + averagePrice: new Big('0'), + currency: 'CHF', + dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('0'), + feeInBaseCurrency: new Big('0'), + firstBuyDate: '2022-03-07', + grossPerformance: new Big('19.86'), + grossPerformancePercentage: new Big('0.13100263852242744063'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.13100263852242744063' + ), + grossPerformanceWithCurrencyEffect: new Big('19.86'), + investment: new Big('0'), + investmentWithCurrencyEffect: new Big('0'), + netPerformance: new Big('19.86'), + netPerformancePercentage: new Big('0.13100263852242744063'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('0.13100263852242744063') + }, + netPerformanceWithCurrencyEffectMap: { + max: new Big('19.86') + }, + marketPrice: 87.8, + marketPriceInBaseCurrency: 87.8, + quantity: new Big('0'), + symbol: 'NOVN.SW', + tags: [], + timeWeightedInvestment: new Big('151.6'), + timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), + transactionCount: 2, + valueInBaseCurrency: new Big('0') + } + ], + totalFeesWithCurrencyEffect: new Big('0'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('0'), + totalInvestmentWithCurrencyEffect: new Big('0'), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') + }); + + expect(last(portfolioSnapshot.historicalData)).toMatchObject( + expect.objectContaining({ + netPerformance: 19.86, + netPerformanceInPercentage: 0.13100263852242744063, + netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063, + netPerformanceWithCurrencyEffect: 19.86, + totalInvestmentValueWithCurrencyEffect: 0 + }) + ); + + expect(investments).toEqual([ + { date: '2022-03-07', investment: new Big('151.6') }, + { date: '2022-04-08', investment: new Big('0') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2022-03-01', investment: 151.6 }, + { date: '2022-04-01', investment: -151.6 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.spec.ts new file mode 100644 index 000000000..536581070 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.spec.ts @@ -0,0 +1,37 @@ +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + redisCacheService = new RedisCacheService(null, null); + + factory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + redisCacheService + ); + }); + + test.skip('Skip empty test', () => 1); +}); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts new file mode 100644 index 000000000..fba0ead84 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts @@ -0,0 +1,964 @@ +import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; +import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface'; +import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; +import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; +import { DATE_FORMAT } from '@ghostfolio/common/helper'; +import { + AssetProfileIdentifier, + SymbolMetrics +} from '@ghostfolio/common/interfaces'; +import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; +import { DateRange } from '@ghostfolio/common/types'; + +import { Logger } from '@nestjs/common'; +import { Big } from 'big.js'; +import { + addDays, + addMilliseconds, + differenceInDays, + eachDayOfInterval, + format, + isBefore +} from 'date-fns'; +import { cloneDeep, first, last, sortBy } from 'lodash'; + +export class TWRPortfolioCalculator extends PortfolioCalculator { + protected calculateOverallPerformance( + positions: TimelinePosition[] + ): PortfolioSnapshot { + let currentValueInBaseCurrency = new Big(0); + let grossPerformance = new Big(0); + let grossPerformanceWithCurrencyEffect = new Big(0); + let hasErrors = false; + let netPerformance = new Big(0); + let totalFeesWithCurrencyEffect = new Big(0); + let totalInterestWithCurrencyEffect = new Big(0); + let totalInvestment = new Big(0); + let totalInvestmentWithCurrencyEffect = new Big(0); + let totalTimeWeightedInvestment = new Big(0); + let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0); + + for (const currentPosition of positions) { + if (currentPosition.feeInBaseCurrency) { + totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus( + currentPosition.feeInBaseCurrency + ); + } + + if (currentPosition.valueInBaseCurrency) { + currentValueInBaseCurrency = currentValueInBaseCurrency.plus( + currentPosition.valueInBaseCurrency + ); + } else { + hasErrors = true; + } + + if (currentPosition.investment) { + totalInvestment = totalInvestment.plus(currentPosition.investment); + + totalInvestmentWithCurrencyEffect = + totalInvestmentWithCurrencyEffect.plus( + currentPosition.investmentWithCurrencyEffect + ); + } else { + hasErrors = true; + } + + if (currentPosition.grossPerformance) { + grossPerformance = grossPerformance.plus( + currentPosition.grossPerformance + ); + + grossPerformanceWithCurrencyEffect = + grossPerformanceWithCurrencyEffect.plus( + currentPosition.grossPerformanceWithCurrencyEffect + ); + + netPerformance = netPerformance.plus(currentPosition.netPerformance); + } else if (!currentPosition.quantity.eq(0)) { + hasErrors = true; + } + + if (currentPosition.timeWeightedInvestment) { + totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus( + currentPosition.timeWeightedInvestment + ); + + totalTimeWeightedInvestmentWithCurrencyEffect = + totalTimeWeightedInvestmentWithCurrencyEffect.plus( + currentPosition.timeWeightedInvestmentWithCurrencyEffect + ); + } else if (!currentPosition.quantity.eq(0)) { + Logger.warn( + `Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`, + 'PortfolioCalculator' + ); + + hasErrors = true; + } + } + + return { + currentValueInBaseCurrency, + hasErrors, + positions, + totalFeesWithCurrencyEffect, + totalInterestWithCurrencyEffect, + totalInvestment, + totalInvestmentWithCurrencyEffect, + historicalData: [], + totalLiabilitiesWithCurrencyEffect: new Big(0), + totalValuablesWithCurrencyEffect: new Big(0) + }; + } + + protected getSymbolMetrics({ + chartDateMap, + dataSource, + end, + exchangeRates, + marketSymbolMap, + start, + symbol + }: { + chartDateMap?: { [date: string]: boolean }; + end: Date; + exchangeRates: { [dateString: string]: number }; + marketSymbolMap: { + [date: string]: { [symbol: string]: Big }; + }; + start: Date; + } & AssetProfileIdentifier): SymbolMetrics { + const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)]; + const currentValues: { [date: string]: Big } = {}; + const currentValuesWithCurrencyEffect: { [date: string]: Big } = {}; + let fees = new Big(0); + let feesAtStartDate = new Big(0); + let feesAtStartDateWithCurrencyEffect = new Big(0); + let feesWithCurrencyEffect = new Big(0); + let grossPerformance = new Big(0); + let grossPerformanceWithCurrencyEffect = new Big(0); + let grossPerformanceAtStartDate = new Big(0); + let grossPerformanceAtStartDateWithCurrencyEffect = new Big(0); + let grossPerformanceFromSells = new Big(0); + let grossPerformanceFromSellsWithCurrencyEffect = new Big(0); + let initialValue: Big; + let initialValueWithCurrencyEffect: Big; + let investmentAtStartDate: Big; + let investmentAtStartDateWithCurrencyEffect: Big; + const investmentValuesAccumulated: { [date: string]: Big } = {}; + const investmentValuesAccumulatedWithCurrencyEffect: { + [date: string]: Big; + } = {}; + const investmentValuesWithCurrencyEffect: { [date: string]: Big } = {}; + let lastAveragePrice = new Big(0); + let lastAveragePriceWithCurrencyEffect = new Big(0); + const netPerformanceValues: { [date: string]: Big } = {}; + const netPerformanceValuesWithCurrencyEffect: { [date: string]: Big } = {}; + const timeWeightedInvestmentValues: { [date: string]: Big } = {}; + + const timeWeightedInvestmentValuesWithCurrencyEffect: { + [date: string]: Big; + } = {}; + + let totalAccountBalanceInBaseCurrency = new Big(0); + let totalDividend = new Big(0); + let totalDividendInBaseCurrency = new Big(0); + let totalInterest = new Big(0); + let totalInterestInBaseCurrency = new Big(0); + let totalInvestment = new Big(0); + let totalInvestmentFromBuyTransactions = new Big(0); + let totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0); + let totalInvestmentWithCurrencyEffect = new Big(0); + let totalLiabilities = new Big(0); + let totalLiabilitiesInBaseCurrency = new Big(0); + let totalQuantityFromBuyTransactions = new Big(0); + let totalUnits = new Big(0); + let totalValuables = new Big(0); + let totalValuablesInBaseCurrency = new Big(0); + let valueAtStartDate: Big; + let valueAtStartDateWithCurrencyEffect: Big; + + // Clone orders to keep the original values in this.orders + let orders: PortfolioOrderItem[] = cloneDeep(this.activities).filter( + ({ SymbolProfile }) => { + return SymbolProfile.symbol === symbol; + } + ); + + if (orders.length <= 0) { + return { + currentValues: {}, + currentValuesWithCurrencyEffect: {}, + feesWithCurrencyEffect: new Big(0), + grossPerformance: new Big(0), + grossPerformancePercentage: new Big(0), + grossPerformancePercentageWithCurrencyEffect: new Big(0), + grossPerformanceWithCurrencyEffect: new Big(0), + hasErrors: false, + initialValue: new Big(0), + initialValueWithCurrencyEffect: new Big(0), + investmentValuesAccumulated: {}, + investmentValuesAccumulatedWithCurrencyEffect: {}, + investmentValuesWithCurrencyEffect: {}, + netPerformance: new Big(0), + netPerformancePercentage: new Big(0), + netPerformancePercentageWithCurrencyEffectMap: {}, + netPerformanceValues: {}, + netPerformanceValuesWithCurrencyEffect: {}, + netPerformanceWithCurrencyEffectMap: {}, + timeWeightedInvestment: new Big(0), + timeWeightedInvestmentValues: {}, + timeWeightedInvestmentValuesWithCurrencyEffect: {}, + timeWeightedInvestmentWithCurrencyEffect: new Big(0), + totalAccountBalanceInBaseCurrency: new Big(0), + totalDividend: new Big(0), + totalDividendInBaseCurrency: new Big(0), + totalInterest: new Big(0), + totalInterestInBaseCurrency: new Big(0), + totalInvestment: new Big(0), + totalInvestmentWithCurrencyEffect: new Big(0), + totalLiabilities: new Big(0), + totalLiabilitiesInBaseCurrency: new Big(0), + totalValuables: new Big(0), + totalValuablesInBaseCurrency: new Big(0) + }; + } + + const dateOfFirstTransaction = new Date(first(orders).date); + + const unitPriceAtStartDate = + marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol]; + + const unitPriceAtEndDate = + marketSymbolMap[format(end, DATE_FORMAT)]?.[symbol]; + + if ( + !unitPriceAtEndDate || + (!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start)) + ) { + return { + currentValues: {}, + currentValuesWithCurrencyEffect: {}, + feesWithCurrencyEffect: new Big(0), + grossPerformance: new Big(0), + grossPerformancePercentage: new Big(0), + grossPerformancePercentageWithCurrencyEffect: new Big(0), + grossPerformanceWithCurrencyEffect: new Big(0), + hasErrors: true, + initialValue: new Big(0), + initialValueWithCurrencyEffect: new Big(0), + investmentValuesAccumulated: {}, + investmentValuesAccumulatedWithCurrencyEffect: {}, + investmentValuesWithCurrencyEffect: {}, + netPerformance: new Big(0), + netPerformancePercentage: new Big(0), + netPerformancePercentageWithCurrencyEffectMap: {}, + netPerformanceWithCurrencyEffectMap: {}, + netPerformanceValues: {}, + netPerformanceValuesWithCurrencyEffect: {}, + timeWeightedInvestment: new Big(0), + timeWeightedInvestmentValues: {}, + timeWeightedInvestmentValuesWithCurrencyEffect: {}, + timeWeightedInvestmentWithCurrencyEffect: new Big(0), + totalAccountBalanceInBaseCurrency: new Big(0), + totalDividend: new Big(0), + totalDividendInBaseCurrency: new Big(0), + totalInterest: new Big(0), + totalInterestInBaseCurrency: new Big(0), + totalInvestment: new Big(0), + totalInvestmentWithCurrencyEffect: new Big(0), + totalLiabilities: new Big(0), + totalLiabilitiesInBaseCurrency: new Big(0), + totalValuables: new Big(0), + totalValuablesInBaseCurrency: new Big(0) + }; + } + + // Add a synthetic order at the start and the end date + orders.push({ + date: format(start, DATE_FORMAT), + fee: new Big(0), + feeInBaseCurrency: new Big(0), + itemType: 'start', + quantity: new Big(0), + SymbolProfile: { + dataSource, + symbol + }, + type: 'BUY', + unitPrice: unitPriceAtStartDate + }); + + orders.push({ + date: format(end, DATE_FORMAT), + fee: new Big(0), + feeInBaseCurrency: new Big(0), + itemType: 'end', + SymbolProfile: { + dataSource, + symbol + }, + quantity: new Big(0), + type: 'BUY', + unitPrice: unitPriceAtEndDate + }); + + let day = start; + let lastUnitPrice: Big; + + const ordersByDate: { [date: string]: PortfolioOrderItem[] } = {}; + + for (const order of orders) { + ordersByDate[order.date] = ordersByDate[order.date] ?? []; + ordersByDate[order.date].push(order); + } + + while (isBefore(day, end)) { + const dateString = format(day, DATE_FORMAT); + + if (ordersByDate[dateString]?.length > 0) { + for (let order of ordersByDate[dateString]) { + order.unitPriceFromMarketData = + marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice; + } + } else if (chartDateMap[dateString]) { + orders.push({ + date: dateString, + fee: new Big(0), + feeInBaseCurrency: new Big(0), + quantity: new Big(0), + SymbolProfile: { + dataSource, + symbol + }, + type: 'BUY', + unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice, + unitPriceFromMarketData: + marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice + }); + } + + const lastOrder = last(orders); + + lastUnitPrice = lastOrder.unitPriceFromMarketData ?? lastOrder.unitPrice; + + day = addDays(day, 1); + } + + // Sort orders so that the start and end placeholder order are at the correct + // position + orders = sortBy(orders, ({ date, itemType }) => { + let sortIndex = new Date(date); + + if (itemType === 'end') { + sortIndex = addMilliseconds(sortIndex, 1); + } else if (itemType === 'start') { + sortIndex = addMilliseconds(sortIndex, -1); + } + + return sortIndex.getTime(); + }); + + const indexOfStartOrder = orders.findIndex(({ itemType }) => { + return itemType === 'start'; + }); + + const indexOfEndOrder = orders.findIndex(({ itemType }) => { + return itemType === 'end'; + }); + + let totalInvestmentDays = 0; + let sumOfTimeWeightedInvestments = new Big(0); + let sumOfTimeWeightedInvestmentsWithCurrencyEffect = new Big(0); + + for (let i = 0; i < orders.length; i += 1) { + const order = orders[i]; + + if (PortfolioCalculator.ENABLE_LOGGING) { + console.log(); + console.log(); + console.log( + i + 1, + order.date, + order.type, + order.itemType ? `(${order.itemType})` : '' + ); + } + + const exchangeRateAtOrderDate = exchangeRates[order.date]; + + if (order.type === 'DIVIDEND') { + const dividend = order.quantity.mul(order.unitPrice); + + totalDividend = totalDividend.plus(dividend); + totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus( + dividend.mul(exchangeRateAtOrderDate ?? 1) + ); + } else if (order.type === 'INTEREST') { + const interest = order.quantity.mul(order.unitPrice); + + totalInterest = totalInterest.plus(interest); + totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus( + interest.mul(exchangeRateAtOrderDate ?? 1) + ); + } else if (order.type === 'ITEM') { + const valuables = order.quantity.mul(order.unitPrice); + + totalValuables = totalValuables.plus(valuables); + totalValuablesInBaseCurrency = totalValuablesInBaseCurrency.plus( + valuables.mul(exchangeRateAtOrderDate ?? 1) + ); + } else if (order.type === 'LIABILITY') { + const liabilities = order.quantity.mul(order.unitPrice); + + totalLiabilities = totalLiabilities.plus(liabilities); + totalLiabilitiesInBaseCurrency = totalLiabilitiesInBaseCurrency.plus( + liabilities.mul(exchangeRateAtOrderDate ?? 1) + ); + } + + if (order.itemType === 'start') { + // Take the unit price of the order as the market price if there are no + // orders of this symbol before the start date + order.unitPrice = + indexOfStartOrder === 0 + ? orders[i + 1]?.unitPrice + : unitPriceAtStartDate; + } + + if (order.fee) { + order.feeInBaseCurrency = order.fee.mul(currentExchangeRate ?? 1); + order.feeInBaseCurrencyWithCurrencyEffect = order.fee.mul( + exchangeRateAtOrderDate ?? 1 + ); + } + + const unitPrice = ['BUY', 'SELL'].includes(order.type) + ? order.unitPrice + : order.unitPriceFromMarketData; + + if (unitPrice) { + order.unitPriceInBaseCurrency = unitPrice.mul(currentExchangeRate ?? 1); + + order.unitPriceInBaseCurrencyWithCurrencyEffect = unitPrice.mul( + exchangeRateAtOrderDate ?? 1 + ); + } + + const valueOfInvestmentBeforeTransaction = totalUnits.mul( + order.unitPriceInBaseCurrency + ); + + const valueOfInvestmentBeforeTransactionWithCurrencyEffect = + totalUnits.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect); + + if (!investmentAtStartDate && i >= indexOfStartOrder) { + investmentAtStartDate = totalInvestment ?? new Big(0); + + investmentAtStartDateWithCurrencyEffect = + totalInvestmentWithCurrencyEffect ?? new Big(0); + + valueAtStartDate = valueOfInvestmentBeforeTransaction; + + valueAtStartDateWithCurrencyEffect = + valueOfInvestmentBeforeTransactionWithCurrencyEffect; + } + + let transactionInvestment = new Big(0); + let transactionInvestmentWithCurrencyEffect = new Big(0); + + if (order.type === 'BUY') { + transactionInvestment = order.quantity + .mul(order.unitPriceInBaseCurrency) + .mul(getFactor(order.type)); + + transactionInvestmentWithCurrencyEffect = order.quantity + .mul(order.unitPriceInBaseCurrencyWithCurrencyEffect) + .mul(getFactor(order.type)); + + totalQuantityFromBuyTransactions = + totalQuantityFromBuyTransactions.plus(order.quantity); + + totalInvestmentFromBuyTransactions = + totalInvestmentFromBuyTransactions.plus(transactionInvestment); + + totalInvestmentFromBuyTransactionsWithCurrencyEffect = + totalInvestmentFromBuyTransactionsWithCurrencyEffect.plus( + transactionInvestmentWithCurrencyEffect + ); + } else if (order.type === 'SELL') { + if (totalUnits.gt(0)) { + transactionInvestment = totalInvestment + .div(totalUnits) + .mul(order.quantity) + .mul(getFactor(order.type)); + transactionInvestmentWithCurrencyEffect = + totalInvestmentWithCurrencyEffect + .div(totalUnits) + .mul(order.quantity) + .mul(getFactor(order.type)); + } + } + + if (PortfolioCalculator.ENABLE_LOGGING) { + console.log('order.quantity', order.quantity.toNumber()); + console.log('transactionInvestment', transactionInvestment.toNumber()); + + console.log( + 'transactionInvestmentWithCurrencyEffect', + transactionInvestmentWithCurrencyEffect.toNumber() + ); + } + + const totalInvestmentBeforeTransaction = totalInvestment; + + const totalInvestmentBeforeTransactionWithCurrencyEffect = + totalInvestmentWithCurrencyEffect; + + totalInvestment = totalInvestment.plus(transactionInvestment); + + totalInvestmentWithCurrencyEffect = + totalInvestmentWithCurrencyEffect.plus( + transactionInvestmentWithCurrencyEffect + ); + + if (i >= indexOfStartOrder && !initialValue) { + if ( + i === indexOfStartOrder && + !valueOfInvestmentBeforeTransaction.eq(0) + ) { + initialValue = valueOfInvestmentBeforeTransaction; + + initialValueWithCurrencyEffect = + valueOfInvestmentBeforeTransactionWithCurrencyEffect; + } else if (transactionInvestment.gt(0)) { + initialValue = transactionInvestment; + + initialValueWithCurrencyEffect = + transactionInvestmentWithCurrencyEffect; + } + } + + fees = fees.plus(order.feeInBaseCurrency ?? 0); + + feesWithCurrencyEffect = feesWithCurrencyEffect.plus( + order.feeInBaseCurrencyWithCurrencyEffect ?? 0 + ); + + totalUnits = totalUnits.plus(order.quantity.mul(getFactor(order.type))); + + const valueOfInvestment = totalUnits.mul(order.unitPriceInBaseCurrency); + + const valueOfInvestmentWithCurrencyEffect = totalUnits.mul( + order.unitPriceInBaseCurrencyWithCurrencyEffect + ); + + const grossPerformanceFromSell = + order.type === 'SELL' + ? order.unitPriceInBaseCurrency + .minus(lastAveragePrice) + .mul(order.quantity) + : new Big(0); + + const grossPerformanceFromSellWithCurrencyEffect = + order.type === 'SELL' + ? order.unitPriceInBaseCurrencyWithCurrencyEffect + .minus(lastAveragePriceWithCurrencyEffect) + .mul(order.quantity) + : new Big(0); + + grossPerformanceFromSells = grossPerformanceFromSells.plus( + grossPerformanceFromSell + ); + + grossPerformanceFromSellsWithCurrencyEffect = + grossPerformanceFromSellsWithCurrencyEffect.plus( + grossPerformanceFromSellWithCurrencyEffect + ); + + lastAveragePrice = totalQuantityFromBuyTransactions.eq(0) + ? new Big(0) + : totalInvestmentFromBuyTransactions.div( + totalQuantityFromBuyTransactions + ); + + lastAveragePriceWithCurrencyEffect = totalQuantityFromBuyTransactions.eq( + 0 + ) + ? new Big(0) + : totalInvestmentFromBuyTransactionsWithCurrencyEffect.div( + totalQuantityFromBuyTransactions + ); + + if (PortfolioCalculator.ENABLE_LOGGING) { + console.log( + 'grossPerformanceFromSells', + grossPerformanceFromSells.toNumber() + ); + console.log( + 'grossPerformanceFromSellWithCurrencyEffect', + grossPerformanceFromSellWithCurrencyEffect.toNumber() + ); + } + + const newGrossPerformance = valueOfInvestment + .minus(totalInvestment) + .plus(grossPerformanceFromSells); + + const newGrossPerformanceWithCurrencyEffect = + valueOfInvestmentWithCurrencyEffect + .minus(totalInvestmentWithCurrencyEffect) + .plus(grossPerformanceFromSellsWithCurrencyEffect); + + grossPerformance = newGrossPerformance; + + grossPerformanceWithCurrencyEffect = + newGrossPerformanceWithCurrencyEffect; + + if (order.itemType === 'start') { + feesAtStartDate = fees; + feesAtStartDateWithCurrencyEffect = feesWithCurrencyEffect; + grossPerformanceAtStartDate = grossPerformance; + + grossPerformanceAtStartDateWithCurrencyEffect = + grossPerformanceWithCurrencyEffect; + } + + if (i > indexOfStartOrder) { + // Only consider periods with an investment for the calculation of + // the time weighted investment + if ( + valueOfInvestmentBeforeTransaction.gt(0) && + ['BUY', 'SELL'].includes(order.type) + ) { + // Calculate the number of days since the previous order + const orderDate = new Date(order.date); + const previousOrderDate = new Date(orders[i - 1].date); + + let daysSinceLastOrder = differenceInDays( + orderDate, + previousOrderDate + ); + if (daysSinceLastOrder <= 0) { + // The time between two activities on the same day is unknown + // -> Set it to the smallest floating point number greater than 0 + daysSinceLastOrder = Number.EPSILON; + } + + // Sum up the total investment days since the start date to calculate + // the time weighted investment + totalInvestmentDays += daysSinceLastOrder; + + sumOfTimeWeightedInvestments = sumOfTimeWeightedInvestments.add( + valueAtStartDate + .minus(investmentAtStartDate) + .plus(totalInvestmentBeforeTransaction) + .mul(daysSinceLastOrder) + ); + + sumOfTimeWeightedInvestmentsWithCurrencyEffect = + sumOfTimeWeightedInvestmentsWithCurrencyEffect.add( + valueAtStartDateWithCurrencyEffect + .minus(investmentAtStartDateWithCurrencyEffect) + .plus(totalInvestmentBeforeTransactionWithCurrencyEffect) + .mul(daysSinceLastOrder) + ); + } + + currentValues[order.date] = valueOfInvestment; + + currentValuesWithCurrencyEffect[order.date] = + valueOfInvestmentWithCurrencyEffect; + + netPerformanceValues[order.date] = grossPerformance + .minus(grossPerformanceAtStartDate) + .minus(fees.minus(feesAtStartDate)); + + netPerformanceValuesWithCurrencyEffect[order.date] = + grossPerformanceWithCurrencyEffect + .minus(grossPerformanceAtStartDateWithCurrencyEffect) + .minus( + feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect) + ); + + investmentValuesAccumulated[order.date] = totalInvestment; + + investmentValuesAccumulatedWithCurrencyEffect[order.date] = + totalInvestmentWithCurrencyEffect; + + investmentValuesWithCurrencyEffect[order.date] = ( + investmentValuesWithCurrencyEffect[order.date] ?? new Big(0) + ).add(transactionInvestmentWithCurrencyEffect); + + timeWeightedInvestmentValues[order.date] = + totalInvestmentDays > 0 + ? sumOfTimeWeightedInvestments.div(totalInvestmentDays) + : new Big(0); + + timeWeightedInvestmentValuesWithCurrencyEffect[order.date] = + totalInvestmentDays > 0 + ? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div( + totalInvestmentDays + ) + : new Big(0); + } + + if (PortfolioCalculator.ENABLE_LOGGING) { + console.log('totalInvestment', totalInvestment.toNumber()); + + console.log( + 'totalInvestmentWithCurrencyEffect', + totalInvestmentWithCurrencyEffect.toNumber() + ); + + console.log( + 'totalGrossPerformance', + grossPerformance.minus(grossPerformanceAtStartDate).toNumber() + ); + + console.log( + 'totalGrossPerformanceWithCurrencyEffect', + grossPerformanceWithCurrencyEffect + .minus(grossPerformanceAtStartDateWithCurrencyEffect) + .toNumber() + ); + } + + if (i === indexOfEndOrder) { + break; + } + } + + const totalGrossPerformance = grossPerformance.minus( + grossPerformanceAtStartDate + ); + + const totalGrossPerformanceWithCurrencyEffect = + grossPerformanceWithCurrencyEffect.minus( + grossPerformanceAtStartDateWithCurrencyEffect + ); + + const totalNetPerformance = grossPerformance + .minus(grossPerformanceAtStartDate) + .minus(fees.minus(feesAtStartDate)); + + const timeWeightedAverageInvestmentBetweenStartAndEndDate = + totalInvestmentDays > 0 + ? sumOfTimeWeightedInvestments.div(totalInvestmentDays) + : new Big(0); + + const timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect = + totalInvestmentDays > 0 + ? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div( + totalInvestmentDays + ) + : new Big(0); + + const grossPerformancePercentage = + timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0) + ? totalGrossPerformance.div( + timeWeightedAverageInvestmentBetweenStartAndEndDate + ) + : new Big(0); + + const grossPerformancePercentageWithCurrencyEffect = + timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt( + 0 + ) + ? totalGrossPerformanceWithCurrencyEffect.div( + timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect + ) + : new Big(0); + + const feesPerUnit = totalUnits.gt(0) + ? fees.minus(feesAtStartDate).div(totalUnits) + : new Big(0); + + const feesPerUnitWithCurrencyEffect = totalUnits.gt(0) + ? feesWithCurrencyEffect + .minus(feesAtStartDateWithCurrencyEffect) + .div(totalUnits) + : new Big(0); + + const netPerformancePercentage = + timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0) + ? totalNetPerformance.div( + timeWeightedAverageInvestmentBetweenStartAndEndDate + ) + : new Big(0); + + const netPerformancePercentageWithCurrencyEffectMap: { + [key: DateRange]: Big; + } = {}; + + const netPerformanceWithCurrencyEffectMap: { + [key: DateRange]: Big; + } = {}; + + for (const dateRange of [ + '1d', + '1y', + '5y', + 'max', + 'mtd', + 'wtd', + 'ytd' + // TODO: + // ...eachYearOfInterval({ end, start }) + // .filter((date) => { + // return !isThisYear(date); + // }) + // .map((date) => { + // return format(date, 'yyyy'); + // }) + ]) { + // TODO: getIntervalFromDateRange(dateRange, start) + let { endDate, startDate } = getIntervalFromDateRange(dateRange); + + if (isBefore(startDate, start)) { + startDate = start; + } + + const currentValuesAtDateRangeStartWithCurrencyEffect = + currentValuesWithCurrencyEffect[format(startDate, DATE_FORMAT)] ?? + new Big(0); + + const investmentValuesAccumulatedAtStartDateWithCurrencyEffect = + investmentValuesAccumulatedWithCurrencyEffect[ + format(startDate, DATE_FORMAT) + ] ?? new Big(0); + + const grossPerformanceAtDateRangeStartWithCurrencyEffect = + currentValuesAtDateRangeStartWithCurrencyEffect.minus( + investmentValuesAccumulatedAtStartDateWithCurrencyEffect + ); + + const dates = eachDayOfInterval({ + end: endDate, + start: startDate + }).map((date) => { + return format(date, DATE_FORMAT); + }); + + let average = new Big(0); + let dayCount = 0; + + for (const date of dates) { + if ( + investmentValuesAccumulatedWithCurrencyEffect[date] instanceof Big && + investmentValuesAccumulatedWithCurrencyEffect[date].gt(0) + ) { + average = average.add( + investmentValuesAccumulatedWithCurrencyEffect[date].add( + grossPerformanceAtDateRangeStartWithCurrencyEffect + ) + ); + + dayCount++; + } + } + + if (dayCount > 0) { + average = average.div(dayCount); + } + + netPerformanceWithCurrencyEffectMap[dateRange] = + netPerformanceValuesWithCurrencyEffect[ + format(endDate, DATE_FORMAT) + ]?.minus( + // If the date range is 'max', take 0 as a start value. Otherwise, + // the value of the end of the day of the start date is taken which + // differs from the buying price. + dateRange === 'max' + ? new Big(0) + : (netPerformanceValuesWithCurrencyEffect[ + format(startDate, DATE_FORMAT) + ] ?? new Big(0)) + ) ?? new Big(0); + + netPerformancePercentageWithCurrencyEffectMap[dateRange] = average.gt(0) + ? netPerformanceWithCurrencyEffectMap[dateRange].div(average) + : new Big(0); + } + + if (PortfolioCalculator.ENABLE_LOGGING) { + console.log( + ` + ${symbol} + Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed( + 2 + )} -> ${unitPriceAtEndDate.toFixed(2)} + Total investment: ${totalInvestment.toFixed(2)} + Total investment with currency effect: ${totalInvestmentWithCurrencyEffect.toFixed( + 2 + )} + Time weighted investment: ${timeWeightedAverageInvestmentBetweenStartAndEndDate.toFixed( + 2 + )} + Time weighted investment with currency effect: ${timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.toFixed( + 2 + )} + Total dividend: ${totalDividend.toFixed(2)} + Gross performance: ${totalGrossPerformance.toFixed( + 2 + )} / ${grossPerformancePercentage.mul(100).toFixed(2)}% + Gross performance with currency effect: ${totalGrossPerformanceWithCurrencyEffect.toFixed( + 2 + )} / ${grossPerformancePercentageWithCurrencyEffect + .mul(100) + .toFixed(2)}% + Fees per unit: ${feesPerUnit.toFixed(2)} + Fees per unit with currency effect: ${feesPerUnitWithCurrencyEffect.toFixed( + 2 + )} + Net performance: ${totalNetPerformance.toFixed( + 2 + )} / ${netPerformancePercentage.mul(100).toFixed(2)}% + Net performance with currency effect: ${netPerformancePercentageWithCurrencyEffectMap[ + 'max' + ].toFixed(2)}%` + ); + } + + return { + currentValues, + currentValuesWithCurrencyEffect, + feesWithCurrencyEffect, + grossPerformancePercentage, + grossPerformancePercentageWithCurrencyEffect, + initialValue, + initialValueWithCurrencyEffect, + investmentValuesAccumulated, + investmentValuesAccumulatedWithCurrencyEffect, + investmentValuesWithCurrencyEffect, + netPerformancePercentage, + netPerformancePercentageWithCurrencyEffectMap, + netPerformanceValues, + netPerformanceValuesWithCurrencyEffect, + netPerformanceWithCurrencyEffectMap, + timeWeightedInvestmentValues, + timeWeightedInvestmentValuesWithCurrencyEffect, + totalAccountBalanceInBaseCurrency, + totalDividend, + totalDividendInBaseCurrency, + totalInterest, + totalInterestInBaseCurrency, + totalInvestment, + totalInvestmentWithCurrencyEffect, + totalLiabilities, + totalLiabilitiesInBaseCurrency, + totalValuables, + totalValuablesInBaseCurrency, + grossPerformance: totalGrossPerformance, + grossPerformanceWithCurrencyEffect: + totalGrossPerformanceWithCurrencyEffect, + hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate), + netPerformance: totalNetPerformance, + timeWeightedInvestment: + timeWeightedAverageInvestmentBetweenStartAndEndDate, + timeWeightedInvestmentWithCurrencyEffect: + timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect + }; + } +} diff --git a/apps/api/src/app/portfolio/current-rate.service.mock.ts b/apps/api/src/app/portfolio/current-rate.service.mock.ts index 76e7aae09..313b09d67 100644 --- a/apps/api/src/app/portfolio/current-rate.service.mock.ts +++ b/apps/api/src/app/portfolio/current-rate.service.mock.ts @@ -1,6 +1,12 @@ import { parseDate, resetHours } from '@ghostfolio/common/helper'; -import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns'; +import { + addDays, + eachDayOfInterval, + endOfDay, + isBefore, + isSameDay +} from 'date-fns'; import { GetValueObject } from './interfaces/get-value-object.interface'; import { GetValuesObject } from './interfaces/get-values-object.interface'; @@ -8,6 +14,13 @@ import { GetValuesParams } from './interfaces/get-values-params.interface'; function mockGetValue(symbol: string, date: Date) { switch (symbol) { + case '55196015-1365-4560-aa60-8751ae6d18f8': + if (isSameDay(parseDate('2022-01-31'), date)) { + return { marketPrice: 3000 }; + } + + return { marketPrice: 0 }; + case 'BALN.SW': if (isSameDay(parseDate('2021-11-12'), date)) { return { marketPrice: 146 }; @@ -17,6 +30,10 @@ function mockGetValue(symbol: string, date: Date) { return { marketPrice: 139.9 }; } else if (isSameDay(parseDate('2021-11-30'), date)) { return { marketPrice: 136.6 }; + } else if (isSameDay(parseDate('2021-12-12'), date)) { + return { marketPrice: 142.0 }; + } else if (isSameDay(parseDate('2021-12-17'), date)) { + return { marketPrice: 143.9 }; } else if (isSameDay(parseDate('2021-12-18'), date)) { return { marketPrice: 148.9 }; } @@ -43,6 +60,17 @@ function mockGetValue(symbol: string, date: Date) { return { marketPrice: 0 }; + case 'MSFT': + if (isSameDay(parseDate('2021-09-16'), date)) { + return { marketPrice: 89.12 }; + } else if (isSameDay(parseDate('2021-11-16'), date)) { + return { marketPrice: 339.51 }; + } else if (isSameDay(parseDate('2023-07-10'), date)) { + return { marketPrice: 331.83 }; + } + + return { marketPrice: 0 }; + case 'NOVN.SW': if (isSameDay(parseDate('2022-04-11'), date)) { return { marketPrice: 87.8 }; @@ -79,7 +107,10 @@ export const CurrentRateServiceMock = { } } } else { - for (const date of dateQuery.in) { + for (const date of eachDayOfInterval({ + end: dateQuery.lt, + start: dateQuery.gte + })) { for (const dataGatheringItem of dataGatheringItems) { values.push({ date, 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 2fc931071..c86dde448 100644 --- a/apps/api/src/app/portfolio/current-rate.service.spec.ts +++ b/apps/api/src/app/portfolio/current-rate.service.spec.ts @@ -1,7 +1,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; -import { UniqueAsset } from '@ghostfolio/common/interfaces'; +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { DataSource, MarketData } from '@prisma/client'; @@ -24,32 +24,32 @@ jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => { }); }, getRange: ({ + assetProfileIdentifiers, dateRangeEnd, - dateRangeStart, - uniqueAssets + dateRangeStart }: { + assetProfileIdentifiers: AssetProfileIdentifier[]; dateRangeEnd: Date; dateRangeStart: Date; - uniqueAssets: UniqueAsset[]; }) => { return Promise.resolve([ { createdAt: dateRangeStart, - dataSource: uniqueAssets[0].dataSource, + dataSource: assetProfileIdentifiers[0].dataSource, date: dateRangeStart, id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d', marketPrice: 1841.823902, state: 'CLOSE', - symbol: uniqueAssets[0].symbol + symbol: assetProfileIdentifiers[0].symbol }, { createdAt: dateRangeEnd, - dataSource: uniqueAssets[0].dataSource, + dataSource: assetProfileIdentifiers[0].dataSource, date: dateRangeEnd, id: '082d6893-df27-4c91-8a5d-092e84315b56', marketPrice: 1847.839966, state: 'CLOSE', - symbol: uniqueAssets[0].symbol + symbol: assetProfileIdentifiers[0].symbol } ]); } @@ -107,7 +107,9 @@ describe('CurrentRateService', () => { currentRateService = new CurrentRateService( dataProviderService, - marketDataService + marketDataService, + null, + null ); }); diff --git a/apps/api/src/app/portfolio/current-rate.service.ts b/apps/api/src/app/portfolio/current-rate.service.ts index ecc00eb9d..24119162d 100644 --- a/apps/api/src/app/portfolio/current-rate.service.ts +++ b/apps/api/src/app/portfolio/current-rate.service.ts @@ -1,13 +1,16 @@ +import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { resetHours } from '@ghostfolio/common/helper'; import { + AssetProfileIdentifier, DataProviderInfo, - ResponseError, - UniqueAsset + ResponseError } from '@ghostfolio/common/interfaces'; +import type { RequestWithUser } from '@ghostfolio/common/types'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; import { isBefore, isToday } from 'date-fns'; import { flatten, isEmpty, uniqBy } from 'lodash'; @@ -19,16 +22,19 @@ import { GetValuesParams } from './interfaces/get-values-params.interface'; export class CurrentRateService { public constructor( private readonly dataProviderService: DataProviderService, - private readonly marketDataService: MarketDataService + private readonly marketDataService: MarketDataService, + private readonly orderService: OrderService, + @Inject(REQUEST) private readonly request: RequestWithUser ) {} + // TODO: Pass user instead of using this.request.user public async getValues({ dataGatheringItems, dateQuery }: GetValuesParams): Promise { const dataProviderInfos: DataProviderInfo[] = []; - const includeToday = + const includesToday = (!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) && (!dateQuery.gte || isBefore(dateQuery.gte, new Date())) && (!dateQuery.in || this.containsToday(dateQuery.in)); @@ -37,10 +43,10 @@ export class CurrentRateService { const quoteErrors: ResponseError['errors'] = []; const today = resetHours(new Date()); - if (includeToday) { + if (includesToday) { promises.push( this.dataProviderService - .getQuotes({ items: dataGatheringItems }) + .getQuotes({ items: dataGatheringItems, user: this.request?.user }) .then((dataResultProvider) => { const result: GetValueObject[] = []; @@ -74,17 +80,16 @@ export class CurrentRateService { ); } - const uniqueAssets: UniqueAsset[] = dataGatheringItems.map( - ({ dataSource, symbol }) => { + const assetProfileIdentifiers: AssetProfileIdentifier[] = + dataGatheringItems.map(({ dataSource, symbol }) => { return { dataSource, symbol }; - } - ); + }); promises.push( this.marketDataService .getRange({ - dateQuery, - uniqueAssets + assetProfileIdentifiers, + dateQuery }) .then((data) => { return data.map(({ dataSource, date, marketPrice, symbol }) => { @@ -117,11 +122,17 @@ export class CurrentRateService { }); if (!value) { + // Fallback to unit price of latest activity + const latestActivity = await this.orderService.getLatestOrder({ + dataSource, + symbol + }); + value = { dataSource, symbol, date: today, - marketPrice: 0 + marketPrice: latestActivity?.unitPrice ?? 0 }; response.values.push(value); diff --git a/apps/api/src/app/portfolio/interfaces/current-positions.interface.ts b/apps/api/src/app/portfolio/interfaces/current-positions.interface.ts deleted file mode 100644 index 5807d6b5e..000000000 --- a/apps/api/src/app/portfolio/interfaces/current-positions.interface.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces'; - -import Big from 'big.js'; - -export interface CurrentPositions extends ResponseError { - positions: TimelinePosition[]; - grossPerformance: Big; - grossPerformanceWithCurrencyEffect: Big; - grossPerformancePercentage: Big; - grossPerformancePercentageWithCurrencyEffect: Big; - netAnnualizedPerformance?: Big; - netAnnualizedPerformanceWithCurrencyEffect?: Big; - netPerformance: Big; - netPerformanceWithCurrencyEffect: Big; - netPerformancePercentage: Big; - netPerformancePercentageWithCurrencyEffect: Big; - currentValue: Big; - totalInvestment: Big; -} diff --git a/apps/api/src/app/portfolio/interfaces/get-value-object.interface.ts b/apps/api/src/app/portfolio/interfaces/get-value-object.interface.ts index 6c42d260c..34b426693 100644 --- a/apps/api/src/app/portfolio/interfaces/get-value-object.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/get-value-object.interface.ts @@ -1,6 +1,6 @@ -import { UniqueAsset } from '@ghostfolio/common/interfaces'; +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; -export interface GetValueObject extends UniqueAsset { +export interface GetValueObject extends AssetProfileIdentifier { date: Date; marketPrice: number; } diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-holding-detail.interface.ts similarity index 77% rename from apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts rename to apps/api/src/app/portfolio/interfaces/portfolio-holding-detail.interface.ts index 6955785f2..3ce23a3bc 100644 --- a/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/portfolio-holding-detail.interface.ts @@ -1,17 +1,19 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { DataProviderInfo, EnhancedSymbolProfile, HistoricalDataItem } from '@ghostfolio/common/interfaces'; -import { OrderWithAccount } from '@ghostfolio/common/types'; import { Account, Tag } from '@prisma/client'; -export interface PortfolioPositionDetail { +export interface PortfolioHoldingDetail { accounts: Account[]; averagePrice: number; dataProviderInfo: DataProviderInfo; dividendInBaseCurrency: number; + dividendYieldPercent: number; + dividendYieldPercentWithCurrencyEffect: number; feeInBaseCurrency: number; firstBuyDate: string; grossPerformance: number; @@ -27,16 +29,10 @@ export interface PortfolioPositionDetail { netPerformancePercent: number; netPerformancePercentWithCurrencyEffect: number; netPerformanceWithCurrencyEffect: number; - orders: OrderWithAccount[]; + orders: Activity[]; quantity: number; SymbolProfile: EnhancedSymbolProfile; tags: Tag[]; transactionCount: number; value: number; } - -export interface HistoricalDataContainer { - isAllTimeHigh: boolean; - isAllTimeLow: boolean; - items: HistoricalDataItem[]; -} diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-calculator.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts similarity index 75% rename from apps/api/src/app/portfolio/interfaces/portfolio-calculator.interface.ts rename to apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts index 357b454fd..06e471d67 100644 --- a/apps/api/src/app/portfolio/interfaces/portfolio-calculator.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts @@ -1,11 +1,12 @@ -import Big from 'big.js'; +import { Big } from 'big.js'; import { PortfolioOrder } from './portfolio-order.interface'; export interface PortfolioOrderItem extends PortfolioOrder { feeInBaseCurrency?: Big; feeInBaseCurrencyWithCurrencyEffect?: Big; - itemType?: '' | 'start' | 'end'; + itemType?: 'end' | 'start'; + unitPriceFromMarketData?: Big; unitPriceInBaseCurrency?: Big; unitPriceInBaseCurrencyWithCurrencyEffect?: Big; } 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 cc3a97752..63a936c32 100644 --- a/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts @@ -1,15 +1,12 @@ -import { DataSource, Tag, Type as TypeOfOrder } from '@prisma/client'; -import Big from 'big.js'; +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; -export interface PortfolioOrder { - currency: string; +export interface PortfolioOrder extends Pick { date: string; - dataSource: DataSource; fee: Big; - name: string; quantity: Big; - symbol: string; - tags?: Tag[]; - type: TypeOfOrder; + SymbolProfile: Pick< + Activity['SymbolProfile'], + 'currency' | 'dataSource' | 'name' | 'symbol' + >; unitPrice: Big; } diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-positions.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-positions.interface.ts deleted file mode 100644 index fa6141a7a..000000000 --- a/apps/api/src/app/portfolio/interfaces/portfolio-positions.interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Position } from '@ghostfolio/common/interfaces'; - -export interface PortfolioPositions { - positions: Position[]; -} 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 5350adccc..d15d02d3a 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,9 +1,11 @@ import { DataSource, Tag } from '@prisma/client'; -import Big from 'big.js'; +import { Big } from 'big.js'; export interface TransactionPointSymbol { + averagePrice: Big; currency: string; dataSource: DataSource; + dividend: Big; fee: Big; firstBuyDate: string; investment: Big; diff --git a/apps/api/src/app/portfolio/interfaces/transaction-point.interface.ts b/apps/api/src/app/portfolio/interfaces/transaction-point.interface.ts index 178df3456..fcbea81ca 100644 --- a/apps/api/src/app/portfolio/interfaces/transaction-point.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/transaction-point.interface.ts @@ -1,6 +1,12 @@ +import { Big } from 'big.js'; + import { TransactionPointSymbol } from './transaction-point-symbol.interface'; export interface TransactionPoint { date: string; + fees: Big; + interest: Big; items: TransactionPointSymbol[]; + liabilities: Big; + valuables: Big; } diff --git a/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts deleted file mode 100644 index 07b53b4de..000000000 --- a/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; -import { parseDate } from '@ghostfolio/common/helper'; - -import Big from 'big.js'; - -import { CurrentRateServiceMock } from './current-rate.service.mock'; -import { PortfolioCalculator } from './portfolio-calculator'; - -jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { - return { - // eslint-disable-next-line @typescript-eslint/naming-convention - CurrentRateService: jest.fn().mockImplementation(() => { - return CurrentRateServiceMock; - }) - }; -}); - -describe('PortfolioCalculator', () => { - let currentRateService: CurrentRateService; - let exchangeRateDataService: ExchangeRateDataService; - - beforeEach(() => { - currentRateService = new CurrentRateService(null, null); - - exchangeRateDataService = new ExchangeRateDataService( - null, - null, - null, - null - ); - }); - - describe('get current positions', () => { - it.only('with BALN.SW buy and sell', async () => { - const portfolioCalculator = new PortfolioCalculator({ - currentRateService, - exchangeRateDataService, - currency: 'CHF', - orders: [ - { - currency: 'CHF', - date: '2021-11-22', - dataSource: 'YAHOO', - fee: new Big(1.55), - name: 'Bâloise Holding AG', - quantity: new Big(2), - symbol: 'BALN.SW', - type: 'BUY', - unitPrice: new Big(142.9) - }, - { - currency: 'CHF', - date: '2021-11-30', - dataSource: 'YAHOO', - fee: new Big(1.65), - name: 'Bâloise Holding AG', - quantity: new Big(2), - symbol: 'BALN.SW', - type: 'SELL', - unitPrice: new Big(136.6) - } - ] - }); - - portfolioCalculator.computeTransactionPoints(); - - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => parseDate('2021-12-18').getTime()); - - const chartData = await portfolioCalculator.getChartData({ - start: parseDate('2021-11-22') - }); - - const currentPositions = await portfolioCalculator.getCurrentPositions( - parseDate('2021-11-22') - ); - - const investments = portfolioCalculator.getInvestments(); - - const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ - data: chartData, - groupBy: 'month' - }); - - spy.mockRestore(); - - expect(currentPositions).toEqual({ - currentValue: new Big('0'), - errors: [], - grossPerformance: new Big('-12.6'), - grossPerformancePercentage: new Big('-0.0440867739678096571'), - grossPerformancePercentageWithCurrencyEffect: new Big( - '-0.0440867739678096571' - ), - grossPerformanceWithCurrencyEffect: new Big('-12.6'), - hasErrors: false, - netPerformance: new Big('-15.8'), - netPerformancePercentage: new Big('-0.0552834149755073478'), - netPerformancePercentageWithCurrencyEffect: new Big( - '-0.0552834149755073478' - ), - netPerformanceWithCurrencyEffect: new Big('-15.8'), - positions: [ - { - averagePrice: new Big('0'), - currency: 'CHF', - dataSource: 'YAHOO', - fee: new Big('3.2'), - firstBuyDate: '2021-11-22', - grossPerformance: new Big('-12.6'), - grossPerformancePercentage: new Big('-0.0440867739678096571'), - grossPerformancePercentageWithCurrencyEffect: new Big( - '-0.0440867739678096571' - ), - grossPerformanceWithCurrencyEffect: new Big('-12.6'), - investment: new Big('0'), - investmentWithCurrencyEffect: new Big('0'), - netPerformance: new Big('-15.8'), - netPerformancePercentage: new Big('-0.0552834149755073478'), - netPerformancePercentageWithCurrencyEffect: new Big( - '-0.0552834149755073478' - ), - netPerformanceWithCurrencyEffect: new Big('-15.8'), - marketPrice: 148.9, - marketPriceInBaseCurrency: 148.9, - quantity: new Big('0'), - symbol: 'BALN.SW', - timeWeightedInvestment: new Big('285.8'), - timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'), - transactionCount: 2 - } - ], - totalInvestment: new Big('0'), - totalInvestmentWithCurrencyEffect: new Big('0') - }); - - expect(investments).toEqual([ - { date: '2021-11-22', investment: new Big('285.8') }, - { date: '2021-11-30', investment: new Big('0') } - ]); - - expect(investmentsByMonth).toEqual([ - { date: '2021-11-01', investment: 0 }, - { date: '2021-12-01', investment: 0 } - ]); - }); - }); -}); diff --git a/apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts deleted file mode 100644 index c60f99990..000000000 --- a/apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; -import { parseDate } from '@ghostfolio/common/helper'; - -import Big from 'big.js'; - -import { CurrentRateServiceMock } from './current-rate.service.mock'; -import { PortfolioCalculator } from './portfolio-calculator'; - -jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { - return { - // eslint-disable-next-line @typescript-eslint/naming-convention - CurrentRateService: jest.fn().mockImplementation(() => { - return CurrentRateServiceMock; - }) - }; -}); - -describe('PortfolioCalculator', () => { - let currentRateService: CurrentRateService; - let exchangeRateDataService: ExchangeRateDataService; - - beforeEach(() => { - currentRateService = new CurrentRateService(null, null); - - exchangeRateDataService = new ExchangeRateDataService( - null, - null, - null, - null - ); - }); - - describe('get current positions', () => { - it.only('with BALN.SW buy', async () => { - const portfolioCalculator = new PortfolioCalculator({ - currentRateService, - exchangeRateDataService, - currency: 'CHF', - orders: [ - { - currency: 'CHF', - date: '2021-11-30', - dataSource: 'YAHOO', - fee: new Big(1.55), - name: 'Bâloise Holding AG', - quantity: new Big(2), - symbol: 'BALN.SW', - type: 'BUY', - unitPrice: new Big(136.6) - } - ] - }); - - portfolioCalculator.computeTransactionPoints(); - - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => parseDate('2021-12-18').getTime()); - - const chartData = await portfolioCalculator.getChartData({ - start: parseDate('2021-11-30') - }); - - const currentPositions = await portfolioCalculator.getCurrentPositions( - parseDate('2021-11-30') - ); - - const investments = portfolioCalculator.getInvestments(); - - const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ - data: chartData, - groupBy: 'month' - }); - - spy.mockRestore(); - - expect(currentPositions).toEqual({ - currentValue: new Big('297.8'), - errors: [], - grossPerformance: new Big('24.6'), - grossPerformancePercentage: new Big('0.09004392386530014641'), - grossPerformancePercentageWithCurrencyEffect: new Big( - '0.09004392386530014641' - ), - grossPerformanceWithCurrencyEffect: new Big('24.6'), - hasErrors: false, - netPerformance: new Big('23.05'), - netPerformancePercentage: new Big('0.08437042459736456808'), - netPerformancePercentageWithCurrencyEffect: new Big( - '0.08437042459736456808' - ), - netPerformanceWithCurrencyEffect: new Big('23.05'), - positions: [ - { - averagePrice: new Big('136.6'), - currency: 'CHF', - dataSource: 'YAHOO', - fee: new Big('1.55'), - firstBuyDate: '2021-11-30', - grossPerformance: new Big('24.6'), - grossPerformancePercentage: new Big('0.09004392386530014641'), - grossPerformancePercentageWithCurrencyEffect: new Big( - '0.09004392386530014641' - ), - grossPerformanceWithCurrencyEffect: new Big('24.6'), - investment: new Big('273.2'), - investmentWithCurrencyEffect: new Big('273.2'), - netPerformance: new Big('23.05'), - netPerformancePercentage: new Big('0.08437042459736456808'), - netPerformancePercentageWithCurrencyEffect: new Big( - '0.08437042459736456808' - ), - netPerformanceWithCurrencyEffect: new Big('23.05'), - marketPrice: 148.9, - marketPriceInBaseCurrency: 148.9, - quantity: new Big('2'), - symbol: 'BALN.SW', - timeWeightedInvestment: new Big('273.2'), - timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'), - transactionCount: 1 - } - ], - totalInvestment: new Big('273.2'), - totalInvestmentWithCurrencyEffect: new Big('273.2') - }); - - expect(investments).toEqual([ - { date: '2021-11-30', investment: new Big('273.2') } - ]); - - expect(investmentsByMonth).toEqual([ - { date: '2021-11-01', investment: 273.2 }, - { date: '2021-12-01', investment: 0 } - ]); - }); - }); -}); diff --git a/apps/api/src/app/portfolio/portfolio-calculator-googl-buy.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-googl-buy.spec.ts deleted file mode 100644 index 9a12bdc83..000000000 --- a/apps/api/src/app/portfolio/portfolio-calculator-googl-buy.spec.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; -import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; -import { parseDate } from '@ghostfolio/common/helper'; - -import Big from 'big.js'; - -import { CurrentRateServiceMock } from './current-rate.service.mock'; -import { PortfolioCalculator } from './portfolio-calculator'; - -jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { - return { - // eslint-disable-next-line @typescript-eslint/naming-convention - CurrentRateService: jest.fn().mockImplementation(() => { - return CurrentRateServiceMock; - }) - }; -}); - -jest.mock( - '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', - () => { - return { - // eslint-disable-next-line @typescript-eslint/naming-convention - ExchangeRateDataService: jest.fn().mockImplementation(() => { - return ExchangeRateDataServiceMock; - }) - }; - } -); - -describe('PortfolioCalculator', () => { - let currentRateService: CurrentRateService; - let exchangeRateDataService: ExchangeRateDataService; - - beforeEach(() => { - currentRateService = new CurrentRateService(null, null); - - exchangeRateDataService = new ExchangeRateDataService( - null, - null, - null, - null - ); - }); - - describe('get current positions', () => { - it.only('with GOOGL buy', async () => { - const portfolioCalculator = new PortfolioCalculator({ - currentRateService, - exchangeRateDataService, - currency: 'CHF', - orders: [ - { - currency: 'USD', - date: '2023-01-03', - dataSource: 'YAHOO', - fee: new Big(1), - name: 'Alphabet Inc.', - quantity: new Big(1), - symbol: 'GOOGL', - type: 'BUY', - unitPrice: new Big(89.12) - } - ] - }); - - portfolioCalculator.computeTransactionPoints(); - - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => parseDate('2023-07-10').getTime()); - - const chartData = await portfolioCalculator.getChartData({ - start: parseDate('2023-01-03') - }); - - const currentPositions = await portfolioCalculator.getCurrentPositions( - parseDate('2023-01-03') - ); - - const investments = portfolioCalculator.getInvestments(); - - const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ - data: chartData, - groupBy: 'month' - }); - - spy.mockRestore(); - - expect(currentPositions).toEqual({ - currentValue: new Big('103.10483'), - errors: [], - grossPerformance: new Big('27.33'), - grossPerformancePercentage: new Big('0.3066651705565529623'), - grossPerformancePercentageWithCurrencyEffect: new Big( - '0.25235044599563974109' - ), - grossPerformanceWithCurrencyEffect: new Big('20.775774'), - hasErrors: false, - netPerformance: new Big('26.33'), - netPerformancePercentage: new Big('0.29544434470377019749'), - netPerformancePercentageWithCurrencyEffect: new Big( - '0.24112962014285697628' - ), - netPerformanceWithCurrencyEffect: new Big('19.851974'), - positions: [ - { - averagePrice: new Big('89.12'), - currency: 'USD', - dataSource: 'YAHOO', - fee: new Big('1'), - firstBuyDate: '2023-01-03', - grossPerformance: new Big('27.33'), - grossPerformancePercentage: new Big('0.3066651705565529623'), - grossPerformancePercentageWithCurrencyEffect: new Big( - '0.25235044599563974109' - ), - grossPerformanceWithCurrencyEffect: new Big('20.775774'), - investment: new Big('89.12'), - investmentWithCurrencyEffect: new Big('82.329056'), - netPerformance: new Big('26.33'), - netPerformancePercentage: new Big('0.29544434470377019749'), - netPerformancePercentageWithCurrencyEffect: new Big( - '0.24112962014285697628' - ), - netPerformanceWithCurrencyEffect: new Big('19.851974'), - marketPrice: 116.45, - marketPriceInBaseCurrency: 103.10483, - quantity: new Big('1'), - symbol: 'GOOGL', - tags: undefined, - timeWeightedInvestment: new Big('89.12'), - timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'), - transactionCount: 1 - } - ], - totalInvestment: new Big('89.12'), - totalInvestmentWithCurrencyEffect: new Big('82.329056') - }); - - expect(investments).toEqual([ - { date: '2023-01-03', investment: new Big('89.12') } - ]); - - expect(investmentsByMonth).toEqual([ - { date: '2023-01-01', investment: 82.329056 }, - { - date: '2023-02-01', - investment: 0 - }, - { - date: '2023-03-01', - investment: 0 - }, - { - date: '2023-04-01', - investment: 0 - }, - { - date: '2023-05-01', - investment: 0 - }, - { - date: '2023-06-01', - investment: 0 - }, - { - date: '2023-07-01', - investment: 0 - } - ]); - }); - }); -}); diff --git a/apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts deleted file mode 100644 index 4b6d243f3..000000000 --- a/apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; -import { parseDate } from '@ghostfolio/common/helper'; - -import Big from 'big.js'; - -import { CurrentRateServiceMock } from './current-rate.service.mock'; -import { PortfolioCalculator } from './portfolio-calculator'; - -jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { - return { - // eslint-disable-next-line @typescript-eslint/naming-convention - CurrentRateService: jest.fn().mockImplementation(() => { - return CurrentRateServiceMock; - }) - }; -}); - -describe('PortfolioCalculator', () => { - let currentRateService: CurrentRateService; - let exchangeRateDataService: ExchangeRateDataService; - - beforeEach(() => { - currentRateService = new CurrentRateService(null, null); - - exchangeRateDataService = new ExchangeRateDataService( - null, - null, - null, - null - ); - }); - - describe('get current positions', () => { - it('with no orders', async () => { - const portfolioCalculator = new PortfolioCalculator({ - currentRateService, - exchangeRateDataService, - currency: 'CHF', - orders: [] - }); - - portfolioCalculator.computeTransactionPoints(); - - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => parseDate('2021-12-18').getTime()); - - const chartData = await portfolioCalculator.getChartData({ - start: new Date() - }); - - const currentPositions = await portfolioCalculator.getCurrentPositions( - new Date() - ); - - const investments = portfolioCalculator.getInvestments(); - - const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ - data: chartData, - groupBy: 'month' - }); - - spy.mockRestore(); - - expect(currentPositions).toEqual({ - currentValue: new Big(0), - grossPerformance: new Big(0), - grossPerformancePercentage: new Big(0), - grossPerformancePercentageWithCurrencyEffect: new Big(0), - grossPerformanceWithCurrencyEffect: new Big(0), - hasErrors: false, - netPerformance: new Big(0), - netPerformancePercentage: new Big(0), - netPerformancePercentageWithCurrencyEffect: new Big(0), - netPerformanceWithCurrencyEffect: new Big(0), - positions: [], - totalInvestment: new Big(0) - }); - - expect(investments).toEqual([]); - - expect(investmentsByMonth).toEqual([]); - }); - }); -}); diff --git a/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts deleted file mode 100644 index 2f93096d9..000000000 --- a/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; -import { parseDate } from '@ghostfolio/common/helper'; - -import Big from 'big.js'; - -import { CurrentRateServiceMock } from './current-rate.service.mock'; -import { PortfolioCalculator } from './portfolio-calculator'; - -jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { - return { - // eslint-disable-next-line @typescript-eslint/naming-convention - CurrentRateService: jest.fn().mockImplementation(() => { - return CurrentRateServiceMock; - }) - }; -}); - -describe('PortfolioCalculator', () => { - let currentRateService: CurrentRateService; - let exchangeRateDataService: ExchangeRateDataService; - - beforeEach(() => { - currentRateService = new CurrentRateService(null, null); - - exchangeRateDataService = new ExchangeRateDataService( - null, - null, - null, - null - ); - }); - - describe('get current positions', () => { - it.only('with NOVN.SW buy and sell partially', async () => { - const portfolioCalculator = new PortfolioCalculator({ - currentRateService, - exchangeRateDataService, - currency: 'CHF', - orders: [ - { - currency: 'CHF', - date: '2022-03-07', - dataSource: 'YAHOO', - fee: new Big(1.3), - name: 'Novartis AG', - quantity: new Big(2), - symbol: 'NOVN.SW', - type: 'BUY', - unitPrice: new Big(75.8) - }, - { - currency: 'CHF', - date: '2022-04-08', - dataSource: 'YAHOO', - fee: new Big(2.95), - name: 'Novartis AG', - quantity: new Big(1), - symbol: 'NOVN.SW', - type: 'SELL', - unitPrice: new Big(85.73) - } - ] - }); - - portfolioCalculator.computeTransactionPoints(); - - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => parseDate('2022-04-11').getTime()); - - const chartData = await portfolioCalculator.getChartData({ - start: parseDate('2022-03-07') - }); - - const currentPositions = await portfolioCalculator.getCurrentPositions( - parseDate('2022-03-07') - ); - - const investments = portfolioCalculator.getInvestments(); - - const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ - data: chartData, - groupBy: 'month' - }); - - spy.mockRestore(); - - expect(currentPositions).toEqual({ - currentValue: new Big('87.8'), - errors: [], - grossPerformance: new Big('21.93'), - grossPerformancePercentage: new Big('0.15113417083448194384'), - grossPerformancePercentageWithCurrencyEffect: new Big( - '0.15113417083448194384' - ), - grossPerformanceWithCurrencyEffect: new Big('21.93'), - hasErrors: false, - netPerformance: new Big('17.68'), - netPerformancePercentage: new Big('0.12184460284330327256'), - netPerformancePercentageWithCurrencyEffect: new Big( - '0.12184460284330327256' - ), - netPerformanceWithCurrencyEffect: new Big('17.68'), - positions: [ - { - averagePrice: new Big('75.80'), - currency: 'CHF', - dataSource: 'YAHOO', - fee: new Big('4.25'), - firstBuyDate: '2022-03-07', - grossPerformance: new Big('21.93'), - grossPerformancePercentage: new Big('0.15113417083448194384'), - grossPerformancePercentageWithCurrencyEffect: new Big( - '0.15113417083448194384' - ), - grossPerformanceWithCurrencyEffect: new Big('21.93'), - investment: new Big('75.80'), - investmentWithCurrencyEffect: new Big('75.80'), - netPerformance: new Big('17.68'), - netPerformancePercentage: new Big('0.12184460284330327256'), - netPerformancePercentageWithCurrencyEffect: new Big( - '0.12184460284330327256' - ), - netPerformanceWithCurrencyEffect: new Big('17.68'), - marketPrice: 87.8, - marketPriceInBaseCurrency: 87.8, - quantity: new Big('1'), - symbol: 'NOVN.SW', - timeWeightedInvestment: new Big('145.10285714285714285714'), - timeWeightedInvestmentWithCurrencyEffect: new Big( - '145.10285714285714285714' - ), - transactionCount: 2 - } - ], - totalInvestment: new Big('75.80'), - totalInvestmentWithCurrencyEffect: new Big('75.80') - }); - - expect(investments).toEqual([ - { date: '2022-03-07', investment: new Big('151.6') }, - { date: '2022-04-08', investment: new Big('75.8') } - ]); - - expect(investmentsByMonth).toEqual([ - { date: '2022-03-01', investment: 151.6 }, - { date: '2022-04-01', investment: -75.8 } - ]); - }); - }); -}); diff --git a/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts deleted file mode 100644 index ada9acc16..000000000 --- a/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; -import { parseDate } from '@ghostfolio/common/helper'; - -import Big from 'big.js'; - -import { CurrentRateServiceMock } from './current-rate.service.mock'; -import { PortfolioCalculator } from './portfolio-calculator'; - -jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { - return { - // eslint-disable-next-line @typescript-eslint/naming-convention - CurrentRateService: jest.fn().mockImplementation(() => { - return CurrentRateServiceMock; - }) - }; -}); - -describe('PortfolioCalculator', () => { - let currentRateService: CurrentRateService; - let exchangeRateDataService: ExchangeRateDataService; - - beforeEach(() => { - currentRateService = new CurrentRateService(null, null); - - exchangeRateDataService = new ExchangeRateDataService( - null, - null, - null, - null - ); - }); - - describe('get current positions', () => { - it.only('with NOVN.SW buy and sell', async () => { - const portfolioCalculator = new PortfolioCalculator({ - currentRateService, - exchangeRateDataService, - currency: 'CHF', - orders: [ - { - currency: 'CHF', - date: '2022-03-07', - dataSource: 'YAHOO', - fee: new Big(0), - name: 'Novartis AG', - quantity: new Big(2), - symbol: 'NOVN.SW', - type: 'BUY', - unitPrice: new Big(75.8) - }, - { - currency: 'CHF', - date: '2022-04-08', - dataSource: 'YAHOO', - fee: new Big(0), - name: 'Novartis AG', - quantity: new Big(2), - symbol: 'NOVN.SW', - type: 'SELL', - unitPrice: new Big(85.73) - } - ] - }); - - portfolioCalculator.computeTransactionPoints(); - - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => parseDate('2022-04-11').getTime()); - - const chartData = await portfolioCalculator.getChartData({ - start: parseDate('2022-03-07') - }); - - const currentPositions = await portfolioCalculator.getCurrentPositions( - parseDate('2022-03-07') - ); - - const investments = portfolioCalculator.getInvestments(); - - const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ - data: chartData, - groupBy: 'month' - }); - - spy.mockRestore(); - - expect(chartData[0]).toEqual({ - date: '2022-03-07', - investmentValueWithCurrencyEffect: 151.6, - netPerformance: 0, - netPerformanceInPercentage: 0, - netPerformanceInPercentageWithCurrencyEffect: 0, - netPerformanceWithCurrencyEffect: 0, - totalInvestment: 151.6, - totalInvestmentValueWithCurrencyEffect: 151.6, - value: 151.6, - valueWithCurrencyEffect: 151.6 - }); - - expect(chartData[chartData.length - 1]).toEqual({ - date: '2022-04-11', - investmentValueWithCurrencyEffect: 0, - netPerformance: 19.86, - netPerformanceInPercentage: 13.100263852242744, - netPerformanceInPercentageWithCurrencyEffect: 13.100263852242744, - netPerformanceWithCurrencyEffect: 19.86, - totalInvestment: 0, - totalInvestmentValueWithCurrencyEffect: 0, - value: 0, - valueWithCurrencyEffect: 0 - }); - - expect(currentPositions).toEqual({ - currentValue: new Big('0'), - errors: [], - grossPerformance: new Big('19.86'), - grossPerformancePercentage: new Big('0.13100263852242744063'), - grossPerformancePercentageWithCurrencyEffect: new Big( - '0.13100263852242744063' - ), - grossPerformanceWithCurrencyEffect: new Big('19.86'), - hasErrors: false, - netPerformance: new Big('19.86'), - netPerformancePercentage: new Big('0.13100263852242744063'), - netPerformancePercentageWithCurrencyEffect: new Big( - '0.13100263852242744063' - ), - netPerformanceWithCurrencyEffect: new Big('19.86'), - positions: [ - { - averagePrice: new Big('0'), - currency: 'CHF', - dataSource: 'YAHOO', - fee: new Big('0'), - firstBuyDate: '2022-03-07', - grossPerformance: new Big('19.86'), - grossPerformancePercentage: new Big('0.13100263852242744063'), - grossPerformancePercentageWithCurrencyEffect: new Big( - '0.13100263852242744063' - ), - grossPerformanceWithCurrencyEffect: new Big('19.86'), - investment: new Big('0'), - investmentWithCurrencyEffect: new Big('0'), - netPerformance: new Big('19.86'), - netPerformancePercentage: new Big('0.13100263852242744063'), - netPerformancePercentageWithCurrencyEffect: new Big( - '0.13100263852242744063' - ), - netPerformanceWithCurrencyEffect: new Big('19.86'), - marketPrice: 87.8, - marketPriceInBaseCurrency: 87.8, - quantity: new Big('0'), - symbol: 'NOVN.SW', - timeWeightedInvestment: new Big('151.6'), - timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), - transactionCount: 2 - } - ], - totalInvestment: new Big('0'), - totalInvestmentWithCurrencyEffect: new Big('0') - }); - - expect(investments).toEqual([ - { date: '2022-03-07', investment: new Big('151.6') }, - { date: '2022-04-08', investment: new Big('0') } - ]); - - expect(investmentsByMonth).toEqual([ - { date: '2022-03-01', investment: 151.6 }, - { date: '2022-04-01', investment: -151.6 } - ]); - }); - }); -}); diff --git a/apps/api/src/app/portfolio/portfolio-calculator.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator.spec.ts deleted file mode 100644 index 1537fb8fe..000000000 --- a/apps/api/src/app/portfolio/portfolio-calculator.spec.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; - -import Big from 'big.js'; - -import { CurrentRateService } from './current-rate.service'; -import { PortfolioCalculator } from './portfolio-calculator'; - -describe('PortfolioCalculator', () => { - let currentRateService: CurrentRateService; - let exchangeRateDataService: ExchangeRateDataService; - - beforeEach(() => { - currentRateService = new CurrentRateService(null, null); - - exchangeRateDataService = new ExchangeRateDataService( - null, - null, - null, - null - ); - }); - - describe('annualized performance percentage', () => { - const portfolioCalculator = new PortfolioCalculator({ - currentRateService, - exchangeRateDataService, - currency: 'USD', - orders: [] - }); - - it('Get annualized performance', async () => { - expect( - portfolioCalculator - .getAnnualizedPerformancePercent({ - daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day - netPerformancePercent: new Big(0) - }) - .toNumber() - ).toEqual(0); - - expect( - portfolioCalculator - .getAnnualizedPerformancePercent({ - daysInMarket: 0, - netPerformancePercent: new Big(0) - }) - .toNumber() - ).toEqual(0); - - /** - * Source: https://www.readyratios.com/reference/analysis/annualized_rate.html - */ - expect( - portfolioCalculator - .getAnnualizedPerformancePercent({ - daysInMarket: 65, // < 1 year - netPerformancePercent: new Big(0.1025) - }) - .toNumber() - ).toBeCloseTo(0.729705); - - expect( - portfolioCalculator - .getAnnualizedPerformancePercent({ - daysInMarket: 365, // 1 year - netPerformancePercent: new Big(0.05) - }) - .toNumber() - ).toBeCloseTo(0.05); - - /** - * Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation - */ - expect( - portfolioCalculator - .getAnnualizedPerformancePercent({ - daysInMarket: 575, // > 1 year - netPerformancePercent: new Big(0.2374) - }) - .toNumber() - ).toBeCloseTo(0.145); - }); - }); -}); diff --git a/apps/api/src/app/portfolio/portfolio-calculator.ts b/apps/api/src/app/portfolio/portfolio-calculator.ts deleted file mode 100644 index 439b39fd7..000000000 --- a/apps/api/src/app/portfolio/portfolio-calculator.ts +++ /dev/null @@ -1,1545 +0,0 @@ -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; -import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; -import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; -import { - DataProviderInfo, - HistoricalDataItem, - InvestmentItem, - ResponseError, - SymbolMetrics, - TimelinePosition -} from '@ghostfolio/common/interfaces'; -import { GroupBy } from '@ghostfolio/common/types'; - -import { Logger } from '@nestjs/common'; -import { Type as TypeOfOrder } from '@prisma/client'; -import Big from 'big.js'; -import { - addDays, - addMilliseconds, - differenceInDays, - endOfDay, - format, - isBefore, - isSameDay, - subDays -} from 'date-fns'; -import { cloneDeep, first, isNumber, last, sortBy, uniq } from 'lodash'; - -import { CurrentRateService } from './current-rate.service'; -import { CurrentPositions } from './interfaces/current-positions.interface'; -import { PortfolioOrderItem } from './interfaces/portfolio-calculator.interface'; -import { PortfolioOrder } from './interfaces/portfolio-order.interface'; -import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.interface'; -import { TransactionPoint } from './interfaces/transaction-point.interface'; - -export class PortfolioCalculator { - private static readonly ENABLE_LOGGING = false; - - private currency: string; - private currentRateService: CurrentRateService; - private dataProviderInfos: DataProviderInfo[]; - private exchangeRateDataService: ExchangeRateDataService; - private orders: PortfolioOrder[]; - private transactionPoints: TransactionPoint[]; - - public constructor({ - currency, - currentRateService, - exchangeRateDataService, - orders - }: { - currency: string; - currentRateService: CurrentRateService; - exchangeRateDataService: ExchangeRateDataService; - orders: PortfolioOrder[]; - }) { - this.currency = currency; - this.currentRateService = currentRateService; - this.exchangeRateDataService = exchangeRateDataService; - this.orders = orders; - - this.orders.sort((a, b) => { - return a.date?.localeCompare(b.date); - }); - } - - public computeTransactionPoints() { - this.transactionPoints = []; - const symbols: { [symbol: string]: TransactionPointSymbol } = {}; - - let lastDate: string = null; - let lastTransactionPoint: TransactionPoint = null; - for (const order of this.orders) { - const currentDate = order.date; - - let currentTransactionPointItem: TransactionPointSymbol; - const oldAccumulatedSymbol = symbols[order.symbol]; - - const factor = this.getFactor(order.type); - const unitPrice = new Big(order.unitPrice); - if (oldAccumulatedSymbol) { - const newQuantity = order.quantity - .mul(factor) - .plus(oldAccumulatedSymbol.quantity); - - let investment = new Big(0); - - if (newQuantity.gt(0)) { - if (order.type === 'BUY') { - investment = oldAccumulatedSymbol.investment.plus( - order.quantity.mul(unitPrice) - ); - } else if (order.type === 'SELL') { - const averagePrice = oldAccumulatedSymbol.investment.div( - oldAccumulatedSymbol.quantity - ); - investment = oldAccumulatedSymbol.investment.minus( - order.quantity.mul(averagePrice) - ); - } - } - - currentTransactionPointItem = { - investment, - currency: order.currency, - dataSource: order.dataSource, - fee: order.fee.plus(oldAccumulatedSymbol.fee), - firstBuyDate: oldAccumulatedSymbol.firstBuyDate, - quantity: newQuantity, - symbol: order.symbol, - tags: order.tags, - transactionCount: oldAccumulatedSymbol.transactionCount + 1 - }; - } else { - currentTransactionPointItem = { - currency: order.currency, - dataSource: order.dataSource, - fee: order.fee, - firstBuyDate: order.date, - investment: unitPrice.mul(order.quantity).mul(factor), - quantity: order.quantity.mul(factor), - symbol: order.symbol, - tags: order.tags, - transactionCount: 1 - }; - } - - symbols[order.symbol] = currentTransactionPointItem; - - const items = lastTransactionPoint?.items ?? []; - const newItems = items.filter( - (transactionPointItem) => transactionPointItem.symbol !== order.symbol - ); - newItems.push(currentTransactionPointItem); - newItems.sort((a, b) => { - return a.symbol?.localeCompare(b.symbol); - }); - if (lastDate !== currentDate || lastTransactionPoint === null) { - lastTransactionPoint = { - date: currentDate, - items: newItems - }; - this.transactionPoints.push(lastTransactionPoint); - } else { - lastTransactionPoint.items = newItems; - } - lastDate = currentDate; - } - } - - public getAnnualizedPerformancePercent({ - daysInMarket, - netPerformancePercent - }: { - daysInMarket: number; - netPerformancePercent: Big; - }): Big { - if (isNumber(daysInMarket) && daysInMarket > 0) { - const exponent = new Big(365).div(daysInMarket).toNumber(); - return new Big( - Math.pow(netPerformancePercent.plus(1).toNumber(), exponent) - ).minus(1); - } - - return new Big(0); - } - - public getTransactionPoints(): TransactionPoint[] { - return this.transactionPoints; - } - - public setTransactionPoints(transactionPoints: TransactionPoint[]) { - this.transactionPoints = transactionPoints; - } - - public async getChartData({ - end = new Date(Date.now()), - start, - step = 1 - }: { - end?: Date; - start: Date; - step?: number; - }): Promise { - const symbols: { [symbol: string]: boolean } = {}; - - const transactionPointsBeforeEndDate = - this.transactionPoints?.filter((transactionPoint) => { - return isBefore(parseDate(transactionPoint.date), end); - }) ?? []; - - const currencies: { [symbol: string]: string } = {}; - const dates: Date[] = []; - const dataGatheringItems: IDataGatheringItem[] = []; - const firstIndex = transactionPointsBeforeEndDate.length; - - let day = start; - - while (isBefore(day, end)) { - dates.push(resetHours(day)); - day = addDays(day, step); - } - - if (!isSameDay(last(dates), end)) { - dates.push(resetHours(end)); - } - - if (transactionPointsBeforeEndDate.length > 0) { - for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) { - dataGatheringItems.push({ - dataSource: item.dataSource, - symbol: item.symbol - }); - currencies[item.symbol] = item.currency; - symbols[item.symbol] = true; - } - } - - const { dataProviderInfos, values: marketSymbols } = - await this.currentRateService.getValues({ - dataGatheringItems, - dateQuery: { - in: dates - } - }); - - this.dataProviderInfos = dataProviderInfos; - - const marketSymbolMap: { - [date: string]: { [symbol: string]: Big }; - } = {}; - - let exchangeRatesByCurrency = - await this.exchangeRateDataService.getExchangeRatesByCurrency({ - currencies: uniq(Object.values(currencies)), - endDate: endOfDay(end), - startDate: parseDate(this.transactionPoints?.[0]?.date), - targetCurrency: this.currency - }); - - for (const marketSymbol of marketSymbols) { - const dateString = format(marketSymbol.date, DATE_FORMAT); - if (!marketSymbolMap[dateString]) { - marketSymbolMap[dateString] = {}; - } - if (marketSymbol.marketPrice) { - marketSymbolMap[dateString][marketSymbol.symbol] = new Big( - marketSymbol.marketPrice - ); - } - } - - const accumulatedValuesByDate: { - [date: string]: { - investmentValueWithCurrencyEffect: Big; - totalCurrentValue: Big; - totalCurrentValueWithCurrencyEffect: Big; - totalInvestmentValue: Big; - totalInvestmentValueWithCurrencyEffect: Big; - totalNetPerformanceValue: Big; - totalNetPerformanceValueWithCurrencyEffect: Big; - totalTimeWeightedInvestmentValue: Big; - totalTimeWeightedInvestmentValueWithCurrencyEffect: Big; - }; - } = {}; - - const valuesBySymbol: { - [symbol: string]: { - currentValues: { [date: string]: Big }; - currentValuesWithCurrencyEffect: { [date: string]: Big }; - investmentValuesAccumulated: { [date: string]: Big }; - investmentValuesAccumulatedWithCurrencyEffect: { [date: string]: Big }; - investmentValuesWithCurrencyEffect: { [date: string]: Big }; - netPerformanceValues: { [date: string]: Big }; - netPerformanceValuesWithCurrencyEffect: { [date: string]: Big }; - timeWeightedInvestmentValues: { [date: string]: Big }; - timeWeightedInvestmentValuesWithCurrencyEffect: { [date: string]: Big }; - }; - } = {}; - - for (const symbol of Object.keys(symbols)) { - const { - currentValues, - currentValuesWithCurrencyEffect, - investmentValuesAccumulated, - investmentValuesAccumulatedWithCurrencyEffect, - investmentValuesWithCurrencyEffect, - netPerformanceValues, - netPerformanceValuesWithCurrencyEffect, - timeWeightedInvestmentValues, - timeWeightedInvestmentValuesWithCurrencyEffect - } = this.getSymbolMetrics({ - end, - marketSymbolMap, - start, - step, - symbol, - exchangeRates: - exchangeRatesByCurrency[`${currencies[symbol]}${this.currency}`], - isChartMode: true - }); - - valuesBySymbol[symbol] = { - currentValues, - currentValuesWithCurrencyEffect, - investmentValuesAccumulated, - investmentValuesAccumulatedWithCurrencyEffect, - investmentValuesWithCurrencyEffect, - netPerformanceValues, - netPerformanceValuesWithCurrencyEffect, - timeWeightedInvestmentValues, - timeWeightedInvestmentValuesWithCurrencyEffect - }; - } - - for (const currentDate of dates) { - const dateString = format(currentDate, DATE_FORMAT); - - for (const symbol of Object.keys(valuesBySymbol)) { - const symbolValues = valuesBySymbol[symbol]; - - const currentValue = - symbolValues.currentValues?.[dateString] ?? new Big(0); - - const currentValueWithCurrencyEffect = - symbolValues.currentValuesWithCurrencyEffect?.[dateString] ?? - new Big(0); - - const investmentValueAccumulated = - symbolValues.investmentValuesAccumulated?.[dateString] ?? new Big(0); - - const investmentValueAccumulatedWithCurrencyEffect = - symbolValues.investmentValuesAccumulatedWithCurrencyEffect?.[ - dateString - ] ?? new Big(0); - - const investmentValueWithCurrencyEffect = - symbolValues.investmentValuesWithCurrencyEffect?.[dateString] ?? - new Big(0); - - const netPerformanceValue = - symbolValues.netPerformanceValues?.[dateString] ?? new Big(0); - - const netPerformanceValueWithCurrencyEffect = - symbolValues.netPerformanceValuesWithCurrencyEffect?.[dateString] ?? - new Big(0); - - const timeWeightedInvestmentValue = - symbolValues.timeWeightedInvestmentValues?.[dateString] ?? new Big(0); - - const timeWeightedInvestmentValueWithCurrencyEffect = - symbolValues.timeWeightedInvestmentValuesWithCurrencyEffect?.[ - dateString - ] ?? new Big(0); - - accumulatedValuesByDate[dateString] = { - investmentValueWithCurrencyEffect: ( - accumulatedValuesByDate[dateString] - ?.investmentValueWithCurrencyEffect ?? new Big(0) - ).add(investmentValueWithCurrencyEffect), - totalCurrentValue: ( - accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0) - ).add(currentValue), - totalCurrentValueWithCurrencyEffect: ( - accumulatedValuesByDate[dateString] - ?.totalCurrentValueWithCurrencyEffect ?? new Big(0) - ).add(currentValueWithCurrencyEffect), - totalInvestmentValue: ( - accumulatedValuesByDate[dateString]?.totalInvestmentValue ?? - new Big(0) - ).add(investmentValueAccumulated), - totalInvestmentValueWithCurrencyEffect: ( - accumulatedValuesByDate[dateString] - ?.totalInvestmentValueWithCurrencyEffect ?? new Big(0) - ).add(investmentValueAccumulatedWithCurrencyEffect), - totalNetPerformanceValue: ( - accumulatedValuesByDate[dateString]?.totalNetPerformanceValue ?? - new Big(0) - ).add(netPerformanceValue), - totalNetPerformanceValueWithCurrencyEffect: ( - accumulatedValuesByDate[dateString] - ?.totalNetPerformanceValueWithCurrencyEffect ?? new Big(0) - ).add(netPerformanceValueWithCurrencyEffect), - totalTimeWeightedInvestmentValue: ( - accumulatedValuesByDate[dateString] - ?.totalTimeWeightedInvestmentValue ?? new Big(0) - ).add(timeWeightedInvestmentValue), - totalTimeWeightedInvestmentValueWithCurrencyEffect: ( - accumulatedValuesByDate[dateString] - ?.totalTimeWeightedInvestmentValueWithCurrencyEffect ?? new Big(0) - ).add(timeWeightedInvestmentValueWithCurrencyEffect) - }; - } - } - - return Object.entries(accumulatedValuesByDate).map(([date, values]) => { - const { - investmentValueWithCurrencyEffect, - totalCurrentValue, - totalCurrentValueWithCurrencyEffect, - totalInvestmentValue, - totalInvestmentValueWithCurrencyEffect, - totalNetPerformanceValue, - totalNetPerformanceValueWithCurrencyEffect, - totalTimeWeightedInvestmentValue, - totalTimeWeightedInvestmentValueWithCurrencyEffect - } = values; - - const netPerformanceInPercentage = totalTimeWeightedInvestmentValue.eq(0) - ? 0 - : totalNetPerformanceValue - .div(totalTimeWeightedInvestmentValue) - .mul(100) - .toNumber(); - - const netPerformanceInPercentageWithCurrencyEffect = - totalTimeWeightedInvestmentValueWithCurrencyEffect.eq(0) - ? 0 - : totalNetPerformanceValueWithCurrencyEffect - .div(totalTimeWeightedInvestmentValueWithCurrencyEffect) - .mul(100) - .toNumber(); - - return { - date, - netPerformanceInPercentage, - netPerformanceInPercentageWithCurrencyEffect, - investmentValueWithCurrencyEffect: - investmentValueWithCurrencyEffect.toNumber(), - netPerformance: totalNetPerformanceValue.toNumber(), - netPerformanceWithCurrencyEffect: - totalNetPerformanceValueWithCurrencyEffect.toNumber(), - totalInvestment: totalInvestmentValue.toNumber(), - totalInvestmentValueWithCurrencyEffect: - totalInvestmentValueWithCurrencyEffect.toNumber(), - value: totalCurrentValue.toNumber(), - valueWithCurrencyEffect: totalCurrentValueWithCurrencyEffect.toNumber() - }; - }); - } - - public async getCurrentPositions( - start: Date, - end = new Date(Date.now()) - ): Promise { - const transactionPointsBeforeEndDate = - this.transactionPoints?.filter((transactionPoint) => { - return isBefore(parseDate(transactionPoint.date), end); - }) ?? []; - - if (!transactionPointsBeforeEndDate.length) { - return { - currentValue: new Big(0), - grossPerformance: new Big(0), - grossPerformancePercentage: new Big(0), - grossPerformancePercentageWithCurrencyEffect: new Big(0), - grossPerformanceWithCurrencyEffect: new Big(0), - hasErrors: false, - netPerformance: new Big(0), - netPerformancePercentage: new Big(0), - netPerformancePercentageWithCurrencyEffect: new Big(0), - netPerformanceWithCurrencyEffect: new Big(0), - positions: [], - totalInvestment: new Big(0) - }; - } - - const lastTransactionPoint = - transactionPointsBeforeEndDate[transactionPointsBeforeEndDate.length - 1]; - - const currencies: { [symbol: string]: string } = {}; - const dataGatheringItems: IDataGatheringItem[] = []; - let dates: Date[] = []; - let firstIndex = transactionPointsBeforeEndDate.length; - let firstTransactionPoint: TransactionPoint = null; - - dates.push(resetHours(start)); - for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) { - dataGatheringItems.push({ - dataSource: item.dataSource, - symbol: item.symbol - }); - - currencies[item.symbol] = item.currency; - } - - for (let i = 0; i < transactionPointsBeforeEndDate.length; i++) { - if ( - !isBefore(parseDate(transactionPointsBeforeEndDate[i].date), start) && - firstTransactionPoint === null - ) { - firstTransactionPoint = transactionPointsBeforeEndDate[i]; - firstIndex = i; - } - if (firstTransactionPoint !== null) { - dates.push( - resetHours(parseDate(transactionPointsBeforeEndDate[i].date)) - ); - } - } - - dates.push(resetHours(end)); - - // Add dates of last week for fallback - dates.push(subDays(resetHours(new Date()), 7)); - dates.push(subDays(resetHours(new Date()), 6)); - dates.push(subDays(resetHours(new Date()), 5)); - dates.push(subDays(resetHours(new Date()), 4)); - dates.push(subDays(resetHours(new Date()), 3)); - dates.push(subDays(resetHours(new Date()), 2)); - dates.push(subDays(resetHours(new Date()), 1)); - dates.push(resetHours(new Date())); - - dates = uniq( - dates.map((date) => { - return date.getTime(); - }) - ) - .map((timestamp) => { - return new Date(timestamp); - }) - .sort((a, b) => { - return a.getTime() - b.getTime(); - }); - - let exchangeRatesByCurrency = - await this.exchangeRateDataService.getExchangeRatesByCurrency({ - currencies: uniq(Object.values(currencies)), - endDate: endOfDay(end), - startDate: parseDate(this.transactionPoints?.[0]?.date), - targetCurrency: this.currency - }); - - const { - dataProviderInfos, - errors: currentRateErrors, - values: marketSymbols - } = await this.currentRateService.getValues({ - dataGatheringItems, - dateQuery: { - in: dates - } - }); - - this.dataProviderInfos = dataProviderInfos; - - const marketSymbolMap: { - [date: string]: { [symbol: string]: Big }; - } = {}; - - for (const marketSymbol of marketSymbols) { - const date = format(marketSymbol.date, DATE_FORMAT); - - if (!marketSymbolMap[date]) { - marketSymbolMap[date] = {}; - } - - if (marketSymbol.marketPrice) { - marketSymbolMap[date][marketSymbol.symbol] = new Big( - marketSymbol.marketPrice - ); - } - } - - const endDateString = format(end, DATE_FORMAT); - - if (firstIndex > 0) { - firstIndex--; - } - - const positions: TimelinePosition[] = []; - let hasAnySymbolMetricsErrors = false; - - const errors: ResponseError['errors'] = []; - - for (const item of lastTransactionPoint.items) { - const marketPriceInBaseCurrency = marketSymbolMap[endDateString]?.[ - item.symbol - ]?.mul( - exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[ - endDateString - ] - ); - - const { - grossPerformance, - grossPerformancePercentage, - grossPerformancePercentageWithCurrencyEffect, - grossPerformanceWithCurrencyEffect, - hasErrors, - netPerformance, - netPerformancePercentage, - netPerformancePercentageWithCurrencyEffect, - netPerformanceWithCurrencyEffect, - timeWeightedInvestment, - timeWeightedInvestmentWithCurrencyEffect, - totalInvestment, - totalInvestmentWithCurrencyEffect - } = this.getSymbolMetrics({ - end, - marketSymbolMap, - start, - exchangeRates: - exchangeRatesByCurrency[`${item.currency}${this.currency}`], - symbol: item.symbol - }); - - hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors; - - positions.push({ - timeWeightedInvestment, - timeWeightedInvestmentWithCurrencyEffect, - averagePrice: item.quantity.eq(0) - ? new Big(0) - : item.investment.div(item.quantity), - currency: item.currency, - dataSource: item.dataSource, - fee: item.fee, - firstBuyDate: item.firstBuyDate, - grossPerformance: !hasErrors ? grossPerformance ?? null : null, - grossPerformancePercentage: !hasErrors - ? grossPerformancePercentage ?? null - : null, - grossPerformancePercentageWithCurrencyEffect: !hasErrors - ? grossPerformancePercentageWithCurrencyEffect ?? null - : null, - grossPerformanceWithCurrencyEffect: !hasErrors - ? grossPerformanceWithCurrencyEffect ?? null - : null, - investment: totalInvestment, - investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect, - marketPrice: - marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? null, - marketPriceInBaseCurrency: - marketPriceInBaseCurrency?.toNumber() ?? null, - netPerformance: !hasErrors ? netPerformance ?? null : null, - netPerformancePercentage: !hasErrors - ? netPerformancePercentage ?? null - : null, - netPerformancePercentageWithCurrencyEffect: !hasErrors - ? netPerformancePercentageWithCurrencyEffect ?? null - : null, - netPerformanceWithCurrencyEffect: !hasErrors - ? netPerformanceWithCurrencyEffect ?? null - : null, - quantity: item.quantity, - symbol: item.symbol, - tags: item.tags, - transactionCount: item.transactionCount - }); - - if ( - (hasErrors || - currentRateErrors.find(({ dataSource, symbol }) => { - return dataSource === item.dataSource && symbol === item.symbol; - })) && - item.investment.gt(0) - ) { - errors.push({ dataSource: item.dataSource, symbol: item.symbol }); - } - } - - const overall = this.calculateOverallPerformance(positions); - - return { - ...overall, - errors, - positions, - hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors - }; - } - - public getDataProviderInfos() { - return this.dataProviderInfos; - } - - public getInvestments(): { date: string; investment: Big }[] { - if (this.transactionPoints.length === 0) { - return []; - } - - return this.transactionPoints.map((transactionPoint) => { - return { - date: transactionPoint.date, - investment: transactionPoint.items.reduce( - (investment, transactionPointSymbol) => - investment.plus(transactionPointSymbol.investment), - new Big(0) - ) - }; - }); - } - - public getInvestmentsByGroup({ - data, - groupBy - }: { - data: HistoricalDataItem[]; - groupBy: GroupBy; - }): InvestmentItem[] { - const groupedData: { [dateGroup: string]: Big } = {}; - - for (const { date, investmentValueWithCurrencyEffect } of data) { - const dateGroup = - groupBy === 'month' ? date.substring(0, 7) : date.substring(0, 4); - groupedData[dateGroup] = (groupedData[dateGroup] ?? new Big(0)).plus( - investmentValueWithCurrencyEffect - ); - } - - return Object.keys(groupedData).map((dateGroup) => ({ - date: groupBy === 'month' ? `${dateGroup}-01` : `${dateGroup}-01-01`, - investment: groupedData[dateGroup].toNumber() - })); - } - - private calculateOverallPerformance(positions: TimelinePosition[]) { - let currentValue = new Big(0); - let grossPerformance = new Big(0); - let grossPerformanceWithCurrencyEffect = new Big(0); - let hasErrors = false; - let netPerformance = new Big(0); - let netPerformanceWithCurrencyEffect = new Big(0); - let totalInvestment = new Big(0); - let totalInvestmentWithCurrencyEffect = new Big(0); - let totalTimeWeightedInvestment = new Big(0); - let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0); - - for (const currentPosition of positions) { - if ( - currentPosition.investment && - currentPosition.marketPriceInBaseCurrency - ) { - currentValue = currentValue.plus( - new Big(currentPosition.marketPriceInBaseCurrency).mul( - currentPosition.quantity - ) - ); - } else { - hasErrors = true; - } - - if (currentPosition.investment) { - totalInvestment = totalInvestment.plus(currentPosition.investment); - - totalInvestmentWithCurrencyEffect = - totalInvestmentWithCurrencyEffect.plus( - currentPosition.investmentWithCurrencyEffect - ); - } else { - hasErrors = true; - } - - if (currentPosition.grossPerformance) { - grossPerformance = grossPerformance.plus( - currentPosition.grossPerformance - ); - - grossPerformanceWithCurrencyEffect = - grossPerformanceWithCurrencyEffect.plus( - currentPosition.grossPerformanceWithCurrencyEffect - ); - - netPerformance = netPerformance.plus(currentPosition.netPerformance); - - netPerformanceWithCurrencyEffect = - netPerformanceWithCurrencyEffect.plus( - currentPosition.netPerformanceWithCurrencyEffect - ); - } else if (!currentPosition.quantity.eq(0)) { - hasErrors = true; - } - - if (currentPosition.timeWeightedInvestment) { - totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus( - currentPosition.timeWeightedInvestment - ); - - totalTimeWeightedInvestmentWithCurrencyEffect = - totalTimeWeightedInvestmentWithCurrencyEffect.plus( - currentPosition.timeWeightedInvestmentWithCurrencyEffect - ); - } else if (!currentPosition.quantity.eq(0)) { - Logger.warn( - `Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`, - 'PortfolioCalculator' - ); - - hasErrors = true; - } - } - - return { - currentValue, - grossPerformance, - grossPerformanceWithCurrencyEffect, - hasErrors, - netPerformance, - netPerformanceWithCurrencyEffect, - totalInvestment, - totalInvestmentWithCurrencyEffect, - netPerformancePercentage: totalTimeWeightedInvestment.eq(0) - ? new Big(0) - : netPerformance.div(totalTimeWeightedInvestment), - netPerformancePercentageWithCurrencyEffect: - totalTimeWeightedInvestmentWithCurrencyEffect.eq(0) - ? new Big(0) - : netPerformanceWithCurrencyEffect.div( - totalTimeWeightedInvestmentWithCurrencyEffect - ), - grossPerformancePercentage: totalTimeWeightedInvestment.eq(0) - ? new Big(0) - : grossPerformance.div(totalTimeWeightedInvestment), - grossPerformancePercentageWithCurrencyEffect: - totalTimeWeightedInvestmentWithCurrencyEffect.eq(0) - ? new Big(0) - : grossPerformanceWithCurrencyEffect.div( - totalTimeWeightedInvestmentWithCurrencyEffect - ) - }; - } - - private getFactor(type: TypeOfOrder) { - let factor: number; - - switch (type) { - case 'BUY': - factor = 1; - break; - case 'SELL': - factor = -1; - break; - default: - factor = 0; - break; - } - - return factor; - } - - private getSymbolMetrics({ - end, - exchangeRates, - isChartMode = false, - marketSymbolMap, - start, - step = 1, - symbol - }: { - end: Date; - exchangeRates: { [dateString: string]: number }; - isChartMode?: boolean; - marketSymbolMap: { - [date: string]: { [symbol: string]: Big }; - }; - start: Date; - step?: number; - symbol: string; - }): SymbolMetrics { - const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)]; - const currentValues: { [date: string]: Big } = {}; - const currentValuesWithCurrencyEffect: { [date: string]: Big } = {}; - let fees = new Big(0); - let feesAtStartDate = new Big(0); - let feesAtStartDateWithCurrencyEffect = new Big(0); - let feesWithCurrencyEffect = new Big(0); - let grossPerformance = new Big(0); - let grossPerformanceWithCurrencyEffect = new Big(0); - let grossPerformanceAtStartDate = new Big(0); - let grossPerformanceAtStartDateWithCurrencyEffect = new Big(0); - let grossPerformanceFromSells = new Big(0); - let grossPerformanceFromSellsWithCurrencyEffect = new Big(0); - let initialValue: Big; - let initialValueWithCurrencyEffect: Big; - let investmentAtStartDate: Big; - let investmentAtStartDateWithCurrencyEffect: Big; - const investmentValuesAccumulated: { [date: string]: Big } = {}; - const investmentValuesAccumulatedWithCurrencyEffect: { - [date: string]: Big; - } = {}; - const investmentValuesWithCurrencyEffect: { [date: string]: Big } = {}; - let lastAveragePrice = new Big(0); - let lastAveragePriceWithCurrencyEffect = new Big(0); - const netPerformanceValues: { [date: string]: Big } = {}; - const netPerformanceValuesWithCurrencyEffect: { [date: string]: Big } = {}; - const timeWeightedInvestmentValues: { [date: string]: Big } = {}; - - const timeWeightedInvestmentValuesWithCurrencyEffect: { - [date: string]: Big; - } = {}; - - let totalInvestment = new Big(0); - let totalInvestmentWithCurrencyEffect = new Big(0); - let totalInvestmentWithGrossPerformanceFromSell = new Big(0); - - let totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect = new Big( - 0 - ); - - let totalUnits = new Big(0); - let valueAtStartDate: Big; - let valueAtStartDateWithCurrencyEffect: Big; - - // Clone orders to keep the original values in this.orders - let orders: PortfolioOrderItem[] = cloneDeep(this.orders).filter( - (order) => { - return order.symbol === symbol; - } - ); - - if (orders.length <= 0) { - return { - currentValues: {}, - currentValuesWithCurrencyEffect: {}, - grossPerformance: new Big(0), - grossPerformancePercentage: new Big(0), - grossPerformancePercentageWithCurrencyEffect: new Big(0), - grossPerformanceWithCurrencyEffect: new Big(0), - hasErrors: false, - initialValue: new Big(0), - initialValueWithCurrencyEffect: new Big(0), - investmentValuesAccumulated: {}, - investmentValuesAccumulatedWithCurrencyEffect: {}, - investmentValuesWithCurrencyEffect: {}, - netPerformance: new Big(0), - netPerformancePercentage: new Big(0), - netPerformancePercentageWithCurrencyEffect: new Big(0), - netPerformanceValues: {}, - netPerformanceValuesWithCurrencyEffect: {}, - netPerformanceWithCurrencyEffect: new Big(0), - timeWeightedInvestment: new Big(0), - timeWeightedInvestmentValues: {}, - timeWeightedInvestmentValuesWithCurrencyEffect: {}, - timeWeightedInvestmentWithCurrencyEffect: new Big(0), - totalInvestment: new Big(0), - totalInvestmentWithCurrencyEffect: new Big(0) - }; - } - - const dateOfFirstTransaction = new Date(first(orders).date); - - const unitPriceAtStartDate = - marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol]; - - const unitPriceAtEndDate = - marketSymbolMap[format(end, DATE_FORMAT)]?.[symbol]; - - if ( - !unitPriceAtEndDate || - (!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start)) - ) { - return { - currentValues: {}, - currentValuesWithCurrencyEffect: {}, - grossPerformance: new Big(0), - grossPerformancePercentage: new Big(0), - grossPerformancePercentageWithCurrencyEffect: new Big(0), - grossPerformanceWithCurrencyEffect: new Big(0), - hasErrors: true, - initialValue: new Big(0), - initialValueWithCurrencyEffect: new Big(0), - investmentValuesAccumulated: {}, - investmentValuesAccumulatedWithCurrencyEffect: {}, - investmentValuesWithCurrencyEffect: {}, - netPerformance: new Big(0), - netPerformancePercentage: new Big(0), - netPerformancePercentageWithCurrencyEffect: new Big(0), - netPerformanceValues: {}, - netPerformanceValuesWithCurrencyEffect: {}, - netPerformanceWithCurrencyEffect: new Big(0), - timeWeightedInvestment: new Big(0), - timeWeightedInvestmentValues: {}, - timeWeightedInvestmentValuesWithCurrencyEffect: {}, - timeWeightedInvestmentWithCurrencyEffect: new Big(0), - totalInvestment: new Big(0), - totalInvestmentWithCurrencyEffect: new Big(0) - }; - } - - // Add a synthetic order at the start and the end date - orders.push({ - symbol, - currency: null, - date: format(start, DATE_FORMAT), - dataSource: null, - fee: new Big(0), - feeInBaseCurrency: new Big(0), - itemType: 'start', - name: '', - quantity: new Big(0), - type: TypeOfOrder.BUY, - unitPrice: unitPriceAtStartDate - }); - - orders.push({ - symbol, - currency: null, - date: format(end, DATE_FORMAT), - dataSource: null, - fee: new Big(0), - feeInBaseCurrency: new Big(0), - itemType: 'end', - name: '', - quantity: new Big(0), - type: TypeOfOrder.BUY, - unitPrice: unitPriceAtEndDate - }); - - let day = start; - let lastUnitPrice: Big; - - if (isChartMode) { - const datesWithOrders = {}; - - for (const order of orders) { - datesWithOrders[order.date] = true; - } - - while (isBefore(day, end)) { - const hasDate = datesWithOrders[format(day, DATE_FORMAT)]; - - if (!hasDate) { - orders.push({ - symbol, - currency: null, - date: format(day, DATE_FORMAT), - dataSource: null, - fee: new Big(0), - feeInBaseCurrency: new Big(0), - name: '', - quantity: new Big(0), - type: TypeOfOrder.BUY, - unitPrice: - marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ?? - lastUnitPrice - }); - } - - lastUnitPrice = last(orders).unitPrice; - - day = addDays(day, step); - } - } - - // Sort orders so that the start and end placeholder order are at the right - // position - orders = sortBy(orders, (order) => { - let sortIndex = new Date(order.date); - - if (order.itemType === 'start') { - sortIndex = addMilliseconds(sortIndex, -1); - } - - if (order.itemType === 'end') { - sortIndex = addMilliseconds(sortIndex, 1); - } - - return sortIndex.getTime(); - }); - - const indexOfStartOrder = orders.findIndex((order) => { - return order.itemType === 'start'; - }); - - const indexOfEndOrder = orders.findIndex((order) => { - return order.itemType === 'end'; - }); - - let totalInvestmentDays = 0; - let sumOfTimeWeightedInvestments = new Big(0); - let sumOfTimeWeightedInvestmentsWithCurrencyEffect = new Big(0); - - for (let i = 0; i < orders.length; i += 1) { - const order = orders[i]; - - if (PortfolioCalculator.ENABLE_LOGGING) { - console.log(); - console.log(); - console.log(i + 1, order.type, order.itemType); - } - - const exchangeRateAtOrderDate = exchangeRates[order.date]; - - if (order.itemType === 'start') { - // Take the unit price of the order as the market price if there are no - // orders of this symbol before the start date - order.unitPrice = - indexOfStartOrder === 0 - ? orders[i + 1]?.unitPrice - : unitPriceAtStartDate; - } - - if (order.fee) { - order.feeInBaseCurrency = order.fee.mul(currentExchangeRate ?? 1); - order.feeInBaseCurrencyWithCurrencyEffect = order.fee.mul( - exchangeRateAtOrderDate ?? 1 - ); - } - - if (order.unitPrice) { - order.unitPriceInBaseCurrency = order.unitPrice.mul( - currentExchangeRate ?? 1 - ); - - order.unitPriceInBaseCurrencyWithCurrencyEffect = order.unitPrice.mul( - exchangeRateAtOrderDate ?? 1 - ); - } - - const valueOfInvestmentBeforeTransaction = totalUnits.mul( - order.unitPriceInBaseCurrency - ); - - const valueOfInvestmentBeforeTransactionWithCurrencyEffect = - totalUnits.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect); - - if (!investmentAtStartDate && i >= indexOfStartOrder) { - investmentAtStartDate = totalInvestment ?? new Big(0); - - investmentAtStartDateWithCurrencyEffect = - totalInvestmentWithCurrencyEffect ?? new Big(0); - - valueAtStartDate = valueOfInvestmentBeforeTransaction; - - valueAtStartDateWithCurrencyEffect = - valueOfInvestmentBeforeTransactionWithCurrencyEffect; - } - - const transactionInvestment = - order.type === 'BUY' - ? order.quantity - .mul(order.unitPriceInBaseCurrency) - .mul(this.getFactor(order.type)) - : totalUnits.gt(0) - ? totalInvestment - .div(totalUnits) - .mul(order.quantity) - .mul(this.getFactor(order.type)) - : new Big(0); - - const transactionInvestmentWithCurrencyEffect = - order.type === 'BUY' - ? order.quantity - .mul(order.unitPriceInBaseCurrencyWithCurrencyEffect) - .mul(this.getFactor(order.type)) - : totalUnits.gt(0) - ? totalInvestmentWithCurrencyEffect - .div(totalUnits) - .mul(order.quantity) - .mul(this.getFactor(order.type)) - : new Big(0); - - if (PortfolioCalculator.ENABLE_LOGGING) { - console.log('totalInvestment', totalInvestment.toNumber()); - - console.log( - 'totalInvestmentWithCurrencyEffect', - totalInvestmentWithCurrencyEffect.toNumber() - ); - - console.log('order.quantity', order.quantity.toNumber()); - console.log('transactionInvestment', transactionInvestment.toNumber()); - - console.log( - 'transactionInvestmentWithCurrencyEffect', - transactionInvestmentWithCurrencyEffect.toNumber() - ); - } - - const totalInvestmentBeforeTransaction = totalInvestment; - - const totalInvestmentBeforeTransactionWithCurrencyEffect = - totalInvestmentWithCurrencyEffect; - - totalInvestment = totalInvestment.plus(transactionInvestment); - - totalInvestmentWithCurrencyEffect = - totalInvestmentWithCurrencyEffect.plus( - transactionInvestmentWithCurrencyEffect - ); - - if (i >= indexOfStartOrder && !initialValue) { - if ( - i === indexOfStartOrder && - !valueOfInvestmentBeforeTransaction.eq(0) - ) { - initialValue = valueOfInvestmentBeforeTransaction; - - initialValueWithCurrencyEffect = - valueOfInvestmentBeforeTransactionWithCurrencyEffect; - } else if (transactionInvestment.gt(0)) { - initialValue = transactionInvestment; - - initialValueWithCurrencyEffect = - transactionInvestmentWithCurrencyEffect; - } - } - - fees = fees.plus(order.feeInBaseCurrency ?? 0); - - feesWithCurrencyEffect = feesWithCurrencyEffect.plus( - order.feeInBaseCurrencyWithCurrencyEffect ?? 0 - ); - - totalUnits = totalUnits.plus( - order.quantity.mul(this.getFactor(order.type)) - ); - - const valueOfInvestment = totalUnits.mul(order.unitPriceInBaseCurrency); - - const valueOfInvestmentWithCurrencyEffect = totalUnits.mul( - order.unitPriceInBaseCurrencyWithCurrencyEffect - ); - - const grossPerformanceFromSell = - order.type === TypeOfOrder.SELL - ? order.unitPriceInBaseCurrency - .minus(lastAveragePrice) - .mul(order.quantity) - : new Big(0); - - const grossPerformanceFromSellWithCurrencyEffect = - order.type === TypeOfOrder.SELL - ? order.unitPriceInBaseCurrencyWithCurrencyEffect - .minus(lastAveragePriceWithCurrencyEffect) - .mul(order.quantity) - : new Big(0); - - grossPerformanceFromSells = grossPerformanceFromSells.plus( - grossPerformanceFromSell - ); - - grossPerformanceFromSellsWithCurrencyEffect = - grossPerformanceFromSellsWithCurrencyEffect.plus( - grossPerformanceFromSellWithCurrencyEffect - ); - - totalInvestmentWithGrossPerformanceFromSell = - totalInvestmentWithGrossPerformanceFromSell - .plus(transactionInvestment) - .plus(grossPerformanceFromSell); - - totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect = - totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect - .plus(transactionInvestmentWithCurrencyEffect) - .plus(grossPerformanceFromSellWithCurrencyEffect); - - lastAveragePrice = totalUnits.eq(0) - ? new Big(0) - : totalInvestmentWithGrossPerformanceFromSell.div(totalUnits); - - lastAveragePriceWithCurrencyEffect = totalUnits.eq(0) - ? new Big(0) - : totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect.div( - totalUnits - ); - - if (PortfolioCalculator.ENABLE_LOGGING) { - console.log( - 'totalInvestmentWithGrossPerformanceFromSell', - totalInvestmentWithGrossPerformanceFromSell.toNumber() - ); - console.log( - 'totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect', - totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect.toNumber() - ); - console.log( - 'grossPerformanceFromSells', - grossPerformanceFromSells.toNumber() - ); - console.log( - 'grossPerformanceFromSellWithCurrencyEffect', - grossPerformanceFromSellWithCurrencyEffect.toNumber() - ); - } - - const newGrossPerformance = valueOfInvestment - .minus(totalInvestment) - .plus(grossPerformanceFromSells); - - const newGrossPerformanceWithCurrencyEffect = - valueOfInvestmentWithCurrencyEffect - .minus(totalInvestmentWithCurrencyEffect) - .plus(grossPerformanceFromSellsWithCurrencyEffect); - - grossPerformance = newGrossPerformance; - - grossPerformanceWithCurrencyEffect = - newGrossPerformanceWithCurrencyEffect; - - if (order.itemType === 'start') { - feesAtStartDate = fees; - feesAtStartDateWithCurrencyEffect = feesWithCurrencyEffect; - grossPerformanceAtStartDate = grossPerformance; - - grossPerformanceAtStartDateWithCurrencyEffect = - grossPerformanceWithCurrencyEffect; - } - - if (i > indexOfStartOrder) { - // Only consider periods with an investment for the calculation of - // the time weighted investment - if (valueOfInvestmentBeforeTransaction.gt(0)) { - // Calculate the number of days since the previous order - const orderDate = new Date(order.date); - const previousOrderDate = new Date(orders[i - 1].date); - - let daysSinceLastOrder = differenceInDays( - orderDate, - previousOrderDate - ); - - // Set to at least 1 day, otherwise the transactions on the same day - // would not be considered in the time weighted calculation - if (daysSinceLastOrder <= 0) { - daysSinceLastOrder = 1; - } - - // Sum up the total investment days since the start date to calculate - // the time weighted investment - totalInvestmentDays += daysSinceLastOrder; - - sumOfTimeWeightedInvestments = sumOfTimeWeightedInvestments.add( - valueAtStartDate - .minus(investmentAtStartDate) - .plus(totalInvestmentBeforeTransaction) - .mul(daysSinceLastOrder) - ); - - sumOfTimeWeightedInvestmentsWithCurrencyEffect = - sumOfTimeWeightedInvestmentsWithCurrencyEffect.add( - valueAtStartDateWithCurrencyEffect - .minus(investmentAtStartDateWithCurrencyEffect) - .plus(totalInvestmentBeforeTransactionWithCurrencyEffect) - .mul(daysSinceLastOrder) - ); - } - - if (isChartMode) { - currentValues[order.date] = valueOfInvestment; - - currentValuesWithCurrencyEffect[order.date] = - valueOfInvestmentWithCurrencyEffect; - - netPerformanceValues[order.date] = grossPerformance - .minus(grossPerformanceAtStartDate) - .minus(fees.minus(feesAtStartDate)); - - netPerformanceValuesWithCurrencyEffect[order.date] = - grossPerformanceWithCurrencyEffect - .minus(grossPerformanceAtStartDateWithCurrencyEffect) - .minus( - feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect) - ); - - investmentValuesAccumulated[order.date] = totalInvestment; - - investmentValuesAccumulatedWithCurrencyEffect[order.date] = - totalInvestmentWithCurrencyEffect; - - investmentValuesWithCurrencyEffect[order.date] = ( - investmentValuesWithCurrencyEffect[order.date] ?? new Big(0) - ).add(transactionInvestmentWithCurrencyEffect); - - timeWeightedInvestmentValues[order.date] = - totalInvestmentDays > 0 - ? sumOfTimeWeightedInvestments.div(totalInvestmentDays) - : new Big(0); - - timeWeightedInvestmentValuesWithCurrencyEffect[order.date] = - totalInvestmentDays > 0 - ? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div( - totalInvestmentDays - ) - : new Big(0); - } - } - - if (PortfolioCalculator.ENABLE_LOGGING) { - console.log('totalInvestment', totalInvestment.toNumber()); - - console.log( - 'totalInvestmentWithCurrencyEffect', - totalInvestmentWithCurrencyEffect.toNumber() - ); - - console.log( - 'totalGrossPerformance', - grossPerformance.minus(grossPerformanceAtStartDate).toNumber() - ); - - console.log( - 'totalGrossPerformanceWithCurrencyEffect', - grossPerformanceWithCurrencyEffect - .minus(grossPerformanceAtStartDateWithCurrencyEffect) - .toNumber() - ); - } - - if (i === indexOfEndOrder) { - break; - } - } - - const totalGrossPerformance = grossPerformance.minus( - grossPerformanceAtStartDate - ); - - const totalGrossPerformanceWithCurrencyEffect = - grossPerformanceWithCurrencyEffect.minus( - grossPerformanceAtStartDateWithCurrencyEffect - ); - - const totalNetPerformance = grossPerformance - .minus(grossPerformanceAtStartDate) - .minus(fees.minus(feesAtStartDate)); - - const totalNetPerformanceWithCurrencyEffect = - grossPerformanceWithCurrencyEffect - .minus(grossPerformanceAtStartDateWithCurrencyEffect) - .minus(feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect)); - - const timeWeightedAverageInvestmentBetweenStartAndEndDate = - totalInvestmentDays > 0 - ? sumOfTimeWeightedInvestments.div(totalInvestmentDays) - : new Big(0); - - const timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect = - totalInvestmentDays > 0 - ? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div( - totalInvestmentDays - ) - : new Big(0); - - const grossPerformancePercentage = - timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0) - ? totalGrossPerformance.div( - timeWeightedAverageInvestmentBetweenStartAndEndDate - ) - : new Big(0); - - const grossPerformancePercentageWithCurrencyEffect = - timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt( - 0 - ) - ? totalGrossPerformanceWithCurrencyEffect.div( - timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect - ) - : new Big(0); - - const feesPerUnit = totalUnits.gt(0) - ? fees.minus(feesAtStartDate).div(totalUnits) - : new Big(0); - - const feesPerUnitWithCurrencyEffect = totalUnits.gt(0) - ? feesWithCurrencyEffect - .minus(feesAtStartDateWithCurrencyEffect) - .div(totalUnits) - : new Big(0); - - const netPerformancePercentage = - timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0) - ? totalNetPerformance.div( - timeWeightedAverageInvestmentBetweenStartAndEndDate - ) - : new Big(0); - - const netPerformancePercentageWithCurrencyEffect = - timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt( - 0 - ) - ? totalNetPerformanceWithCurrencyEffect.div( - timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect - ) - : new Big(0); - - if (PortfolioCalculator.ENABLE_LOGGING) { - console.log( - ` - ${symbol} - Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed( - 2 - )} -> ${unitPriceAtEndDate.toFixed(2)} - Total investment: ${totalInvestment.toFixed(2)} - Total investment with currency effect: ${totalInvestmentWithCurrencyEffect.toFixed( - 2 - )} - Time weighted investment: ${timeWeightedAverageInvestmentBetweenStartAndEndDate.toFixed( - 2 - )} - Time weighted investment with currency effect: ${timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.toFixed( - 2 - )} - Gross performance: ${totalGrossPerformance.toFixed( - 2 - )} / ${grossPerformancePercentage.mul(100).toFixed(2)}% - Gross performance with currency effect: ${totalGrossPerformanceWithCurrencyEffect.toFixed( - 2 - )} / ${grossPerformancePercentageWithCurrencyEffect - .mul(100) - .toFixed(2)}% - Fees per unit: ${feesPerUnit.toFixed(2)} - Fees per unit with currency effect: ${feesPerUnitWithCurrencyEffect.toFixed( - 2 - )} - Net performance: ${totalNetPerformance.toFixed( - 2 - )} / ${netPerformancePercentage.mul(100).toFixed(2)}% - Net performance with currency effect: ${totalNetPerformanceWithCurrencyEffect.toFixed( - 2 - )} / ${netPerformancePercentageWithCurrencyEffect.mul(100).toFixed(2)}%` - ); - } - - return { - currentValues, - currentValuesWithCurrencyEffect, - grossPerformancePercentage, - grossPerformancePercentageWithCurrencyEffect, - initialValue, - initialValueWithCurrencyEffect, - investmentValuesAccumulated, - investmentValuesAccumulatedWithCurrencyEffect, - investmentValuesWithCurrencyEffect, - netPerformancePercentage, - netPerformancePercentageWithCurrencyEffect, - netPerformanceValues, - netPerformanceValuesWithCurrencyEffect, - timeWeightedInvestmentValues, - timeWeightedInvestmentValuesWithCurrencyEffect, - totalInvestment, - totalInvestmentWithCurrencyEffect, - grossPerformance: totalGrossPerformance, - grossPerformanceWithCurrencyEffect: - totalGrossPerformanceWithCurrencyEffect, - hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate), - netPerformance: totalNetPerformance, - netPerformanceWithCurrencyEffect: totalNetPerformanceWithCurrencyEffect, - timeWeightedInvestment: - timeWeightedAverageInvestmentBetweenStartAndEndDate, - timeWeightedInvestmentWithCurrencyEffect: - timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect - }; - } -} diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 31dd6cd73..7ce0b0847 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -1,16 +1,20 @@ import { AccessService } from '@ghostfolio/api/app/access/access.service'; +import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { hasNotDefinedValuesInObject, nullifyValuesInObject } from '@ghostfolio/api/helper/object.helper'; -import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor'; -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 { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; +import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; +import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { DEFAULT_CURRENCY, HEADER_KEY_IMPERSONATION @@ -18,11 +22,17 @@ import { import { PortfolioDetails, PortfolioDividends, + PortfolioHoldingsResponse, PortfolioInvestments, PortfolioPerformanceResponse, PortfolioPublicDetails, PortfolioReport } from '@ghostfolio/common/interfaces'; +import { + hasReadRestrictedAccessPermission, + isRestrictedView, + permissions +} from '@ghostfolio/common/permissions'; import type { DateRange, GroupBy, @@ -30,12 +40,14 @@ import type { } from '@ghostfolio/common/types'; import { + Body, Controller, Get, Headers, HttpException, Inject, Param, + Put, Query, UseGuards, UseInterceptors, @@ -43,12 +55,13 @@ import { } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; -import Big from 'big.js'; +import { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; +import { Big } from 'big.js'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; -import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface'; -import { PortfolioPositions } from './interfaces/portfolio-positions.interface'; +import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface'; import { PortfolioService } from './portfolio.service'; +import { UpdateHoldingTagsDto } from './update-holding-tags.dto'; @Controller('portfolio') export class PortfolioController { @@ -57,6 +70,8 @@ export class PortfolioController { private readonly apiService: ApiService, private readonly configurationService: ConfigurationService, private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly impersonationService: ImpersonationService, + private readonly orderService: OrderService, private readonly portfolioService: PortfolioService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly userService: UserService @@ -71,15 +86,13 @@ export class PortfolioController { @Query('accounts') filterByAccounts?: string, @Query('assetClasses') filterByAssetClasses?: string, @Query('range') dateRange: DateRange = 'max', - @Query('tags') filterByTags?: string + @Query('tags') filterByTags?: string, + @Query('withMarkets') withMarketsParam = 'false' ): Promise { + const withMarkets = withMarketsParam === 'true'; + let hasDetails = true; let hasError = false; - const hasReadRestrictedAccessPermission = - this.userService.hasReadRestrictedAccessPermission({ - impersonationId, - user: this.request.user - }); if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { hasDetails = this.request.user.subscription.type === 'Premium'; @@ -91,21 +104,15 @@ export class PortfolioController { filterByTags }); - const { - accounts, - filteredValueInBaseCurrency, - filteredValueInPercentage, - hasErrors, - holdings, - platforms, - summary, - totalValueInBaseCurrency - } = await this.portfolioService.getDetails({ - dateRange, - filters, - impersonationId, - userId: this.request.user.id - }); + const { accounts, hasErrors, holdings, platforms, summary } = + await this.portfolioService.getDetails({ + dateRange, + filters, + impersonationId, + withMarkets, + userId: this.request.user.id, + withSummary: true + }); if (hasErrors || hasNotDefinedValuesInObject(holdings)) { hasError = true; @@ -114,31 +121,35 @@ export class PortfolioController { let portfolioSummary = summary; if ( - hasReadRestrictedAccessPermission || - this.userService.isRestrictedView(this.request.user) + hasReadRestrictedAccessPermission({ + impersonationId, + user: this.request.user + }) || + isRestrictedView(this.request.user) ) { const totalInvestment = Object.values(holdings) - .map((portfolioPosition) => { - return portfolioPosition.investment; + .map(({ investment }) => { + return investment; }) .reduce((a, b) => a + b, 0); const totalValue = Object.values(holdings) - .map((portfolioPosition) => { - return this.exchangeRateDataService.toCurrency( - portfolioPosition.quantity * portfolioPosition.marketPrice, - portfolioPosition.currency, - this.request.user.Settings.settings.baseCurrency + .filter(({ assetClass, assetSubClass }) => { + return ( + assetClass !== AssetClass.LIQUIDITY && + assetSubClass !== AssetSubClass.CASH ); }) - .reduce((a, b) => a + b, 0); + .map(({ valueInBaseCurrency }) => { + return valueInBaseCurrency; + }) + .reduce((a, b) => { + return a + b; + }, 0); - for (const [symbol, portfolioPosition] of Object.entries(holdings)) { - portfolioPosition.grossPerformance = null; + for (const [, portfolioPosition] of Object.entries(holdings)) { portfolioPosition.investment = portfolioPosition.investment / totalInvestment; - portfolioPosition.netPerformance = null; - portfolioPosition.quantity = null; portfolioPosition.valueInPercentage = portfolioPosition.valueInBaseCurrency / totalValue; } @@ -154,29 +165,34 @@ export class PortfolioController { if ( hasDetails === false || - hasReadRestrictedAccessPermission || - this.userService.isRestrictedView(this.request.user) + hasReadRestrictedAccessPermission({ + impersonationId, + user: this.request.user + }) || + isRestrictedView(this.request.user) ) { portfolioSummary = nullifyValuesInObject(summary, [ 'cash', 'committedFunds', - 'currentGrossPerformance', - 'currentGrossPerformanceWithCurrencyEffect', - 'currentNetPerformance', - 'currentNetPerformanceWithCurrencyEffect', - 'currentValue', - 'dividend', + 'currentNetWorth', + 'currentValueInBaseCurrency', + 'dividendInBaseCurrency', 'emergencyFund', 'excludedAccountsAndActivities', 'fees', + 'filteredValueInBaseCurrency', 'fireWealth', + 'grossPerformance', + 'grossPerformanceWithCurrencyEffect', 'interest', 'items', 'liabilities', - 'netWorth', + 'netPerformance', + 'netPerformanceWithCurrencyEffect', 'totalBuy', 'totalInvestment', - 'totalSell' + 'totalSell', + 'totalValueInBaseCurrency' ]); } @@ -184,15 +200,16 @@ export class PortfolioController { holdings[symbol] = { ...portfolioPosition, assetClass: - hasDetails || portfolioPosition.assetClass === 'CASH' + hasDetails || portfolioPosition.assetClass === AssetClass.LIQUIDITY ? portfolioPosition.assetClass : undefined, assetSubClass: - hasDetails || portfolioPosition.assetSubClass === 'CASH' + hasDetails || portfolioPosition.assetSubClass === AssetSubClass.CASH ? portfolioPosition.assetSubClass : undefined, countries: hasDetails ? portfolioPosition.countries : [], currency: hasDetails ? portfolioPosition.currency : undefined, + holdings: hasDetails ? portfolioPosition.holdings : [], markets: hasDetails ? portfolioPosition.markets : undefined, marketsAdvanced: hasDetails ? portfolioPosition.marketsAdvanced @@ -203,12 +220,9 @@ export class PortfolioController { return { accounts, - filteredValueInBaseCurrency, - filteredValueInPercentage, hasError, holdings, platforms, - totalValueInBaseCurrency, summary: portfolioSummary }; } @@ -223,28 +237,38 @@ export class PortfolioController { @Query('range') dateRange: DateRange = 'max', @Query('tags') filterByTags?: string ): Promise { - const hasReadRestrictedAccessPermission = - this.userService.hasReadRestrictedAccessPermission({ - impersonationId, - user: this.request.user - }); - const filters = this.apiService.buildFiltersFromQueryParams({ filterByAccounts, filterByAssetClasses, filterByTags }); - let dividends = await this.portfolioService.getDividends({ - dateRange, + const impersonationUserId = + await this.impersonationService.validateImpersonationId(impersonationId); + const userCurrency = this.request.user.Settings.settings.baseCurrency; + + const { endDate, startDate } = getIntervalFromDateRange(dateRange); + + const { activities } = await this.orderService.getOrders({ + endDate, filters, - groupBy, - impersonationId + startDate, + userCurrency, + userId: impersonationUserId || this.request.user.id, + types: ['DIVIDEND'] + }); + + let dividends = await this.portfolioService.getDividends({ + activities, + groupBy }); if ( - hasReadRestrictedAccessPermission || - this.userService.isRestrictedView(this.request.user) + hasReadRestrictedAccessPermission({ + impersonationId, + user: this.request.user + }) || + isRestrictedView(this.request.user) ) { const maxDividend = dividends.reduce( (investment, item) => Math.max(investment, item.investment), @@ -269,6 +293,37 @@ export class PortfolioController { return { dividends }; } + @Get('holdings') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(RedactValuesInResponseInterceptor) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async getHoldings( + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Query('accounts') filterByAccounts?: string, + @Query('assetClasses') filterByAssetClasses?: string, + @Query('holdingType') filterByHoldingType?: string, + @Query('query') filterBySearchQuery?: string, + @Query('range') dateRange: DateRange = 'max', + @Query('tags') filterByTags?: string + ): Promise { + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAccounts, + filterByAssetClasses, + filterByHoldingType, + filterBySearchQuery, + filterByTags + }); + + const { holdings } = await this.portfolioService.getDetails({ + dateRange, + filters, + impersonationId, + userId: this.request.user.id + }); + + return { holdings: Object.values(holdings) }; + } + @Get('investments') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async getInvestments( @@ -279,12 +334,6 @@ export class PortfolioController { @Query('range') dateRange: DateRange = 'max', @Query('tags') filterByTags?: string ): Promise { - const hasReadRestrictedAccessPermission = - this.userService.hasReadRestrictedAccessPermission({ - impersonationId, - user: this.request.user - }); - const filters = this.apiService.buildFiltersFromQueryParams({ filterByAccounts, filterByAssetClasses, @@ -300,8 +349,11 @@ export class PortfolioController { }); if ( - hasReadRestrictedAccessPermission || - this.userService.isRestrictedView(this.request.user) + hasReadRestrictedAccessPermission({ + impersonationId, + user: this.request.user + }) || + isRestrictedView(this.request.user) ) { const maxInvestment = investments.reduce( (investment, item) => Math.max(investment, item.investment), @@ -346,13 +398,9 @@ export class PortfolioController { @Query('assetClasses') filterByAssetClasses?: string, @Query('range') dateRange: DateRange = 'max', @Query('tags') filterByTags?: string, - @Query('withExcludedAccounts') withExcludedAccounts = false + @Query('withExcludedAccounts') withExcludedAccountsParam = 'false' ): Promise { - const hasReadRestrictedAccessPermission = - this.userService.hasReadRestrictedAccessPermission({ - impersonationId, - user: this.request.user - }); + const withExcludedAccounts = withExcludedAccountsParam === 'true'; const filters = this.apiService.buildFiltersFromQueryParams({ filterByAccounts, @@ -369,14 +417,18 @@ export class PortfolioController { }); if ( - hasReadRestrictedAccessPermission || - this.request.user.Settings.settings.viewMode === 'ZEN' || - this.userService.isRestrictedView(this.request.user) + hasReadRestrictedAccessPermission({ + impersonationId, + user: this.request.user + }) || + isRestrictedView(this.request.user) || + this.request.user.Settings.settings.viewMode === 'ZEN' ) { performanceInformation.chart = performanceInformation.chart.map( ({ date, netPerformanceInPercentage, + netPerformanceInPercentageWithCurrencyEffect, netWorth, totalInvestment, value @@ -384,6 +436,7 @@ export class PortfolioController { return { date, netPerformanceInPercentage, + netPerformanceInPercentageWithCurrencyEffect, netWorthInPercentage: performanceInformation.performance.currentNetWorth === 0 ? 0 @@ -397,10 +450,14 @@ export class PortfolioController { .div(performanceInformation.performance.totalInvestment) .toNumber(), valueInPercentage: - performanceInformation.performance.currentValue === 0 + performanceInformation.performance.currentValueInBaseCurrency === + 0 ? 0 : new Big(value) - .div(performanceInformation.performance.currentValue) + .div( + performanceInformation.performance + .currentValueInBaseCurrency + ) .toNumber() }; } @@ -409,12 +466,12 @@ export class PortfolioController { performanceInformation.performance = nullifyValuesInObject( performanceInformation.performance, [ - 'currentGrossPerformance', - 'currentGrossPerformanceWithCurrencyEffect', - 'currentNetPerformance', - 'currentNetPerformanceWithCurrencyEffect', 'currentNetWorth', - 'currentValue', + 'currentValueInBaseCurrency', + 'grossPerformance', + 'grossPerformanceWithCurrencyEffect', + 'netPerformance', + 'netPerformanceWithCurrencyEffect', 'totalInvestment' ] ); @@ -429,46 +486,21 @@ export class PortfolioController { return nullifyValuesInObject(item, ['totalInvestment', 'value']); } ); + performanceInformation.performance = nullifyValuesInObject( + performanceInformation.performance, + ['netPerformance'] + ); } return performanceInformation; } - @Get('positions') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) - @UseInterceptors(RedactValuesInResponseInterceptor) - @UseInterceptors(TransformDataSourceInResponseInterceptor) - public async getPositions( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, - @Query('accounts') filterByAccounts?: string, - @Query('assetClasses') filterByAssetClasses?: string, - @Query('query') filterBySearchQuery?: string, - @Query('range') dateRange: DateRange = 'max', - @Query('tags') filterByTags?: string - ): Promise { - const filters = this.apiService.buildFiltersFromQueryParams({ - filterByAccounts, - filterByAssetClasses, - filterBySearchQuery, - filterByTags - }); - - return this.portfolioService.getPositions({ - dateRange, - filters, - impersonationId - }); - } - @Get('public/:accessId') @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getPublic( @Param('accessId') accessId ): Promise { const access = await this.accessService.access({ id: accessId }); - const user = await this.userService.user({ - id: access.userId - }); if (!access) { throw new HttpException( @@ -478,15 +510,20 @@ export class PortfolioController { } let hasDetails = true; + + const user = await this.userService.user({ + id: access.userId + }); + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { hasDetails = user.subscription.type === 'Premium'; } const { holdings } = await this.portfolioService.getDetails({ - dateRange: 'max', filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }], impersonationId: access.userId, - userId: user.id + userId: user.id, + withMarkets: true }); const portfolioPublicDetails: PortfolioPublicDetails = { @@ -515,7 +552,8 @@ export class PortfolioController { dateOfFirstActivity: portfolioPosition.dateOfFirstActivity, markets: hasDetails ? portfolioPosition.markets : undefined, name: portfolioPosition.name, - netPerformancePercent: portfolioPosition.netPerformancePercent, + netPerformancePercentWithCurrencyEffect: + portfolioPosition.netPerformancePercentWithCurrencyEffect, sectors: hasDetails ? portfolioPosition.sectors : [], symbol: portfolioPosition.symbol, url: portfolioPosition.url, @@ -533,23 +571,23 @@ export class PortfolioController { @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async getPosition( @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, - @Param('dataSource') dataSource, - @Param('symbol') symbol - ): Promise { - const position = await this.portfolioService.getPosition( + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + const holding = await this.portfolioService.getPosition( dataSource, impersonationId, symbol ); - if (position) { - return position; + if (!holding) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); } - throw new HttpException( - getReasonPhrase(StatusCodes.NOT_FOUND), - StatusCodes.NOT_FOUND - ); + return holding; } @Get('report') @@ -572,4 +610,36 @@ export class PortfolioController { return report; } + + @HasPermission(permissions.updateOrder) + @Put('position/:dataSource/:symbol/tags') + @UseInterceptors(TransformDataSourceInRequestInterceptor) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async updateHoldingTags( + @Body() data: UpdateHoldingTagsDto, + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + const holding = await this.portfolioService.getPosition( + dataSource, + impersonationId, + symbol + ); + + if (!holding) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + await this.portfolioService.updateTags({ + dataSource, + impersonationId, + symbol, + tags: data.tags, + userId: this.request.user.id + }); + } } diff --git a/apps/api/src/app/portfolio/portfolio.module.ts b/apps/api/src/app/portfolio/portfolio.module.ts index 4b5034979..7f1f375b1 100644 --- a/apps/api/src/app/portfolio/portfolio.module.ts +++ b/apps/api/src/app/portfolio/portfolio.module.ts @@ -2,7 +2,11 @@ import { AccessModule } from '@ghostfolio/api/app/access/access.module'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { OrderModule } from '@ghostfolio/api/app/order/order.module'; +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module'; +import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module'; +import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; +import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; @@ -15,6 +19,7 @@ import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/sym import { Module } from '@nestjs/common'; +import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory'; import { CurrentRateService } from './current-rate.service'; import { PortfolioController } from './portfolio.controller'; import { PortfolioService } from './portfolio.service'; @@ -34,13 +39,18 @@ import { RulesService } from './rules.service'; MarketDataModule, OrderModule, PrismaModule, + RedactValuesInResponseModule, + RedisCacheModule, SymbolProfileModule, + TransformDataSourceInRequestModule, + TransformDataSourceInResponseModule, UserModule ], providers: [ AccountBalanceService, AccountService, CurrentRateService, + PortfolioCalculatorFactory, PortfolioService, RulesService ] diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 68fce7977..4e5ae2bb6 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -3,10 +3,8 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { OrderService } from '@ghostfolio/api/app/order/order.service'; -import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; -import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface'; -import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface'; import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account'; import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment'; @@ -17,10 +15,13 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data 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 { + getAnnualizedPerformancePercent, + getIntervalFromDateRange +} from '@ghostfolio/common/calculation-helper'; import { DEFAULT_CURRENCY, EMERGENCY_FUND_TAG_ID, - MAX_CHART_ITEMS, UNKNOWN_KEY } from '@ghostfolio/common/config'; import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper'; @@ -29,6 +30,7 @@ import { EnhancedSymbolProfile, Filter, HistoricalDataItem, + InvestmentItem, PortfolioDetails, PortfolioInvestments, PortfolioPerformanceResponse, @@ -36,15 +38,13 @@ import { PortfolioReport, PortfolioSummary, Position, - TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces'; -import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; +import { TimelinePosition } from '@ghostfolio/common/models'; import type { AccountWithValue, DateRange, GroupBy, - OrderWithAccount, RequestWithUser, UserWithSettings } from '@ghostfolio/common/types'; @@ -55,13 +55,14 @@ import { Account, Type as ActivityType, AssetClass, + AssetSubClass, DataSource, Order, Platform, Prisma, Tag } from '@prisma/client'; -import Big from 'big.js'; +import { Big } from 'big.js'; import { differenceInDays, format, @@ -69,24 +70,17 @@ import { isBefore, isSameMonth, isSameYear, - isValid, - max, - min, parseISO, - set, - startOfWeek, - startOfMonth, - startOfYear, - subDays, - subYears + set } from 'date-fns'; import { isEmpty, last, uniq, uniqBy } from 'lodash'; +import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { - HistoricalDataContainer, - PortfolioPositionDetail -} from './interfaces/portfolio-position-detail.interface'; -import { PortfolioCalculator } from './portfolio-calculator'; + PerformanceCalculationType, + PortfolioCalculatorFactory +} from './calculator/portfolio-calculator.factory'; +import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface'; import { RulesService } from './rules.service'; const asiaPacificMarkets = require('../../assets/countries/asia-pacific-markets.json'); @@ -99,7 +93,7 @@ export class PortfolioService { public constructor( private readonly accountBalanceService: AccountBalanceService, private readonly accountService: AccountService, - private readonly currentRateService: CurrentRateService, + private readonly calculatorFactory: PortfolioCalculatorFactory, private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly impersonationService: ImpersonationService, @@ -119,7 +113,7 @@ export class PortfolioService { userId: string; withExcludedAccounts?: boolean; }): Promise { - const where: Prisma.AccountWhereInput = { userId: userId }; + const where: Prisma.AccountWhereInput = { userId }; const accountFilter = filters?.find(({ type }) => { return type === 'ACCOUNT'; @@ -216,29 +210,16 @@ export class PortfolioService { } public async getDividends({ - dateRange, - filters, - groupBy, - impersonationId + activities, + groupBy }: { - dateRange: DateRange; - filters?: Filter[]; + activities: Activity[]; groupBy?: GroupBy; - impersonationId: string; }): Promise { - const userId = await this.getUserId(impersonationId, this.request.user.id); - - const { activities } = await this.orderService.getOrders({ - filters, - userId, - types: ['DIVIDEND'], - userCurrency: this.request.user.Settings.settings.baseCurrency - }); - - let dividends = activities.map((dividend) => { + let dividends = activities.map(({ date, valueInBaseCurrency }) => { return { - date: format(dividend.date, DATE_FORMAT), - investment: dividend.valueInBaseCurrency + date: format(date, DATE_FORMAT), + investment: valueInBaseCurrency }; }); @@ -246,14 +227,7 @@ export class PortfolioService { dividends = this.getDividendsByGroup({ dividends, groupBy }); } - const startDate = this.getStartDate( - dateRange, - parseDate(dividends[0]?.date) - ); - - return dividends.filter(({ date }) => { - return !isBefore(parseDate(date), startDate); - }); + return dividends; } public async getInvestments({ @@ -271,37 +245,34 @@ export class PortfolioService { }): Promise { const userId = await this.getUserId(impersonationId, this.request.user.id); - const { portfolioOrders, transactionPoints } = - await this.getTransactionPoints({ + const { endDate, startDate } = getIntervalFromDateRange(dateRange); + + const { activities } = + await this.orderService.getOrdersForPortfolioCalculator({ filters, userId, - includeDrafts: true + userCurrency: this.getUserCurrency() }); - if (transactionPoints.length === 0) { + if (activities.length === 0) { return { investments: [], streaks: { currentStreak: 0, longestStreak: 0 } }; } - const portfolioCalculator = new PortfolioCalculator({ - currency: this.request.user.Settings.settings.baseCurrency, - currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: portfolioOrders + const portfolioCalculator = this.calculatorFactory.createCalculator({ + activities, + filters, + userId, + calculationType: PerformanceCalculationType.TWR, + currency: this.request.user.Settings.settings.baseCurrency }); - portfolioCalculator.setTransactionPoints(transactionPoints); + const { historicalData } = await portfolioCalculator.getSnapshot(); - const { items } = await this.getChart({ - dateRange, - impersonationId, - portfolioOrders, - transactionPoints, - userId, - userCurrency: this.request.user.Settings.settings.baseCurrency, - withDataDecimation: false + const items = historicalData.filter(({ date }) => { + return !isBefore(date, startDate) && !isAfter(date, endDate); }); let investments: InvestmentItem[]; @@ -340,13 +311,17 @@ export class PortfolioService { filters, impersonationId, userId, - withExcludedAccounts = false + withExcludedAccounts = false, + withMarkets = false, + withSummary = false }: { dateRange?: DateRange; filters?: Filter[]; impersonationId: string; userId: string; withExcludedAccounts?: boolean; + withMarkets?: boolean; + withSummary?: boolean; }): Promise { userId = await this.getUserId(impersonationId, userId); const user = await this.userService.user({ id: userId }); @@ -356,28 +331,23 @@ export class PortfolioService { (user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 ); - const { orders, portfolioOrders, transactionPoints } = - await this.getTransactionPoints({ + const { activities } = + await this.orderService.getOrdersForPortfolioCalculator({ filters, - userId, - withExcludedAccounts + userCurrency, + userId }); - const portfolioCalculator = new PortfolioCalculator({ - currency: userCurrency, - currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: portfolioOrders + const portfolioCalculator = this.calculatorFactory.createCalculator({ + activities, + filters, + userId, + calculationType: PerformanceCalculationType.TWR, + currency: userCurrency }); - portfolioCalculator.setTransactionPoints(transactionPoints); - - const portfolioStart = parseDate( - transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT) - ); - const startDate = this.getStartDate(dateRange, portfolioStart); - const currentPositions = - await portfolioCalculator.getCurrentPositions(startDate); + const { currentValueInBaseCurrency, hasErrors, positions } = + await portfolioCalculator.getSnapshot(); const cashDetails = await this.accountService.getCashDetails({ filters, @@ -386,43 +356,49 @@ export class PortfolioService { }); const holdings: PortfolioDetails['holdings'] = {}; - const totalValueInBaseCurrency = currentPositions.currentValue.plus( + + const totalValueInBaseCurrency = currentValueInBaseCurrency.plus( cashDetails.balanceInBaseCurrency ); const isFilteredByAccount = - filters?.some((filter) => { - return filter.type === 'ACCOUNT'; + filters?.some(({ type }) => { + return type === 'ACCOUNT'; + }) ?? false; + + const isFilteredByCash = filters?.some(({ id, type }) => { + return id === AssetClass.LIQUIDITY && type === 'ASSET_CLASS'; + }); + + const isFilteredByClosedHoldings = + filters?.some(({ id, type }) => { + return id === 'CLOSED' && type === 'HOLDING_TYPE'; }) ?? false; let filteredValueInBaseCurrency = isFilteredByAccount ? totalValueInBaseCurrency - : currentPositions.currentValue; + : currentValueInBaseCurrency; if ( filters?.length === 0 || (filters?.length === 1 && - filters[0].type === 'ASSET_CLASS' && - filters[0].id === 'CASH') + filters[0].id === AssetClass.LIQUIDITY && + filters[0].type === 'ASSET_CLASS') ) { filteredValueInBaseCurrency = filteredValueInBaseCurrency.plus( cashDetails.balanceInBaseCurrency ); } - const dataGatheringItems = currentPositions.positions.map( - ({ dataSource, symbol }) => { - return { - dataSource, - symbol - }; - } - ); + const dataGatheringItems = positions.map(({ dataSource, symbol }) => { + return { + dataSource, + symbol + }; + }); - const [dataProviderResponses, symbolProfiles] = await Promise.all([ - this.dataProviderService.getQuotes({ items: dataGatheringItems }), - this.symbolProfileService.getSymbolProfiles(dataGatheringItems) - ]); + const symbolProfiles = + await this.symbolProfileService.getSymbolProfiles(dataGatheringItems); const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {}; for (const symbolProfile of symbolProfiles) { @@ -430,125 +406,105 @@ export class PortfolioService { } const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {}; - for (const position of currentPositions.positions) { + for (const position of positions) { portfolioItemsNow[position.symbol] = position; } - for (const item of currentPositions.positions) { - if (item.quantity.lte(0)) { - // Ignore positions without any quantity - continue; + for (const { + currency, + dividend, + firstBuyDate, + grossPerformance, + grossPerformanceWithCurrencyEffect, + grossPerformancePercentage, + grossPerformancePercentageWithCurrencyEffect, + investment, + marketPrice, + netPerformance, + netPerformancePercentage, + netPerformancePercentageWithCurrencyEffectMap, + netPerformanceWithCurrencyEffectMap, + quantity, + symbol, + tags, + transactionCount, + valueInBaseCurrency + } of positions) { + if (isFilteredByClosedHoldings === true) { + if (!quantity.eq(0)) { + // Ignore positions with a quantity + continue; + } + } else { + if (quantity.eq(0)) { + // Ignore positions without any quantity + continue; + } } - const value = item.quantity.mul(item.marketPriceInBaseCurrency ?? 0); - const symbolProfile = symbolProfileMap[item.symbol]; - const dataProviderResponse = dataProviderResponses[item.symbol]; - - 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 - }; - - 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(); - } + const assetProfile = symbolProfileMap[symbol]; - 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(); + let markets: PortfolioPosition['markets']; + let marketsAdvanced: PortfolioPosition['marketsAdvanced']; - marketsAdvanced[UNKNOWN_KEY] = new Big(marketsAdvanced[UNKNOWN_KEY]) - .plus(value) - .toNumber(); + if (withMarkets) { + ({ markets, marketsAdvanced } = this.getMarkets({ + assetProfile, + valueInBaseCurrency + })); } - holdings[item.symbol] = { + holdings[symbol] = { + currency, markets, marketsAdvanced, + marketPrice, + symbol, + tags, + transactionCount, allocationInPercentage: filteredValueInBaseCurrency.eq(0) ? 0 - : value.div(filteredValueInBaseCurrency).toNumber(), - assetClass: symbolProfile.assetClass, - assetSubClass: symbolProfile.assetSubClass, - countries: symbolProfile.countries, - currency: item.currency, - dataSource: symbolProfile.dataSource, - dateOfFirstActivity: parseDate(item.firstBuyDate), - grossPerformance: item.grossPerformance?.toNumber() ?? 0, - grossPerformancePercent: - item.grossPerformancePercentage?.toNumber() ?? 0, - investment: item.investment.toNumber(), - marketPrice: item.marketPrice, - marketState: dataProviderResponse?.marketState ?? 'delayed', - name: symbolProfile.name, - netPerformance: item.netPerformance?.toNumber() ?? 0, - netPerformancePercent: item.netPerformancePercentage?.toNumber() ?? 0, - quantity: item.quantity.toNumber(), - sectors: symbolProfile.sectors, - symbol: item.symbol, - tags: item.tags, - transactionCount: item.transactionCount, - url: symbolProfile.url, - valueInBaseCurrency: value.toNumber() + : valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(), + assetClass: assetProfile.assetClass, + assetSubClass: assetProfile.assetSubClass, + countries: assetProfile.countries, + dataSource: assetProfile.dataSource, + dateOfFirstActivity: parseDate(firstBuyDate), + dividend: dividend?.toNumber() ?? 0, + grossPerformance: grossPerformance?.toNumber() ?? 0, + grossPerformancePercent: grossPerformancePercentage?.toNumber() ?? 0, + grossPerformancePercentWithCurrencyEffect: + grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0, + grossPerformanceWithCurrencyEffect: + grossPerformanceWithCurrencyEffect?.toNumber() ?? 0, + holdings: assetProfile.holdings.map( + ({ allocationInPercentage, name }) => { + return { + allocationInPercentage, + name, + valueInBaseCurrency: valueInBaseCurrency + .mul(allocationInPercentage) + .toNumber() + }; + } + ), + investment: investment.toNumber(), + name: assetProfile.name, + netPerformance: netPerformance?.toNumber() ?? 0, + netPerformancePercent: netPerformancePercentage?.toNumber() ?? 0, + netPerformancePercentWithCurrencyEffect: + netPerformancePercentageWithCurrencyEffectMap?.[ + dateRange + ]?.toNumber() ?? 0, + netPerformanceWithCurrencyEffect: + netPerformanceWithCurrencyEffectMap?.[dateRange]?.toNumber() ?? 0, + quantity: quantity.toNumber(), + sectors: assetProfile.sectors, + url: assetProfile.url, + valueInBaseCurrency: valueInBaseCurrency.toNumber() }; } - const isFilteredByCash = filters?.some((filter) => { - return filter.type === 'ASSET_CLASS' && filter.id === 'CASH'; - }); - if (filters?.length === 0 || isFilteredByAccount || isFilteredByCash) { const cashPositions = await this.getCashPositions({ cashDetails, @@ -562,8 +518,8 @@ export class PortfolioService { } const { accounts, platforms } = await this.getValueOfAccountsAndPlatforms({ + activities, filters, - orders, portfolioItemsNow, userCurrency, userId, @@ -605,28 +561,29 @@ export class PortfolioService { }; } - const summary = await this.getSummary({ - impersonationId, - userCurrency, - userId, - balanceInBaseCurrency: cashDetails.balanceInBaseCurrency, - emergencyFundPositionsValueInBaseCurrency: - this.getEmergencyFundPositionsValueInBaseCurrency({ - holdings - }) - }); + let summary: PortfolioSummary; + + if (withSummary) { + summary = await this.getSummary({ + filteredValueInBaseCurrency, + impersonationId, + portfolioCalculator, + userCurrency, + userId, + balanceInBaseCurrency: cashDetails.balanceInBaseCurrency, + emergencyFundPositionsValueInBaseCurrency: + this.getEmergencyFundPositionsValueInBaseCurrency({ + holdings + }) + }); + } return { accounts, + hasErrors, holdings, platforms, - summary, - filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(), - filteredValueInPercentage: summary.netWorth - ? filteredValueInBaseCurrency.div(summary.netWorth).toNumber() - : 0, - hasErrors: currentPositions.hasErrors, - totalValueInBaseCurrency: summary.netWorth + summary }; } @@ -634,16 +591,16 @@ export class PortfolioService { aDataSource: DataSource, aImpersonationId: string, aSymbol: string - ): Promise { + ): Promise { const userId = await this.getUserId(aImpersonationId, this.request.user.id); const user = await this.userService.user({ id: userId }); const userCurrency = this.getUserCurrency(user); - const { activities } = await this.orderService.getOrders({ - userCurrency, - userId, - withExcludedAccounts: true - }); + const { activities } = + await this.orderService.getOrdersForPortfolioCalculator({ + userCurrency, + userId + }); const orders = activities.filter(({ SymbolProfile }) => { return ( @@ -652,15 +609,14 @@ export class PortfolioService { ); }); - let tags: Tag[] = []; - if (orders.length <= 0) { return { - tags, accounts: [], averagePrice: undefined, dataProviderInfo: undefined, dividendInBaseCurrency: undefined, + dividendYieldPercent: undefined, + dividendYieldPercentWithCurrencyEffect: undefined, feeInBaseCurrency: undefined, firstBuyDate: undefined, grossPerformance: undefined, @@ -679,6 +635,7 @@ export class PortfolioService { orders: [], quantity: undefined, SymbolProfile: undefined, + tags: [], transactionCount: undefined, value: undefined }; @@ -688,43 +645,21 @@ export class PortfolioService { { dataSource: aDataSource, symbol: aSymbol } ]); - const portfolioOrders: PortfolioOrder[] = orders - .filter((order) => { - tags = tags.concat(order.tags); - - return order.type === 'BUY' || order.type === 'SELL'; - }) - .map((order) => ({ - currency: order.SymbolProfile.currency, - dataSource: order.SymbolProfile.dataSource, - date: format(order.date, DATE_FORMAT), - fee: new Big(order.fee), - name: order.SymbolProfile?.name, - quantity: new Big(order.quantity), - symbol: order.SymbolProfile.symbol, - tags: order.tags, - type: order.type, - unitPrice: new Big(order.unitPrice) - })); - - tags = uniqBy(tags, 'id'); - - const portfolioCalculator = new PortfolioCalculator({ - currency: userCurrency, - currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: portfolioOrders + const portfolioCalculator = this.calculatorFactory.createCalculator({ + userId, + activities: orders.filter((order) => { + return ['BUY', 'DIVIDEND', 'ITEM', 'SELL'].includes(order.type); + }), + calculationType: PerformanceCalculationType.TWR, + currency: userCurrency }); - portfolioCalculator.computeTransactionPoints(); + const portfolioStart = portfolioCalculator.getStartDate(); const transactionPoints = portfolioCalculator.getTransactionPoints(); - const portfolioStart = parseDate(transactionPoints[0].date); - - const currentPositions = - await portfolioCalculator.getCurrentPositions(portfolioStart); + const { positions } = await portfolioCalculator.getSnapshot(); - const position = currentPositions.positions.find(({ symbol }) => { + const position = positions.find(({ symbol }) => { return symbol === aSymbol; }); @@ -733,29 +668,44 @@ export class PortfolioService { averagePrice, currency, dataSource, + dividendInBaseCurrency, fee, firstBuyDate, marketPrice, quantity, + tags, + timeWeightedInvestment, + timeWeightedInvestmentWithCurrencyEffect, transactionCount } = position; - const accounts: PortfolioPositionDetail['accounts'] = uniqBy( - orders, + const accounts: PortfolioHoldingDetail['accounts'] = uniqBy( + orders.filter(({ Account }) => { + return Account; + }), 'Account.id' ).map(({ Account }) => { return Account; }); - const dividendInBaseCurrency = getSum( - orders - .filter(({ type }) => { - return type === 'DIVIDEND'; - }) - .map(({ valueInBaseCurrency }) => { - return new Big(valueInBaseCurrency); - }) - ); + const dividendYieldPercent = getAnnualizedPerformancePercent({ + daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)), + netPerformancePercentage: timeWeightedInvestment.eq(0) + ? new Big(0) + : dividendInBaseCurrency.div(timeWeightedInvestment) + }); + + const dividendYieldPercentWithCurrencyEffect = + getAnnualizedPerformancePercent({ + daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)), + netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq( + 0 + ) + ? new Big(0) + : dividendInBaseCurrency.div( + timeWeightedInvestmentWithCurrencyEffect + ) + }); const historicalData = await this.dataProviderService.getHistorical( [{ dataSource, symbol: aSymbol }], @@ -790,9 +740,7 @@ export class PortfolioService { ); if (currentSymbol) { - currentAveragePrice = currentSymbol.quantity.eq(0) - ? 0 - : currentSymbol.investment.div(currentSymbol.quantity).toNumber(); + currentAveragePrice = currentSymbol.averagePrice.toNumber(); currentQuantity = currentSymbol.quantity.toNumber(); } @@ -832,6 +780,9 @@ export class PortfolioService { averagePrice: averagePrice.toNumber(), dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0], dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), + dividendYieldPercent: dividendYieldPercent.toNumber(), + dividendYieldPercentWithCurrencyEffect: + dividendYieldPercentWithCurrencyEffect.toNumber(), feeInBaseCurrency: this.exchangeRateDataService.toCurrency( fee.toNumber(), SymbolProfile.currency, @@ -849,9 +800,11 @@ export class PortfolioService { netPerformance: position.netPerformance?.toNumber(), netPerformancePercent: position.netPerformancePercentage?.toNumber(), netPerformancePercentWithCurrencyEffect: - position.netPerformancePercentageWithCurrencyEffect?.toNumber(), + position.netPerformancePercentageWithCurrencyEffectMap?.[ + 'max' + ]?.toNumber(), netPerformanceWithCurrencyEffect: - position.netPerformanceWithCurrencyEffect?.toNumber(), + position.netPerformanceWithCurrencyEffectMap?.['max']?.toNumber(), quantity: quantity.toNumber(), value: this.exchangeRateDataService.toCurrency( quantity.mul(marketPrice ?? 0).toNumber(), @@ -861,6 +814,7 @@ export class PortfolioService { }; } else { const currentData = await this.dataProviderService.getQuotes({ + user, items: [{ dataSource: DataSource.YAHOO, symbol: aSymbol }] }); const marketPrice = currentData[aSymbol]?.marketPrice; @@ -873,11 +827,19 @@ export class PortfolioService { ); if (isEmpty(historicalData)) { - historicalData = await this.dataProviderService.getHistoricalRaw( - [{ dataSource: DataSource.YAHOO, symbol: aSymbol }], - portfolioStart, - new Date() - ); + try { + historicalData = await this.dataProviderService.getHistoricalRaw({ + dataGatheringItems: [ + { dataSource: DataSource.YAHOO, symbol: aSymbol } + ], + from: portfolioStart, + to: new Date() + }); + } catch { + historicalData = { + [aSymbol]: {} + }; + } } const historicalDataArray: HistoricalDataItem[] = []; @@ -902,11 +864,12 @@ export class PortfolioService { minPrice, orders, SymbolProfile, - tags, accounts: [], averagePrice: 0, dataProviderInfo: undefined, dividendInBaseCurrency: 0, + dividendYieldPercent: 0, + dividendYieldPercentWithCurrencyEffect: 0, feeInBaseCurrency: 0, firstBuyDate: undefined, grossPerformance: undefined, @@ -920,6 +883,7 @@ export class PortfolioService { netPerformancePercentWithCurrencyEffect: undefined, netPerformanceWithCurrencyEffect: undefined, quantity: 0, + tags: [], transactionCount: undefined, value: 0 }; @@ -939,35 +903,33 @@ export class PortfolioService { return type === 'SEARCH_QUERY'; })?.id; const userId = await this.getUserId(impersonationId, this.request.user.id); + const user = await this.userService.user({ id: userId }); - const { portfolioOrders, transactionPoints } = - await this.getTransactionPoints({ + const { activities } = + await this.orderService.getOrdersForPortfolioCalculator({ filters, - userId + userId, + userCurrency: this.getUserCurrency() }); - if (transactionPoints?.length <= 0) { + if (activities?.length <= 0) { return { hasErrors: false, positions: [] }; } - const portfolioCalculator = new PortfolioCalculator({ - currency: this.request.user.Settings.settings.baseCurrency, - currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: portfolioOrders + const portfolioCalculator = this.calculatorFactory.createCalculator({ + activities, + filters, + userId, + calculationType: PerformanceCalculationType.TWR, + currency: this.request.user.Settings.settings.baseCurrency }); - portfolioCalculator.setTransactionPoints(transactionPoints); - - const portfolioStart = parseDate(transactionPoints[0].date); - const startDate = this.getStartDate(dateRange, portfolioStart); - const currentPositions = - await portfolioCalculator.getCurrentPositions(startDate); + let { hasErrors, positions } = await portfolioCalculator.getSnapshot(); - let positions = currentPositions.positions.filter(({ quantity }) => { + positions = positions.filter(({ quantity }) => { return !quantity.eq(0); }); @@ -979,7 +941,7 @@ export class PortfolioService { }); const [dataProviderResponses, symbolProfiles] = await Promise.all([ - this.dataProviderService.getQuotes({ items: dataGatheringItems }), + this.dataProviderService.getQuotes({ user, items: dataGatheringItems }), this.symbolProfileService.getSymbolProfiles( positions.map(({ dataSource, symbol }) => { return { dataSource, symbol }; @@ -1006,7 +968,7 @@ export class PortfolioService { } return { - hasErrors: currentPositions.hasErrors, + hasErrors, positions: positions.map( ({ averagePrice, @@ -1021,8 +983,8 @@ export class PortfolioService { investmentWithCurrencyEffect, netPerformance, netPerformancePercentage, - netPerformancePercentageWithCurrencyEffect, - netPerformanceWithCurrencyEffect, + netPerformancePercentageWithCurrencyEffectMap, + netPerformanceWithCurrencyEffectMap, quantity, symbol, timeWeightedInvestment, @@ -1055,9 +1017,12 @@ export class PortfolioService { netPerformancePercentage: netPerformancePercentage?.toNumber() ?? null, netPerformancePercentageWithCurrencyEffect: - netPerformancePercentageWithCurrencyEffect?.toNumber() ?? null, + netPerformancePercentageWithCurrencyEffectMap?.[ + dateRange + ]?.toNumber() ?? null, netPerformanceWithCurrencyEffect: - netPerformanceWithCurrencyEffect?.toNumber() ?? null, + netPerformanceWithCurrencyEffectMap?.[dateRange]?.toNumber() ?? + null, quantity: quantity.toNumber(), timeWeightedInvestment: timeWeightedInvestment?.toNumber(), timeWeightedInvestmentWithCurrencyEffect: @@ -1072,12 +1037,14 @@ export class PortfolioService { dateRange = 'max', filters, impersonationId, + portfolioCalculator, userId, withExcludedAccounts = false }: { dateRange?: DateRange; filters?: Filter[]; impersonationId: string; + portfolioCalculator?: PortfolioCalculator; userId: string; withExcludedAccounts?: boolean; }): Promise { @@ -1098,11 +1065,16 @@ export class PortfolioService { ) => { const formattedDate = format(date, DATE_FORMAT); - // Store the item in the map, overwriting if the date already exists - map[formattedDate] = { - date: formattedDate, - value: valueInBaseCurrency - }; + if (map[formattedDate]) { + // If the value exists, add the current value to the existing one + map[formattedDate].value += valueInBaseCurrency; + } else { + // Otherwise, initialize the value for that date + map[formattedDate] = { + date: formattedDate, + value: valueInBaseCurrency + }; + } return map; }, @@ -1110,151 +1082,86 @@ export class PortfolioService { ) ); - const { portfolioOrders, transactionPoints } = - await this.getTransactionPoints({ + const { activities } = + await this.orderService.getOrdersForPortfolioCalculator({ filters, - userId, - withExcludedAccounts + userCurrency, + userId }); - const portfolioCalculator = new PortfolioCalculator({ - currency: userCurrency, - currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: portfolioOrders - }); - - if (accountBalanceItems?.length <= 0 && transactionPoints?.length <= 0) { + if (accountBalanceItems?.length <= 0 && activities?.length <= 0) { return { chart: [], firstOrderDate: undefined, hasErrors: false, performance: { - currentGrossPerformance: 0, - currentGrossPerformancePercent: 0, - currentGrossPerformancePercentWithCurrencyEffect: 0, - currentGrossPerformanceWithCurrencyEffect: 0, - currentNetPerformance: 0, - currentNetPerformancePercent: 0, - currentNetPerformancePercentWithCurrencyEffect: 0, - currentNetPerformanceWithCurrencyEffect: 0, currentNetWorth: 0, - currentValue: 0, + currentValueInBaseCurrency: 0, + netPerformance: 0, + netPerformancePercentage: 0, + netPerformancePercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, totalInvestment: 0 } }; } - portfolioCalculator.setTransactionPoints(transactionPoints); - - const portfolioStart = min( - [ - parseDate(accountBalanceItems[0]?.date), - parseDate(transactionPoints[0]?.date) - ].filter((date) => { - return isValid(date); - }) - ); - - const startDate = this.getStartDate(dateRange, portfolioStart); - const { - currentValue, - errors, - grossPerformance, - grossPerformancePercentage, - grossPerformancePercentageWithCurrencyEffect, - grossPerformanceWithCurrencyEffect, - hasErrors, - netPerformance, - netPerformancePercentage, - netPerformancePercentageWithCurrencyEffect, - netPerformanceWithCurrencyEffect, - totalInvestment - } = await portfolioCalculator.getCurrentPositions(startDate); - - let currentNetPerformance = netPerformance; - - let currentNetPerformancePercent = netPerformancePercentage; - - let currentNetPerformancePercentWithCurrencyEffect = - netPerformancePercentageWithCurrencyEffect; - - let currentNetPerformanceWithCurrencyEffect = - netPerformanceWithCurrencyEffect; - - const { items } = await this.getChart({ - dateRange, - impersonationId, - portfolioOrders, - transactionPoints, - userCurrency, - userId - }); - - const itemOfToday = items.find(({ date }) => { - return date === format(new Date(), DATE_FORMAT); - }); - - if (itemOfToday) { - currentNetPerformance = new Big(itemOfToday.netPerformance); + portfolioCalculator = + portfolioCalculator ?? + this.calculatorFactory.createCalculator({ + accountBalanceItems, + activities, + filters, + userId, + calculationType: PerformanceCalculationType.TWR, + currency: userCurrency + }); - currentNetPerformancePercent = new Big( - itemOfToday.netPerformanceInPercentage - ).div(100); + const { errors, hasErrors, historicalData } = + await portfolioCalculator.getSnapshot(); - currentNetPerformancePercentWithCurrencyEffect = new Big( - itemOfToday.netPerformanceInPercentageWithCurrencyEffect - ).div(100); + const { endDate, startDate } = getIntervalFromDateRange(dateRange); - currentNetPerformanceWithCurrencyEffect = new Big( - itemOfToday.netPerformanceWithCurrencyEffect - ); - } - - accountBalanceItems = accountBalanceItems.filter(({ date }) => { - return !isBefore(parseDate(date), startDate); - }); - - const accountBalanceItemOfToday = accountBalanceItems.find(({ date }) => { - return date === format(new Date(), DATE_FORMAT); + const { chart } = await portfolioCalculator.getPerformance({ + end: endDate, + start: startDate }); - if (!accountBalanceItemOfToday) { - accountBalanceItems.push({ - date: format(new Date(), DATE_FORMAT), - value: last(accountBalanceItems)?.value ?? 0 - }); - } - - const mergedHistoricalDataItems = this.mergeHistoricalDataItems( - accountBalanceItems, - items - ); - - const currentHistoricalDataItem = last(mergedHistoricalDataItems); - const currentNetWorth = currentHistoricalDataItem?.netWorth ?? 0; + const { + netPerformance, + netPerformanceInPercentage, + netPerformanceInPercentageWithCurrencyEffect, + netPerformanceWithCurrencyEffect, + netWorth, + totalInvestment, + valueWithCurrencyEffect + } = + chart?.length > 0 + ? last(chart) + : { + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + netWorth: 0, + totalInvestment: 0, + valueWithCurrencyEffect: 0 + }; return { + chart, errors, hasErrors, - chart: mergedHistoricalDataItems, - firstOrderDate: parseDate(items[0]?.date), + firstOrderDate: parseDate(historicalData[0]?.date), performance: { - currentNetWorth, - currentGrossPerformance: grossPerformance.toNumber(), - currentGrossPerformancePercent: grossPerformancePercentage.toNumber(), - currentGrossPerformancePercentWithCurrencyEffect: - grossPerformancePercentageWithCurrencyEffect.toNumber(), - currentGrossPerformanceWithCurrencyEffect: - grossPerformanceWithCurrencyEffect.toNumber(), - currentNetPerformance: currentNetPerformance.toNumber(), - currentNetPerformancePercent: currentNetPerformancePercent.toNumber(), - currentNetPerformancePercentWithCurrencyEffect: - currentNetPerformancePercentWithCurrencyEffect.toNumber(), - currentNetPerformanceWithCurrencyEffect: - currentNetPerformanceWithCurrencyEffect.toNumber(), - currentValue: currentValue.toNumber(), - totalInvestment: totalInvestment.toNumber() + netPerformance, + netPerformanceWithCurrencyEffect, + totalInvestment, + currentNetWorth: netWorth, + currentValueInBaseCurrency: valueWithCurrencyEffect, + netPerformancePercentage: netPerformanceInPercentage, + netPerformancePercentageWithCurrencyEffect: + netPerformanceInPercentageWithCurrencyEffect } }; } @@ -1264,29 +1171,23 @@ export class PortfolioService { const user = await this.userService.user({ id: userId }); const userCurrency = this.getUserCurrency(user); - const { orders, portfolioOrders, transactionPoints } = - await this.getTransactionPoints({ + const { activities } = + await this.orderService.getOrdersForPortfolioCalculator({ + userCurrency, userId }); - const portfolioCalculator = new PortfolioCalculator({ - currency: userCurrency, - currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: portfolioOrders + const portfolioCalculator = this.calculatorFactory.createCalculator({ + activities, + userId, + calculationType: PerformanceCalculationType.TWR, + currency: this.request.user.Settings.settings.baseCurrency }); - portfolioCalculator.setTransactionPoints(transactionPoints); + let { totalFeesWithCurrencyEffect, positions, totalInvestment } = + await portfolioCalculator.getSnapshot(); - const portfolioStart = parseDate( - transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT) - ); - const currentPositions = - await portfolioCalculator.getCurrentPositions(portfolioStart); - - const positions = currentPositions.positions.filter( - (item) => !item.quantity.eq(0) - ); + positions = positions.filter((item) => !item.quantity.eq(0)); const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {}; @@ -1295,7 +1196,7 @@ export class PortfolioService { } const { accounts } = await this.getValueOfAccountsAndPlatforms({ - orders, + activities, portfolioItemsNow, userCurrency, userId @@ -1305,7 +1206,7 @@ export class PortfolioService { return { rules: { - accountClusterRisk: isEmpty(orders) + accountClusterRisk: isEmpty(activities) ? undefined : await this.rulesService.evaluate( [ @@ -1320,7 +1221,7 @@ export class PortfolioService { ], userSettings ), - currencyClusterRisk: isEmpty(orders) + currencyClusterRisk: isEmpty(activities) ? undefined : await this.rulesService.evaluate( [ @@ -1348,8 +1249,8 @@ export class PortfolioService { [ new FeeRatioInitialInvestment( this.exchangeRateDataService, - currentPositions.totalInvestment.toNumber(), - this.getFees({ userCurrency, activities: orders }).toNumber() + totalInvestment.toNumber(), + totalFeesWithCurrencyEffect.toNumber() ) ], userSettings @@ -1358,6 +1259,24 @@ export class PortfolioService { }; } + public async updateTags({ + dataSource, + impersonationId, + symbol, + tags, + userId + }: { + dataSource: DataSource; + impersonationId: string; + symbol: string; + tags: Tag[]; + userId: string; + }) { + userId = await this.getUserId(impersonationId, userId); + + await this.orderService.assignTags({ dataSource, symbol, tags, userId }); + } + private async getCashPositions({ cashDetails, userCurrency, @@ -1408,67 +1327,6 @@ export class PortfolioService { return cashPositions; } - private async getChart({ - dateRange = 'max', - impersonationId, - portfolioOrders, - transactionPoints, - userCurrency, - userId, - withDataDecimation = true - }: { - dateRange?: DateRange; - impersonationId: string; - portfolioOrders: PortfolioOrder[]; - transactionPoints: TransactionPoint[]; - userCurrency: string; - userId: string; - withDataDecimation?: boolean; - }): Promise { - if (transactionPoints.length === 0) { - return { - isAllTimeHigh: false, - isAllTimeLow: false, - items: [] - }; - } - - userId = await this.getUserId(impersonationId, userId); - - const portfolioCalculator = new PortfolioCalculator({ - currency: userCurrency, - currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: portfolioOrders - }); - - portfolioCalculator.setTransactionPoints(transactionPoints); - - const endDate = new Date(); - - const portfolioStart = parseDate(transactionPoints[0].date); - const startDate = this.getStartDate(dateRange, portfolioStart); - - let step = 1; - - if (withDataDecimation) { - const daysInMarket = differenceInDays(new Date(), startDate); - step = Math.round(daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS)); - } - - const items = await portfolioCalculator.getChartData({ - step, - end: endDate, - start: startDate - }); - - return { - items, - isAllTimeHigh: false, - isAllTimeLow: false - }; - } - private getDividendsByGroup({ dividends, groupBy @@ -1554,33 +1412,6 @@ export class PortfolioService { return valueInBaseCurrencyOfEmergencyFundPositions.toNumber(); } - private getFees({ - activities, - date = new Date(0), - userCurrency - }: { - activities: OrderWithAccount[]; - date?: Date; - userCurrency: string; - }) { - return activities - .filter((activity) => { - // Filter out all activities before given date (drafts) - return isBefore(date, new Date(activity.date)); - }) - .map(({ fee, SymbolProfile }) => { - return this.exchangeRateDataService.toCurrency( - fee, - SymbolProfile.currency, - userCurrency - ); - }) - .reduce( - (previous, current) => new Big(previous).plus(current), - new Big(0) - ); - } - private getInitialCashPosition({ balance, currency @@ -1591,19 +1422,24 @@ export class PortfolioService { return { currency, allocationInPercentage: 0, - assetClass: AssetClass.CASH, - assetSubClass: AssetClass.CASH, + assetClass: AssetClass.LIQUIDITY, + assetSubClass: AssetSubClass.CASH, countries: [], dataSource: undefined, dateOfFirstActivity: undefined, + dividend: 0, grossPerformance: 0, grossPerformancePercent: 0, + grossPerformancePercentWithCurrencyEffect: 0, + grossPerformanceWithCurrencyEffect: 0, + holdings: [], investment: balance, marketPrice: 0, - marketState: 'open', name: currency, netPerformance: 0, netPerformancePercent: 0, + netPerformancePercentWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, quantity: 0, sectors: [], symbol: currency, @@ -1613,50 +1449,84 @@ export class PortfolioService { }; } - private getStartDate(aDateRange: DateRange, portfolioStart: Date) { - switch (aDateRange) { - case '1d': - portfolioStart = max([ - portfolioStart, - subDays(new Date().setHours(0, 0, 0, 0), 1) - ]); - break; - case 'mtd': - portfolioStart = max([ - portfolioStart, - subDays(startOfMonth(new Date().setHours(0, 0, 0, 0)), 1) - ]); - break; - case 'wtd': - portfolioStart = max([ - portfolioStart, - subDays( - startOfWeek(new Date().setHours(0, 0, 0, 0), { weekStartsOn: 1 }), - 1 + private getMarkets({ + assetProfile, + valueInBaseCurrency + }: { + assetProfile: EnhancedSymbolProfile; + valueInBaseCurrency: Big; + }) { + const markets = { + [UNKNOWN_KEY]: 0, + developedMarkets: 0, + emergingMarkets: 0, + otherMarkets: 0 + }; + const marketsAdvanced = { + [UNKNOWN_KEY]: 0, + asiaPacific: 0, + emergingMarkets: 0, + europe: 0, + japan: 0, + northAmerica: 0, + otherMarkets: 0 + }; + + if (assetProfile.countries.length > 0) { + for (const country of assetProfile.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 ) - ]); - break; - case 'ytd': - portfolioStart = max([ - portfolioStart, - subDays(startOfYear(new Date().setHours(0, 0, 0, 0)), 1) - ]); - break; - case '1y': - portfolioStart = max([ - portfolioStart, - subYears(new Date().setHours(0, 0, 0, 0), 1) - ]); - break; - case '5y': - portfolioStart = max([ - portfolioStart, - subYears(new Date().setHours(0, 0, 0, 0), 5) - ]); - break; + .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(valueInBaseCurrency) + .toNumber(); + + marketsAdvanced[UNKNOWN_KEY] = new Big(marketsAdvanced[UNKNOWN_KEY]) + .plus(valueInBaseCurrency) + .toNumber(); } - return portfolioStart; + return { markets, marketsAdvanced }; } private getStreaks({ @@ -1684,76 +1554,85 @@ export class PortfolioService { private async getSummary({ balanceInBaseCurrency, emergencyFundPositionsValueInBaseCurrency, + filteredValueInBaseCurrency, impersonationId, + portfolioCalculator, userCurrency, userId }: { balanceInBaseCurrency: number; emergencyFundPositionsValueInBaseCurrency: number; + filteredValueInBaseCurrency: Big; impersonationId: string; + portfolioCalculator: PortfolioCalculator; userCurrency: string; userId: string; }): Promise { userId = await this.getUserId(impersonationId, userId); const user = await this.userService.user({ id: userId }); - const performanceInformation = await this.getPerformance({ - impersonationId, - userId - }); - const { activities } = await this.orderService.getOrders({ - userCurrency, - userId - }); - - let { activities: excludedActivities } = await this.orderService.getOrders({ userCurrency, userId, withExcludedAccounts: true }); - excludedActivities = excludedActivities.filter(({ Account: account }) => { - return account?.isExcluded ?? false; + const excludedActivities: Activity[] = []; + const nonExcludedActivities: Activity[] = []; + + for (const activity of activities) { + if (activity.Account?.isExcluded) { + excludedActivities.push(activity); + } else { + nonExcludedActivities.push(activity); + } + } + + const { currentValueInBaseCurrency, totalInvestment } = + await portfolioCalculator.getSnapshot(); + + const { performance } = await this.getPerformance({ + impersonationId, + userId }); - const dividend = this.getSumOfActivityType({ - activities, - userCurrency, - activityType: 'DIVIDEND' - }).toNumber(); + const { + netPerformance, + netPerformancePercentage, + netPerformancePercentageWithCurrencyEffect, + netPerformanceWithCurrencyEffect + } = performance; + + const dividendInBaseCurrency = + await portfolioCalculator.getDividendInBaseCurrency(); + const emergencyFund = new Big( Math.max( emergencyFundPositionsValueInBaseCurrency, (user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 ) ); - const fees = this.getFees({ activities, userCurrency }).toNumber(); - const firstOrderDate = activities[0]?.date; - 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 fees = await portfolioCalculator.getFeesInBaseCurrency(); + + const firstOrderDate = portfolioCalculator.getStartDate(); + + const interest = await portfolioCalculator.getInterestInBaseCurrency(); + + const liabilities = + await portfolioCalculator.getLiabilitiesInBaseCurrency(); + + const valuables = await portfolioCalculator.getValuablesInBaseCurrency(); const totalBuy = this.getSumOfActivityType({ - activities, userCurrency, + activities: nonExcludedActivities, activityType: 'BUY' }).toNumber(); + const totalSell = this.getSumOfActivityType({ - activities, userCurrency, + activities: nonExcludedActivities, activityType: 'SELL' }).toNumber(); @@ -1761,7 +1640,9 @@ export class PortfolioService { .minus(emergencyFund) .plus(emergencyFundPositionsValueInBaseCurrency) .toNumber(); + const committedFunds = new Big(totalBuy).minus(totalSell); + const totalOfExcludedActivities = this.getSumOfActivityType({ userCurrency, activities: excludedActivities, @@ -1790,43 +1671,42 @@ export class PortfolioService { .toNumber(); const netWorth = new Big(balanceInBaseCurrency) - .plus(performanceInformation.performance.currentValue) - .plus(items) + .plus(currentValueInBaseCurrency) + .plus(valuables) .plus(excludedAccountsAndActivities) .minus(liabilities) .toNumber(); const daysInMarket = differenceInDays(new Date(), firstOrderDate); - const annualizedPerformancePercent = new PortfolioCalculator({ - currency: userCurrency, - currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: [] - }) - .getAnnualizedPerformancePercent({ + const annualizedPerformancePercent = getAnnualizedPerformancePercent({ + daysInMarket, + netPerformancePercentage: new Big(netPerformancePercentage) + })?.toNumber(); + + const annualizedPerformancePercentWithCurrencyEffect = + getAnnualizedPerformancePercent({ daysInMarket, - netPerformancePercent: new Big( - performanceInformation.performance.currentNetPerformancePercent + netPerformancePercentage: new Big( + netPerformancePercentageWithCurrencyEffect ) - }) - ?.toNumber(); + })?.toNumber(); return { - ...performanceInformation.performance, annualizedPerformancePercent, + annualizedPerformancePercentWithCurrencyEffect, cash, - dividend, excludedAccountsAndActivities, - fees, firstOrderDate, - interest, - items, - liabilities, - netWorth, + netPerformance, + netPerformancePercentage, + netPerformancePercentageWithCurrencyEffect, + netPerformanceWithCurrencyEffect, totalBuy, totalSell, committedFunds: committedFunds.toNumber(), + currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(), + dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), emergencyFund: { assets: emergencyFundPositionsValueInBaseCurrency, cash: emergencyFund @@ -1834,111 +1714,60 @@ export class PortfolioService { .toNumber(), total: emergencyFund.toNumber() }, - fireWealth: new Big(performanceInformation.performance.currentValue) + fees: fees.toNumber(), + filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(), + filteredValueInPercentage: netWorth + ? filteredValueInBaseCurrency.div(netWorth).toNumber() + : undefined, + fireWealth: new Big(currentValueInBaseCurrency) .minus(emergencyFundPositionsValueInBaseCurrency) .toNumber(), + grossPerformance: new Big(netPerformance).plus(fees).toNumber(), + grossPerformanceWithCurrencyEffect: new Big( + netPerformanceWithCurrencyEffect + ) + .plus(fees) + .toNumber(), + interest: interest.toNumber(), + items: valuables.toNumber(), + liabilities: liabilities.toNumber(), ordersCount: activities.filter(({ type }) => { - return type === 'BUY' || type === 'SELL'; - }).length + return ['BUY', 'SELL'].includes(type); + }).length, + totalInvestment: totalInvestment.toNumber(), + totalValueInBaseCurrency: netWorth }; } private getSumOfActivityType({ activities, activityType, - date = new Date(0), userCurrency }: { - activities: OrderWithAccount[]; + activities: Activity[]; 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, - userId, - withExcludedAccounts = false - }: { - filters?: Filter[]; - includeDrafts?: boolean; - userId: string; - withExcludedAccounts?: boolean; - }): Promise<{ - transactionPoints: TransactionPoint[]; - orders: Activity[]; - portfolioOrders: PortfolioOrder[]; - }> { - const userCurrency = - this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY; - - const { activities, count } = await this.orderService.getOrders({ - filters, - includeDrafts, - userCurrency, - userId, - withExcludedAccounts, - types: ['BUY', 'SELL'] - }); - - if (count <= 0) { - return { transactionPoints: [], orders: [], portfolioOrders: [] }; - } - - const portfolioOrders: PortfolioOrder[] = activities.map((order) => ({ - currency: order.SymbolProfile.currency, - dataSource: order.SymbolProfile.dataSource, - date: format(order.date, DATE_FORMAT), - fee: new Big(order.fee), - name: order.SymbolProfile?.name, - quantity: new Big(order.quantity), - symbol: order.SymbolProfile.symbol, - tags: order.tags, - type: order.type, - unitPrice: new Big(order.unitPrice) - })); - - const portfolioCalculator = new PortfolioCalculator({ - currency: userCurrency, - currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: portfolioOrders - }); - - portfolioCalculator.computeTransactionPoints(); - - return { - portfolioOrders, - orders: activities, - transactionPoints: portfolioCalculator.getTransactionPoints() - }; + return getSum( + activities + .filter(({ isDraft, type }) => { + return isDraft === false && type === activityType; + }) + .map(({ quantity, SymbolProfile, unitPrice }) => { + return new Big( + this.exchangeRateDataService.toCurrency( + new Big(quantity).mul(unitPrice).toNumber(), + SymbolProfile.currency, + userCurrency + ) + ); + }) + ); } - private getUserCurrency(aUser: UserWithSettings) { + private getUserCurrency(aUser?: UserWithSettings) { return ( - aUser.Settings?.settings.baseCurrency ?? + aUser?.Settings?.settings.baseCurrency ?? this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY ); @@ -1952,29 +1781,20 @@ export class PortfolioService { } private async getValueOfAccountsAndPlatforms({ + activities, filters = [], - orders, portfolioItemsNow, userCurrency, userId, withExcludedAccounts = false }: { + activities: Activity[]; filters?: Filter[]; - orders: OrderWithAccount[]; portfolioItemsNow: { [p: string]: TimelinePosition }; userCurrency: string; userId: string; withExcludedAccounts?: boolean; }) { - const { activities: ordersOfTypeItemOrLiability } = - await this.orderService.getOrders({ - filters, - userCurrency, - userId, - withExcludedAccounts, - types: ['ITEM', 'LIABILITY'] - }); - const accounts: PortfolioDetails['accounts'] = {}; const platforms: PortfolioDetails['platforms'] = {}; @@ -1992,7 +1812,7 @@ export class PortfolioService { }); } else { const accountIds = uniq( - orders + activities .filter(({ accountId }) => { return accountId; }) @@ -2012,19 +1832,10 @@ export class PortfolioService { }); for (const account of currentAccounts) { - let ordersByAccount = orders.filter(({ accountId }) => { + const ordersByAccount = activities.filter(({ accountId }) => { return accountId === account.id; }); - const ordersOfTypeItemOrLiabilityByAccount = - ordersOfTypeItemOrLiability.filter(({ accountId }) => { - return accountId === account.id; - }); - - ordersByAccount = ordersByAccount.concat( - ordersOfTypeItemOrLiabilityByAccount - ); - accounts[account.id] = { balance: account.balance, currency: account.currency, @@ -2056,41 +1867,39 @@ export class PortfolioService { }; } - for (const order of ordersByAccount) { + for (const { + Account, + quantity, + SymbolProfile, + type + } of ordersByAccount) { let currentValueOfSymbolInBaseCurrency = - order.quantity * - (portfolioItemsNow[order.SymbolProfile.symbol] - ?.marketPriceInBaseCurrency ?? - order.unitPrice ?? + getFactor(type) * + quantity * + (portfolioItemsNow[SymbolProfile.symbol]?.marketPriceInBaseCurrency ?? 0); - if (order.type === 'LIABILITY' || order.type === 'SELL') { - currentValueOfSymbolInBaseCurrency *= -1; - } - - if (accounts[order.Account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) { - accounts[order.Account?.id || UNKNOWN_KEY].valueInBaseCurrency += + if (accounts[Account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) { + accounts[Account?.id || UNKNOWN_KEY].valueInBaseCurrency += currentValueOfSymbolInBaseCurrency; } else { - accounts[order.Account?.id || UNKNOWN_KEY] = { + accounts[Account?.id || UNKNOWN_KEY] = { balance: 0, - currency: order.Account?.currency, + currency: Account?.currency, name: account.name, valueInBaseCurrency: currentValueOfSymbolInBaseCurrency }; } if ( - platforms[order.Account?.Platform?.id || UNKNOWN_KEY] - ?.valueInBaseCurrency + platforms[Account?.Platform?.id || UNKNOWN_KEY]?.valueInBaseCurrency ) { - platforms[ - order.Account?.Platform?.id || UNKNOWN_KEY - ].valueInBaseCurrency += currentValueOfSymbolInBaseCurrency; + platforms[Account?.Platform?.id || UNKNOWN_KEY].valueInBaseCurrency += + currentValueOfSymbolInBaseCurrency; } else { - platforms[order.Account?.Platform?.id || UNKNOWN_KEY] = { + platforms[Account?.Platform?.id || UNKNOWN_KEY] = { balance: 0, - currency: order.Account?.currency, + currency: Account?.currency, name: account.Platform?.name, valueInBaseCurrency: currentValueOfSymbolInBaseCurrency }; @@ -2100,44 +1909,4 @@ export class PortfolioService { return { accounts, platforms }; } - - private mergeHistoricalDataItems( - accountBalanceItems: HistoricalDataItem[], - performanceChartItems: HistoricalDataItem[] - ): HistoricalDataItem[] { - const historicalDataItemsMap: { [date: string]: HistoricalDataItem } = {}; - let latestAccountBalance = 0; - - for (const item of accountBalanceItems.concat(performanceChartItems)) { - const isAccountBalanceItem = accountBalanceItems.includes(item); - - const totalAccountBalance = isAccountBalanceItem - ? item.value - : latestAccountBalance; - - if (isAccountBalanceItem && performanceChartItems.length > 0) { - latestAccountBalance = item.value; - } else { - historicalDataItemsMap[item.date] = { - ...item, - totalAccountBalance, - netWorth: - (isAccountBalanceItem ? 0 : item.value) + totalAccountBalance - }; - } - } - - // Convert to an array and sort by date in ascending order - const historicalDataItems = Object.keys(historicalDataItemsMap).map( - (date) => { - return historicalDataItemsMap[date]; - } - ); - - historicalDataItems.sort((a, b) => { - return new Date(a.date).getTime() - new Date(b.date).getTime(); - }); - - return historicalDataItems; - } } diff --git a/apps/api/src/app/portfolio/rules.service.ts b/apps/api/src/app/portfolio/rules.service.ts index 7dfcee56a..6b6526144 100644 --- a/apps/api/src/app/portfolio/rules.service.ts +++ b/apps/api/src/app/portfolio/rules.service.ts @@ -12,13 +12,26 @@ export class RulesService { aRules: Rule[], aUserSettings: UserSettings ) { - return aRules - .filter((rule) => { - return rule.getSettings(aUserSettings)?.isActive; - }) - .map((rule) => { - const evaluationResult = rule.evaluate(rule.getSettings(aUserSettings)); - return { ...evaluationResult, name: rule.getName() }; - }); + return aRules.map((rule) => { + if (rule.getSettings(aUserSettings)?.isActive) { + const { evaluation, value } = rule.evaluate( + rule.getSettings(aUserSettings) + ); + + return { + evaluation, + value, + isActive: true, + key: rule.getKey(), + name: rule.getName() + }; + } else { + return { + isActive: false, + key: rule.getKey(), + name: rule.getName() + }; + } + }); } } diff --git a/apps/api/src/app/portfolio/update-holding-tags.dto.ts b/apps/api/src/app/portfolio/update-holding-tags.dto.ts new file mode 100644 index 000000000..11efe189d --- /dev/null +++ b/apps/api/src/app/portfolio/update-holding-tags.dto.ts @@ -0,0 +1,7 @@ +import { Tag } from '@prisma/client'; +import { IsArray } from 'class-validator'; + +export class UpdateHoldingTagsDto { + @IsArray() + tags: Tag[]; +} 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 8acd644f6..4b4168168 100644 --- a/apps/api/src/app/redis-cache/redis-cache.module.ts +++ b/apps/api/src/app/redis-cache/redis-cache.module.ts @@ -15,6 +15,7 @@ import { RedisCacheService } from './redis-cache.service'; inject: [ConfigurationService], useFactory: async (configurationService: ConfigurationService) => { return { + db: configurationService.get('REDIS_DB'), 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.mock.ts b/apps/api/src/app/redis-cache/redis-cache.service.mock.ts new file mode 100644 index 000000000..2422e88ab --- /dev/null +++ b/apps/api/src/app/redis-cache/redis-cache.service.mock.ts @@ -0,0 +1,13 @@ +import { RedisCacheService } from './redis-cache.service'; + +export const RedisCacheServiceMock = { + get: (key: string): Promise => { + return Promise.resolve(null); + }, + getPortfolioSnapshotKey: (userId: string): string => { + return `portfolio-snapshot-${userId}`; + }, + set: (key: string, value: string, ttlInSeconds?: number): Promise => { + return Promise.resolve(value); + } +}; diff --git a/apps/api/src/app/redis-cache/redis-cache.service.ts b/apps/api/src/app/redis-cache/redis-cache.service.ts index fe3fad13a..341dc4acf 100644 --- a/apps/api/src/app/redis-cache/redis-cache.service.ts +++ b/apps/api/src/app/redis-cache/redis-cache.service.ts @@ -1,9 +1,10 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; -import { UniqueAsset } from '@ghostfolio/common/interfaces'; +import { AssetProfileIdentifier, Filter } from '@ghostfolio/common/interfaces'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Inject, Injectable, Logger } from '@nestjs/common'; +import { createHash } from 'crypto'; import type { RedisCache } from './interfaces/redis-cache.interface'; @@ -21,23 +22,67 @@ export class RedisCacheService { } public async get(key: string): Promise { - return await this.cache.get(key); + return this.cache.get(key); } - public getQuoteKey({ dataSource, symbol }: UniqueAsset) { + public async getKeys(aPrefix?: string): Promise { + let prefix = aPrefix; + + if (prefix) { + prefix = `${prefix}*`; + } + + return this.cache.store.keys(prefix); + } + + public getPortfolioSnapshotKey({ + filters, + userId + }: { + filters?: Filter[]; + userId: string; + }) { + let portfolioSnapshotKey = `portfolio-snapshot-${userId}`; + + if (filters?.length > 0) { + const filtersHash = createHash('sha256') + .update(JSON.stringify(filters)) + .digest('hex'); + + portfolioSnapshotKey = `${portfolioSnapshotKey}-${filtersHash}`; + } + + return portfolioSnapshotKey; + } + + public getQuoteKey({ dataSource, symbol }: AssetProfileIdentifier) { return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`; } public async remove(key: string) { - await this.cache.del(key); + return this.cache.del(key); + } + + public async removePortfolioSnapshotsByUserId({ + userId + }: { + userId: string; + }) { + const keys = await this.getKeys( + `${this.getPortfolioSnapshotKey({ userId })}` + ); + + for (const key of keys) { + await this.remove(key); + } } public async reset() { - await this.cache.reset(); + return this.cache.reset(); } public async set(key: string, value: string, ttlInSeconds?: number) { - await this.cache.set( + return 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 index 7975573d1..ea21906ef 100644 --- a/apps/api/src/app/sitemap/sitemap.controller.ts +++ b/apps/api/src/app/sitemap/sitemap.controller.ts @@ -1,8 +1,10 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DATE_FORMAT, getYesterday, interpolate } from '@ghostfolio/common/helper'; +import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools'; import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common'; import { format } from 'date-fns'; @@ -14,7 +16,9 @@ import * as path from 'path'; export class SitemapController { public sitemapXml = ''; - public constructor() { + public constructor( + private readonly configurationService: ConfigurationService + ) { try { this.sitemapXml = fs.readFileSync( path.join(__dirname, 'assets', 'sitemap.xml'), @@ -25,11 +29,51 @@ export class SitemapController { @Get() @Version(VERSION_NEUTRAL) - public async flushCache(@Res() response: Response): Promise { + public async getSitemapXml(@Res() response: Response): Promise { + const currentDate = format(getYesterday(), DATE_FORMAT); + response.setHeader('content-type', 'application/xml'); response.send( interpolate(this.sitemapXml, { - currentDate: format(getYesterday(), DATE_FORMAT) + currentDate, + personalFinanceTools: this.configurationService.get( + 'ENABLE_FEATURE_SUBSCRIPTION' + ) + ? personalFinanceTools + .map(({ alias, key }) => { + return [ + '', + ` https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-${alias ?? key}`, + ` ${currentDate}T00:00:00+00:00`, + '', + '', + ` https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-${alias ?? key}`, + ` ${currentDate}T00:00:00+00:00`, + '', + '', + ` https://ghostfol.io/es/recursos/personal-finance-tools/alternativa-de-software-libre-a-${alias ?? key}`, + ` ${currentDate}T00:00:00+00:00`, + '', + '', + ` https://ghostfol.io/fr/ressources/personal-finance-tools/alternative-open-source-a-${alias ?? key}`, + ` ${currentDate}T00:00:00+00:00`, + '', + '', + ` https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-${alias ?? key}`, + ` ${currentDate}T00:00:00+00:00`, + '', + '', + ` https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-${alias ?? key}`, + ` ${currentDate}T00:00:00+00:00`, + '', + '', + ` https://ghostfol.io/pt/recursos/personal-finance-tools/alternativa-de-software-livre-ao-${alias ?? key}`, + ` ${currentDate}T00:00:00+00:00`, + '' + ].join('\n'); + }) + .join('\n') + : '' }) ); } diff --git a/apps/api/src/app/sitemap/sitemap.module.ts b/apps/api/src/app/sitemap/sitemap.module.ts index ba60d43f6..d1059d408 100644 --- a/apps/api/src/app/sitemap/sitemap.module.ts +++ b/apps/api/src/app/sitemap/sitemap.module.ts @@ -1,10 +1,4 @@ -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'; @@ -12,14 +6,6 @@ import { SitemapController } from './sitemap.controller'; @Module({ controllers: [SitemapController], - imports: [ - ConfigurationModule, - DataGatheringModule, - DataProviderModule, - ExchangeRateDataModule, - PrismaModule, - RedisCacheModule, - SymbolProfileModule - ] + imports: [ConfigurationModule] }) export class SitemapModule {} diff --git a/apps/api/src/app/subscription/subscription.controller.ts b/apps/api/src/app/subscription/subscription.controller.ts index 7aef70273..f4ca6d427 100644 --- a/apps/api/src/app/subscription/subscription.controller.ts +++ b/apps/api/src/app/subscription/subscription.controller.ts @@ -116,7 +116,7 @@ export class SubscriptionController { @Body() { couponId, priceId }: { couponId: string; priceId: string } ) { try { - return await this.subscriptionService.createCheckoutSession({ + return this.subscriptionService.createCheckoutSession({ couponId, priceId, user: this.request.user diff --git a/apps/api/src/app/subscription/subscription.service.ts b/apps/api/src/app/subscription/subscription.service.ts index d53944787..545450669 100644 --- a/apps/api/src/app/subscription/subscription.service.ts +++ b/apps/api/src/app/subscription/subscription.service.ts @@ -22,7 +22,7 @@ export class SubscriptionService { this.stripe = new Stripe( this.configurationService.get('STRIPE_SECRET_KEY'), { - apiVersion: '2022-11-15' + apiVersion: '2024-04-10' } ); } diff --git a/apps/api/src/app/symbol/interfaces/symbol-item.interface.ts b/apps/api/src/app/symbol/interfaces/symbol-item.interface.ts index 358658672..710a84144 100644 --- a/apps/api/src/app/symbol/interfaces/symbol-item.interface.ts +++ b/apps/api/src/app/symbol/interfaces/symbol-item.interface.ts @@ -1,6 +1,9 @@ -import { HistoricalDataItem, UniqueAsset } from '@ghostfolio/common/interfaces'; +import { + AssetProfileIdentifier, + HistoricalDataItem +} from '@ghostfolio/common/interfaces'; -export interface SymbolItem extends UniqueAsset { +export interface SymbolItem extends AssetProfileIdentifier { currency: string; historicalData: HistoricalDataItem[]; marketPrice: number; diff --git a/apps/api/src/app/symbol/symbol.controller.ts b/apps/api/src/app/symbol/symbol.controller.ts index 5b50599cf..b3b9dc109 100644 --- a/apps/api/src/app/symbol/symbol.controller.ts +++ b/apps/api/src/app/symbol/symbol.controller.ts @@ -1,6 +1,6 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; -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 { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; +import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import type { RequestWithUser } from '@ghostfolio/common/types'; @@ -39,9 +39,11 @@ export class SymbolController { @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async lookupSymbol( - @Query('includeIndices') includeIndices: boolean = false, + @Query('includeIndices') includeIndicesParam = 'false', @Query('query') query = '' ): Promise<{ items: LookupItem[] }> { + const includeIndices = includeIndicesParam === 'true'; + try { return this.symbolService.lookup({ includeIndices, diff --git a/apps/api/src/app/symbol/symbol.module.ts b/apps/api/src/app/symbol/symbol.module.ts index 3b5379a62..223a0a832 100644 --- a/apps/api/src/app/symbol/symbol.module.ts +++ b/apps/api/src/app/symbol/symbol.module.ts @@ -1,4 +1,5 @@ -import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; +import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; @@ -12,10 +13,11 @@ import { SymbolService } from './symbol.service'; controllers: [SymbolController], exports: [SymbolService], imports: [ - ConfigurationModule, DataProviderModule, MarketDataModule, - PrismaModule + PrismaModule, + TransformDataSourceInRequestModule, + TransformDataSourceInResponseModule ], providers: [SymbolService] }) diff --git a/apps/api/src/app/symbol/symbol.service.ts b/apps/api/src/app/symbol/symbol.service.ts index 9a3f7a3a0..2baca18dd 100644 --- a/apps/api/src/app/symbol/symbol.service.ts +++ b/apps/api/src/app/symbol/symbol.service.ts @@ -40,13 +40,13 @@ export class SymbolService { const days = includeHistoricalData; const marketData = await this.marketDataService.getRange({ - dateQuery: { gte: subDays(new Date(), days) }, - uniqueAssets: [ + assetProfileIdentifiers: [ { dataSource: dataGatheringItem.dataSource, symbol: dataGatheringItem.symbol } - ] + ], + dateQuery: { gte: subDays(new Date(), days) } }); historicalData = marketData.map(({ date, marketPrice: value }) => { @@ -74,11 +74,21 @@ export class SymbolService { date = new Date(), symbol }: IDataGatheringItem): Promise { - const historicalData = await this.dataProviderService.getHistoricalRaw( - [{ dataSource, symbol }], - date, - date - ); + let historicalData: { + [symbol: string]: { + [date: string]: IDataProviderHistoricalResponse; + }; + } = { + [symbol]: {} + }; + + try { + historicalData = await this.dataProviderService.getHistoricalRaw({ + dataGatheringItems: [{ dataSource, symbol }], + from: date, + to: date + }); + } catch {} return { marketPrice: diff --git a/apps/api/src/app/user/delete-own-user.dto.ts b/apps/api/src/app/user/delete-own-user.dto.ts new file mode 100644 index 000000000..1e3f940cb --- /dev/null +++ b/apps/api/src/app/user/delete-own-user.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class DeleteOwnUserDto { + @IsString() + accessToken: string; +} diff --git a/apps/api/src/app/user/update-user-setting.dto.ts b/apps/api/src/app/user/update-user-setting.dto.ts index 317a31527..6ea6d7427 100644 --- a/apps/api/src/app/user/update-user-setting.dto.ts +++ b/apps/api/src/app/user/update-user-setting.dto.ts @@ -1,7 +1,10 @@ +import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code'; import type { ColorScheme, DateRange, - ViewMode + HoldingsViewMode, + ViewMode, + XRayRulesSettings } from '@ghostfolio/common/types'; import { @@ -13,14 +16,15 @@ import { IsOptional, IsString } from 'class-validator'; +import { eachYearOfInterval, format } from 'date-fns'; export class UpdateUserSettingDto { @IsNumber() @IsOptional() annualInterestRate?: number; + @IsCurrencyCode() @IsOptional() - @IsString() baseCurrency?: string; @IsString() @@ -31,7 +35,20 @@ export class UpdateUserSettingDto { @IsOptional() colorScheme?: ColorScheme; - @IsIn(['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd']) + @IsIn([ + '1d', + '1y', + '5y', + 'max', + 'mtd', + 'wtd', + 'ytd', + ...eachYearOfInterval({ end: new Date(), start: new Date(0) }).map( + (date) => { + return format(date, 'yyyy'); + } + ) + ]) @IsOptional() dateRange?: DateRange; @@ -51,6 +68,10 @@ export class UpdateUserSettingDto { @IsOptional() 'filters.tags'?: string[]; + @IsIn(['CHART', 'TABLE']) + @IsOptional() + holdingsViewMode?: HoldingsViewMode; + @IsBoolean() @IsOptional() isExperimentalFeatures?: boolean; @@ -82,4 +103,7 @@ export class UpdateUserSettingDto { @IsIn(['DEFAULT', 'ZEN']) @IsOptional() viewMode?: ViewMode; + + @IsOptional() + xRayRules?: XRayRulesSettings; } diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index cf332b2e8..c23870437 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -1,5 +1,6 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { User, UserSettings } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; @@ -23,8 +24,9 @@ import { JwtService } from '@nestjs/jwt'; import { AuthGuard } from '@nestjs/passport'; import { User as UserModel } from '@prisma/client'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; -import { size } from 'lodash'; +import { merge, size } from 'lodash'; +import { DeleteOwnUserDto } from './delete-own-user.dto'; import { UserItem } from './interfaces/user-item.interface'; import { UpdateUserSettingDto } from './update-user-setting.dto'; import { UserService } from './user.service'; @@ -32,12 +34,41 @@ import { UserService } from './user.service'; @Controller('user') export class UserController { public constructor( + private readonly configurationService: ConfigurationService, private readonly jwtService: JwtService, private readonly propertyService: PropertyService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly userService: UserService ) {} + @Delete() + @HasPermission(permissions.deleteOwnUser) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async deleteOwnUser( + @Body() data: DeleteOwnUserDto + ): Promise { + const hashedAccessToken = this.userService.createAccessToken( + data.accessToken, + this.configurationService.get('ACCESS_TOKEN_SALT') + ); + + const [user] = await this.userService.users({ + where: { accessToken: hashedAccessToken, id: this.request.user.id } + }); + + if (!user) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.userService.deleteUser({ + accessToken: hashedAccessToken, + id: user.id + }); + } + @Delete(':id') @HasPermission(permissions.deleteUser) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @@ -113,10 +144,13 @@ export class UserController { ); } - const userSettings: UserSettings = { - ...(this.request.user.Settings.settings), - ...data - }; + const emitPortfolioChangedEvent = 'baseCurrency' in data; + + const userSettings: UserSettings = merge( + {}, + this.request.user.Settings.settings, + data + ); for (const key in userSettings) { if (userSettings[key] === false || userSettings[key] === null) { @@ -124,7 +158,8 @@ export class UserController { } } - return await this.userService.updateUserSetting({ + return this.userService.updateUserSetting({ + emitPortfolioChangedEvent, userSettings, userId: this.request.user.id }); diff --git a/apps/api/src/app/user/user.module.ts b/apps/api/src/app/user/user.module.ts index 35f78dc5f..063cfef82 100644 --- a/apps/api/src/app/user/user.module.ts +++ b/apps/api/src/app/user/user.module.ts @@ -1,3 +1,4 @@ +import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; @@ -19,6 +20,7 @@ import { UserService } from './user.service'; secret: process.env.JWT_SECRET_KEY, signOptions: { expiresIn: '30 days' } }), + OrderModule, PrismaModule, PropertyModule, SubscriptionModule, diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 2c4d3760c..f8746881f 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -1,11 +1,15 @@ +import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { environment } from '@ghostfolio/api/environments/environment'; +import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { I18nService } from '@ghostfolio/api/services/i18n/i18n.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_LANGUAGE_CODE, PROPERTY_IS_READ_ONLY_MODE, PROPERTY_SYSTEM_MESSAGE, locale @@ -23,6 +27,7 @@ import { import { UserWithSettings } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { Prisma, Role, User } from '@prisma/client'; import { differenceInDays } from 'date-fns'; import { sortBy, without } from 'lodash'; @@ -31,8 +36,12 @@ const crypto = require('crypto'); @Injectable() export class UserService { + private i18nService = new I18nService(); + public constructor( private readonly configurationService: ConfigurationService, + private readonly eventEmitter: EventEmitter2, + private readonly orderService: OrderService, private readonly prismaService: PrismaService, private readonly propertyService: PropertyService, private readonly subscriptionService: SubscriptionService, @@ -47,13 +56,22 @@ export class UserService { { Account, id, permissions, Settings, subscription }: UserWithSettings, aLocale = locale ): Promise { - const access = await this.prismaService.access.findMany({ - include: { - User: true - }, - orderBy: { alias: 'asc' }, - where: { GranteeUser: { id } } - }); + let [access, firstActivity, tags] = await Promise.all([ + this.prismaService.access.findMany({ + include: { + User: true + }, + orderBy: { alias: 'asc' }, + where: { GranteeUser: { id } } + }), + this.prismaService.order.findFirst({ + orderBy: { + date: 'asc' + }, + where: { userId: id } + }), + this.tagService.getByUser(id) + ]); let systemMessage: SystemMessage; @@ -65,8 +83,6 @@ export class UserService { systemMessage = systemMessageProperty; } - let tags = await this.tagService.getByUser(id); - if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && subscription.type === 'Basic' @@ -87,6 +103,7 @@ export class UserService { }; }), accounts: Account, + dateOfFirstActivity: firstActivity?.date ?? new Date(), settings: { ...(Settings.settings), locale: (Settings.settings)?.locale ?? aLocale @@ -106,28 +123,6 @@ export class UserService { return usersWithAdminRole.length > 0; } - public hasReadRestrictedAccessPermission({ - impersonationId, - user - }: { - impersonationId: string; - user: UserWithSettings; - }) { - if (!impersonationId) { - return false; - } - - const access = user.Access?.find(({ id }) => { - return id === impersonationId; - }); - - return access?.permissions?.includes('READ_RESTRICTED') ?? true; - } - - public isRestrictedView(aUser: UserWithSettings) { - return aUser.Settings.settings.isRestrictedView ?? false; - } - public async user( userWhereUniqueInput: Prisma.UserWhereUniqueInput ): Promise { @@ -195,13 +190,25 @@ export class UserService { (user.Settings.settings as UserSettings).dateRange = (user.Settings.settings as UserSettings).viewMode === 'ZEN' ? 'max' - : (user.Settings.settings as UserSettings)?.dateRange ?? 'max'; + : ((user.Settings.settings as UserSettings)?.dateRange ?? 'max'); // Set default value for view mode if (!(user.Settings.settings as UserSettings).viewMode) { (user.Settings.settings as UserSettings).viewMode = 'DEFAULT'; } + // Set default values for X-ray rules + if (!(user.Settings.settings as UserSettings).xRayRules) { + (user.Settings.settings as UserSettings).xRayRules = { + AccountClusterRiskCurrentInvestment: { isActive: true }, + AccountClusterRiskSingleAccount: { isActive: true }, + CurrencyClusterRiskBaseCurrencyCurrentInvestment: { isActive: true }, + CurrencyClusterRiskCurrentInvestment: { isActive: true }, + EmergencyFundSetup: { isActive: true }, + FeeRatioInitialInvestment: { isActive: true } + }; + } + let currentPermissions = getPermissions(user.role); if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) { @@ -242,15 +249,22 @@ export class UserService { currentPermissions = without( currentPermissions, + permissions.accessHoldingsChart, permissions.createAccess ); // Reset benchmark user.Settings.settings.benchmark = undefined; - } - if (user.subscription?.type === 'Premium') { + // Reset holdings view mode + user.Settings.settings.holdingsViewMode = undefined; + } else if (user.subscription?.type === 'Premium') { currentPermissions.push(permissions.reportDataGlitch); + + currentPermissions = without( + currentPermissions, + permissions.deleteOwnUser + ); } } @@ -325,8 +339,10 @@ export class UserService { Account: { create: { currency: DEFAULT_CURRENCY, - isDefault: true, - name: 'Default Account' + name: this.i18nService.getTranslation({ + id: 'myAccount', + languageCode: DEFAULT_LANGUAGE_CODE // TODO + }) } }, Settings: { @@ -400,8 +416,8 @@ export class UserService { } catch {} try { - await this.prismaService.order.deleteMany({ - where: { userId: where.id } + await this.orderService.deleteOrders({ + userId: where.id }); } catch {} @@ -417,17 +433,17 @@ export class UserService { } public async updateUserSetting({ + emitPortfolioChangedEvent, userId, userSettings }: { + emitPortfolioChangedEvent: boolean; userId: string; userSettings: UserSettings; }) { - const settings = userSettings as unknown as Prisma.JsonObject; - - await this.prismaService.settings.upsert({ + const { settings } = await this.prismaService.settings.upsert({ create: { - settings, + settings: userSettings as unknown as Prisma.JsonObject, User: { connect: { id: userId @@ -435,25 +451,35 @@ export class UserService { } }, update: { - settings + settings: userSettings as unknown as Prisma.JsonObject }, where: { - userId: userId + userId } }); - return; + if (emitPortfolioChangedEvent) { + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId + }) + ); + } + + return settings; } private getRandomString(length: number) { + const bytes = crypto.randomBytes(length); const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; const result = []; for (let i = 0; i < length; i++) { - result.push( - characters.charAt(Math.floor(Math.random() * characters.length)) - ); + const randomByte = bytes[i]; + result.push(characters[randomByte % characters.length]); } + return result.join(''); } } diff --git a/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json b/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json index c3e595fc6..c63ba5c73 100644 --- a/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json +++ b/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json @@ -1,26 +1,41 @@ { + "7": "Lucky7", "42": "42 Coin", "300": "300 token", "365": "365Coin", + "369": "Nikola Tesla Token", "404": "404Coin", "433": "433 Token", "611": "SixEleven", + "777": "Jackpot", "808": "808", "888": "Octocoin", "1337": "EliteCoin", "1717": "1717 Masonic Commemorative Token", "2015": "2015 coin", "2024": "2024", + "2049": "TOKEN 2049", + "2192": "LERNITAS", "$MAID": "MaidCoin", "$ROPE": "Rope", "$TIME": "Madagascar Token", "$TREAM": "World Stream Finance", "00": "ZER0ZER0", "007": "007 coin", + "0KN": "0 Knowledge Network", + "0NE": "Stone", "0X1": "0x1.tools: AI Multi-tool Plaform", "0XBTC": "0xBitcoin", + "0XDEV": "DEVAI", + "0XG": "0xGpu.ai", + "0XGAS": "0xGasless", + "0XL": "0x Leverage", + "0XOS": "0xOS AI", + "0XSEARCH": "Search", + "0XVOX": "HashVox AI", "0x0": "0x0.ai", "0xDIARY": "The 0xDiary Token", + "0xVPN": "0xVPN.org", "1-UP": "1-UP", "1000SATS": "SATS", "10SET": "Tenset", @@ -29,9 +44,16 @@ "1CR": "1Credit", "1EARTH": "EarthFund", "1ECO": "1eco", + "1EX": "1ex Trading Board", + "1FLR": "Flare Token", "1GOLD": "1irstGold", "1INCH": "1inch", "1IRST": "1irstcoin", + "1MCT": "MicroCreditToken", + "1MDC": "1MDC", + "1MIL": "1MillionNFTs", + "1MT": "1Move", + "1NFT": "1NFT", "1PECO": "1peco", "1SG": "1SG", "1SOL": "1Sol", @@ -40,27 +62,49 @@ "1UP": "Uptrennd", "1WO": "1World", "2022M": "2022MOON", + "21X": "21X", "2BACCO": "2BACCO Coin", "2BASED": "2Based Finance", "2CRZ": "2crazyNFT", + "2DAI": "2DAI.io", "2GCC": "2G Carbon Coin", "2GIVE": "2GiveCoin", "2GT": "2GETHER", "2KEY": "2key.network", "2LC": "2local", + "2MOON": "The Moon Metaverse", + "2OMB": "2omb Finance", + "2SHARES": "2SHARE", "2TF": "2TF", "300F": "300FIT", + "314DAO": "Tonken 314 DAO", "32BIT": "32Bitcoin", + "360NS": "360 NOSCOPE INSTASWAP WALLBANG", "37C": "37Protocol", + "3AIR": "3air", + "3CEO": "FLOKI SHIBA PEPE CEO", + "3CRV": "LP 3pool Curve", "3D3D": "3d3d", "3DES": "3DES", "3FT": "ThreeFold Token", + "3KM": "3 Kingdoms Multiverse", + "3P": "Web3Camp", "3ULL": "3ULL Coin", "3XD": "3DChain", + "404A": "404Aliens", + "404BLOCKS": "404Blocks", "420CHAN": "420chan", "4ART": "4ART Coin", "4CHAN": "4Chan", + "4CZ": "FourCZ", "4JNET": "4JNET", + "4MW": "For Meta World", + "4RZ": "4REALZA COIN", + "4TOKEN": "Ignore Fud", + "4WMM": "4-Way Mirror Money", + "50C": "50Cent", + "50X": "50x.com", + "5IRE": "5ire", "77G": "GraphenTech", "7E": "7ELEVEN", "88MPH": "88mph", @@ -68,28 +112,45 @@ "8BT": "8 Circuit Studios", "8PAY": "8Pay", "8X8": "8X8 Protocol", + "9DOGS": "NINE DOGS", "9GAG": "9GAG", + "9MM": "Shigure UI", "A": "Alpha Token", + "A2A": "A2A", + "A4": "A4 Finance", + "A4M": "AlienForm", + "A51": "A51 Finance", "A5T": "Alpha5", "AA": "Alva", "AAA": "Moon Rabbit", "AAB": "AAX Token", + "AABL": "Abble", "AAC": "Double-A Chain", "AAG": "AAG Ventures", + "AAI": "AutoAir AI", "AAPX": "AMPnet", "AART": "ALL.ART", + "AAST": "AASToken", "AAT": "Agricultural Trade Chain", "AAVE": "Aave", + "AAZ": "ATLAZ", "ABA": "EcoBall", "ABBC": "ABBC Coin", "ABC": "ABC Chain", "ABCC": "ABCC Token", + "ABCD": "Crypto Inu", + "ABD": "AB DEFI", "ABEL": "Abelian", + "ABET": "Altbet", "ABEY": "Abey", "ABIC": "Arabic", "ABJ": "Abjcoin", "ABL": "Airbloc", + "ABN": "Antofy", + "ABO": "Albino", "ABOND": "ApeBond", + "ABONDV1": "ApeSwap", + "ABR": "Allbridge", "ABT": "ArcBlock", "ABX": "Arbidex", "ABY": "ArtByte", @@ -97,6 +158,7 @@ "AC": "Asia Coin", "AC3": "AC3", "ACA": "Acala", + "ACALAUSD": "Acala Dollar (Acala)", "ACAT": "Alphacat", "ACATO": "ACA Token", "ACCEL": "Accel Defi", @@ -110,15 +172,20 @@ "ACETH": "Acether", "ACH": "Alchemy Pay", "ACHC": "AchieveCoin", + "ACHI": "achi", "ACID": "AcidCoin", "ACK": "Arcade Kingdoms", "ACM": "AC Milan Fan Token", "ACN": "AvonCoin", "ACOIN": "ACoin", "ACP": "Anarchists Prime", + "ACPT": "Crypto Accept", "ACQ": "Acquire.Fi", + "ACRE": "Arable Protocol", + "ACRIA": "Acria.AI", "ACS": "Access Protocol", "ACT": "Achain", + "ACTA": "Acta Finance", "ACTIN": "Actinium", "ACTN": "Action Coin", "ACU": "ACU Platform", @@ -128,14 +195,19 @@ "AD": "ADreward", "ADA": "Cardano", "ADAB": "Adab Solutions", + "ADACASH": "ADACash", "ADAI": "Aave DAI", + "ADANA": "Adanaspor Fan Token", "ADAO": "ADADao", "ADAPAD": "ADAPad", "ADAT": "Adadex Tools", "ADAX": "ADAX", "ADB": "Adbank", "ADC": "AudioCoin", + "ADCO": "Advertise Coin", "ADD": "ADD.xyz", + "ADDAMS": "ADDAMS AI", + "ADDY": "Adamant", "ADEL": "Akropolis Delphi", "ADF": "Art de Finance", "ADH": "Adhive", @@ -145,17 +217,25 @@ "ADM": "ADAMANT Messenger", "ADN": "Aladdin", "ADNT": "Aiden", + "ADO": "ADO Protocol", + "ADOGE": "Arbidoge", + "ADON": "Adonis", "ADP": "Adappter Token", + "ADR": "Adroverse", "ADRX": "Adrenaline Chain", "ADS": "Adshares", "ADT": "AdToken", "ADUX": "Adult X Token", + "ADVT": "Advantis", "ADX": "Ambire AdEx", "ADXX": "AnonyDoxx", "ADZ": "Adzcoin", "AE": "Aeternity", "AEC": "AcesCoin", + "AEG": "Aether Games", + "AEGGS": "aEGGS", "AEGIS": "Aegis", + "AELIN": "Aelin", "AEN": "Aenco", "AENS": "AEN Smart", "AENT": "AEN", @@ -164,35 +244,55 @@ "AERGO": "AERGO", "AERM": "Aerium", "AERO": "Aerodrome Finance", + "AEROBUD": "Aerobud", "AEROCOIN": "Aero Coin", + "AEROT": "AEROTYME", "AES": "Artis Aes Evolution", "AET": "AfterEther", "AETH": "Aave ETH", "AETHC": "Ankr Reward-Bearing Staked ETH", "AETHERV2": "AetherV2", "AEUR": "Anchored Coins AEUR", - "AEVO": "Always Evolving", + "AEVO": "Aevo", + "AEVUM": "Aevum", + "AFB": "A Fund Baby", "AFC": "Arsenal Fan Token", "AFCT": "Allforcrypto", "AFEN": "AFEN Blockchain", "AFFC": "Affil Coin", + "AFG": "Army of Fortune Gem", "AFIN": "Asian Fintech", "AFIT": "Actifit", "AFK": "AFKDAO", + "AFNTY": "Affinity", "AFO": "AllForOneBusiness", + "AFP": "Animal Farm Pigs", + "AFR": "Afreum", + "AFRO": "Afrostar", + "AFROX": "AfroDex", "AFTT": "Africa Trading Chain", "AFX": "Afrix", + "AFYON": "Afyonspor Fan Token", + "AG": "AGAME", "AG8": "ATROMG8", "AGA": "AGA Token", "AGATA": "Agatech", + "AGB": "Apes Go Bananas", "AGET": "Agetron", "AGEUR": "agEUR", "AGF": "Augmented Finance", + "AGG": "AGG", "AGI": "Delysium", + "AGII": "AGII", + "AGIV1": "SingularityNET v1", "AGIX": "SingularityNET", "AGLA": "Angola", "AGLD": "Adventure Gold", "AGM": "Argoneum", + "AGN": "Agnus Ai", + "AGO": "AgoDefi", + "AGON": "Arabian Dragon", + "AGOV": "Answer Governance", "AGPC": "AGPC", "AGRO": "Bit Agro", "AGRS": "Agoras Token", @@ -200,52 +300,105 @@ "AGT": "aGifttoken", "AGV": "Astra Guild Ventures", "AGVC": "AgaveCoin", + "AGVE": "Agave", "AGX": "Agricoin", "AHOO": "Ahoolee", "AHT": "AhaToken", "AI": "Sleepless", + "AIA": "AIA Chain", + "AIAI": "All In AI", + "AIAKITA": "AiAkita", + "AIAT": "AI Analysis Token", "AIB": "AdvancedInternetBlock", + "AIBABYDOGE": "AIBabyDoge", "AIBB": "AiBB", + "AIBCOIN": "AIBLOCK", "AIBK": "AIB Utility Token", + "AIBU": "AIBUZZ TOKEN", "AIC": "AI Crypto", + "AICH": "AIChain", "AICO": "AICON", + "AICODE": "AI CODE", + "AICORE": "AICORE", "AID": "AidCoin", "AIDI": "Aidi Inu", "AIDOC": "AI Doctor", "AIDOG": "AiDoge", "AIDOGE": "ArbDoge AI", + "AIDOGEX": "AI DogeX", + "AIDOGEXLM": "AIDOGE Stellar", "AIDT": "AIDUS TOKEN", "AIDUS": "AIDUS Token", + "AIE": "A.I.Earn", + "AIEN": "AIENGLISH", + "AIF": "AI FREEDOM TOKEN", + "AIFLOKI": "AI Floki", + "AIG": "A.I Genesis", + "AIGPU": "AIGPU Token", + "AII": "Artificial Idiot", + "AIINU": "AI INU", + "AIKEK": "AlphaKEK.AI", "AILINK": "AiLink Token", "AIM": "ModiHost", + "AIMARKET": "Acria.AI AIMARKET", + "AIMBOT": "AimBot AI", + "AIMEE": "AIMEE", + "AIMR": "MeromAI", "AIMS": "HighCastle Token", "AIMX": "Aimedis", "AIN": "AI Network", + "AINN": "AINN", + "AINU": "Ainu Token", "AION": "Aion", "AIONE": "AiONE", "AIOT": "AIOT Token", "AIOZ": "AIOZ Network", + "AIPAD": "AIPAD", "AIPE": "AI Prediction Ecosystem", + "AIPEPE": "AI PEPE KING", + "AIPG": "AI Power Grid", "AIPIN": "AI PIN", "AIR": "Altair", + "AIRB": "BillionAir", + "AIRBTC": "AIRBTC", "AIRE": "Tokenaire", + "AIRI": "aiRight", "AIRT": "Aircraft", + "AIRTNT": "Tenti", "AIRTOKEN": "AirToken", "AIRX": "Aircoins", - "AIT": "AIChain Token", + "AIS": "AISwap", + "AISCII": "AISCII", + "AISHIB": "ARBSHIB", + "AIT": "AIT Protocol", "AITECH": "Artificial Intelligence Utility Token", + "AITEK": "AI Technology", "AITHEON": "Aitheon", + "AITIGER": "BNB Tiger AI", + "AITK": "AITK", + "AITN": "Artificial Intelligence Technology Network", "AITRA": "Aitra", "AITT": "AITrading", + "AIUS": "Arbius", + "AIWALLET": "AiWallet Token", "AIX": "Aigang", + "AJNA": "Ajna Protocol", + "AK12": "AK12", "AKA": "Akroma", + "AKI": "Aki Network", "AKITA": "Akita Inu", + "AKITAX": "Akitavax", "AKN": "Akoin", "AKNC": "Aave KNC v1", + "AKREP": "Antalyaspor Token", "AKRO": "Akropolis", "AKT": "Akash Network", "AKTIO": "AKTIO Coin", "ALA": "ALA", + "ALAN": "Alan the Alien", + "ALB": "Alien Base", + "ALBART": "Albärt", + "ALBEDO": "ALBEDO", "ALBT": "AllianceBlock", "ALC": "Arab League Coin", "ALCAZAR": "Alcazar", @@ -254,6 +407,8 @@ "ALCHE": "Alchemist", "ALCX": "Alchemix", "ALD": "AladdinDAO", + "ALDIN": "Alaaddin.ai", + "ALE": "Ailey", "ALEPH": "Aleph.im", "ALEX": "ALEX Lab", "ALEXANDRITE": "Alexandrite", @@ -262,12 +417,14 @@ "ALGB": "Algebra", "ALGO": "Algorand", "ALGOBLK": "AlgoBlocks", + "ALGOW": "Algowave", "ALH": "AlloHash", "ALI": "Alethea Artificial Liquid Intelligence Token", "ALIAS": "Alias", "ALIC": "AliCoin", "ALICE": "My Neighbor Alice", "ALIEN": "AlienCoin", + "ALIF": " ALIF COIN", "ALINK": "Aave LINK v1", "ALIS": "ALISmedia", "ALIT": "Alitas", @@ -277,14 +434,21 @@ "ALLBI": "ALL BEST ICO", "ALLEY": "NFT Alley", "ALLIN": "All in", + "ALM": "Alium Finance", + "ALMC": "Awkward Look Monkey Club", + "ALME": "Alita", "ALN": "Aluna", "ALOHA": "Aloha", + "ALOT": "Dexalot", "ALP": "Alphacon", "ALPA": "Alpaca", "ALPACA": "Alpaca Finance", "ALPH": "Alephium", "ALPHA": "Alpha Finance Lab", + "ALPHAAI": "Alpha AI", + "ALPHABET": "Alphabet", "ALPHAC": "Alpha Coin", + "ALPHAS": "Alpha Shards", "ALPHR": "Alphr", "ALPINE": "Alpine F1 Team Fan Token", "ALPS": "Alpenschillling", @@ -293,24 +457,38 @@ "ALTB": "Altbase", "ALTCOIN": "ALTcoin", "ALTCOM": "AltCommunity Coin", + "ALTD": "Altitude", + "ALTMAN": "SAM", "ALTOCAR": "AltoCar", + "ALTR": "Altranium", "ALU": "Altura", "ALUSD": "Alchemix USD", "ALUX": "Alux Bank", "ALV": "Allive", + "ALVA": "Alvara Protocol", + "ALWAYS": "Always Evolving", "ALX": "ALAX", "ALY": "Ally", "AM": "AeroMe", "AMA": "MrWeb", + "AMADEUS": "AMADEUS", "AMAL": "AMAL", + "AMAPT": "Amnis Finance", "AMATEN": "Amaten", + "AMAZINGTEAM": "AmazingTeamDAO", "AMB": "AirDAO", "AMBER": "AmberCoin", + "AMBO": "Sheertopia", + "AMBR": "Ambra", "AMBT": "AMBT Token", "AMDC": "Allmedi Coin", "AMDG": "AMDG", "AME": "Amepay", + "AMERICA": "America", "AMERICANCOIN": "AmericanCoin", + "AMF": "AddMeFast", + "AMG": "DeHeroGame Amazing Token", + "AMI": "AMMYI Coin", "AMIO": "Amino Network", "AMIS": "AMIS", "AMKT": "Alongside Crypto Market Index", @@ -319,6 +497,7 @@ "AMMO": "Ammo Rewards", "AMN": "Amon", "AMO": "AMO Coin", + "AMOGUS": "Sussy Baka Impostor", "AMON": "AmonD", "AMOS": "Amos", "AMP": "Amp", @@ -326,31 +505,46 @@ "AMPLIFI": "AmpliFi", "AMS": "Amsterdam Coin", "AMT": "Acumen", + "AMU": "Amulet", + "AMV": "Avatar Musk Verse", "AMX": "Amero", "AMY": "Amygws", "AMZE": "The Amaze World", "ANA": "Nirvana ANA", "ANAL": "AnalCoin", "ANALOS": "analoS", + "ANALY": "Analysoor", "ANB": "Angryb", "ANC": "Anchor Protocol", + "ANCHOR": "AnchorSwap", "ANCP": "Anacrypt", "ANCT": "Anchor", "AND": "AndromedaCoin", "ANDC": "Android chain", + "ANDR": "Andromeda", "ANDROTTWEILER": "Androttweiler Token", + "ANDWU": "Chinese Andy", "ANDX": "Arrano", + "ANDY": "ANDY", "ANGEL": "Crypto Angel", "ANGL": "Angel Token", "ANGLE": "ANGLE", + "ANGO": "Aureus Nummus Gold", + "ANGRYSLERF": "ANGRYSLERF", "ANI": "Animecoin", + "ANIM": "Animalia", + "ANIMA": "Realm Anima", + "ANIME": "Anime", "ANJ": "Aragon Court", "ANJI": "Anji", "ANK": "AlphaLink", - "ANKA.BITCI": "Ankaragücü Fan Token", + "ANKA": "Ankaragücü Fan Token", "ANKORUS": "Ankorus Token", "ANKR": "Ankr Network", + "ANKRBNB": "Ankr Staked BNB", "ANKRETH": "Ankr Staked ETH", + "ANKRFTM": "Ankr Staked FTM", + "ANKRMATIC": "Ankr Staked MATIC", "ANML": "Animal Concerts", "ANN": "Annex Finance", "ANON": "ANON", @@ -365,23 +559,36 @@ "ANTI": "Anti Bitcoin", "ANTIS": "Antis Inu", "ANTS": "ANTS Reloaded", + "ANUS": "URANUS", "ANV": "Aniverse", "ANW": "Anchor Neural World", "ANY": "Anyswap", + "ANYONE": "ANyONe Protocol", + "AOC": "Alickshundra Occasional-Cortex", "AOG": "AgeOfGods", "AOK": "AOK", "AOP": "Averopay", "AOS": "AOS", + "AOT": "Age of Tanks", + "AP": "AppleSwap AI", "APAD": "Anypad", "APC": "AlpaCoin", + "APCG": "ALLPAYCOIN", + "APD": "Aptopad", "APE": "ApeCoin", "APECOIN": "Asia Pacific Electronic Coin", "APED": "Baddest Alpha Ape Bundle", + "APES": "Alpha Petto Shells", + "APETARDIO": "Apetardio", + "APEWIFHAT": "ApeWifHat", "APEX": "ApeX Protocol", "APEXCOIN": "ApexCoin", + "APEXT": "ApexToken", + "APFC": "APF coin", "APH": "Aphelion", "API": "Application Programming Interface", "API3": "API3", + "APING": "aping", "APIS": "APIS", "APIX": "APIX", "APL": "Apollo Currency", @@ -389,14 +596,21 @@ "APN": "Apron", "APOD": "AirPod", "APOLLO": "Apollo Crypto", - "APP": "SappChat", + "APP": "Moon App", + "APPA": "Dappad", "APPC": "AppCoins", + "APPLE": "AppleSwap", "APRICOT": "Apricot Finance", "APRIL": "April", + "APRS": "Aperios", "APS": "APRES", "APT": "Aptos", "APTCOIN": "Aptcoin", - "APU": "Apu", + "APTOGE": "Aptoge", + "APTR": "Aperture Finance", + "APU": "Apu Apustaja", + "APUAPU": "APU", + "APUGURL": "APU GURL", "APW": "APWine", "APX": "ApolloX", "APXP": "APEX Protocol", @@ -406,9 +620,11 @@ "APYS": "APYSwap", "APZ": "Alprockz", "AQT": "Alpha Quark Token", + "AQTIS": "AQTIS", "AQU": "aQuest", "AQUA": "Aquarius", "AQUAC": "Aquachain", + "AQUACITY": "Aquacity", "AQUAGOAT": "Aqua Goat", "AQUAP": "Planet Finance", "AQUARI": "Aquari", @@ -419,13 +635,21 @@ "ARB": "Arbitrum", "ARBI": "Arbi", "ARBIT": "Arbit Coin", + "ARBS": "Arbswap", "ARBT": "ARBITRAGE", + "ARBUZ": "ARBUZ", "ARC": "ArcticCoin", "ARCA": "Arca", + "ARCAD": "Arcadeum", "ARCADE": "ARCADE", + "ARCADEF": "arcadefi", "ARCANE": "Arcane Token", + "ARCAS": "Arcas", "ARCH": "Archway", + "ARCHA": "ArchAngel Token", "ARCHCOIN": "ArchCoin", + "ARCHE": "Archean", + "ARCHIVE": "Chainback", "ARCHL": "ArchLoot", "ARCO": "AquariusCoin", "ARCONA": "Arcona", @@ -434,6 +658,7 @@ "ARDR": "Ardor", "ARDX": "ArdCoin", "ARE": "Aurei", + "AREA": "Areon Network", "AREN": "Arenon", "ARENA": "Arena", "AREPA": "Arepacoin", @@ -444,15 +669,19 @@ "ARGON": "Argon", "ARGUS": "ArgusCoin", "ARI": "AriCoin", + "ARI10": "Ari10", "ARIA": "Legends of Aria", "ARIA20": "Arianee", "ARIX": "Arix", "ARK": "ARK", + "ARKEN": "Arken Finance", "ARKER": "Arker", "ARKI": "ArkiTech", "ARKM": "Arkham", "ARKN": "Ark Rivals", + "ARKY": "Arky", "ARM": "Armory Coin", + "ARMA": "Aarma", "ARMOR": "ARMOR", "ARMR": "ARMR", "ARMS": "2Acoin", @@ -462,20 +691,27 @@ "ARNX": "Aeron", "ARNXM": "Armor NXM", "ARO": "Arionum", + "ARON": "Astronaut Aron", + "AROR": "Arora", "ARPA": "ARPA Chain", "ARPAC": "ArpaCoin", "ARQ": "ArQmA", "ARR": "ARROUND", + "ARRO": "Arro Social", "ARRR": "Pirate Chain", + "ARSL": "Aquarius Loan", + "ARSW": "ArthSwap", "ART": "Maecenas", "ARTC": "Artcoin", "ARTDECO": "ARTDECO", + "ARTDRAW": "ArtDraw", "ARTE": "Artemine", "ARTEM": "Artem", "ARTEON": "Arteon", "ARTEQ": "artèQ", "ARTEX": "Artex", "ARTF": "Artfinity Token", + "ARTFI": "ARTFI", "ARTG": "Goya Giant Token", "ARTH": "ARTH", "ARTI": "Arti Project", @@ -483,6 +719,8 @@ "ARTL": "ARTL", "ARTM": "ARTM", "ARTP": "ArtPro", + "ARTR": "Artery Network", + "ARTT": "ARTT Network", "ARTY": "Artyfact", "ARV": "Ariva", "ARW": "Arowana Token", @@ -491,37 +729,52 @@ "AS": "AmaStar", "ASA": "ASA Coin", "ASAFE2": "Allsafe", + "ASAN": "ASAN VERSE", "ASAP": "Asap Sniper Bot", + "ASC": "Ascend", "ASD": "AscendEX Token", + "ASDEX": "AstraDEX", + "ASEED": "aUSD SEED (Acala)", "ASG": "Asgard", "ASGC": "ASG", "ASH": "ASH", + "ASI": "Artificial Superintelligence Alliance", "ASIA": "Asia Coin", "ASIMI": "ASIMI", + "ASIX": "ASIX+", "ASK": "Permission Coin", "ASKO": "Asko", "ASM": "Assemble Protocol", "ASN": "Ascension Coin", + "ASNT": "Assent Protocol", "ASP": "Aspire", + "ASPC": "Astropup Coin", "ASPO": "ASPO Shards", "ASQT": "ASQ Protocol", "ASR": "AS Roma Fan Token", "ASS": "Australian Safe Shepherd", "ASSA": "AssaPlay", "ASSARA": "ASSARA", + "ASSET": "iAssets", "ASST": "AssetStream", "AST": "AirSwap", "ASTA": "ASTA", "ASTO": "Altered State Token", "ASTON": "Aston", "ASTR": "Astar", + "ASTRA": "Astra Protocol", + "ASTRADAO": "Astra DAO", "ASTRAFER": "Astrafer", + "ASTRAFERV1": "Astrafer v1", "ASTRAL": "Astral", "ASTRO": "AstroSwap", "ASTROC": "Astroport Classic", "ASTROLION": "AstroLion", "ASTRONAUT": "Astronaut", + "ASTROP": "AstroPepeX", + "ASTX": "Asterix Labs", "ASUNA": "Asuna Hentai", + "ASVA": "Asva", "ASW": "AdaSwap", "ASY": "ASYAGRO", "AT": "AWARE", @@ -530,28 +783,43 @@ "ATC": "AutoBlock", "ATCC": "ATC Coin", "ATD": "A2DAO", + "ATEC": "AnonTech", + "ATECH": "AvaxTech", + "ATEM": "Atem Network", "ATF": "Alion Tech Food", + "ATFI": "Atlantic Finance Token", "ATFS": "ATFS Project", - "ATH": "All Time High Vodka", + "ATH": "Aethir", "ATHE": "Atheios", + "ATHEN": "Athenas AI", + "ATHVODKA": "All Time High Vodka", + "ATID": "AstridDAO Token", "ATK": "Attack Wagon", "ATKN": "A-Token", "ATL": "ATLANT", "ATLAS": "Star Atlas", + "ATLX": "Atlantis Loans Polygon", "ATM": "Atletico de Madrid Fan Token", + "ATMA": "ATMA", "ATMC": "Autumncoin", "ATMCHAIN": "ATMChain", "ATMI": "Atonomi", "ATMOS": "Novusphere", "ATN": "ATN", + "ATNT": "Artizen", + "ATO": "Atocha Protocol", "ATOLO": "RIZON", "ATOM": "Cosmos", "ATON": "Further Network", + "ATOPLUS": "ATO+", "ATOR": "ATOR Protocol", "ATOZ": "Race Kingdom", "ATP": "Atlas Protocol", + "ATPAY": "AtPay", "ATR": "Ather", "ATRI": "Atari Token", + "ATRNO": "AETERNUS", + "ATROFA": "Atrofarm", "ATS": "Atlas DEX", "ATT": "Attila", "ATTR": "Attrace", @@ -562,13 +830,18 @@ "AUCO": "Advanced United Continent", "AUCTION": "Bounce", "AUDC": "Aussie Digital", + "AUDF": "Forte AUD", "AUDIO": "Audius", + "AUDT": "Auditchain", "AUDX": "eToro Australian Dollar", "AUK": "Aukcecoin", "AUN": "Authoreon", "AUNIT": "Aunit", "AUPC": "Authpaper", "AUR": "AUREO", + "AURA": "Aura Network", + "AURABAL": "Aura BAL", + "AURAF": "Aura Finance", "AURO": "Aurora", "AURORA": "Aurora", "AURORAC": "Auroracoin", @@ -579,34 +852,53 @@ "AUSCM": "Auric Network", "AUSD": "Appeal dollar", "AUSDC": "Aave USDC v1", + "AUSDT": "aUSDT", "AUT": "Autoria", "AUTHORSHIP": "Authorship", + "AUTISM": "AUTISM", "AUTO": "Auto", "AUTON": "Autonio", + "AUTUMN": "Autumn", "AUX": "Auxilium", "AV": "Avatar Coin", "AVA": "Travala", + "AVAI": "Orca AVAI", "AVAL": "Avaluse", "AVALON": "Avalon", + "AVALOX": "AVALOX", + "AVAO": "AvaOne Finance", "AVAT": "AVATA Network", + "AVATAR": "Avatar", "AVATLY": "Avatly", + "AVAV": "AVAV", "AVAX": "Avalanche", "AVAXIOU": "Avalanche IOU", "AVDO": "AvocadoCoin", "AVE": "Avesta", "AVG": "Avocado DAO", "AVH": "Animation Vision Cash", + "AVI": "Aviator", "AVINOC": "AVINOC", + "AVIVE": "Avive World", "AVL": "Aston Villa Fan Token", + "AVM": "AVM (Atomicals)", + "AVME": "AVME", "AVN": "AVNRich", "AVO": "Avoteo", + "AVR": "Avrora Metaverse", + "AVS": "Aves", "AVT": "Aventus", + "AVTM": "Aventis Metaverse", "AVXL": "Avaxlauncher", + "AVXT": "Avaxtars Token", "AWAX": "AWAX", "AWC": "Atomic Wallet Coin", + "AWK": "Awkward Monkey Base", + "AWM": "Another World", "AWNEX": "AWNEX token", "AWO": "AiWork", "AWORK": "Aworker", + "AWP": "Ansem Wif Photographer", "AWR": "Award", "AWS": "AurusSILVER", "AWT": "Airdrop World", @@ -615,6 +907,7 @@ "AXC": "AXIA Coin", "AXE": "Axe", "AXEL": "AXEL", + "AXGT": "AxonDAO Governance Token", "AXIAL": "AXiaL", "AXIAV3": "Axia", "AXIOM": "Axiom Coin", @@ -622,11 +915,16 @@ "AXIST": "AXIS Token", "AXL": "Axelar", "AXLINU": "AXL INU", + "AXLUSDC": "Axelar Wrapped USDC", + "AXLW": "Axel Wrapped", + "AXM": "Axiome", "AXN": "Axion", "AXNT": "Axentro", + "AXO": "Axo", "AXPR": "aXpire", "AXR": "AXRON", "AXS": "Axie Infinity Shards", + "AXSV1": "Axie Infinity Shards v1", "AXT": "AIX", "AXYS": "Axys", "AYA": "Aryacoin", @@ -635,11 +933,18 @@ "AZART": "Azart", "AZBI": "AZBI CORE", "AZERO": "Aleph Zero", + "AZIT": "Azit", + "AZR": "Azure", "AZU": "Azultec", "AZUKI": "Azuki", + "AZUKIDAO": "AzukiDAO", "AZUM": "Azuma Coin", + "AZUR": "Azuro Protocol", + "AZURE": "Azure Wallet", "AZY": "Amazy", "B": "BankCoin", + "B01": "b0rder1ess", + "B1P": "B ONE PAYMENT", "B20": "B20", "B21": "B21", "B26": "B26 Finance", @@ -647,59 +952,137 @@ "B2M": "Bit2Me", "B2X": "SegWit2x", "B3": "B3 Coin", + "B3X": "Bnext Token", "B91": "B91", + "BA": "BAHA", "BAAS": "BaaSid", + "BAB": "Babacoin", + "BABI": "Babylons", "BABL": "Babylon Finance", "BABY": "BabySwap", + "BABYANDY": "Baby Andy", + "BABYB": "Baby Bali", + "BABYBINANCE": "BABYBINANCE", + "BABYBITC": "BabyBitcoin", + "BABYBNB": "BabyBNB", + "BABYBNBTIGER": "BabyBNBTiger", + "BABYBO": "BabyBonk", "BABYBOB": "Baby Bob", + "BABYBOME": "Book of Baby Memes", + "BABYBOMEOW": "Baby of BOMEOW", "BABYBONK": "Baby Bonk", + "BABYBTC": "BABYBTC", + "BABYCAT": "Baby Cat Coin", + "BABYCATS": "Baby Cat Coin", + "BABYCEO": "Baby Doge CEO", + "BABYCRASH": "BabyCrash", + "BABYCRAZYT": "BABY CRAZY TIGER", "BABYCUBAN": "Baby Cuban", "BABYDOGE": "BabyDoge", + "BABYDOGEINU": "BABY DOGE INU", + "BABYDOGEZILLA": "BabyDogeZilla", + "BABYDRAGON": "Baby Dragon", "BABYELON": "BabyElon", "BABYFB": "Baby Floki Billionaire", "BABYFLOKI": "BabyFloki", + "BABYFLOKIZILLA": "BabyFlokiZilla", + "BABYGME": "Baby GameStop", + "BABYGOLDEN": "Baby Golden Coin", "BABYGROK": "Baby Grok", + "BABYGUMMY": "BABY GUMMY", "BABYHKTIGER": "BabyHkTiger", + "BABYHONK": "Baby Honk", + "BABYJERRY": "Baby Jerry", + "BABYJESUS": "BabyJesusCoin", + "BABYKABOSU": "Baby Kabosu", + "BABYKITTY": "BabyKitty", + "BABYLONG": "Baby Long", + "BABYMAGA": "Baby Maga", "BABYMEME": "Baby Memecoin", + "BABYMUSK": "Baby Musk", + "BABYMYRO": "Babymyro", + "BABYOKX": "BABYOKX", "BABYPEPE": "Babypepe (BSC)", + "BABYPORK": "Baby Pepe Fork", + "BABYRATS": "Baby Rats", + "BABYRWA": "BabyRWA", "BABYSAITAMA": "Baby Saitama", + "BABYSHARK": "Baby Shark", + "BABYSHIB": "Baby Shiba Inu", + "BABYSHIBAINU": "Baby Shiba Inu", + "BABYSHIV": "Baby Shiva", + "BABYSLERF": "BabySlerf", + "BABYSOL": "Baby Solana", + "BABYSORA": "Baby Sora", + "BABYSWEEP": "BabySweep", + "BABYT": "BABYTRUMP", "BABYTK": "Baby Tiger King", + "BABYTOMCAT": "Baby Tomcat", + "BABYTOSHI": "Baby Toshi", + "BABYTROLL": "Baby Troll", + "BABYTRUMP": "BABYTRUMP", + "BABYWIF": "babydogwifhat", + "BABYX": "Baby X", "BAC": "Basis Cash", "BACK": "DollarBack", "BACOIN": "BACoin", "BACON": "BaconDAO (BACON)", "BAD": "Bad Idea AI", + "BADCAT": "Andy’s Alter Ego", "BADGER": "Badger DAO", - "BAG": "BondAppetit", + "BAFC": "BabyApeFunClub", + "BAG": "Bag", "BAGS": "Basis Gold Share", + "BAHAMAS": "Bahamas", + "BAI": "BearAI", + "BAICA": "Baica", + "BAJU": "Bajun Network", "BAK": "BaconCoin", "BAKAC": "Baka Casino", "BAKE": "BakeryToken", "BAKED": "Baked", "BAKT": "Backed Protocol", "BAL": "Balancer", + "BALA": "Shambala", + "BALD": "Bald", + "BALIN": "Balin Bank", + "BALLZ": "Wolf Wif", "BALPHA": "bAlpha", + "BALT": "Brett's cat", + "BALTO": "Balto Token", "BAMA": "BabyAMA", + "BAMBIT": "BAMBIT", "BAMBOO": "BambooDeFi", "BAN": "Banano", - "BANANA": "ApeSwap", - "BANANAG": "Banana Gun", + "BANANA": "Banana Gun", + "BANANAS": "Monkey Peepo", "BANC": "Babes and Nerds", "BANCA": "BANCA", "BAND": "Band Protocol", "BANDEX": "Banana Index", + "BANG": "BANG", "BANK": "Float Protocol", "BANKETH": "BankEth", "BANNER": "BannerCoin", + "BANUS": "Banus.Finance", + "BANX": "Banx.gg", "BAO": "Bao Finance", "BAOE": "Business Age of Empires", + "BAOM": "Battle of Memes", + "BAOS": "BaoBaoSol", "BAR": "FC Barcelona Fan Token", "BARA": "Capybara", + "BARC": "The Blu Arctic Water Company", + "BAREBEARS": "BAREBEARS", "BARK": "Bored Ark", + "BARRON": "Time Traveler", "BART": "BarterTrade", "BAS": "Basis Share", "BASE": "Base Protocol", + "BASEAI": "BaseAI", "BASED": "Based Money", + "BASEDAI": "BasedAI", + "BASEHEROES": "Baseheroes", "BASH": "LuckChain", "BASHC": "BashCoin", "BASHOS": "Bashoswap", @@ -708,54 +1091,70 @@ "BASIS": "Basis", "BASK": "BasketDAO", "BAST": "Bast", + "BASTET": "Bastet Goddess", "BAT": "Basic Attention Token", "BATH": "Battle Hero", "BATS": "Batcoin", + "BAVA": "Baklava", "BAX": "BABB", "BAXS": "BoxAxis", "BAY": "BitBay", - "BB": "Baby Bali", + "BAYSE": "coynbayse", + "BAZED": "Bazed Games", + "BB": "BounceBit", "BB1": "Bitbond", "BBADGER": "Badger Sett Badger", "BBANK": "BlockBank", "BBB": "BitBullBot", - "BBC": "BigBang Core", + "BBC": "Bull BTC Club", "BBCC": "BaseballCardCoin", + "BBCG": "BBC Gold Coin", "BBCT": "TraDove B2BCoin", "BBDC": "Block Beats Network", "BBDT": "BBD Token", + "BBEER": "BABY BEERCOIN", "BBF": "Bubblefong", "BBFT": "Block Busters Tech Token", "BBG": "BigBang", "BBGC": "BigBang Game", "BBI": "BelugaPay", "BBK": "BitBlocks", + "BBL": "beoble", "BBN": "BBNCOIN", "BBO": "Bigbom", "BBOS": "Blackbox Foundation", "BBP": "BiblePay", "BBR": "Boolberry", + "BBRETT": "Baby Brett", "BBS": "BBSCoin", "BBT": "BitBook", - "BBTC": "BlakeBitcoin", + "BBTC": "Binance Wrapped BTC", + "BBTF": "Block Buster Tech Inc", + "BBUSD": "BounceBit USD", "BC": "Bitcoin Confidential", "BCA": "Bitcoin Atom", "BCAC": "Business Credit Alliance Chain", "BCAP": "Blockchain Capital", "BCAT": "BitClave", + "BCAU": "BetaCarbon", "BCB": "BCB Blockchain", - "BCCOIN": "BitConnect Coin", + "BCCOIN": "BlackCardCoin", "BCD": "Bitcoin Diamond", "BCDN": "BlockCDN", "BCDT": "EvidenZ", "BCEO": "bitCEO", "BCF": "BitcoinFast", + "BCG": "BlockChainGames", "BCH": "Bitcoin Cash", "BCHA": "Bitcoin Cash ABC", + "BCHB": "Bitcoin Cash on Base", "BCHC": "BitCherry", "BCHT": "Blockchain Terminal", "BCI": "Bitcoin Interest", "BCIO": "Blockchain.io", + "BCITY": "Bitcoin City Coin", + "BCL": "Bitcoin Legend", + "BCLAT": "BOMBOCLAT", "BCMC": "Blockchain Monster Hunt", "BCMC1": "BeforeCoinMarketCap", "BCN": "ByteCoin", @@ -764,65 +1163,101 @@ "BCNX": "BCNEX", "BCO": "BridgeCoin", "BCOIN": "Bombcrypto", + "BCOINM": "Bomb Crypto (MATIC)", + "BCOQ": "BLACK COQINU", "BCP": "BitcashPay", "BCPT": "BlockMason Credit Protocol", "BCR": "BitCredit", + "BCRO": "Bonded Cronos", "BCS": "Business Credit Substitute", "BCT": "Toucan Protocol: Base Carbon Tonne", "BCUBE": "B-cube.ai", "BCUG": "Blockchain Cuties Universe Governance", + "BCUT": "bitsCrunch", "BCV": "BitCapitalVendor", "BCVB": "BCV Blue Chip", "BCX": "BitcoinX", "BCY": "BitCrystals", "BCZERO": "Buggyra Coin Zero", + "BD": "BlastDEX", + "BD20": "BRC-20 DEX", "BDAY": "Birthday Cake", "BDB": "Big Data Block", "BDC": "Based", "BDCC": "BDCC COIN", + "BDCLBSC": "BorderCollieBSC", "BDG": "BitDegree", + "BDID": "BDID", "BDL": "Bitdeal", "BDOG": "Bulldog Token", "BDOT": "Binance Wrapped DOT", "BDP": "Big Data Protocol", "BDPI": "Interest Bearing Defi Pulse Index", "BDR": "BlueDragon", + "BDRM": "Bodrumspor Fan Token", + "BDROP": "BlockDrop", "BDX": "Beldex", "BDY": "Buddy DAO", + "BEA": "Beagle Inu", "BEACH": "BeachCoin", "BEAI": "BeNFT Solutions", "BEAM": "Beam", "BEAMMW": "Beam", - "BEAN": "AzukiDAO", + "BEAN": "Bean", + "BEANS": "Moonbeans", "BEAST": "CryptoBeast", "BEAT": "BEAT Token", + "BEATLES": "JohnLennonC0IN", + "BEATS": "Sol Beats", + "BEBE": "BEBE", "BEC": "Betherchip", "BECH": "Beauty Chain", + "BECN": "Beacon", + "BECO": "BecoSwap Token", "BED": "Bankless BED Index", "BEE": "Herbee", - "BEER": "BEER Coin", + "BEEP": "BEEP", + "BEER": "BEERCOIN", + "BEERUSCAT": "BeerusCat", + "BEET": "BEETroot", "BEETLE": "Beetle Coin", "BEETOKEN": "Bee Token", "BEETS": "Beethoven X", + "BEFE": "BEFE", + "BEFI": "BeFi Labs", + "BEFTM": "Beefy Escrowed Fantom", "BEFX": "Belifex", + "BEFY": "Befy Protocol", "BEG": "BEG", "BEL": "Bella Protocol", "BELA": "Bela", "BELR": "Belrium", "BELT": "Belt", + "BELUGA": "Beluga", "BEM": "BEMIL Coin", + "BEMD": "Betterment Digital", "BEN": "Ben", "BEND": "BendDao", + "BENDOG": "Ben the Dog", + "BENG": "Based Peng", "BENJACOIN": "Benjacoin", "BENJI": "BenjiRolls", + "BENK": "BENK", "BENT": "Bent Finance", + "BENTO": "Bento", + "BENV1": "Ben v1", "BENX": "BlueBenx", "BENZI": "Ben Zi Token", "BEP": "Blucon", + "BEPE": "Blast Pepe", "BEPR": "Blockchain Euro Project", "BEPRO": "BEPRO Network", "BERN": "BERNcash", + "BERNIE": "BERNIE SENDERS", "BERRY": "Berry", + "BES": "battle esports coin", + "BESA": "Besa Gaming", + "BESHARE": "Beshare Token", "BEST": "Bitpanda Ecosystem Token", "BESTC": "BestChain", "BETA": "Beta Finance", @@ -833,10 +1268,12 @@ "BETHER": "Bethereum", "BETR": "BetterBetting", "BETROCK": "Betrock", + "BETS": "BetSwirl", "BETT": "Bettium", "BETU": "Betu", + "BETZ": "Bet Lounge", "BEX": "BEX token", - "BEY": "Beyond Finance", + "BEY": "NBX", "BEYOND": "Beyond Protocol", "BEZ": "Bezop", "BEZOGE": "Bezoge Earth", @@ -845,78 +1282,115 @@ "BFCH": "Big Fun Chain", "BFDT": "Befund", "BFEX": "BFEX", + "BFG": "BFG Token", "BFHT": "BeFaster Holder Token", "BFI": "BitDefi", "BFIC": "Bficoin", + "BFICGOLD": "BFICGOLD", + "BFK WARZONE": "BFK Warzone", "BFLOKI": "BurnFloki", "BFLY": "Butterfly Protocol", "BFT": "BF Token", - "BFT.BITCI": "Brazil Fan Token", + "BFTB": "Brazil Fan Token", + "BFTC": "BITS FACTOR", "BFX": "BitFinex Tokens", + "BG": "BunnyPark Game", "BGB": "Bitget token", "BGBP": "Binance GBP Stable Coin", "BGC": "Bee Token", "BGG": "BGG Token", "BGLD": "Based Gold", "BGONE": "BigONE Token", + "BGPT": "BlockGPT", "BGS": "Battle of Guardians Share", + "BGUY": "The Big Guy", + "BGVT": "Bit Game Verse Token", "BHAO": "Bithao", + "BHAT": "BH Network", "BHAX": "Bithashex", + "BHBD": "bHBD", "BHC": "BillionHappiness", "BHEROES": "BombHeroes coin", + "BHIG": "BuckHathCoin", "BHIRE": "BitHIRE", "BHIVE": "Hive", "BHO": "Bholdus Token", "BHP": "Blockchain of Hash Power", "BHPC": "BHPCash", + "BIAO": "Biaocoin", + "BIB": "BIB Token", + "BIBI": "BIBI", "BIBL": "Biblecoin", + "BIBO": "Bible of Memes", "BIC": "Bikercoins", + "BICITY": "BiCity AI Projects", "BICO": "Biconomy", + "BICS": "Biceps", "BID": "TopBidder", "BIDAO": "Bidao", "BIDCOM": "Bidcommerce", + "BIDEN": "Dark Brandon", + "BIDEN2024": "BIDEN 2024", "BIDI": "Bidipass", "BIDP": "BID Protocol", "BIDR": "Binance IDR Stable Coin", + "BIDZ": "BIDZ Coin", "BIFI": "Beefy.Finance", "BIFIF": "BiFi", "BIG": "Big Eyes", + "BIGBANGCORE": "BigBang Core", "BIGHAN": "BighanCoin", + "BIGLEZ": "THE BIG LEZ SHOW", + "BIGMIKE": "Big Mike", "BIGSB": "BigShortBets", "BIGTIME": "Big Time", "BIGUP": "BigUp", "BIH": "BitHostCoin", "BIHU": "Key", + "BIIS": "biis (Ordinals)", "BIKI": "BIKI", "BILL": "TillBilly", + "BILLICAT": "BilliCat", "BIM": "BitminerCoin", "BIND": "Compendia", "BINEM": "Binemon", "BINGO": "Tomorrowland", + "BINO": "Binopoly", "BINS": "Bitsense", "BINTEX": "Bintex Futures", + "BINU": "Blast Inu", "BIO": "BITONE", "BIOB": "BioBar", "BIOC": "BioCrypt", "BIOCOIN": "Biocoin", "BIOFI": "Biometric Financial", + "BIOP": "Biop", "BIOS": "BiosCrypto", "BIOT": "Bio Passport", "BIP": "Minter", "BIPC": "BipCoin", "BIPX": "Bispex", + "BIR": "Birake", + "BIRB": "Birb", "BIRD": "Bird.Money", "BIRDCHAIN": "Birdchain", + "BIRDDOG": "Bird Dog", "BIS": "Bismuth", + "BISKIT": "Biskit Protocol", + "BISO": "BISOSwap", "BIST": "Bistroo", "BIT": "BitDAO", "BIT16": "16BitCoin", "BITAIR": "Bitair", "BITASEAN": "BitAsean", "BITB": "BeanCash", + "BITBEDR": "Bitcoin EDenRich", "BITBOOST": "BitBoost", + "BITBULL": "Bitbull", + "BITBURN": "Bitburn", "BITC": "BitCash", "BITCAR": "BitCar", + "BITCAT": "Bitcat", "BITCCA": "Bitcci Cash", "BITCI": "Bitcicoin", "BITCM": "Bitcomo", @@ -924,6 +1398,7 @@ "BITCOINC": "Bitcoin Classic", "BITCOINP": "Bitcoin Private", "BITCOINV": "BitcoinV", + "BITCONNECT": "BitConnect Coin", "BITCRATIC": "Bitcratic Token", "BITF": "Bit Financial", "BITFLIP": "BitFlip", @@ -934,7 +1409,10 @@ "BITM": "BitMoney", "BITN": "Bitnet", "BITNEW": "BitNewChain", + "BITO": "BitoPro Exchange Token", "BITOK": "BitOKX", + "BITORB": "BitOrbit", + "BITRA": "Bitratoken", "BITREWARDS": "BitRewards", "BITROLIUM": "Bitrolium", "BITRUE": "Bitrue Coin", @@ -945,6 +1423,7 @@ "BITSZ": "Bitsz", "BITT": "BiTToken", "BITTO": "BITTO", + "BITUNE": "Bitune", "BITUSD": "bitUSD", "BITVOLT": "BitVolt", "BITX": "BitScreener", @@ -953,33 +1432,54 @@ "BIVE": "BIZVERSE", "BIX": "BiboxCoin", "BIXB": "BIXBCOIN", + "BIXI": "Bixi", + "BIZA": "BizAuto", "BIZZ": "BIZZCOIN", + "BJ": "Blocjerk", "BJK": "Beşiktaş", "BKBT": "BeeKan", "BKC": "Balkancoin", "BKING": "King Arthur", "BKK": "BKEX Token", "BKN": "Brickken", + "BKPT": "Biokript", "BKR": "Balkari Token", "BKRW": "Binance KRW", "BKS": "Barkis Network", "BKT": "Blocktrade token", "BKX": "BANKEX", "BLA": "BlaBlaGame", + "BLAC": "Blacksmith Token", "BLACK": "BLACKHOLE PROTOCOL", + "BLACKD": "Blackder AI", + "BLACKDRAGON": "Black Dragon", + "BLACKROCK": "BlackRock", + "BLACKSALE": "Black Sale", "BLACKSWAN": "BlackSwan AI", "BLADE": "BladeWarrior", + "BLAKEBTC": "BlakeBitcoin", "BLANK": "Blank Token", "BLAS": "BlakeStar", "BLAST": "BLAST", + "BLASTA": "BlastAI", "BLAUNCH": "B-LAUNCH", + "BLAZE": "Blaze", + "BLAZEX": "BlazeX", "BLAZR": "BlazerCoin", + "BLBY": "Badluckbaby", "BLC": "BlakeCoin", "BLCT": "Bloomzed Loyalty Club Ticket", "BLD": "Agoric", + "BLENDR": "Blendr Network", + "BLEPE": "Blepe", + "BLERF": "BLERF", "BLES": "Blind Boxes", + "BLF": "Baby Luffy", "BLHC": "BlackholeCoin", + "BLI": "BALI TOKEN", + "BLID": "Bolide", "BLIN": "Blin Metaverse", + "BLING": "PLEB DREKE", "BLINK": "BlockMason Link", "BLINU": "Baby Lambo Inu", "BLITZ": "BlitzCoin", @@ -990,6 +1490,7 @@ "BLKS": "Blockshipping", "BLN": "Bulleon", "BLNM": "Bolenum", + "BLOB": "Blob", "BLOC": "Blockcloud", "BLOCK": "Blockasset", "BLOCKIFY": "Blockify.Games", @@ -997,10 +1498,16 @@ "BLOCKPAY": "BlockPay", "BLOCKS": "BLOCKS", "BLOCKSTAMP": "BlockStamp", + "BLOCKT": "Blocktools", + "BLOCKW": "Blockwise", "BLOCM": "BLOC.MONEY", + "BLOCX": "BLOCX.", "BLOK": "Bloktopia", + "BLOO": "bloo foster coin", "BLOODY": "Bloody Token", + "BLOOM": "BloomBeans", "BLOOMT": "Bloom Token", + "BLOVELY": "Baby Lovely Inu", "BLOX": "BLOX", "BLP": "BullPerks", "BLRY": "BillaryCoin", @@ -1013,6 +1520,7 @@ "BLU": "BlueCoin", "BLUE": "Ethereum Blue", "BLUEM": "BlueMove", + "BLUES": "Blueshift", "BLUESPARROW": "BlueSparrow Token", "BLUESPARROWOLD": "BlueSparrowToken", "BLUI": "Blui", @@ -1027,27 +1535,51 @@ "BLY": "Blocery", "BLZ": "Bluzelle", "BLZD": "Blizzard.money", + "BLZE": "BLAZE TOKEN", "BM": "BitMoon", + "BMAGA": "Baby Maga", "BMARS": "Binamars", + "BMBO": "Bamboo Coin", "BMC": "Blackmoon Crypto", + "BMDA": "Bermuda", "BME": "BitcoMine", "BMEX": "BitMEX", + "BMF": "MetaFame", "BMG": "Borneo", "BMH": "BlockMesh", "BMI": "Bridge Mutual", "BMIC": "Bitmic", + "BMICKEY": "Baby Mickey", "BMK": "Benchmark", "BMON": "Binamon", + "BMONEY": "B-money", "BMP": "Brother Music Platform", "BMT": "BMChain", + "BMW": "BMW", "BMX": "BitMart Token", "BMXT": "Bitmxittz", "BMXX": "Multiplier", "BNA": "BananaTok", "BNANA": "Chimpion", "BNB": "Binance Coin", + "BNBBONK": "BNB BONK", + "BNBBUNNY": "BNB BUNNY", + "BNBCAT": "BNBcat", "BNBCH": "BNB Cash", + "BNBDOG": "BNB DOG INU", + "BNBDOGE": "BNBdoge", + "BNBDRGN": "BNBDragon", + "BNBFROG": "BNBFROG", "BNBH": "BnbHeroes Token", + "BNBLION": "BNB LION", + "BNBOLYMPIC": "BNB OLYMPIC", + "BNBP": "BNBPot", + "BNBSNAKE": "BNB SNAKE", + "BNBSONGOKU": "BNBsongoku", + "BNBTC": "BNbitcoin", + "BNBVEGETA": "BNB VEGETA", + "BNBWHALES": "BNB Whales", + "BNBX": "Stader BNBx", "BNC": "Bifrost Native Coin", "BND": "Bened", "BNF": "BonFi", @@ -1059,6 +1591,7 @@ "BNR": "BiNeuro", "BNRTX": "BnrtxCoin", "BNS": "BNS token", + "BNSAI": "bonsAI Network", "BNSD": "BNSD Finance", "BNSOLD": "BNS token ", "BNSX": "Bitcoin Name Service System", @@ -1067,60 +1600,118 @@ "BNTN": "Blocnation", "BNTY": "Bounty0x", "BNU": "ByteNext", + "BNUSD": "Balanced Dollars", "BNX": "BinaryX", "BOA": "BOSAGORA", + "BOAI": "BOLICAI", + "BOARD": "SurfBoard Finance", "BOAT": "Doubloon", "BOBA": "Boba Network", "BOBAI": "Bob AI", + "BOBAOPPA": "Bobaoppa", + "BOBBY": "Kennedy Coin", + "BOBBYM": "Bobby Moore", "BOBC": "Bobcoin", + "BOBE": "BOOK OF BILLIONAIRES", + "BOBO": "BOBO", "BOBS": "Bob's Repair", "BOBT": "BOB Token", + "BOBUKI": "Bobuki Neko", + "BOC": "BOCOIN", + "BOCA": "BookOfPussyCats", + "BOCAC": "BocaChica token", + "BOCAT": "BOCAT", + "BODA": "Based Yoda", + "BODAV2": "BODA Token", + "BODE": "Book of Derp", + "BODEN": "Jeo Boden", "BODHI": "Bodhi Network", + "BODO": "BOOK OF DOGS", + "BODOG": "Book of Doge", "BODYP": "Body Profile", "BOE": "Bodhi", "BOG": "Bogged Finance", "BOGCOIN": "Bogcoin", + "BOGE": "Boge", "BOGEY": "Bogey", + "BOGGY": "Boggy Coin", + "BOJAK": "Based Wojak", "BOJI": "BOJI Token", "BOK": "Blockium", + "BOKI": "BOOK OF KILLER", + "BOKU": "Boryoku Dragonz", + "BOLBOL": "BOLBOL", "BOLD": "Bold", "BOLI": "BolivarCoin", "BOLT": "Bolt", "BOLTT": "BolttCoin", + "BOMA": "Book of Maga", "BOMB": "BOMB", "BOMBC": "BombCoin", "BOMBM": "Bomb Money", + "BOMBS": "Bomb Shelter Inu", + "BOME": "BOOK OF MEME", + "BOME2": "Book of Meme 2.0", + "BOMEDOGE": "BOOK OF DOGE MEMES", + "BOMEOW": "Book of Meow", + "BOMES": "BOOK OF MEMES", + "BOMK": "BOMK", "BON": "Bonpay", "BONA": "Bonafi", "BOND": "BarnBridge", + "BONDAPPETIT": "BondAppetit", "BONDLY": "Bondly", + "BONDLYV1": "Bondly Finance", "BONE": "Bone ShibaSwap", "BONES": "BonesCoin", + "BONFIRE": "Bonfire", + "BONG": "BonkWifGlass", "BONIX": "Blockonix", "BONK": "Bonk", + "BONKCON": "Bonkcon", + "BONKFA": "Bonk of America", + "BONKFORK": "BonkFork", + "BONKGROK": "Bonk Grok", "BONKH": "BonkHoneyHNTMobileSOL", "BONKIN": "Bonkinu", + "BONKKONG": "BONK KONG", + "BONKONETH": "Bonk On ETH", "BONO": "Bonorum Coin", "BONTE": "Bontecoin", + "BONUS": "BonusBlock", "BONUSCAKE": "Bonus Cake", "BOO": "Spookyswap", "BOOB": "BooBank", - "BOOM": "Boom Token", + "BOOE": "Book of Ethereum", + "BOOFI": "Boo Finance", + "BOOK": "Solbook", + "BOOKIE": "BookieBot", + "BOOM": "BOOM DAO", + "BOOMCOIN": "Boom Token", + "BOOMER": "Boomer", "BOONS": "BOONSCoin", + "BOOP": "Boop", "BOOST": "Boost", "BOOSTO": "BOOSTO", + "BOOT": "Bostrom", + "BOP": "Boring Protocol", "BOR": "BoringDAO", "BORA": "BORA", "BORED": "Bored Museum", "BORG": "SwissBorg", "BORING": "BoringDAO", + "BORK": "Bork", + "BORKIE": "Borkie", "BORUTO": "Boruto Inu", "BOS": "BOScoin", "BOSE": "Bitbose", + "BOSHI": "Boshi", + "BOSOL": "Book of Solana", "BOSON": "Boson Protocol", "BOSONC": "BosonCoin", "BOSS": "BitBoss", "BOSSBABY": "BossBaby", + "BOSSCOQ": "THE COQFATHER", "BOST": "BoostCoin", "BOSU": "Bosu Inu", "BOT": "Bot Planet", @@ -1132,17 +1723,28 @@ "BOUNCE": "Bounce Token", "BOUTS": "BoutsPro", "BOW": "Archer Swap", + "BOWE": "Book of Whales", "BOX": "ContentBox", + "BOXETH": "Cat-in-a-Box Ether", "BOXT": "BOX Token", "BOXX": "Blockparty", "BOXY": "BoxyCoin", + "BOYS": "CRASHBOYS", + "BOYSCLUB": "Matt Furie's Boys Club", + "BOZO": "BOZO", + "BOZOH": "bozo Hybrid", + "BOZY": "Book of Crazy", "BP": "BunnyPark", "BPAD": "BlokPad", "BPAY": "BNBPay", "BPD": "Beautiful Princess Disorder", + "BPEPEF": "Baby Pepe Floki", + "BPET": "BPET", + "BPINKY": "BPINKY", "BPL": "BlockPool", "BPLC": "BlackPearl Token", "BPN": "beepnow", + "BPOKO": "BabyPoko", "BPRIVA": "Privapp Network", "BPRO": "BitCloud Pro", "BPS": "BitcoinPoS", @@ -1154,32 +1756,56 @@ "BQQQ": "Bitsdaq Token", "BQT": "Blockchain Quotations Index Token", "BQTX": "BQT", + "BR": "BOHR", + "BR34P": "BR34P", + "BRACE": "Bitci Racing Token", "BRAIN": "BrainCoin", + "BRAINERS": "Brainers", + "BRAINZ": "Brainz Finance", + "BRAM": "Defibox bRAM", + "BRANA": "Branaverse", "BRAND": "BrandProtect", "BRAT": "BROTHER", + "BRATT": "Son of Brett", + "BRAWL": "BitBrawl", "BRAZ": "Brazio", "BRC": "Baer Chain", "BRCP": "BRCP Token", + "BRCST": "BRCStarter", + "BRCT": "BRC App", "BRD": "Bread token", "BRDD": "BeardDollars", "BRDG": "Bridge Protocol", + "BREAD": "Breadchain Cooperative", "BREE": "CBDAO", "BREED": "BreederDAO", "BREPE": "BREPE", + "BRETT": "Brett", + "BRETTA": "Bretta", + "BRETTBASE": "Brett Base", + "BRETTETH": "Brett ETH", "BREW": "CafeSwap Token", + "BREWERY": "Brewery Consortium Coin", + "BREWLABS": "Brewlabs", "BRG": "Bridge Oracle", + "BRGE": "OrdBridge", "BRGX": "Bridge$", "BRI": "Baroin", "BRIA": "Briacoin", + "BRIAN": "Brianwifhat", "BRIC": "BrightCoin", "BRICK": "Brickchain FInance", + "BRICKS": "MyBricks", "BRIDGE": "Bridge Bot", "BRIGHT": "Bright Token", "BRIGHTU": "Bright Union", "BRIK": "BrikBit", "BRISE": "Bitgert", "BRIT": "BritCoin", + "BRITT": "Britt", "BRITTO": "Britto", + "BRIUM": "Bearium", + "BRIUN": "Briun Armstrung", "BRIX": "OpenBrix", "BRK": "BreakoutCoin", "BRKL": "Brokoli Token", @@ -1189,10 +1815,18 @@ "BRNX": "Bronix", "BRO": "Bitradio", "BROCK": "Bitrock", + "BROGG": "Brett's Dog", "BRONZ": "BitBronze", + "BROOT": "BROOT", + "BROWN": "BrowniesSwap", + "BRRR": "Burrow", + "BRS": "Broovs Projects", "BRT": "Bikerush", "BRTR": "Barter", "BRTX": "Bertinity", + "BRUNE": "BitRunes", + "BRUSH": "PaintSwap", + "BRUV": "Bruv", "BRWL": "Blockchain Brawlers", "BRX": "Breakout Stake", "BRY": "Berry Data", @@ -1202,60 +1836,85 @@ "BRZN": "Brayzin", "BS": "BlackShadowCoin", "BSAFE": "BlockSafe", + "BSAFU": "BlockSAFU", "BSATOSHI": "BabySatoshi", + "BSB": "Based Street Bets", "BSC": "BowsCoin", + "BSCAKE": "Bunscake", "BSCBURN": "BSCBURN", "BSCGIRL": "Binance Smart Chain Girl", "BSCH": "BitSchool", "BSCM": "BSC MemePad", "BSCPAD": "BSCPAD", + "BSCPAY": "BSC PAYMENTS", "BSCS": "BSC Station", "BSCV": "Bscview", "BSE": "BitSerial", "BSEND": "BitSend", + "BSFM": "BABY SAFEMOON", + "BSG": "Baby Squid Game", "BSGG": "Betswap.gg", "BSGS": "Basis Gold Share", + "BSHARE": "Bomb Money", + "BSHIB": "Based Shiba Inu", "BSI": "Bali Social Integrated", "BSKT": "BasketCoin", "BSL": "BankSocial", + "BSOL": "BlazeStake Staked SOL", "BSOV": "BitcoinSoV", "BSP": "BallSwap", "BSPM": "Bitcoin Supreme", + "BSPT": "Blocksport", "BSR": "BitSoar Coin", "BSSB": "BitStable Finance", - "BST": "Beshare Token", + "BST": "Blocksquare Token", "BSTAR": "Blackstar", + "BSTC": "BST Chain", "BSTK": "BattleStake", "BSTN": "BitStation", + "BSTS": "Magic Beasties", "BSTY": "GlobalBoost", "BSV": "Bitcoin SV", "BSW": "Biswap", + "BSWAP": "BaseSwap", + "BSWT": "BaySwap", "BSX": "Basilisk", + "BSY": "Bestay", "BSYS": "BSYS", "BT": "BT.Finance", "BT1": "Bitfinex Bitcoin Future", "BT2": "Bitcoin SegWit2X", "BTA": "Bata", + "BTAD": "Bitcoin Adult", + "BTAF": "BTAF token", + "BTAMA": "Basetama", "BTB": "BitBar", "BTBL": "Bitball", "BTBS": "BitBase Token", "BTC": "Bitcoin", "BTC2": "Bitcoin 2", + "BTC2XFLI": "BTC 2x Flexible Leverage Index", "BTCA": "BITCOIN ADDITIONAL", + "BTCAB": "Bitcoin Avalanche Bridged", "BTCAS": "BitcoinAsia", + "BTCAT": "Bitcoin Cat", "BTCB": "Bitcoin BEP2", "BTCBR": "Bitcoin BR", "BTCC": "Bitcoin Core", "BTCD": "BitcoinDark", + "BTCDRAGON": "BTC Dragon", "BTCE": "EthereumBitcoin", "BTCEX": "BtcEX", "BTCF": "BitcoinFile", "BTCGO": "BitcoinGo", "BTCH": "Bitcoin Hush", "BTCHD": "Bitcoin HD", + "BTCINU": "Bitcoin Inu", + "BTCIX": "BITCOLOJIX", "BTCK": "Bitcoin Turbo Koin", "BTCL": "BTC Lite", "BTCM": "BTCMoon", + "BTCMT": "Minto", "BTCN": "BitcoiNote", "BTCP": "Bitcoin Palladium", "BTCPAY": "Bitcoin Pay", @@ -1268,6 +1927,7 @@ "BTCT": "Bitcoin Token", "BTCUS": "Bitcoinus", "BTCV": "Bitcoin Vault", + "BTCVB": "BitcoinVB", "BTCZ": "BitcoinZ", "BTD": "Bitcloud", "BTDX": "Bitcloud 2.0", @@ -1285,17 +1945,23 @@ "BTMI": "BitMiles", "BTMK": "BitMark", "BTMXBULL": "3X Long BitMax Token Token", + "BTNT": "BitNautic Token", + "BTNTV2": "BitNautic Token", "BTNYX": "BitOnyx Token", "BTO": "Bottos", "BTOP": "Botopia.Finance", + "BTP": "Bitpaid", "BTPL": "Bitcoin Planet", "BTQ": "BitQuark", "BTR": "BTRIPS", + "BTRC": "Bitro Coin", "BTRFLY": "Redacted Cartel", + "BTRL": "BitcoinRegular", "BTRM": "Betrium Token", "BTRN": "Biotron", "BTRS": "Bitball Treasure", "BTRST": "Braintrust", + "BTRU": "Biblical Truth", "BTS": "Bitshares", "BTSC": "BTS Chain", "BTSE": "BTSE Token", @@ -1304,45 +1970,74 @@ "BTTF": "Coin to the Future", "BTTOLD": "BitTorrent", "BTTR": "BitTiger", + "BTTY": "Bitcointry Token", "BTU": "BTU Protocol", "BTV": "Bitvote", "BTW": "BitWhite", "BTX": "BitCore", "BTXC": "Bettex coin", "BTY": "Bityuan", + "BTYC": "BigTycoon", "BTZ": "BitzCoin", "BTZC": "BeatzCoin", "BTZN": "Bitzon", "BU": "BUMO", + "BUB": "BUBCAT", + "BUBBA": "Bubba", + "BUBBLE": "Bubble", "BUBO": "Budbo", "BUBU": "BUBU", "BUC": "Beau Cat", + "BUCK": "Coinbuck", "BUCKS": "SwagBucks", + "BUCKY": "Bucky", "BUD": "Buddy", + "BUDDHA": "Buddha", + "BUDG": "Bulldogswap", + "BUF": "Buftoad", "BUFF": "Buffalo Swap", "BUFFDOGE": "Buff Doge", + "BUFFET": "Worried", "BUGATTI": "BUGATTI", "BUGG": "Bugg Inu", + "BUGS": "Bugs Bunny", + "BUIDL": "Starter.xyz", + "BUILD": "BuildAI", "BUILDIN": "Buildin Token", "BUILDTEAM": "BuildTeam", "BUK": "CryptoBuk", + "BUL": "bul", + "BULEI": "Bulei", "BULL": "Bullieverse", "BULLC": "BuySell", + "BULLINU": "Bull inu", "BULLION": "BullionFX", "BULLMOON": "Bull Moon", + "BULLPEPE": "Bullpepe", "BULLS": "Bull Coin", "BULLSH": "Bullshit Inu", + "BULLYINGCAT": "Bullying Cat", + "BULT": "Bullit", + "BUM": "WillyBumBum", "BUMN": "BUMooN", "BUMP": "Bumper", "BUN": "BunnyCoin", + "BUND": "Bund V2.0", + "BUNDL": "Bundl Tools", "BUNI": "Bunicorn", "BUNNY": "Pancake Bunny", "BUNNYINU": "Bunny Inu", "BUNNYROCKET": "BunnyRocket", "BURGER": "Burger Swap", - "BURN": "Bitburn", + "BURN": "BurnedFi", "BURNDOGE": "BurnDoge", + "BURNIFYAI": "BurnifyAI", + "BURNKING": "BurnKing", + "BURNNY": "Burnny Inu", + "BURNS": "Burnsdefi", + "BURNZ": "BURNZ", "BURP": "CoinBurp", + "BURRRD": "BURRRD", "BUSD": "Binance USD", "BUSDC": "BUSD", "BUSY": "Busy DAO", @@ -1352,12 +2047,17 @@ "BUY": "Burency", "BUYI": "Buying.com", "BUZZ": "BuzzCoin", + "BV3A": "Buccaneer V3 Arbitrum", "BVC": "BeaverCoin", + "BVM": "BVM", "BVND": "Binance VND", "BVO": "BRAVO Pay", + "BVT": "BovineVerse Token", "BWB": "Bit World Token", "BWF": "Beowulf", + "BWJ": "Baby WOJ", "BWK": "Bulwark", + "BWLD": "Bowled.io", "BWN": "BitWings", "BWO": "Battle World", "BWS": "BitcoinWSpectrum", @@ -1366,20 +2066,29 @@ "BWX": "Blue Whale", "BX": "BlockXpress", "BXA": "Blockchain Exchange Alliance", + "BXBT": "BoxBet", "BXC": "BonusCloud", "BXF": "BlackFort Token", "BXH": "BXH", "BXK": "Bitbook Gambling", + "BXMI": "Bxmi Token", + "BXN": "BlackFort Exchange Network", + "BXR": "Blockster", "BXT": "BitTokens", "BXTB": "BXTB Foundation", "BXX": "Baanx", "BXY": "Beaxy", + "BYAT": "Byat", "BYC": "ByteCent", + "BYG": "Black Eye Galaxy", + "BYTE": "Byte", + "BYTES": "Neo Tokyo", "BYTHER": "Bytether ", "BYTS": "Bytus", "BYTZ": "BYTZ", "BZ": "Bit-Z", "BZENIQ": "Wrapped Zeniq (BNB)", + "BZET": "Bzetcoin", "BZKY": "Bizkey", "BZL": "BZLCoin", "BZNT": "Bezant", @@ -1392,28 +2101,39 @@ "C2": "Coin.2", "C20": "Crypto20", "C25": "C25 Coin", + "C2H6": "Ethane", "C2X": "C2X", "C3": "Charli3", "C98": "Coin98", "CA": "Coupon Assets", "CAAVE": "cAAVE", "CAB": "CabbageUnit", + "CABO": "CatBonk", "CABS": "CryptoABS", + "CACAO": "Maya Protocol", "CACH": "Cachecoin", "CACHE": "Cache", "CACHEGOLD": "CACHE Gold", + "CACTUS": "CACTUS", + "CADC": "CAD Coin", + "CADINU": "Canadian Inuit Dog", "CADN": "Content and AD Network", "CADX": "eToro Canadian Dollar", + "CAESAR": "Caesar's Arena", + "CAF": "Childrens Aid Foundation", "CAG": "Change", "CAGA": "Crypto Asset Governance Alliance", "CAH": "Moon Tropica", "CAI": "Cai Token", "CAID": "ClearAid", + "CAIR": "Crypto-AI-Robo.com", "CAIX": "CAIx", "CAIZ": "Caizcoin", "CAKE": "PancakeSwap", "CAKEBOT": "CakeBot", "CAKEMOON": "CakeMoon", + "CAKESWAP": "CakeSwap", + "CAKEW": "CakeWSwap", "CAL": "Calcium", "CALC": "CaliphCoin", "CALI": "CaliCoin", @@ -1422,28 +2142,43 @@ "CAM": "Consumption Avatar Matrix", "CAMC": "Camcoin", "CAMEL": "The Camel", + "CAMLY": "Camly Coin", "CAMP": "Camp", "CAN": "Channels", + "CANCER": "Cancer", "CAND": "Canary Dollar", + "CANDLE": "Candle Cat", "CANDY": "UnicornGo Candy", + "CANDYLAD": "Candylad", "CANN": "CannabisCoin", + "CANNF": "CANNFINITY", "CANTI": "Cantina Royale", + "CANTO": "CANTO", "CANYA": "CanYaCoin", "CAP": "Capverto", + "CAPA": "Cake Panda", "CAPD": "Capdax", + "CAPO": "IL CAPO OF CRYPTO", "CAPP": "Cappasity", + "CAPRI": "Caprisun Monkey", "CAPRICOIN": "CapriCoin", "CAPS": "Ternoa", "CAPT": "Bitcoin Captain", "CAPTAINPLANET": "Captain Planet", + "CAPY": "Capybara", "CAR": "CarBlock", "CARAT": "Carats Token", + "CARBO": "CleanCarbon", "CARBON": "Carboncoin", + "CARBONGEMS": "Carbon GEMS", "CARD": "Cardstack", "CARDS": "Cardstarter", + "CARDSWAP": "CardSwap", "CARE": "Carebit", "CARES": "CareCoin", + "CARLO": "Carlo", "CARO": "Meta Ricaro", + "CAROL": "CAROLToken", "CARPE": "CarpeDiemCoin", "CARR": "Carnomaly", "CARROT": "CarrotSwap", @@ -1453,36 +2188,69 @@ "CAS": "Cashaa", "CASH": "CashCoin", "CASHT": "Cash Tech", + "CASINU": "Casinu Inu", "CASIO": "CasinoXMetaverse", "CASPER": "Casper DeFi", "CAST": "Castello Coin", "CASTLE": "bitCastle", "CAT": "Cat Token", + "CATA": "CATAMOTO", + "CATAI": "Catgirl AI", + "CATBA": "CATBA INU", "CATBOY": "Catboy", "CATC": "Catcoin", + "CATCEO": "CATCEO", + "CATCH": "SpaceCatch", "CATCOIN": "CatCoin Cash", "CATCOINETH": "Catcoin", + "CATDOGE": "CAT DOGE", "CATE": "CateCoin", + "CATELON": "CatElonMars", + "CATEX": "CATEX", + "CATFISH": "Catfish", + "CATGAME": "Cookie Cat Game", "CATGIRL": "Catgirl", + "CATGPT": "CatGPT", + "CATHAT": "catwifhat", "CATHEON": "Catheon Gaming", + "CATHERO": "Cat Hero", + "CATKING": "CAT KING", + "CATMAN": "Catman", + "CATME": "ELON’S CAT", + "CATO": "CATO", + "CATPAY": "CATpay", + "CATPEPE": "CAT PEPE", "CATS": "CatCoin Token", + "CATSHIRA": "Shira Cat", "CATT": "Catex", + "CATVAX": "Catvax", + "CATVILLS": "Catvills Coin", + "CATWARRIOR": "Cat warrior", + "CATWIF": "CatWifHat", "CATX": "CAT.trade Protocol", "CATZ": "CatzCoin", + "CAU": "Canxium", "CAVA": "Cavapoo", + "CAVADA": "Cavada", "CAVE": "Deepcave", "CAVO": "Excavo Finance", "CAW": "A Hunters Dream", + "CAWCEO": "CAW CEO", "CB": "COINBIG", + "CBAB": "CreBit", + "CBABY": "Cosmo Baby", "CBANK": "Crypto Bank", "CBAT": "Compound Basic Attention Token", "CBC": "Casino Betting Coin", "CBD": "CBD Crystals", "CBDC": "CannaBCoin", + "CBDG": "CBD Global", "CBE": "The Chain of Business Entertainment", + "CBET": "CryptoBet", "CBETH": "Coinbase Wrapped Staked ETH", "CBFT": "CoinBene Future Token", "CBG": "Chainbing", + "CBIXP": "Cubiex Power", "CBK": "Cobak Token", "CBM": "CryptoBonusMiles", "CBNT": "Create Breaking News Together", @@ -1491,21 +2259,28 @@ "CBRL": "Crypto BRL", "CBRT": "Cybereits Token", "CBS": "Cerberus", + "CBSL": "CeBioLabs", "CBSN": "BlockSwap Network", "CBT": "CommerceBlock Token", + "CBU": "Banque Universal", "CBUCKS": "CRYPTOBUCKS", "CBUK": "CurveBlock", "CBX": "CropBytes", + "CBY": "Carbify", "CC": "CloudChat", "CC10": "Cryptocurrency Top 10 Tokens Index", "CCA": "CCA", "CCAKE": "CheeseCake Swap", "CCAR": "CryptoCars", + "CCASH": "C-cash", + "CCAT": "Crypto Cat", "CCC": "CCCoin", "CCCX": "Clipper Coin Capital", "CCD": "Concordium", "CCE": "CloudCoin", + "CCGDS": "CCGDS", "CCH": "Coinchase", + "CCHG": "C+Charge", "CCI": "Cyber Capital Invest", "CCIN": "Cryptocoin Insurance", "CCL": "CyClean", @@ -1519,21 +2294,27 @@ "CCRB": "CryptoCarbon", "CCT": "Carbon Credit", "CCTN": "Connectchain", + "CCV2": "CelebrityCoinV2", "CCX": "Conceal", "CCXC": "CoolinDarkCoin", "CCXX": "CounosX", "CDAI": "Compound Dai", + "CDCETH": "Crypto.com Staked ETH", "CDEX": "Cryptodex", "CDL": "CoinDeal Token", "CDN": "Canada eCoin", + "CDOG": "Corn Dog", + "CDOGE": "cyberdoge", "CDPT": "Creditor Data Platform", "CDRX": "CDRX", "CDT": "CheckDot", "CDX": "CDX Network", "CDY": "Bitcoin Candy", + "CDragon": "Clumsy Dragon", "CEDEX": "CEDEX Coin", "CEEK": "CEEK Smart VR Token", "CEFS": "CryptopiaFeeShares", + "CEICAT": "CEILING CAT", "CEJI": "Ceji", "CEL": "Celsius Network", "CELEB": "CELEBPLUS", @@ -1548,13 +2329,16 @@ "CENT": "CENTERCOIN", "CENTRA": "Centra", "CENX": "Centcex", + "CEODOGE": "CEO DOGE", "CERE": "Cere Network", + "CERES": "Ceres", "CESC": "Crypto Escudo", "CET": "CoinEx Token", "CETH": "Compound Ethereum", "CETI": "CETUS Coin", "CETUS": "Cetus Protocol", "CEUR": "Celo Euro", + "CEX": "Catena X", "CF": "Californium", "CFC": "CoinField Coin", "CFD": "Confido", @@ -1572,77 +2356,128 @@ "CFun": "CFun", "CGA": "Cryptographic Anomaly", "CGG": "Chain Guardians", + "CGL": "Crypto Gladiator Shards", "CGLD": "Celo Gold", "CGO": "Comtech Gold", "CGPT": "ChainGPT", "CGS": "Crypto Gladiator Shards", "CGT": "Coin Gabbar Token", "CGU": "Crypto Gaming United", + "CGV": "Cogito Finance", "CHA": "Charity Coin", + "CHACHA": "Chacha", + "CHAD": "Chad Coin", + "CHADCAT": "CHAD CAT", "CHADS": "CHADS VC", "CHAIN": "Chain Games", "CHAINCADE": "ChainCade", "CHAL": "Chalice Finance", + "CHAM": "Champion", "CHAMP": "NFT Champions", + "CHAMPZ": "Champz", "CHAN": "ChanCoin", + "CHANCE": "Ante Casino", "CHANGE": "ChangeX", "CHAO": "23 Skidoo", + "CHAPZ": "Chappyz", + "CHARGED": "GoCharge Tech", "CHARIZARD": "Charizard Inu", "CHARM": "Charm Coin", "CHARS": "CHARS", "CHART": "BetOnChart", + "CHARTA": "CHARTAI", + "CHARTIQ": "ChartIQ", "CHASH": "CleverHash", - "CHAT": "OpenChat", + "CHAT": "Solchat", + "CHATGPT": "AI Dragon", "CHB": "COINHUB TOKEN", "CHBR": "CryptoHub", "CHC": "ChainCoin", + "CHD": "CharityDAO", "CHECK": "Paycheck", "CHECKR": "CheckerChain", "CHECOIN": "CheCoin", + "CHED": "Giggleched", "CHEDDA": "Chedda", + "CHEEKS": "CHEEKS", "CHEEL": "Cheelee", "CHEEMS": "Cheems", + "CHEEPEPE": "CHEEPEPE", + "CHEERS": "DICAPRIO CHEERS", "CHEESE": "CHEESE", "CHEESUS": "Cheesus", + "CHENG": "Chengshi", "CHEQ": "CHEQD Network", "CHER": "Cherry Network", "CHERRY": "CherrySwap", "CHESS": "Tranchess", "CHESSCOIN": "ChessCoin", + "CHET": "ChetGPT", + "CHEWY": "Chewy", "CHEX": "Chintai", "CHFN": "NOKU CHF", "CHFT": "Crypto Holding", + "CHFU": "Upper Swiss Franc", "CHFX": "eToro Swiss Franc", + "CHH": "Chihuahua Token", "CHI": "Chi Gastoken", + "CHICA": "CHICA", "CHICKS": "SolChicks", + "CHIDO": "Chinese Doge Wow", "CHIEF": "TheChiefCoin", "CHIHUA": "Chihua Token", "CHII": "Chiiper Chain", "CHILD": "ChildCoin", + "CHILI": "CHILI", + "CHILL": "ChillPill", + "CHIM": "Chimera", + "CHINAZILLA": "ChinaZilla", + "CHINGON": "Mexico Chingon", "CHINU": "Chubby Inu", "CHIP": "Chip", + "CHIPPY": "Chippy", "CHIPS": "CHIPS", "CHIRP": "Chirp", + "CHITCAT": "ChitCAT", "CHIWAWA": "Chiwawa", "CHK": "Chek", + "CHKN": "Chickencoin", "CHLT": "Chellitcoin", "CHMB": "Chumbi Valley", + "CHMPZ": "Chimpzee", "CHN": "Chain", "CHNG": "Chainge Finance", "CHO": "Choise", + "CHOKE": "Artichoke Protocol", + "CHOMP": "ChompCoin", + "CHON": "Chonk The Cat", "CHONK": "Chonk", + "CHONKY": "CHONKY", + "CHOO": "Chooky", "CHOOF": "ChoofCoin", "CHOPPER": "Chopper Inu", + "CHOPPY": "Choppy", "CHORIZO": "Chorizo", "CHOW": "Chow Chow Finance", + "CHOY": "Bok Choy", "CHP": "CoinPoker Token", "CHR": "Chroma", + "CHRETT": "Chinese BRETT", + "CHRISPUMP": "Christmas Pump", "CHRP": "Chirpley", "CHS": "Chainsquare", "CHSB": "SwissBorg", "CHT": "Countinghouse Fund", + "CHUANPU": "Chuan Pu", + "CHUB": "CallHub", + "CHUC": "CHUCK", + "CHUCHU": "CHUCHU", + "CHUCK": "Chuck Norris", + "CHUMP": "Chump Change", + "CHURRO": "CHURRO-The Jupiter Dog", "CHVF": "Chives Finance", "CHW": "Chrysalis Coin", + "CHWY": "CHEWY", "CHX": "Own", "CHY": "Concern Poverty Chain", "CHZ": "Chiliz", @@ -1654,11 +2489,17 @@ "CIND": "Cindrum", "CINNI": "CINNICOIN", "CINU": "CHEEMS INU", + "CINUV1": "CHEEMS INU v1", "CINX": "CINDX", + "CIOTX": "Crosschain IOTX", "CIPHC": "Cipher Core Token", "CIR": "CircuitCoin", "CIRC": "CryptoCircuits", + "CIRCLE": "You Looked", + "CIRCUS": "Cirque Du Sol", + "CIRRUS": "Cirrus", "CIRUS": "Cirus", + "CITI": "CITI Fediverse", "CITY": "Manchester City Fan Token", "CIV": "Civilization", "CIVIT": "Civitas Protocol", @@ -1669,29 +2510,45 @@ "CJR": "Conjure", "CJT": "ConnectJob Token", "CKB": "Nervos Network", + "CKBTC": "Chain-key Bitcoin", "CKC": "Clockcoin", "CKEK": "CryptoKek", + "CKETH": "Chain-key Ethereum", + "CKP": "Cakepie", "CKT": "Caketools", "CKUSD": "CKUSD", "CL": "CoinLancer", + "CLA": "ClaimSwap", "CLAM": "CLAMS", + "CLAS": "Classic USDC", + "CLASS": "Class Coin", + "CLAY": "Clay Nation", "CLB": "Cloudbric", + "CLBR": "Colibri Protocol", + "CLCT": "CollectCoin", "CLD": "Cloud", "CLDX": "Cloverdex", + "CLEAR": "Clear Water", "CLEARPOLL": "ClearPoll", "CLEG": "Chain of Legends", "CLEO": "Cleo Tech", + "CLEV": "CLever Token", "CLEVERCOIN": "CleverCoin", + "CLFI": "cLFi", "CLH": "ClearDAO", "CLICK": "Clickcoin", "CLIFF": "Clifford Inu", + "CLIMB": "CLIMB TOKEN FINANCE", "CLIN": "Clinicoin", "CLINK": "cLINK", "CLINT": "Clinton", + "CLIPS": "Clips", "CLIQ": "DefiCliq", "CLIST": "Chainlist", "CLM": "CoinClaim", + "CLMRS": "Crolon Mars", "CLN": "Colu Local Network", + "CLNX": "Coloniume Network", "CLNY": "Colony", "CLO": "Callisto Network", "CLOAK": "CloakCoin", @@ -1717,15 +2574,22 @@ "CMCC": "CMC Coin", "CMCT": "Crowd Machine", "CMCX": "CORE MultiChain", + "CMDX": "Comdex", "CMERGE": "CoinMerge", + "CMFI": "Compendium", + "CMINER": "ChainMiner", + "CMIT": "CMITCOIN", "CMK": "Credmark", "CMKR": "cMKR", "CML": "Camelcoin", "CMM": "Commercium", "CMN": "Crypto Media Network", + "CMONK": "CRAZY MONKEY", + "CMOON": "CryptoMoonShot", "CMOS": "CoinMerge OS", "CMP": "Caduceus", "CMPCO": "CampusCoin", + "CMPT": "Spatial Computing", "CMQ": "Communique", "CMS": "COMSA", "CMSN": "The Commission", @@ -1733,12 +2597,15 @@ "CMTC": "CometCoin", "CMZ": "CRYPTOMAGZ", "CNAB": "Cannabium", + "CNAME": "Cloudname", "CNB": "Coinsbit Token", "CNBC": "Cash & Back Coin", "CNC": "ChinaCoin", + "CNCL": "The Ordinals Council", "CNCT": "CONNECT", "CND": "Cindicator", "CNDL": "Candle", + "CNETA": "AnetaBTC", "CNF": "CryptoNeur Network foundation", "CNFI": "Connect Financial", "CNG": "Changer", @@ -1755,19 +2622,25 @@ "CNTR": "Centaur", "CNUS": "CoinUs", "CNX": "Cryptonex", + "CNYD": "Chinese NY Dragon", "CNYT": "CNY Tether", "CNYX": "eToro Chinese Yuan", "CO": "Corite", "CO2": "CO2 Token", "COAL": "BitCoal", "COB": "Cobinhood", + "COBE": "Castle of Blackwater", "COC": "Coin of the champions", "COCK": "Shibacock", + "COCO": "0xCoco", + "CODAI": "CODAI", + "CODEG": "CodeGenie", "CODEO": "Codeo Token", "CODEX": "CODEX Finance", "CODI": "Codi Finance", "CODY": "Coindy", "COE": "CoEval", + "COFEEE": "COFEEE", "COFFEECOIN": "CoffeeCoin", "COFI": "CoinFi", "COFIX": "CoFIX", @@ -1780,36 +2653,53 @@ "COINBT": "CoinBot", "COINDEFI": "Coin", "COING": "Coingrid", + "COINH": "Coinhound", "COINLION": "CoinLion", "COINSCOPE": "Coinscope", "COINSL": "CoinsLoot", "COINVEST": "Coinvest", + "COINYE": "Coinye West", + "COK": "Cat Own Kimono", "COKE": "Cocaine Cowboy Shards", "COL": "Clash of Lilliput", "COLA": "Cola", "COLL": "Collateral Pay", + "COLLAR": "PolyPup Finance", "COLLE": "Collective Care", + "COLLECT": "CoinCollect", "COLLG": "Collateral Pay Governance", "COLR": "colR Coin", + "COLT": "Collateral Network", "COLX": "ColossusCoinXT", "COM": "Coliseum", + "COMAI": "Commune AI", "COMB": "Combo", "COMBO": "COMBO", + "COME": "Community of Meme", + "COMEW": "Coin In Meme World", "COMFI": "CompliFi", "COMM": "Community Coin", "COMMUNITYCOIN": "Community Coin", "COMP": "Compound Governance Token", "COMPCOIN": "Compcoin", "COMPD": "Compound Coin", + "COMPU": "Compute Network", "COMT": "Community Token", + "CONAN": "Conan", + "CONC": "Concentrator", "CONDENSATE": "Condensate", + "CONDO": "CONDO", "CONE": "BitCone", "CONG": "The Conglomerate Capital", "CONI": "CoinBene", + "CONJ": "Conjee", + "CONK": "ShibaPoconk", "CONS": "ConSpiracy Coin", "CONSENTIUM": "Consentium", "CONV": "Convergence", "CONX": "Connex", + "COOCHIE": "Cucci", + "COOHA": "CoolHash", "COOK": "Cook", "COOL": "CoolCoin", "COOP": "Coop Network", @@ -1817,7 +2707,9 @@ "COPI": "Cornucopias", "COPIUM": "Copium", "COPS": "Cops Finance", - "COR": "Corion", + "COPYCAT": "Copycat Finance", + "COQ": "Coq Inu", + "COR": "Coreto", "CORAL": "CoralPay", "CORE": "Core", "COREDAO": "coreDAO", @@ -1826,19 +2718,28 @@ "CORGI": "Corgi Inu", "CORGIAI": "CorgiAI", "CORGIB": "The Corgi of PolkaBridge", + "CORION": "Corion", "CORN": "CORN", + "CORNELLA": "CORNELLA", "CORX": "CorionX", "COS": "Contentos", "COSHI": "CoShi Inu", "COSM": "CosmoChain", + "COSMI": "Cosmic FOMO", "COSMIC": "CosmicSwap", "COSP": "Cosplay Token", "COSS": "COS", + "COST": "Costco Hot Dog", "COSX": "Cosmecoin", "COT": "CoTrader", "COTI": "COTI", "COU": "Couchain", "COUNOS": "Counos Coin", + "COUNOSBIT": "Counos Bit", + "COUNOSH": "Counos H", + "COUNOSU": "Counos U", + "COUP": "CouponBay", + "COURAGE": "Courage the Cowardly Dog", "COV": "Covesting", "COVA": "COVA", "COVAL": "Circuits of Value", @@ -1849,24 +2750,31 @@ "COVIR": "COVIR", "COVN": "Covenant", "COW": "CoW Protocol", + "COWRIE": "MYCOWRIE", "COX": "CobraCoin", "COY": "Coin Analyst", "COZP": "COZPlus", + "COZY": "Cozy Pepe", "CP": "CoPuppy", + "CPA": "CryptoPulse AdBot", "CPAD": "Cronospad", "CPAN": "CryptoPlanes", "CPAY": "CryptoPay", "CPC": "CPChain", "CPCOIN": "CPCoin", "CPD": "CoinsPaid", + "CPET": "Chain Pet", "CPEX": "CoinPulseToken", "CPH": "Cypherium", "CPI": "Crypto Price Index", + "CPIGGY": "Vix Finance", "CPL": "CoinPlace Token", "CPLO": "Cpollo", "CPN": "CompuCoin", "CPO": "Cryptopolis", + "CPOO": "Cockapoo", "CPOOL": "Clearpool", + "CPOS": "Cpos Cloud Payment", "CPR": "Cipher", "CPROP": "CPROP", "CPRX": "Crypto Perx", @@ -1881,6 +2789,7 @@ "CR8": "Crazy8Token", "CRA": "Crabada", "CRAB": "CrabCoin", + "CRACER": "Coinracer Reloaded", "CRACK": "CrackCoin", "CRADLE": "Cradle of Sins", "CRAFT": "TaleCraft", @@ -1888,11 +2797,23 @@ "CRAIG": "CraigsCoin", "CRAMER": "Cramer Coin", "CRANEPAY": "Cranepay", + "CRASH": "Solana Crash", "CRAVE": "CraveCoin", + "CRAYRABBIT": "CrazyRabbit", + "CRAZYBONK": "CRAZY BONK", + "CRAZYBUNNY": "Crazy Bunny", + "CRAZYCAT": "CRAZY CAT", + "CRAZYDOGE": "CRAZY DOGE", + "CRAZYDRAGON": "CRAZY DRAGON", + "CRAZYMUSK": "CRAZY MUSK", + "CRAZYPEPE": "CrazyPepe", + "CRAZYTIGER": "CRAZY TIGER", "CRB": "Creditbit", "CRBN": "Carbon", + "CRBRUS": "Cerberus", "CRC": "CryCash", "CRD": "CRD Network", + "CRDC": "Cardiocoin", "CRDN": "Cardence", "CRDNC": "Credence Coin", "CRDS": "Credits", @@ -1901,7 +2822,9 @@ "CRE": "Carry", "CRE8": "Creaticles", "CREA": "CreativeChain", + "CREAL": "Celo Brazilian Real", "CREAM": "Cream", + "CREAML": "Creamlands", "CREATIVE": "Creative Token", "CRED": "Verify", "CREDI": "Credefi", @@ -1909,34 +2832,60 @@ "CREDITS": "Credits", "CREDO": "Credo", "CREED": "Thecreed", + "CREMAT": "Cremation Coin", "CREO": "Creo Engine", "CREP": "Compound Augur", + "CREPE": "Crepe Coin", "CRES": "Cresio", "CREV": "CryptoRevolution", "CREVA": "Creva Coin", + "CREW": "CREW INU", "CRF": "Crafting Finance", "CRFI": "CrossFi", "CRGO": "CargoCoin", + "CRGPT": "CryptoGPT", + "CRH": "Crypto Hunters Coin", + "CRHT": "CryptHub", + "CRI3X": "CRI3X", + "CRICKETS": "Kermit", "CRIME": "Crime Gold", + "CRIMINGO": "Criminal Flamingo", + "CRK": "Croking", "CRL": "Cryptelo Coin", "CRM": "Cream", + "CRMS": "Cryptomus", + "CRNCHY": "Crunchy Network", "CRNK": "CrankCoin", "CRO": "Cronos", + "CROAK": "Croakey", "CROAT": "Croat", + "CROB": "Crob Coin", + "CROCO": "Croco", + "CRODIE": "Crodie", "CROGE": "Crogecoin", + "CROID": "Cronos ID", "CRON": "Cryptocean", + "CRONA": "CronaSwap", + "CRONK": "CRONK", "CROPPER": "CropperFinance", "CROWD": "CrowdCoin", "CROWDWIZ": "Crowdwiz", + "CROWN": "Crown by Third Time Games", + "CROX": "CroxSwap", "CRP": "Crypton", "CRPS": "CryptoPennies", "CRPT": "Crypterium", "CRS": "CRYSTALS", "CRSP": "CryptoSpots", "CRT": "Carr.Finance", + "CRTB": "Coritiba F.C. Fan Token", "CRTM": "Cryptum", "CRTS": "Cratos", "CRU": "Crust Network", + "CRUD": "CRUDE OIL BRENT", + "CRUIZ": "Cruiz", + "CRUMP": "Crypto Trump", + "CRUX": "CryptoMines Reborn", "CRV": "Curve DAO Token", "CRVUSD": "crvUSD", "CRVY": "Curve Inu", @@ -1944,19 +2893,29 @@ "CRWD": "CRWD Network", "CRWNY": "Crowny Token", "CRX": "ChronosCoin", + "CRY": "Crypto News Flash AI", + "CRYN": "CRYN", + "CRYO": "CryoDAO", "CRYP": "CrypticCoin", "CRYPT": "CryptCoin", "CRYPTOBULLION": "CryptoBullion", "CRYPTOE": "Cryptoenter", + "CRYPTON": "CRYPTON", "CRYPTONITE": "Cryptonite", "CRYPTOPRO": "CryptoProfile", "CRYPTOSDG": "Crypto SDG", + "CRYPTOU": "CryptoUnity", + "CRYSTAL": "Crystal", "CRYSTALCLEAR": "Crystal Clear Token", + "CRYSTL": "Crystl Finance", + "CS": "Child Support", "CSAC": "Credit Safe Application Chain", "CSAI": "Compound SAI", + "CSAS": "csas (Ordinals)", "CSC": "CasinoCoin", - "CSEN": "Consensus", + "CSEN": "Sentient Coin", "CSH": "CashOut", + "CSIX": "Carbon Browser", "CSM": "Crust Shadow", "CSMIC": "Cosmic", "CSNO": "BitDice", @@ -1972,14 +2931,18 @@ "CSTL": "Castle", "CSTR": "CoreStarter", "CSUSHI": "cSUSHI", - "CSWAP": "CardSwap", + "CSWAP": "ChainSwap", "CSX": "Coinstox", + "CT": "CryptoTwitter", + "CTA": "Cross The Ages", "CTAG": "CTAGtoken", "CTASK": "CryptoTask", "CTC": "Creditcoin", "CTCN": "Contracoin", "CTE": "Crypto Tron", + "CTEX": "Crypto tex", "CTF": "CyberTime Finance", + "CTG": "City Tycoon Games", "CTI": "ClinTex CTi", "CTIC": "Coinmatic", "CTK": "Shentu", @@ -1989,10 +2952,13 @@ "CTLX": "Cash Telex", "CTN": "Continuum Finance", "CTO": "Crypto", + "CTOK": "Codyfight", "CTP": "Ctomorrow Platform", "CTPL": "Cultiplan", "CTPT": "Contents Protocol", "CTR": "Creator Platform", + "CTRL2XY": "Control2XY", + "CTRT": "Cryptrust", "CTS": "Citrus", "CTSI": "Cartesi", "CTT": "Castweet", @@ -2000,19 +2966,29 @@ "CTX": "Cryptex", "CTXC": "Cortex", "CTY": "Connecty", + "CU": "Crypto Unicorns", + "CUAN": "CuanSwap.com", + "CUB": "Cub Finance", "CUBE": "Cube Network", "CUBEAUTO": "Cube", + "CUBEB": "CubeBase", + "CUCCI": "Cat in Gucci", "CUCK": "Cuckadoodledoo", "CUDOS": "Cudos", "CUE": "CUE Protocol", "CUEX": "Cuex", + "CUFF": "Jail Cat", + "CULO": "CULO", "CULT": "Cult DAO", + "CUM": "Cumbackbears", + "CUMINU": "CumInu", "CUMMIES": "CumRocket", "CUNI": "Compound Uni", "CURA": "Cura Network", "CURE": "Curecoin", "CURI": "Curium", "CURIO": "Curio Governance", + "CURR": "Curry", "CURRY": "CurrySwap", "CUSD": "Carbon", "CUSDC": "Compound USD Coin", @@ -2029,6 +3005,9 @@ "CVC": "Civic", "CVCC": "CryptoVerificationCoin", "CVCOIN": "Crypviser", + "CVG": "Convergence", + "CVIP": "CVIP", + "CVN": "ConsciousDao", "CVNC": "CovenCoin", "CVNG": "Crave-NG", "CVNT": "Conscious Value Network", @@ -2036,16 +3015,21 @@ "CVPT": "Concentrated Voting Power", "CVR": "Polkacover", "CVS": "CoinVisa", + "CVSHOT": "CV SHOTS", "CVT": "CyberVein", "CVTC": "CavatCoin", + "CVTX": "Carrieverse", "CVX": "Convex Finance", "CVXCRV": "Convex CRV", + "CVXFXS": "Convex FXS", "CW": "CardWallet", + "CWA": "Chris World Asset", "CWAR": "Cryowar Token", "CWBTC": "Compound Wrapped BTC", "CWD": "CROWD", "CWEB": "Coinweb", "CWEX": "Crypto Wine Exchange", + "CWIF": "catwifhat", "CWIS": "Crypto Wisdom Coin", "CWN": "CryptoWorldNews", "CWR": "Cowrium", @@ -2063,43 +3047,65 @@ "CXP": "Caixa Pay", "CXPAD": "CoinxPad", "CXT": "Coinonat", + "CY97": "Cyclops97", + "CYB": "CYBERTRUCK", + "CYBA": "CYBRIA", "CYBER": "CyberConnect", "CYBERC": "CyberCoin", "CYBERD": "Cyber Doge", "CYBERTRUCK": "Cyber Truck", + "CYBERTRUMP": "CyberTrump", "CYBERWAY": "CyberWay", + "CYBONK": "CYBONK", "CYBR": "CYBR", "CYC": "Cyclone Protocol", + "CYCAT": "Chi Yamada Cat", "CYCE": "Crypto Carbon Energy", + "CYCLE": "Cycle Finance", "CYCLUB": "Cyclub", "CYCON": "CONUN", "CYDER": "Cyder Coin", "CYFI": "cYFI", "CYG": "Cygnus", "CYL": "Crystal Token", + "CYM": "Cylum Finance", + "CYMT": "CyberMusic", "CYOP": "CyOp Protocol", "CYP": "CypherPunkCoin", + "CYPEPE": "CyPepe", "CYRS": "Cyrus Token", "CYRUS": "Cyrus Exchange", "CYS": "BlooCYS", "CYT": "Cryptokenz", "CZC": "Crazy Coin", + "CZF": "CZodiac Farming Token", "CZGOAT": "CZ THE GOAT", + "CZOL": "Czolana", "CZR": "CanonChain", "CZRX": "Compound 0x", + "CZSHARES": "CZshares", + "CZUSD": "CZUSD", "CZZ": "ClassZZ", "D": "Denarius", "D11": "DeFi11", + "D2O": "DAM Finance", + "D2T": "Dash 2 Trade", + "D3D": "D3D Social", "D4RK": "DarkPayCoin", + "DAAPL": "Apple Tokenized Stock Defichain", "DAB": "DABANKING", "DAC": "Davinci Coin", "DACASH": "DACash", + "DACAT": "daCat", "DACC": "Decentralized Accessible Content Chain", "DACC2": "DACC2", "DACH": "DACH Coin", + "DACKIE": "DackieSwap", "DACS": "Dacsee", "DACXI": "Dacxi", "DAD": "DAD", + "DADA": "DADA", + "DADDY": "Daddy Tate", "DADDYDOGE": "Daddy Doge", "DADI": "Edge", "DAF": "DaFIN", @@ -2110,23 +3116,32 @@ "DAGT": "Digital Asset Guarantee Token", "DAI": "Dai", "DAILY": "Coindaily", + "DAILYS": "DailySwap Token", "DAIMO": "Diamond Token", "DAIN": "Dain Token", "DAIQ": "Daiquilibrium", + "DAISY": "Daisy Launch Pad", "DAL": "DAOLaunch", "DALI": "Dalichain", "DAM": "Datamine", "DAMEX": "DAMEX", "DAMO": "Coinzen", + "DAMOON": "Damoon Coin", "DAN": "Daneel", "DANA": "Ardana", + "DANG": "Guangdang", + "DANGEL": "dAngel Fund", "DANK": "DarkKush", "DAO": "DAO Maker", + "DAO1": "DAO1", "DAOACT": "ACT", "DAOB": "DAOBet", + "DAOP": "Dao Space", + "DAOSOL": "MonkeDAO", "DAOVC": "DAO.VC", "DAOX": "Daox", "DAPP": "LiquidApps", + "DAPPSY": "Dappsy", "DAPPT": "Dapp Token", "DAPPX": "dAppstore", "DAPS": "DAPS Coin", @@ -2135,7 +3150,10 @@ "DARB": "Darb Token", "DARC": "Konstellation", "DARCRUS": "Darcrus", + "DARE": "The Dare", + "DARED": "Daredevil Dog", "DARICO": "Darico", + "DARIK": "Darik", "DARK": "Dark", "DARKEN": "Dark Energy Crystals", "DART": "dART Insurance", @@ -2144,13 +3162,21 @@ "DASC": "DasCoin", "DASH": "Dash", "DASHD": "Dash Diamond", + "DASHG": "Dash Green", "DAT": "Datum", "DATA": "Streamr", "DATAWALLET": "DataWallet", + "DATP": "Decentralized Asset Trading Platform", "DATX": "DATx", + "DAUMEN": "Daumenfrosch", "DAV": "DAV", + "DAVE": "DAVE", + "DAVINCI": "Davincigraph", "DAVIS": "Davis Cup Fan Token", "DAVP": "Davion", + "DAW": "DAWKOINS", + "DAWCURRENCY": "Daw Currency", + "DAWG": "Dawg Coin", "DAWGS": "SpaceDawgs", "DAWN": "Dawn Protocol", "DAX": "DAEX", @@ -2164,9 +3190,11 @@ "DBD": "Day By Day", "DBEAR": "DBear Coin", "DBET": "Decent.bet", + "DBI": "Don't Buy Inu", "DBIC": "DubaiCoin", "DBIX": "DubaiCoin", "DBL": "Doubloon", + "DBOE": "DBOE", "DBOX": "DefiBox", "DBR": "Düber", "DBTC": "DebitCoin", @@ -2177,22 +3205,35 @@ "DBY": "Dobuy", "DBZ": "Diamond Boyz Coin", "DC": "Dogechain", + "DCA": "AutoDCA", + "DCAR": "Dragon Crypto Argenti", + "DCARD": "DECENTRACARD", "DCASH": "Diabolo", + "DCAU": "Dragon Crypto Aurum", "DCB": "Decubate", "DCC": "Distributed Credit Chain", "DCCT": "DocuChain", + "DCHF": "DeFi Franc", + "DCI": "Decentralized Cloud Infrastructure", "DCIP": "Decentralized Community Investment Protocol", - "DCK": "DickCoin", + "DCK": "DexCheck AI", + "DCLOUD": "DecentraCloud", + "DCM": "Ducky City", "DCN": "Dentacoin", "DCNT": "Decanect", "DCNTR": "Decentrahub Coin", + "DCOIN": "Crypto Delivery", "DCR": "Decred", "DCRE": "DeltaCredits", + "DCRN": "Decred-Next", "DCS.": "deCLOUDs", "DCT": "Decent", + "DCTO": "Decentralized Crypto Token", "DCX": "DeCEX", "DCY": "Dinastycoin", + "DD": "DuckDAO", "DDAM": "DDAM", + "DDAO": "DDAO Hunters", "DDD": "Scry.info", "DDDD": "People's Punk", "DDF": "Digital Developers Fund", @@ -2207,41 +3248,69 @@ "DDS": "DDS.Store", "DDX": "DerivaDAO", "DEA": "Degas Coin", + "DEAI": "Zero1 Lab", "DEAL": "iDealCash", "DEB": "Debitum Token", "DEBASE": "Debase", "DEBT": "The Debt Box", "DEC": "Decentr", + "DECENTRALG": "Decentral Games ICE", + "DECHAT": "Dechat", + "DECI": "Maximus DECI", + "DECL": "Decimal token", + "DECODE": "Decode Coin", "DEDE": "Dede", + "DEED": "Deed (Ordinals)", + "DEEM": "iShares MSCI Emerging Markets ETF Defichain", "DEEP": "DeepCloud AI", "DEEPG": "Deep Gold", + "DEER": "ToxicDeer Finance", "DEEX": "DEEX", "DEEZ": "DEEZ NUTS", - "DEFI": "Defi", + "DEFC": "Defi Coin", + "DEFEND": "Blockdefend AI", + "DEFI": "DeFi", "DEFI5": "DEFI Top 5 Tokens Index", + "DEFIDO": "DeFido", + "DEFIK": "DeFi Kingdoms JADE", "DEFIL": "DeFIL", + "DEFILAB": "Defi", + "DEFISCALE": "DeFiScale", "DEFIT": "Digital Fitness", "DEFLA": "Defla", "DEFLCT": "Deflect", "DEFLECT": "Deflect Harbor AI", "DEFLY": "Deflyball", + "DEFROGS": "DeFrogs", "DEFT": "DeFi Factory Token", + "DEFX": "DeFinity", "DEFY": "DEFY", "DEG": "Degis", - "DEGEN": "DegenReborn", + "DEGA": "Dega", + "DEGEN": "Degen", + "DEGENR": "DegenReborn", "DEGO": "Dego Finance", "DEGOV": "Degov", + "DEGOV1": "Dego Finance v1", "DEHUB": "DeHub", "DEI": "Deimos", "DEK": "DekBox", "DEL": "Decimal", "DELCHAIN": "DelChain", "DELFI": "DeltaFi", + "DELI": "NFTDeli", + "DELIGHTPAY": "DelightPay", + "DELOT": "DELOT.IO", "DELTA": "Delta Financial", "DELTAC": "DeltaChain", "DEM": "eMark", + "DEMI": "DeMi", + "DEMIR": "Adana Demirspor Token", "DEMOS": "DEMOS", "DENT": "Dent", + "DENTX": "DENTNet", + "DEO": "Demeter", + "DEOD": "Decentrawood", "DEOR": "Decentralized Oracle", "DEP": "DEAPCOIN", "DEPO": "Depo", @@ -2250,11 +3319,14 @@ "DERC": "DeRace", "DERI": "Deri Protocol", "DERO": "Dero", + "DERP": "Derp", "DES": "DeSpace Protocol", "DESI": "Desico", "DESO": "Decentralized Social", "DESTINY": "Destiny", "DESU": "Dexsport", + "DETENSOR": "DeTensor", + "DETF": "Decentralized ETF", "DETH": "DarkEther", "DEUR": "DigiEuro", "DEUS": "DEUS Finance", @@ -2262,29 +3334,39 @@ "DEVCOIN": "DevCoin", "DEVO": "DeVolution", "DEVT": "DeHorizon", + "DEVVE": "Devve", "DEVX": "Developeo", "DEX": "DEX", "DEXA": "DEXA COIN", "DEXE": "DeXe", "DEXG": "Dextoken Governance", + "DEXIO": "Dexioprotocol", "DEXM": "Dexmex", + "DEXNET": "DexNet", + "DEXO": "DEXO", + "DEXSHARE": "dexSHARE", "DEXT": "DEXTools", "DEXTF": "DEXTF", + "DEXTV1": "DEXTools V1", "DF": "dForce", "DFA": "DeFine", + "DFB": "Facebook Tokenized Stock Defichain", "DFBT": "DentalFix", - "DFC": "DeFiScale", + "DFC": "DeFinder Capital", "DFD": "DefiDollar DAO", "DFG": "Defigram", "DFGL": "DeFi Gold", + "DFH": "DeFiHorse", "DFI": "DeFiChain", "DFIAT": "DeFiato", "DFIO": "DeFi Omega", "DFIS": "DfiStarter", "DFL": "DeFi Land", "DFND": "dFund", + "DFNDR": "Defender Bot", "DFP": "Digital Fund Coin", "DFSG": "DFSocial Gaming", + "DFSM": "DFS MAFIA", "DFSOCIAL": "DefiSocial (OLD)", "DFSPORTS": "Digital Fantasy Sports", "DFT": "DigiFinexToken", @@ -2297,35 +3379,59 @@ "DGCL": "DigiCol Token", "DGD": "Digix DAO", "DGDC": "DarkGold", + "DGEN": "The MVP Society", + "DGH": "Digihealth", + "DGI": "DGI Game", "DGLD": "Digital Gold", + "DGLN": "Dogelana", "DGM": "DigiMoney", + "DGME": "GameStop Tokenized Stock Defichain", "DGMS": "Digigems", + "DGMT": "DigiMax DGMT", + "DGMV": "DigiMetaverse", "DGN": "Diagon", + "DGNX": "DegenX", + "DGOLD": "PolyDragon", "DGORE": "DogeGoreCoin", "DGP": "DGPayment", "DGPT": "DigiPulse", "DGTX": "Digitex Token", "DGVC": "DegenVC", "DGX": "Digix Gold token", + "DHLT": "DeHealth", + "DHN": "Dohrnii", + "DHP": "dHealth", "DHR": "DeHR Network", "DHS": "Dirham Crypto", "DHT": "dHedge DAO", "DHV": "DeHive", + "DHX": "DataHighway", "DIA": "DIA", + "DIABLO": "Diablo IV", "DIAM": "Diamond", "DIAMND": "Projekt Diamond", + "DIAMOND": "Diamond Coin", + "DIBBLE": "Dibbles", + "DIBC": "DIBCOIN", "DIC": "Daikicoin", "DICE": "Klaydice", "DICEM": "DICE Money", "DICETRX": "TRONbetDice", + "DICK": "adDICKted", + "DICKCOIN": "DickCoin", "DID": "Didcoin", + "DIDID": "Didi Duck", "DIE": "Die Protocol", "DIEM": "Facebook Diem", "DIESEL": "Diesel", + "DIFF": "Diffusion", "DIFI": "Digital Files", "DIFX": "Digital Financial Exchange", "DIG": "DIEGO", + "DIGAU": "Dignity Gold", + "DIGEX": "Digex", "DIGG": "DIGG", + "DIGI": "Digiverse", "DIGIC": "DigiCube", "DIGIF": "DigiFel", "DIGITAL": "Digital Reserve Currency", @@ -2333,142 +3439,242 @@ "DIGS": "Diggits", "DIKO": "Arkadiko", "DILI": "D Community", + "DILIGENT": "Diligent Pepe", + "DILL": "dillwifit", "DIM": "DIMCOIN", "DIME": "DimeCoin", "DIMO": "DIMO", "DIN": "Dinero", + "DINERO": "Dinerobet", "DINGER": "Dinger Token", "DINGO": "Dingocoin", "DINO": "DinoSwap", + "DINT": "DinarTether", "DINU": "Dogey-Inu", + "DINW": "Dinowars", "DIO": "Decimated", "DIONE": "Dione", "DIP": "Etherisc", - "DIS": "TosDis", + "DIPA": "Doge Ipa", + "DIRTY": "Dirty Street Cats", + "DIS": "DisChain", "DISCOVERY": "DiscoveryIoT", "DISK": "Dark Lisk", "DISPEPE": "Disabled Pepe", "DIT": "Ditcoin", + "DITH": "Dither AI", "DIVA": "DIVA Protocol", "DIVER": "Divergence Protocol", "DIVI": "Divi Project", "DIVO": "DIVO Token", "DIVX": "Divi Exchange Token", "DIW": "DIWtoken", + "DIYAR": "Diyarbekirspor Token", "DJED": "Djed", + "DJT": "Save America", "DK": "Dominant Kong", "DKA": "dKargo", "DKC": "DarkKnightCoin", "DKD": "Dekado", "DKEY": "DKEY Bank", "DKKT": "DKK Token", + "DKNIGHT": "Dark Knight", "DKS": "DarkShield", + "DKT": "Duelist King", + "DKUMA": "KumaDex Token", "DLA": "Dolla", + "DLANCE": "DeeLance", + "DLB": "DiemLibre", "DLC": "Diamond Launch", "DLISK": "Dlisk", + "DLLR": "Sovryn Dollar", "DLO": "Delio", + "DLORD": "DORK LORD", "DLPD": "DLP Duck Token", "DLPT": "Deliverers Power Token", "DLR": "DollarOnline", "DLT": "Agrello Delta", + "DLTA": "delta.theta", "DLX": "DAppLinks", "DLXV": "Delta-X", + "DLY": "Daily Finance", + "DLYCOP": "Daily COP", + "DMA": "Dragoma", + "DMAIL": "DMAIL Network", + "DMAR": "DMarket", "DMC": "Dream21", + "DMCC": "DiscoverFeed", "DMCH": "DARMA Cash", "DMD": "DMD", "DMG": "DMM: Governance", "DMGBULL": "3X Long DMM Governance Token", + "DMIND": "DecentraMind", "DML": "Decentralized Machine Learning", "DMLG": "Demole", "DMOD": "Demodyfi Token", + "DMOON": "Dollarmoon", "DMS": "Documentchain", - "DMT": "DMarket", + "DMT": "Dream Machine Token", "DMTC": "Demeter Chain", "DMTR": "Dimitra", "DMX": "Dymmax", + "DMZ": "DeMon Token", "DN8": "Pldgr", "DNA": "Metaverse", + "DND": "Diamond DND", "DNET": "DeNet", "DNF": "DNFT Protocol", + "DNFLX": "Netflix Tokenized Stock Defichain", "DNFT": "DareNFT", "DNN": "DNN Token", "DNO": "Denaro", + "DNODE": "DecentraNode", "DNOTES": "Dnotes", "DNS": "BitDNS", "DNT": "district0x", "DNTX": "DNAtix", + "DNVDA": "Nvidia Tokenized Stock Defichain", "DNX": "Dynex", "DNXC": "DinoX", - "DNZ.BITCI": "Denizlispor Fan Token", + "DNY": "Dynasty Coin", + "DNZ": "Denizlispor Fan Token", + "DOBBY": "Dobby", + "DOBEN": "dark boden", "DOBO": "DogeBonk", "DOC": "Dochain", "DOCC": "Doc Coin", "DOCK": "Dock.io", + "DOCSWAP": "Dex on Crypto", "DOCT": "DocTailor", + "DOD": "Day Of Defeat 2.0", + "DOD100": "Day of Defeat Mini 100x", "DODI": "DoubleDice", "DODO": "DODO", "DOE": "Dogs Of Elon", - "DOG": "The Doge NFT", + "DOFI": "Doge Floki Coin", + "DOG": " DOG•GO•TO•THE•MOON", "DOGA": "Dogami", "DOGACOIN": "DogaCoin", + "DOGAI": "Dogai", + "DOGALD": "dogald trump", "DOGB": "DogeBoy", "DOGBOSS": "Dog Boss", + "DOGC": "Dogeclub", "DOGDEFI": "DogDeFiCoin", "DOGE": "Dogecoin", + "DOGE1SAT": "DOGE-1SATELLITE", "DOGE20": "Doge 2.0", + "DOGEB": "DogeBonk", "DOGEBNB": "DogeBNB", "DOGEC": "DogeCash", "DOGECEO": "Doge CEO", + "DOGECO": "Dogecolony", "DOGECOIN": "Buff Doge Coin", "DOGECOLA": "DogeCola", "DOGECUBE": "DogeCube", "DOGED": "DogeCoinDark", "DOGEDAO": "DogeDao", "DOGEDASH": "Doge Dash", + "DOGEFORK": "DogeFork", "DOGEGF": "DogeGF", + "DOGEGROK": "Doge Grok", + "DOGEGROKAI": "Doge Of Grok AI", + "DOGEKING": "DogeKing", + "DOGELEGION": "DOGE LEGION", "DOGEMETA": "Dogemetaverse", + "DOGEMOB": "DOGEMOB", + "DOGENFT": "The Doge NFT", + "DOGEP": "Doge Protocol", + "DOGEPAY": "Doge Payment", + "DOGER": "Robotic Doge", + "DOGERA": "Dogera", + "DOGES": "Dogeswap", "DOGESWAP": "Dogeswap Token (HECO)", "DOGETH": "EtherDoge", + "DOGEVERSE": "DogeVerse", + "DOGEWHALE": "Dogewhale", "DOGEX": "DogeHouse Capital", + "DOGEY": "Dogey", "DOGEZILLA": "DogeZilla", + "DOGGS": "Doggensnout", "DOGGY": "Doggy", + "DOGH": "a dog in a hoodie", + "DOGI": "dogi", + "DOGIN": "Doginhood", + "DOGINC": "dog in cats world", + "DOGINME": "doginme", "DOGIRA": "Dogira", + "DOGMI": "DOGMI", "DOGO": "DogemonGo", "DOGPAD": "DogPad Finance", + "DOGRMY": "DogeArmy", + "DOGS": "Dogcoin", + "DOGSROCK": "Dogs Rock", + "DOGSWAG": "DogSwaghat", + "DOGWIFHAT": "dogwifhat", + "DOGWIFSEAL": "dogwifseal", "DOGY": "DogeYield", "DOGZ": "Dogz", "DOJO": "ProjectDojo", "DOKI": "Doki Doki Finance", + "DOKY": "Donkey King", "DOLA": "Dola USD Stablecoin", "DOLLARCOIN": "DollarCoin", + "DOLLUR": "Dollur Go Brrr", "DOLZ": "DOLZ", + "DOM": "Ancient Kingdom", "DOME": "Everdome", "DOMI": "Domi", "DON": "Donnie Finance", "DONA": "DONASWAP", + "DONALD": "DONALD TRUMP", + "DONALDT": "Donald The Trump", "DONATION": "DonationCoin", + "DONG": "DongCoin", + "DONGO": "Dongo AI", "DONK": "Don-key", + "DONKE": "DONKE", "DONS": "The Dons", + "DONU": "Donu", "DONUT": "Donut", + "DONUTS": "The Simpsons", + "DOODOO": "Doodoo", + "DOOGLE": "Doogle", "DOOH": "Bidooh", + "DOOMER": "Doomer", "DOOR": "DOOR", - "DOPE": "DopeCoin", + "DOPE": "Dopamine App", + "DOPECOIN": "DopeCoin", + "DOPU": "DOPU The Dog with A Purpose", "DOR": "Dorado", "DORA": "Dora Factory", + "DORAV1": "Dora Factory v1", + "DORK": "DORK", "DORKL": "DORK LORD", + "DORKY": "Dork Lord", "DOS": "DOS Network", "DOSE": "DOSE", + "DOSHIB": "DogeShiba", "DOT": "Polkadot", "DOTC": "Dotcoin", "DOTR": "Cydotori", + "DOUG": "Doug The Duck", "DOUGH": "PieDAO v2 (DOUGH)", "DOV": "DOVU", + "DOVI": "Dovi(Ordinals)", + "DOVU": "DOVU", "DOWS": "Shadows", "DP": "DigitalPrice", - "DPAY": "DelightPay", + "DPAD": "Dpad Finance", + "DPAY": "Devour", + "DPDBC": "PDBC Defichain", "DPET": "My DeFi Pet", + "DPEX": "DPEX", "DPI": "DeFiPulse Index", "DPIE": "DeFiPie", + "DPLAT": "zbyte", + "DPLN": "DePlan", + "DPLTR": "Palantir Tokenized Stock Defichain", "DPN": "DIPNET", "DPP": "Digital Assets Power Play", "DPR": "Deeper Network", @@ -2476,13 +3682,24 @@ "DPT": "Diamond Platform Token", "DPX": "Dopex", "DPY": "Delphy", + "DQQQ": "Invesco QQQ Trust Defichain", "DRA": "DraculaCoin", + "DRAC": "DRAC Network", + "DRACE": "DeathRoad", "DRACO": "DT Token", "DRACOO": "DracooMaster", "DRAFTC": "Draftcoin", + "DRAGGY": "Draggy", "DRAGON": "Dragon", + "DRAGONGROK": "DragonGROK", + "DRAGONKING": "DragonKing", "DRAGONMA": "Dragon Mainland Shards", + "DRAGU": "DRAGU", + "DRAGY": "Dragy", + "DRAM": "DRAM", "DRAW": "Drawshop Kingdom Reverse", + "DRB": "Digimon Rabbit", + "DRBT": "DeFi-Robot", "DRC": "DRC Mobility", "DRCT": "Ally Direct", "DRE": "DoRen", @@ -2492,6 +3709,7 @@ "DRF": "Drife", "DRG": "Dragon Coin", "DRGN": "Dragonchain", + "DRIFT": "Drift protocol", "DRINK": "DrinkChain", "DRIP": "Drip Network", "DRIV": "DRIVEZ", @@ -2500,9 +3718,11 @@ "DRKT": "DarkTron", "DRM": "DoDreamChain", "DRM8": "Dream8Coin", + "DROGGY": "Droggy", "DRONE": "Drone Coin", "DROP": "Dropil", "DROPS": "Drops", + "DROVERS": "Drover Inu", "DRP": "DCORP", "DRPU": "DRP Utility", "DRS": "Digital Rupees", @@ -2515,11 +3735,21 @@ "DSCP": "Dreamscape", "DSCVR": "DSCVR.Finance", "DSD": "Dynamic Set Dollar", + "DSFR": "Digital Swiss Franc", "DSG": "Dinosaureggs", "DSH": "Dashcoin", + "DSHARE": "Dibs Share", + "DSHIB": "DOLLAR SHIBA INU", + "DSK": "Darüşşafaka Spor Kulübü Token", "DSLA": "DSLA Protocol", + "DSLV": "iShares Silver Trust Defichain", + "DSQ": "Dsquared.finance", "DSR": "Desire", + "DSRUN": "Derby Stars", + "DST": "Double Swap Token", "DSTR": "Dynamic Supply Tracker", + "DSUN": "DsunDAO", + "DSYNC": "Destra Network", "DT": "DarkToken", "DT1": "Dollar Token 1", "DTA": "Data", @@ -2528,39 +3758,66 @@ "DTCT": "DetectorToken", "DTEM": "Dystem", "DTEP": "DECOIN", + "DTG": "Defi Tiger", "DTH": "Dether", + "DTJR": "Donald Trump Jr.", + "DTLT": "iShares 20+ Year Treasury Bond ETF Defichain", "DTN": "Datareum", "DTO": "DotOracle", "DTOP": "DTOP Token", + "DTORO": "DexToro", "DTR": "Dotori", "DTRC": "Datarius", + "DTSLA": "Tesla Tokenized Stock Defichain", "DTX": "DataBroker DAO", + "DUA": "Brillion", + "DUAL": "Dual Finance", "DUB": "DubCoin", "DUBAICAT": "Dubai Cat", + "DUBBZ": "Dubbz", "DUBI": "Decentralized Universal Basic Income", + "DUBX": "DUBXCOIN", "DUC": "DucatusCoin", "DUCATO": "Ducato Protocol Token", "DUCK": "Unit Protocol New", "DUCKD": "DuckDuckCoin", + "DUCKER": "Ducker", "DUCKIES": "Yellow Duckies", - "DUEL": "Duel Network", + "DUCKO": "Duck Off Coin", + "DUCX": "DucatusX", + "DUDE": "DuDe", + "DUEL": "GameGPT", "DUELERS": "Block Duelers", + "DUELN": "Duel Network", + "DUELV1": "Duel Network v1", "DUET": "Duet Protocol", + "DUG": "DUG", + "DUGE": "DUGE", "DUK+": "Dukascoin", "DUKE": "Duke Inu", + "DUKO": "DUKO", "DUMMY": "Dummy", "DUN": "Dune", + "DUNG": "Scarab Tools", "DUO": "ParallelCoin", "DUOT": "DUO Network", + "DUREV": "Povel Durev", + "DURTH": "iShares MSCI World ETF Tokenized Stock Defichain", + "DUSD": "Decentralized USD", "DUSK": "Dusk Network", "DUST": "DUST Protocol", "DUX": "DuxCoin", + "DUZCE": "Duzce Token", "DV": "Dreamverse", "DVC": "DragonVein", "DVDX": "Derived", "DVF": "Rhino.fi", "DVG": "DAOventures", "DVI": "Dvision Network", + "DVINCI": "Davinci Jeremie", + "DVK": "Devikins", + "DVNQ": "Vanguard Real Estate Tokenized Stock Defichain ()", + "DVOO": "Vanguard S&P 500 ETF Tokenized Stock Defichain", "DVP": "Decentralized Vulnerability Platform", "DVPN": "Sentinel", "DVRS": "DaoVerse", @@ -2568,10 +3825,13 @@ "DVT": "DeVault", "DVTC": "DivotyCoin", "DVX": "Derivex", + "DWARS": "Dynasty Wars", "DWC": "Digital Wallet", "DWEB": "DecentraWeb", + "DWT": "DiveWallet Token", "DWZ": "DeFi Wizard", "DX": "DxChain Token", + "DXA": "DEXART", "DXB": "DefiXBet", "DXC": "DixiCoin", "DXCT": "DNAxCAT", @@ -2584,17 +3844,24 @@ "DXN": "DEXON", "DXO": "Dextro", "DXR": "DEXTER", + "DXS": "Dx Spot", "DXT": "Dexit Finance", "DYC": "Dycoin", "DYDX": "dYdX", + "DYM": "Dymension", "DYN": "Dynamic", "DYNA": "Dynamix", "DYNAMICTRADING": "Dynamic Trading Rights", "DYNCOIN": "Dyncoin", + "DYNEX": "Dynex GPU", "DYNMT": "Dynamite", "DYNO": "DYNO", + "DYOR": "DYOR Token", "DYP": "Dypius", + "DYPV1": "Dypius v1", + "DYST": "Dystopia", "DYT": "DoYourTip", + "DYZILLA": "DYZilla", "DZAR": "Digital Rand", "DZCC": "DZCC", "DZDD": "DZD", @@ -2604,9 +3871,11 @@ "Dow": "DowCoin", "E21": "E21 Coin", "E2C": "Electronic Energy Coin", + "E8": "Energy8", "EA": "EagleCoin", "EAC": "Education Assessment Cult", "EAG": "Emerging Assets Group", + "EAGLE": "Eagle Token", "EAGS": "EagsCoin", "EAI": "Edain", "EARN": "EarnGuild", @@ -2615,6 +3884,7 @@ "EASYF": "EasyFeedback", "EAT": "EDGE Activity Token", "EAURIC": "Eauric", + "EAVE": "EaveAI", "EB3": "EB3coin", "EBA": "Elpis Battle", "EBASE": "EURBASE", @@ -2626,8 +3896,11 @@ "EBOX": "Ethbox Token", "EBS": "EbolaShare", "EBSC": "EarlyBSC", + "EBSO": "eBlockStock", "EBST": "eBoost", + "EBT": "ELON BUYS TWITTER", "EBTC": "eBitcoin", + "EBYT": "EarthByt", "EBZ": "Ebitz", "EC": "Echoin", "ECA": "Electra", @@ -2635,9 +3908,15 @@ "ECC": "Etherconnect", "ECD": "Echidna", "ECELL": "Consensus Cell Network", + "ECET": "Evercraft Ecotechnologies", + "ECG": "EcoSmart", "ECH": "EthereCash", "ECHO": "ECHO BOT", + "ECHOD": "EchoDEX", "ECHT": "e-Chat", + "ECI": "Euro Cup Inu", + "ECL": "ECLAT", + "ECLD": "Ethernity Cloud", "ECLIP": "Eclipse", "ECO": "Ormeus Ecosystem", "ECOB": "EcoBit", @@ -2647,42 +3926,59 @@ "ECOIN": "Ecoin", "ECOM": "Omnitude", "ECOREAL": "Ecoreal Estate", + "ECOTERRA": "ecoterra", "ECOX": "ECOx", "ECP": "ECP+ Technology", "ECR": "EcoVerse", + "ECS": "eCredits", "ECT": "SuperEdge", "ECTE": "EurocoinToken", "ECU": "ECOSC", + "EDAT": "EnviDa", "EDC": "EDC Blockchain", "EDDA": "EDDASwap", "EDDIE": "Eddie coin", "EDE": "El Dorado Exchange", + "EDEL": "Coin Edelweis", "EDEN": "EDEN", "EDEXA": "edeXa Security Token", "EDFI": "EdFi", "EDG": "Edgeless", "EDGE": "EDGE", + "EDGESOL": "Edgevana Staked SOL", "EDGEW": "Edgeware", "EDGT": "Edgecoin", + "EDI": "Freight Trust & Clearing Network", "EDLC": "Edelcoin", "EDN": "EdenChain", "EDNS": "EDNS Token", + "EDOGE": "ElonDoge", "EDR": "Endor Protocol Token", "EDRC": "EDRCoin", + "EDSE": "Eddie Seal", "EDT": "E-Drive Token", "EDU": "Open Campus", "EDUC": "EducoinV", "EDUCOIN": "EduCoin", + "EDUM": "EDUM", + "EDUX": "Edufex", + "EDX": "Equilibrium", "EER": "Ethereum eRush", + "EETH": "ether fi", + "EFBAI": "EuroFootball AI", "EFC": "Everton Fan Token", + "EFCR": "EFLANCER", "EFFT": "Effort Economy ", "EFI": "Efinity", "EFIL": "Ethereum Wrapped Filecoin", "EFK": "ReFork", "EFL": "E-Gulden", + "EFT": "ETH Fan Token Ecosystem", "EFX": "The Effect.ai", + "EG": "EG Token", "EGAME": "Every Game", "EGAS": "ETHGAS", + "EGAX": "Egochain", "EGAZ": "EGAZ", "EGC": "EverGrowCoin", "EGCC": "Engine", @@ -2690,11 +3986,17 @@ "EGEM": "EtherGem", "EGG": "Goose Finance", "EGGC": "EggCoin", + "EGGMAN": "Eggman Inu", "EGGP": "Eggplant Finance", + "EGGY": "EGGY", "EGI": "eGame", "EGLD": "eGold", "EGO": "EGOcoin", + "EGOD": "EgodCoin", + "EGOLD": "EGOLD", + "EGON": "EgonCoin", "EGR": "Egoras Rights", + "EGRN": "Energreen", "EGS": "EdgeSwap", "EGT": "Egretia", "EGX": "Enegra", @@ -2702,11 +4004,13 @@ "EHIVE": "eHive", "EHRT": "Eight Hours Token", "EIFI": "EIFI FINANCE", + "EIM": "Expert Infra", "EJAC": "EJA Coin", "EJS": "Enjinstarter", "EKG": "Ekon Gold", "EKN": "Elektron", "EKO": "EchoLink", + "EKS": "Elumia Krystal Shards", "EKT": "EDUCare", "EKTA": "Ekta", "EL": "ELYSIA", @@ -2721,69 +4025,99 @@ "ELDA": "Eldarune", "ELE": "Elementrem", "ELEC": "Electrify.Asia", + "ELECTRON": "Electron (Atomicals)", "ELEMENTS": "Elements", "ELEN": "Everlens", + "ELEPEPE": "ElephantPepe", + "ELEPHANT": "Elephant Money", "ELES": "Elements Estates", "ELET": "Elementeum", "ELF": "aelf", + "ELFI": "ELYFI", + "ELFW": "ELF Wallet", "ELG": "EscoinToken", + "ELGATO": "el gato", "ELI": "GoCrypto", "ELIC": "Elicoin", "ELITE": "EthereumLite", "ELIX": "Elixir", + "ELIXIR": "Starchi", + "ELK": "Elk Finance", "ELLA": "Ellaism", "ELLI": "ElliotCoin", "ELM": "Elements Play", "ELMO": "ELMOERC", "ELMON": "Elemon", "ELMT": "Element", + "ELO": "ElonPark", "ELON": "Dogelon Mars", + "ELON2024": "ELON 2024(BSC)", + "ELON404": "Elon404", "ELONCAT": "ELON CAT COIN", + "ELONDOGE": "ELON DOGE", + "ELONDRAGON": "ELON DRAGON", "ELONGD": "Elongate Deluxe", "ELONGT": "Elon GOAT", "ELONIUM": "Elonium", + "ELONM": "ELON MEME", + "ELONMARS": "ELON MARS", "ELONONE": "AstroElon", + "ELONPEPE": "Elon Pepe Robot", "ELP": "Ellerium", "ELS": "Ethlas", "ELT": "Element Black", "ELTC2": "eLTC", "ELTCOIN": "ELTCOIN", + "ELTG": "Graphen", "ELU": "Elumia", + "ELUSKMON": "Elusk Mon", "ELV": "Elvantis", "ELVN": "11Minutes", "ELX": "Energy Ledger", "ELY": "Elysian", "ELYSIUM": "Elysium", "EM": "Eminer", + "EMAID": "MaidSafeCoin", "EMANATE": "EMANATE", "EMAR": "EmaratCoin", "EMAX": "EthereumMax", "EMB": "Overline Emblem", "EMBER": "EmberCoin", "EMBR": "Embr", - "EMC": "Emercoin", + "EMC": "Edge Matrix Computing", "EMC2": "Einsteinium", "EMD": "Emerald", + "EMERCOIN": "Emercoin", "EMIGR": "EmiratesGoldCoin", + "EMILY": "Emily", + "EMIT": "Time Machine NFTs", "EML": "EML Protocol", "EMN.CUR": "Eastman Chemical", + "EMOJI": "MOMOJI", "EMON": "Ethermon", "EMOT": "Sentigraph.io", "EMOTI": "EmotiCoin", + "EMP": "Emp Money", "EMPC": "EmporiumCoin", "EMPH": "Emphy", "EMPIRE": "Empire Token", "EMPR": "empowr", + "EMR": "Emorya Finance", + "EMRLD": "The Emerald Company", "EMRX": "Emirex Token", "EMT": "EasyMine", "EMU": "eMusic", "EMV": "Ethereum Movie Venture", "EMX": "EMX", + "ENA": "Ethena", "ENC": "Encores Token", "ENCD": "Encircled", "ENCN": "EndChain", + "ENCR": "ENCRYPT", "ENCRYPG": "EncrypGen", + "ENCS": "ENCOINS", "ENCX": "Encrybit", + "ENDCEX": "Endpoint CeX Fan Token", "ENDLESS": "Endless Board Game", "ENE": "EneCoin", "ENEDEX": "Enedex", @@ -2796,7 +4130,9 @@ "ENNO": "ENNO Cash", "ENO": "Enotoken", "ENQ": "Enecuum", + "ENQAI": "enqAI", "ENRG": "EnergyCoin", + "ENRX": "Enrex", "ENS": "Ethereum Name Service", "ENT": "Eternity", "ENTC": "EnterButton", @@ -2805,6 +4141,7 @@ "ENTRC": "ENTER COIN", "ENTRP": "Hut34 Project", "ENTRY": "ENTRY", + "ENTS": "Ents", "ENTT": "Presale Ventures", "ENU": "Enumivo", "ENV": "ENVOY", @@ -2819,25 +4156,35 @@ "EOSC": "EOSForce", "EOSDAC": "eosDAC", "EOSDT": "EOSDT", + "EOST": "EOS TRUST", + "EOTH": "Echo Of The Horizon", "EOX": "EXTRA ORDINARY", "EPAN": "Paypolitan Token", "EPANUS": "Epanus", + "EPEP": "Epep", + "EPETS": "Etherpets", "EPIC": "Epic Cash", "EPIK": "EPIK Token", + "EPIKO": "Epiko", + "EPIX": "Byepix", "EPK": "EpiK Protocol", "EPS": "Ellipsis (OLD)", "EPTT": "Evident Proof Transaction Token", "EPX": "Ellipsis X", "EPY": "Empyrean", - "EQ": "Equilibrium", + "EQ": "Equilibrium Games", + "EQ9": "EQ9", "EQC": "Ethereum Qchain Token", "EQL": "EQUAL", "EQM": "Equilibrium Coin", "EQO": "EQO", + "EQPAY": "EquityPay", "EQT": "EquiTrader", + "EQU": "Equation", "EQUAD": "Quadrant Protocol", "EQUAL": "EqualCoin", "EQUI": "EQUI", + "EQUIL": "Equilibrium", "EQUITOKEN": "EQUI Token", "EQX": "EQIFi", "EQZ": "Equalizer", @@ -2850,6 +4197,7 @@ "ERE": "Erecoin", "EREAL": "eREAL", "ERG": "Ergo", + "ERIC": "Elon's Pet Fish ERIC", "ERIS": "Eristica", "ERK": "Eureka Coin", "ERO": "Eroscoin", @@ -2857,6 +4205,7 @@ "EROTICA": "Erotica", "ERR": "Coinerr", "ERROR": "484 Fund", + "ERROR404": "ERROR404 MEME", "ERRORCOIN": "ErrorCoin", "ERSDL": "UnFederalReserve", "ERT": "Esports.com", @@ -2864,16 +4213,25 @@ "ERTHA": "Ertha", "ERW": "ZeLoop Eco Reward", "ERY": "Eryllium", + "ERZ": "Erzurumspor Token", "ES": "Era Swap Token", + "ESAI": "Ethscan AI", "ESBC": "ESBC", + "ESCE": "Escroco Emerald", + "ESCROW": "Cryptegrity DAO", + "ESCU": "EYESECU AI", "ESD": "Empty Set Dollar", - "ESES.BITCI": "Eskişehir Fan Tokens", + "ESE": "Eesee", + "ESES": "Eskişehir Fan Tokens", "ESG": "ESG", "ESGC": "ESG Chain", "ESH": "Switch", + "ESHIB": "Euro Shiba Inu", "ESN": "Ethersocial", "ESNC": "Galaxy Arena Metaverse", "ESP": "Espers", + "ESPR": "Espresso Bot", + "ESRC": "ESR Coin", "ESS": "Essentia", "EST": "ESports Chain", "ESTATE": "AgentMile", @@ -2886,6 +4244,7 @@ "ETBT": "Ethereum Black", "ETC": "Ethereum Classic", "ETE": "EXTRADECOIN", + "ETER": "Eternal AI", "ETERNAL": "CryptoMines Eternal", "ETERNALC": "Eternal Coin", "ETERNALT": "Eternal Token", @@ -2907,7 +4266,10 @@ "ETHERKING": "Ether Kingdoms Token", "ETHERNITY": "Ethernity Chain", "ETHEROLL": "Etheroll", + "ETHERW": "Ether Wars", "ETHF": "EthereumFair", + "ETHFAI": "ETHforestAI", + "ETHFI": "Ether.fi", "ETHI": "Ethical Finance", "ETHIX": "EthicHub", "ETHM": "Ethereum Meta", @@ -2933,23 +4295,27 @@ "ETM": "En-Tan-Mo", "ETN": "Electroneum", "ETNA": "ETNA Network", - "ETNY": "Ethernity Cloud", "ETP": "Metaverse", "ETPOS": "EtherPOS", "ETR": "Electric Token", + "ETRL": "Ethereal", "ETRNT": "Eternal Trusts", "ETS": "ETH Share", "ETSC": "Ether star blockchain", "ETT": "EncryptoTel", + "ETX": "Ethrix", "ETY": "Ethereum Cloud", "ETZ": "EtherZero", + "EU24": "EURO2024", "EUC": "Eurocoin", "EUCOIN": "EU Coin", "EUCX": "EUCX", "EUL": "Euler", + "EULER": "Euler Tools", "EUM": "Elitium", "EUNO": "EUNO", "EURC": "Euro Coin", + "EURE": "Monerium EUR emoney", "EURN": "NOKU EUR", "EUROE": "EUROe Stablecoin", "EURS": "STASIS EURS", @@ -2957,29 +4323,39 @@ "EURU": "Upper Euro", "EURX": "eToro Euro", "EUSD": "Egoras Dollar", + "EV": "EVAI", "EVA": "Evadore", "EVAN": "Evanesco Network", "EVAULT": "EthereumVault", "EVC": "Eventchain", "EVCC": "Eco Value Coin", "EVCOIN": "EverestCoin", + "EVDC": "Electric Vehicle Direct Currency", "EVE": "Devery", + "EVEAI": "EVEAI", "EVED": "Evedo", "EVENC": "EvenCoin", "EVENT": "Event Token", "EVER": "Everscale", "EVEREST": "Everest", + "EVERETH": "EverETH Reflect", "EVERGREEN": "EverGreenCoin", "EVERLIFE": "EverLife.AI", + "EVERMOON": "EverMoon", + "EVERY": "Everyworld", "EVIL": "EvilCoin", "EVILPEPE": "Evil Pepe", "EVMOS": "Evmos", "EVN": "Evn Token", "EVO": "EvoVerses", + "EVOAI": "EvolveAI", + "EVOC": "EVOCPLUS", "EVOL": "EVOL NETWORK", "EVOS": "EVOS", "EVR": "Everus", "EVRICE": "Evrice", + "EVRM": "Evrmore", + "EVRT": "Everest Token", "EVRY": "Evrynet", "EVT": "EveriToken", "EVU": "Evulus Token", @@ -2987,15 +4363,21 @@ "EVY": "EveryCoin", "EVZ": "Electric Vehicle Zone", "EWC": "Erugo World Coin", + "EWIF": "elonwifcoin", + "EWON": "Ewon Mucks", "EWT": "Energy Web Token", "EWTT": "Ecowatt", + "EXA": "Exactly Protocol", "EXB": "ExaByte (EXB)", "EXC": "Eximchain", "EXCC": "ExchangeCoin", "EXCHANGEN": "ExchangeN", "EXCL": "Exclusive Coin", + "EXD": "Exorde", "EXE": "ExeCoin", + "EXEN": "Exen Coin", "EXFI": "Flare Finance", + "EXGO": "EXGOLAND", "EXIP": "EXIP", "EXIT": "ExitCoin", "EXLT": "ExtraLovers", @@ -3004,11 +4386,15 @@ "EXN": "Exeno", "EXNT": "EXNT", "EXO": "Exosis", + "EXOS": "Exobots", "EXP": "Expanse", + "EXPO": "Exponential Capital", "EXRD": "Radix", "EXRN": "EXRNchain", "EXTN": "Extensive Coin", "EXTP": "TradePlace", + "EXTRA": "Extra Finance", + "EXVG": "Exverse", "EXY": "Experty", "EXZO": "ExzoCoin 2.0", "EYE": "MEDIA EYE", @@ -3016,20 +4402,28 @@ "EYETOKEN": "EYE Token", "EZ": "EasyFi V2", "EZC": "EZCoin", + "EZETH": "Renzo Restaked ETH", + "EZI": "Ezillion", "EZM": "EZMarket", "EZT": "EZToken", + "EZY": "EzyStayz", "ElvishMagic": "EMAGIC", "F16": "F16Coin", + "F1C": "Future1coin", "F2C": "Ftribe Fighters", "F2K": "Farm2Kitchen", + "F3": "Friend3", "F7": "Five7", "F9": "Falcon Nine", "FAB": "FABRK Token", "FABA": "Faba Invest", "FABRIC": "MetaFabric", + "FAC": "Flying Avocado Cat", "FACE": "FaceDAO", "FACETER": "Faceter", + "FACT": "Orcfax", "FACTOM": "Factom", + "FACTR": "Defactor", "FADO": "FADO Go", "FAG": "PoorFag", "FAI": "Fairum", @@ -3037,42 +4431,64 @@ "FAIRC": "Faireum Token", "FAIRG": "FairGame", "FAKE": "FAKE COIN", + "FAKEAI": "DeepFakeAI", "FAKT": "Medifakt", "FALCONS": "Falcon Swaps", + "FALX": "FalconX", "FAME": "Fame MMA", "FAMEC": "FameCoin", "FAMILY": "The Bitcoin Family", + "FAMOUSF": "Famous Fox Federation", "FAN": "Fanadise", "FAN360": "Fan360", "FANC": "fanC", + "FAND": "Fandomdao", + "FANG": "FANG Token", "FANV": "FanVerse", + "FANX": "FrontFanz", "FANZ": "FanChain", "FAR": "Farmland Protocol", "FARA": "FaraLand", + "FARCA": "Farcana", "FARM": "Harvest Finance", "FARMA": "FarmaTrust", "FARMC": "FARM Coin", - "FAST": "PodFast", + "FARMING": "Farming Bad", + "FARMS": "Farmsent", + "FAST": "Fastswap", "FASTMOON": "FastMoon", - "FASTSWAP": "Fastswap", + "FASTV1": "Fastswap v1", "FAT": "Fatcoin", "FATCAKE": "FatCake", + "FATHER": "DogeFather", + "FATHOM": "Fathom", + "FATMICHI": "FATMICHI", + "FAV": "Football At AlphaVerse", + "FAVR": "FAVOR", + "FAYD": "Fayda", "FAYRE": "Fayre", "FAZZ": "FazzCoin", "FB": "Fenerbahçe Token", + "FBA": "Firebird Aggregator", "FBB": "FilmBusinessBuster", + "FBG": "Fort Block Games", "FBN": "Five balance", "FBNB": "ForeverBNB", + "FBURN": "Forever Burn", "FBX": "Finance Blocks", "FC": "Facecoin", "FC2": "Fuel2Coin", + "FCC": "Freechat", "FCD": "FreshCut Diamond", "FCF": "French Connection Finance", "FCH": "Freecash", + "FCK": "Find & Check", "FCL": "Fractal", "FCN": "FantomCoin", + "FCO": "Fanatico", "FCOIN": "FCoin", "FCON": "SpaceFalcon", + "FCP": "FILIPCOIN", "FCQ": "Fortem Capital", "FCS": "CryptoFocus", "FCT": "FirmaChain", @@ -3087,19 +4503,27 @@ "FDX": "fidentiaX", "FDZ": "Friendz", "FEAR": "Fear", + "FECES": "FECES", "FEED": "Feeder Finance", "FEENIXV2": "ProjectFeenixv2", + "FEES": "UNIFEES", + "FEFE": "Fefe", "FEG": "FEG Token", + "FEGV1": "FEG Token v1", "FEI": "Fei Protocol", - "FELIX": "Felix 2.0 ETH", + "FELIX": "FelixCoin", + "FELIX2": "Felix 2.0 ETH", "FEN": "First Ever NFT", "FENOMY": "Fenomy", + "FENTANYL": "Chinese Communist Dragon", "FER": "Ferro", "FERC": "FairERC20", "FERMA": "Ferma", + "FERT": "Chikn Fert", "FERZAN": "Ferzan", "FESS": "Fesschain", "FET": "Fetch.AI", + "FETCH": "Fetch", "FEVR": "RealFevr", "FEX": "FEX Token", "FEY": "Feyorra", @@ -3112,48 +4536,84 @@ "FFN": "Fairy Forest", "FFUEL": "getFIFO", "FFYI": "Fiscus FYI", + "FGC": "FantasyGold", "FGD": "Freedom God DAO", + "FGM": "Feels Good Man", + "FGT": "Flozo Game Token", "FGZ": "Free Game Zone", + "FHB": "FHB", + "FHM": "FantOHM", + "FI": "Fideum", + "FIA": "FIA Protocol", + "FIBO": "FibSWAP DEx", "FIBRE": "FIBRE", "FIC": "Filecash", + "FID": "Fidira", "FIDA": "Bonfida", "FIDLE": "Fidlecoin", + "FIDO": "FIDO", + "FIDU": "Fidu", "FIELD": "Fieldcoin", + "FIERO": "Fieres", + "FIF": "flokiwifhat", "FIFTY": "FIFTYONEFIFTY", "FIG": "FlowCom", + "FIGHT": "Crypto Fight Club", "FIH": "Fidelity House", "FIII": "Fiii", "FIL": "FileCoin", "FILDA": "Filda", + "FILES": "Solfiles", "FILL": "Fillit", "FILM": "Filmpass", "FILST": "Filecoin Standard Hashrate Token", "FIN": "DeFiner", "FINA": "Defina Finance", + "FINALE": "Ben's Finale", + "FINB": "Finblox", + "FINC": "Finceptor", "FIND": "FindCoin", "FINE": "Refinable", + "FINGER": "Finger Blast", + "FINK": "FINK", + "FINN": "Huckleberry", "FINOM": "Finom FIN Token", + "FINS": "AutoShark DEX", + "FINT": "FintraDao", "FINU": "Formula Inu", "FIO": "FIO Protocol", - "FIRE": "FireCoin", + "FIRA": "Defira", + "FIRE": "Matr1x Fire", + "FIRECOIN": "FireCoin", "FIRO": "Firo", + "FIRSTHARE": "FirstHare", "FIRU": "Firulais Finance", "FIS": "Stafi", + "FISH": "Polycat Finance", "FIST": "FistBump", "FIT": "Financial Investment Token", "FITC": "Fitcoin", "FITFI": "Step App", + "FITT": "Fitmint", "FIU": "beFITTER", "FIWA": "Defi Warrior", + "FIX00": "FIX00", "FJB": "Freedom. Jobs. Business.", "FJC": "FujiCoin", + "FJO": "Fjord Foundry", "FJT": "Fuji FJT", "FK": "FK Coin", + "FKBIDEN": "Fkbiden", + "FKGARY": "Fuck Gary Gensler", + "FKPEPE": "Fuck Pepe", + "FKRPRO": "FlickerPro", + "FKSK": "Fatih Karagümrük SK", "FKX": "FortKnoxster", "FL": "Freeliquid", "FLAG": "Flag Network", "FLAME": "FireStarter", "FLAP": "Flappy Coin", + "FLAPPY": "Flappy", "FLAS": "Flas Exchange Token", "FLASH": "Flashstake", "FLASHC": "FLASH coin", @@ -3161,11 +4621,17 @@ "FLD": "FluidAI", "FLDC": "Folding Coin", "FLDT": "FairyLand", + "FLEPE": "Floki VS Pepe", "FLETA": "FLETA", "FLEX": "FLEX Coin", + "FLEXUSD": "flexUSD", "FLG": "Folgory Coin", + "FLIBERO": "Fantom Libero Financial", "FLIC": "Skaflic", + "FLIGHT": "FLIGHTCLUPCOIN", "FLIK": "FLiK", + "FLIKO": "Fliko Uni", + "FLINU": "FLOKI INU", "FLIP": "Chainflip", "FLIX": "OmniFlix Network", "FLIXX": "Flixxo", @@ -3173,22 +4639,47 @@ "FLLW": "Follow Coin", "FLM": "Flamingo", "FLMC": "FOLM coin", + "FLN": "Falcon", "FLO": "Flo", "FLOAT": "Float Protocol", + "FLOCHI": "Flochi", + "FLOCKA": "Waka Flocka", + "FLOKA": "FLOKA", + "FLOKEI": "FLOKEI", "FLOKI": "Floki Inu", + "FLOKIBURN": "FlokiBurn", + "FLOKICASH": "Floki Cash", + "FLOKIM": "Flokimooni", + "FLOKIMOON": "FLOKIMOON", + "FLOKINY": "Floki New Year", + "FLOKIPEPE": "FlokiPepe", + "FLOKITA": "FLOKITA", + "FLOKIX": "FLOKI X", + "FLOOF": "FLOOF", "FLOOR": "FloorDAO", + "FLOP": "Big Floppa", + "FLORK": "FLORK BNB", "FLORKY": "Florky", + "FLOSHIDO": "FLOSHIDO INU", "FLOT": "FireLotto", + "FLOVI": "Flovi inu", "FLOVM": "FLOV MARKET", "FLOW": "Flow", "FLOWP": "Flow Protocol", + "FLOYX": "Floyx", "FLP": "Gameflip", "FLR": "Flare", + "FLRBRG": "Floor Cheese Burger", "FLRS": "Flourish Coin", "FLS": "Flits", - "FLT": "FlutterCoin", + "FLT": "Fluence", + "FLUFFYS": "Fluffys", + "FLUID": "Fluid", "FLURRY": "Flurry Finance", + "FLUT": "Flute", + "FLUTTERCOIN": "FlutterCoin", "FLUX": "Flux", + "FLUXB": "Fluxbot", "FLUXT": "Flux Token", "FLUZ": "FluzFluz", "FLVR": "FlavorCoin", @@ -3196,6 +4687,10 @@ "FLY": "Franklin", "FLYCOIN": "FlyCoin", "FLZ": "Fellaz", + "FM": "Flowmatic", + "FMA": "FLAMA", + "FMB": "FREEMOON BINANCE", + "FMC": "Fimarkcoin", "FME": "FME", "FMEX": "FMex", "FMG": "FM Gallery", @@ -3204,28 +4699,35 @@ "FN": "Filenet", "FNB": "FNB protocol", "FNC": "Fancy Games", + "FNCT": "Financie Token", "FNCY": "Fancy That", "FND": "Rare FND", "FNDZ": "FNDZ Token", "FNF": "FunFi", "FNK": "FunKeyPay", "FNL": "Finlocale", + "FNLX": "Fignal X", "FNO": "Fonero", "FNP": "FlipNpik", "FNSA": "FINSCHIA", "FNTB": "FinTab", "FNX": "FinNexus", + "FNZ": "Fanzee", "FO": "FIBOS", + "FOA": "Fragments of arker", "FOAM": "Foam", "FOC": "TheForce Trade", "FOCV": "FOCV", "FODL": "Fodl Finance", "FOF": "Future Of Fintech", + "FOFAR": "Fofar", "FOFO": "FOFO Token", "FOGE": "Fat Doge", "FOIN": "Foin", "FOL": "Folder Protocol", "FOLD": "Manifold Finance", + "FOLO": "Alpha Impact", + "FOMO": "Aavegotchi FOMO", "FON": "INOFI", "FONE": "Fone", "FONS": "FONSmartChain", @@ -3233,10 +4735,14 @@ "FONZ": "FonzieCoin", "FOOD": "FoodCoin", "FOODC": "Food Club", + "FOOM": "FOOM", + "FOOX": "Foox", "FOPA": "Fopacoin", "FOR": "ForTube", + "FORA": "UFORIKA", "FORCE": "TriForce Tokens", "FORCEC": "Force Coin", + "FORE": "FORE Protocol", "FORESTPLUS": "The Forbidden Forest", "FOREVER": "Forever Coin", "FOREVERFOMO": "ForeverFOMO", @@ -3246,28 +4752,43 @@ "FOREXCOIN": "FOREXCOIN", "FORK": "Gastro Advisor Token", "FORM": "Formation FI", + "FORS": "Forus", "FORT": "Forta", "FORTH": "Ampleforth Governance Token", "FORTHB": "ForthBox", "FORTUNA": "Fortuna", "FORTUNE": "Fortune", + "FORWARD": "Forward Protocol", "FOTA": "Fight Of The Ages", "FOTO": "Unique Photo", + "FOTTIE": "Fottie", + "FOUND": "ccFound", "FOUNTAIN": "Fountain", "FOUR": "4THPILLAR TECHNOLOGIES", "FOX": "ShapeShift FOX Token", + "FOXAI": "FOXAI", "FOXD": "Foxdcoin", + "FOXE": "Foxe", "FOXF": "Fox Finance", + "FOXGIRL": "FoxGirl", + "FOXSY": "Foxsy AI", "FOXT": "Fox Trading", "FOXV2": "FoxFinanceV2", + "FOXY": "Foxy", + "FP": "Fren Pet", + "FPAD": "FantomPAD", "FPC": "Futurepia", + "FPEPE": "Based Father Pepe", "FPFT": "Peruvian National Football Team Fan Token", + "FPI": "Frax Price Index", "FPIS": "Frax Price Index Share", + "FQS": "FQSwap V2", "FR": "Freedom Reserve", "FRA": "Findora", "FRAC": "FractalCoin", "FRAX": "Frax", "FRAZ": "FrazCoin", + "FRBK": " FreeBnk", "FRC": "FireRoosterCoin", "FRD": "Farad", "FRDX": "Frodo Tech", @@ -3275,11 +4796,23 @@ "FREC": "Freyrchain", "FRECNX": "FreldoCoinX", "FRED": "FREDEnergy", + "FREDDY": "FREDDY", "FREE": "FREE coin", "FREED": "FreedomCoin", + "FREEDO": "Freedom", + "FREELA": "DecentralFree", "FREEROSS": "FreeRossDAO", "FREL": "Freela", "FREN": "FREN", + "FRENCH": "French On Base", + "FRENS": "Farmer Friends", + "FRESCO": "Fresco", + "FRF": "France REV Finance", + "FRGST": "Froggies Token", + "FRGX": "FRGX", + "FRIC": "Frictionless", + "FRIEND": "Friend.tech", + "FRIES": "Soltato FRIES", "FRIN": "Fringe Finance", "FRK": "Franko", "FRKT": "FRAKT Token", @@ -3288,24 +4821,38 @@ "FRN": "Francs", "FRNT": "Final Frontier", "FROG": "FrogSwap", + "FROGB": "Frog Bsc", + "FROGCEO": "Frog Ceo", "FROGE": "Froge Finance", + "FROGEX": "FrogeX", + "FROGGY": "Froggy", + "FROGLIC": "Pink Hood Froglicker", + "FROGO": "Frogo", + "FRONK": "Fronk", "FRONT": "Frontier", "FROYO": "Froyo Games", + "FROZE": "FrozenAi", "FRP": "Fame Reward Plus", "FRR": "Frontrow", "FRSP": "Forkspot", "FRST": "FirstCoin", + "FRTC": "FART COIN", + "FRTN": "EbisusBay Fortune", "FRTS": "Fruits", "FRV": "Fitrova", "FRWC": "Frankywillcoin", "FRXETH": "Frax Ether", "FRZ": "Frozy Inu", "FRZSS": "Frz Solar System", + "FRZSSCOIN": "FRZ Solar System Coin", "FS": "FantomStarter", "FSBT": "Forty Seven Bank", "FSC": "FriendshipCoin", + "FSCC": "Fisco Coin", "FSHN": "Fashion Coin", + "FSM": "Floki SafeMoon", "FSN": "Fusion", + "FSO": "FSociety", "FST": "Futureswap", "FSTC": "FastCoin", "FSW": "Falconswap", @@ -3313,9 +4860,12 @@ "FTB": "Fit&Beat", "FTC": "FeatherCoin", "FTG": "fantomGO", + "FTH": "Fintyhub Token", + "FTHM": "Fathom Protocol", "FTI": "FansTime", "FTK": "FToken", "FTM": "Fantom", + "FTMO": "Fantom Oasis", "FTN": "Fasttoken", "FTO": "FuturoCoin", "FTP": "FuturePoints", @@ -3324,47 +4874,69 @@ "FTRC": "FutureCoin", "FTS": "Fortress Lending", "FTT": "FTX Token", + "FTTOKEN": "Finance Token", "FTUM": "Fatum", + "FTVT": "FashionTV Token", "FTW": "FutureWorks", "FTX": "FintruX", + "FTXT": "FUTURAX", + "FUBAO": "FUBAO", "FUCK": "Fuck Token", "FUD": "FUD.finance", "FUEL": "Jetfuel Finance", + "FUFU": "Fufu Token", "FUJIN": "Fujinto", "FUKU": "Furukuru", "FUL": "Fulcrom Finance", "FUMO": "Alien Milady Fumo", "FUN": "FUN Token", + "FUNASSYI": "Funassyi", "FUNC": "FunCoin", + "FUNCH": "FUNCH", "FUND": "Unification", "FUNDC": "FUNDChains", "FUNDP": "Fund Platform", "FUNDREQUEST": "FundRequest", + "FUNDX": "Funder One Capital", "FUNDYOUR": "FundYourselfNow", "FUNDZ": "FundFantasy", + "FUNG": "Fungify", + "FUNGI": "Fungi", "FUNK": "Cypherfunks Coin", "FUR": "Furio", + "FURIE": "Matt Furie", "FURU": "Furucombo", "FURY": "Engines of Fury", + "FURYX": "Metafury", "FUS": "Fus", + "FUSD": "Fantom USD", + "FUSDC": "Fluidity", "FUSE": "Fuse Network Token", "FUSION": "FusionBot", "FUSO": "Fusotao", "FUTC": "FutCoin", "FUTURE": "FutureCoin", + "FUTUREAI": "Future AI", + "FUZE": "FUZE Token", "FUZN": "Fuzion", "FUZZ": "Fuzzballs", "FVT": "Finance Vote", + "FWATCH": "Foliowatch", "FWB": "Friends With Benefits Pro", "FWC": "Qatar 2022", + "FWH": "FigureWifHat", "FWT": "Freeway Token", "FWW": "Farmers World Wood", "FX": "Function X", + "FXB": "FxBox", "FXC": "Flexacoin", + "FXD": "Fathom Dollar", "FXDX": "FXDX", "FXF": "Finxflo", + "FXI": "FX1 Sports", "FXP": "FXPay", "FXS": "Frax Share", + "FXST": "FX Stock Token", "FXT": "FuzeX", "FXY": "Floxypay", "FYD": "FYDcoin", @@ -3374,22 +4946,29 @@ "FYZNFT": "Fyooz NFT", "G": "GRN Grid", "G1X": "GoldFinX", + "G3": "GAM3S.GG", "G50": "G50", + "G8C": "ONEG8.ONE", "G999": "G999", "GAC": "Green Art Coin", "GAD": "Green App Development", + "GAFA": "Gafa", "GAFI": "GameFi", + "GAGA": "Gaga", "GAI": "GraphGrail AI", "GAIA": "Gaia Everworld", "GAIAPLATFORM": "GAIA Platform", "GAIN": "Gainfy", "GAINS": "Gains", + "GAINSV1": "Gains v1", "GAJ": "Gaj Finance", "GAKH": "GAKHcoin", "GAL": "Galxe", "GALA": "Gala", "GALATA": "Galatasaray Fan Token", + "GALAV1": "Gala v1", "GALAX": "Galaxy Finance", + "GALAXIS": "Galaxis", "GALAXY": "GalaxyCoin", "GALEON": "Galeon", "GALI": "Galilel", @@ -3397,14 +4976,22 @@ "GALT": "Galtcoin", "GAM": "Gambit coin", "GAMB": "GAMB", - "GAME": "Gamestarter", + "GAMBIT": "Gambit", + "GAMBL": "Metagamble", + "GAME": "GameBuild", + "GAMEBUD": "GAMEBUD", "GAMEC": "Game", "GAMECRED": "GameCredits", "GAMEFI": "GameFi Token", + "GAMEFORK": "GameFork", "GAMEIN": "Game Infinity", + "GAMER": "GameStation", + "GAMERFI": "GamerFI", + "GAMES": "Gamestarter", "GAMESTARS": "Game Stars", "GAMEX": "GameX", "GAMI": "GAMI World", + "GAMINGDOGE": "GAMINGDOGE", "GAMINGSHIBA": "GamingShiba", "GAMMA": "Gamma Strategies", "GAN": "Galactic Arena: The NFTverse", @@ -3412,12 +4999,15 @@ "GAP": "Gaps Chain", "GAPC": "Gapcoin", "GARD": "Hashgard", + "GARFIELD": "Garfield Cat", "GARI": "Gari Network", "GARK": "Game Ark", "GART": "Griffin Art", "GARTS": "Glink Arts Share", "GARU": "Garuda Coin", "GARUDA": "GarudaSwap", + "GARWIF": "Garfield Wif Hat", + "GARY": "Gary", "GAS": "Gas", "GASDAO": "Gas DAO", "GASG": "Gasgains", @@ -3426,11 +5016,16 @@ "GAT": "GATCOIN", "GATE": "GATENet", "GATEWAY": "Gateway Protocol", + "GATHER": "Gather", "GATSBY": "Gatsby Inu", + "GAU": "Gamer Arena", + "GAUSS": "Gauss0x", "GAYPEPE": "Gay Pepe", + "GAYSLER": "Gaysler", "GAZE": "GazeTV", "GB": "GoldBlocks", "GBA": "Geeba", + "GBC": "Green Blue Coin", "GBCR": "Gold BCR", "GBD": "Great Bounty Dealer", "GBE": "Godbex", @@ -3438,57 +5033,81 @@ "GBG": "Golos Gold", "GBIT": "GravityBit", "GBK": "Goldblock", + "GBL": "Global Token", "GBO": "Gabro.io", "GBOT": "GBOT", + "GBOY": "GameBoy", "GBPT": "poundtoken", + "GBPU": "Upper Pound", "GBRC": "GBR Coin", + "GBSK": "Gençlerbirliği Fan Token", "GBT": "GameBetCoin", "GBTC": "GigTricks", + "GBURN": "GBURN", "GBX": "GoByte", "GBXT": "Globitex Token", "GBYTE": "Obyte", "GC": "Gric Coin", "GCAKE": "Pancake Games", + "GCAT": "Giga Cat on Base", + "GCB": "Global Commercial Business", "GCC": "GuccioneCoin", + "GCME": "GoCryptoMe", "GCN": "gCn Coin", "GCOIN": "Galaxy Fight Club", + "GCOTI": "COTI Governance Token", "GCR": "Global Currency Reserve", + "GCRE": "Gluwa Creditcoin Vesting Token", "GCW": "GCWine", "GDAO": "Governor DAO", "GDC": "Global Digital Content", + "GDCC": "GLOBAL DIGITAL CLUSTER COIN", "GDDY": "Giddy", "GDE": "Golden Eagle", "GDL": "GodlyCoin", "GDO": "GroupDao", "GDOGE": "Golden Doge", "GDR": "Guider.Travel", + "GDRT": "Good Driver Reward Token", "GDS": "Grat Deal Coin", "GDSC": "Golden Safety Coin", "GDT": "Globe Derivative Exchange", "GDX": "VanEck Vectors Gold Miners Etf", "GE": "GEchain", "GEA": "Goldea", - "GEAR": "MetaGear", + "GEAR": "Gearbox Protocol", "GEC": "Geco.one", + "GECKO": "Gecko Coin", "GEEQ": "Geeq", + "GEGE": "Gege", "GEIST": "Geist Finance", + "GEKKO": "Gekko HQ", "GELATO": "Gelato", - "GEM": "Gems", + "GELO": "Grok Elo", + "GEM": "Gemie", "GEMA": "Gemera", + "GEME": "GAMESTUMP", "GEMG": "GemGuardian", "GEMINI": "Gemini Ai", "GEMINIT": "Gemini", - "GEMS": "Carbon GEMS", + "GEMS": "Gems", + "GEMSTON": "GEMSTON", "GEMZ": "Gemz Social", "GEN": "DAOstack", "GENE": "Genopets", "GENESIS": "Genesis Worlds", + "GENI": "Genius", "GENIE": "The Genie", + "GENIEC": "GenieCoin", "GENIX": "Genix", + "GENO": "GenomeFi", + "GENOME": "GenomesDao", "GENS": "Genshiro", + "GENSLR": "Good Gensler", "GENSTAKE": "Genstake", "GENX": "Genx Token", "GENXNET": "Genesis Network", + "GENZ": "GENZ Token", "GEO": "GeoCoin", "GEOD": "GEODNET", "GEODB": "GeoDB", @@ -3498,33 +5117,46 @@ "GEP": "Gaia", "GER": "GermanCoin", "GERA": "Gera Coin", + "GERMANY": "Germany Rabbit Token", "GERO": "GeroWallet", "GES": "Galaxy eSolutions", "GESE": "Gese", "GET": "Guaranteed Entrance Token", + "GETA": "Getaverse", "GETH": "Guarded Ether", "GETX": "Guaranteed Ethurance Token Extra", "GEX": "Gexan", + "GEZY": "EZZY GAME GEZY", "GF": "GuildFi", "GFAL": "Games for a Living", "GFARM2": "Gains V2", "GFCE": "GFORCE", "GFCS": "Global Funeral Care", "GFI": "Goldfinch", + "GFLY": "BattleFly", "GFN": "Graphene", + "GFOX": "Galaxy Fox", "GFT": "Gifto", "GFUN": "GoldFund", "GFX": "GamyFi Token", "GFY": "go fu*k yourself", "GG": "Reboot", + "GGAVAX": "GoGoPool AVAX", "GGC": "Global Game Coin", + "GGCM": "Gold Guaranteed Coin", "GGG": "Good Games Guild", "GGH": "Green Grass Hopper", "GGM": "Monster Galaxy", + "GGMT": "GG MetaGame", "GGOLD": "GramGold Coin", "GGP": "GGPro", + "GGPT": "Generative GPT", "GGR": "GGRocket", "GGS": "Gilgam", + "GGT": "Goat Gang", + "GGTK": "GGDApp", + "GGTKN": "GG Token", + "GHA": "Ghast", "GHC": "Galaxy Heroes Coin", "GHCOLD": "Galaxy Heroes Coin", "GHD": "Giftedhands", @@ -3535,77 +5167,120 @@ "GHOSTM": "GhostMarket", "GHOUL": "Ghoul Coin", "GHST": "Aavegotchi", + "GHSY": "Ghosty Cash", "GHT": "Global Human Trust", + "GHUB": "GemHUB", "GHX": "GamerCoin", + "GIA": "Gamia", + "GIAC": "Gorilla In A Coupe", "GIB": "Bible Coin", "GIC": "Giant", + "GICT": "GICTrade", "GIF": "Gift Token", "GIFT": "GiftNet", "GIG": "GigaCoin", "GIGA": "GigaSwap", + "GIGACHAD": "GigaChad", "GIGX": "GigXCoin", + "GIKO": "Giko Cat", "GIM": "Gimli", "GIMMER": "Gimmer", "GIN": "GINcoin", + "GINGER": "GINGER", + "GINOA": "Ginoa", "GINUX": "Green Shiba Inu", + "GINZA": "GINZA NETWORK", "GIO": "Graviocoin", "GIOT": "Giotto Coin", + "GIOVE": "GIOVE", + "GIR": "Girlfriend", + "GIV": "Giveth", "GIVE": "GiveCoin", "GIZ": "GIZMOcoin", "GJC": "Global Jobcoin", + "GKF": "Galatic Kitty Fighters", "GKI": "GKi", + "GL": "Lemmings", "GLA": "Gladius", + "GLAX": "BLOCK GALAXY NETWORK", + "GLB": "Golden Ball", "GLC": "GoldCoin", "GLCH": "Glitch", "GLD": "Goldario", + "GLDGOV": "Gold DAO", "GLDR": "Golder Coin", "GLDS": "Gdigit", "GLDX": "Goldex", "GLDY": "Buzzshow", + "GLE": "Green Life Energy", "GLEEC": "Gleec Coin", + "GLF": "Galaxy Finance", "GLFT": "Global Fan Token", + "GLI": "GLI TOKEN", + "GLIDE": "Glide Finance", + "GLINK": "Gemlink", "GLINT": "BeamSwap", "GLM": "Golem Network Token", "GLMR": "Moonbeam", "GLN": "Galion Token", "GLOBAL": "GlobalCoin", "GLOBE": "Global", + "GLORY": "SEKAI GLORY", "GLOS": "GLOS", "GLOWSHA": "GlowShares", "GLQ": "GraphLinq Protocol", + "GLR": "Glory Finance", "GLS": "Glass Chain", "GLT": "GlobalToken", "GLX": "GalaxyCoin", "GLYPH": "GlyphCoin", "GM": "GM", "GMA": "Goldchip Mining Asset", + "GMAC": "Gemach", "GMAT": "GoWithMi", "GMB": "GMB", + "GMBL": "GMBL Computer", "GMC": "Gridmaster", "GMCN": "GambleCoin", "GMCOIN": "GMCoin", + "GMDP": "GMD Protocol", + "GME": "GameStop", "GMEE": "GAMEE", + "GMEPEPE": "GAMESTOP PEPE", + "GMETRUMP": "GME TRUMP", "GMEX": "Game Coin", + "GMFAM": "GMFAM", + "GMFI": "Golden Magfi", "GMI": "GamiFi", "GML": "GameLeagueCoin", "GMM": "Gamium", "GMMT": "Green Mining Movement Token", + "GMNG": "Global Gaming", + "GMNT": "Gmining", "GMPD": "GamesPad", "GMR": "GAMER", + "GMRX": "Gaimin", "GMS": "Gemstra", "GMT": "STEPN", + "GMTO": "Game Meteor Coin", "GMTT": "GMT Token", + "GMUSD": "GND Protocol", "GMX": "GMX", "GN": "GN", "GNBT": "Genebank Token", "GNC": "Greencoin", + "GND": "GND Protoco", + "GNFT": "GNFT", + "GNG": "GreenGold", "GNJ": "GanjaCoin V2", "GNNX": "Gennix", "GNO": "Gnosis", + "GNOME": "GNOME", "GNR": "Gainer", "GNS": "Gains Network", "GNT": "GreenTrust", "GNTO": "GoldeNugget Token", + "GNUS": "GENIUS TOKEN", "GNX": "Genaro Network", "GNY": "GNY", "GO": "GoChain", @@ -3621,32 +5296,47 @@ "GODL": "GODL", "GODS": "Gods Unchained", "GODZ": "Cryptogodz", + "GOETH": "Algomint", "GOF": "Golff", "GOFF": "Gift Off Token", "GOFX": "GooseFX", "GOG": "Guild of Guardians", "GOGO": "GOGO Finance", "GOGU": "GOGU Coin", + "GOIN": "GOinfluencer", "GOJOCOIN": "Gojo Coin", "GOKU": "Goku", "GOL": "GogolCoin", "GOLC": "GOLCOIN", "GOLD": "CyberDragon Gold", + "GOLDCAT": "GOLD CAT", + "GOLDEN": "Golden Inu", "GOLDENG": "Golden Goose", + "GOLDF": "Gold Fever", "GOLDMIN": "GoldMiner", "GOLDPIECES": "GoldPieces", "GOLDX": "eToro Gold", + "GOLDY": "DeFi Land Gold", "GOLF": "GolfCoin", "GOLOS": "Golos", "GOLOSBLOCKCHAIN": "Golos Blockchain", "GOM": "Gomics", "GOM2": "GoMoney2", "GOMA": "GOMA Finance", + "GOMD": "GOMDori", + "GOME": "Game of Memes", "GOMT": "GoMeat", + "GONE": "GONE", + "GOO": "Gooeys", + "GOOCH": "Gooch", "GOOD": "Goodomy", + "GOOGLE": "Deepmind Ai", + "GOOGLY": "Googly Cat", "GOON": "Goonies", "GOPX": "GOPX Token", + "GORA": "Gora", "GOREC": "GoRecruit", + "GORGONZOLA": "Heroes 3 Foundation", "GORILLA": "Gorilla", "GORILLAD": "Gorilla Diamond", "GORILLAINU": "Gorilla Inu", @@ -3657,24 +5347,40 @@ "GOTEM": "gotEM", "GOTG": "Got Guaranteed", "GOTX": "GothicCoin", + "GOV": "SubDAO", "GOVI": "Govi", "GOVT": "The Government Network", "GOW39": "God Of Wealth", "GOYOO": "GoYoo", "GOZ": "Göztepe S.K. Fan Token", "GP": "Wizards And Dragons", + "GPAWS": "Golden Paws", "GPBP": "Genius Playboy Billionaire Philanthropist", + "GPCX": "Good Person Coin", "GPKR": "Gold Poker", "GPL": "Gold Pressed Latinum", + "GPLX": "Gplx", + "GPN": "Gamepass Network", + "GPO": "GoldPesa Option", "GPPT": "Pluto Project Coin", "GPS": "Triffic", - "GPU": "GPU Coin", + "GPT": "QnA3.AI", + "GPT4O": "GPT-4o", + "GPTG": "GPT Guru", + "GPTPLUS": "GPTPlus", + "GPTV": "GPTVerse", + "GPU": "Node AI", + "GPUCOIN": "GPU Coin", + "GPUINU": "GPU Inu", "GPX": "GPEX", "GQ": "Galactic Quadrant", "GR": "GROM", "GRACY": "Gracy", + "GRAI": "Gravita Protocol", "GRAIL": "Camelot Token", + "GRAIN": "Granary", "GRANDCOIN": "GrandCoin", + "GRAPE": "GrapeCoin", "GRAV": "Graviton", "GRAVITAS": "Gravitas", "GRAVITYF": "Gravity Finance", @@ -3686,6 +5392,8 @@ "GREARN": "GrEarn", "GREEN": "GreenX", "GREENT": "Greentoken", + "GREG": "greg", + "GRELF": "GRELF", "GREXIT": "GrexitCoin", "GREY": "Grey Token", "GRFT": "Graft Blockchain", @@ -3693,9 +5401,12 @@ "GRID": "Grid+", "GRIDCOIN": "GridCoin", "GRIDZ": "GridZone.io", + "GRIM": "GRIMREAPER", "GRIMACE": "Grimace", + "GRIMEVO": "Grim EVO", "GRIMEX": "SpaceGrime", "GRIN": "Grin", + "GRL": "Greelance", "GRLC": "Garlicoin", "GRM": "GridMaster", "GRMD": "GreenMed", @@ -3703,20 +5414,36 @@ "GRND": "SuperWalk", "GRO": "Gro DAO Token", "GROK": "Grok", + "GROK2": "GROK 2.0", + "GROKBANK": "Grok Bank", + "GROKBOY": "GrokBoy", + "GROKCAT": "Grok Cat", + "GROKCEO": "GROK CEO", + "GROKCOIN": "Grok Coin", "GROKFATHER": "Grok Father", + "GROKGIRL": "Grok Girl", "GROKGROW": "GrokGrow", + "GROKHEROES": "GROK heroes", + "GROKINU": "Grok Inu", "GROKKING": "GrokKing", "GROKKY": "GroKKy", + "GROKMOON": "Grok Moon", "GROKOLAUS": "GROKolaus", "GROKQUEEN": "Grok Queen", + "GROKSORAX": "GROKSORAX", "GROKX": "GROKX", + "GROKXAI": "Grok X Ai", "GRON": "Gron Digital", + "GROOOOOK": "Groooook", + "GROOVE": "GROOVE", "GROW": "GrownCoin", "GROWTH": "GROWTH DeFi", + "GROYPER": "Groyper", "GRP": "Grape", "GRPL": "Golden Ratio Per Liquidity", "GRS": "Groestlcoin", "GRT": "The Graph", + "GRUM": "Grumpy (Ordinals)", "GRUMPY": "Grumpy Finance", "GRV": "GroveCoin", "GRVE": "Grave", @@ -3724,16 +5451,20 @@ "GRWI": "Growers International", "GRX": "Gold Reward Token", "GS": "Genesis Shards", + "GS1": "NFTGamingStars", "GSC": "Global Social Chain", "GSE": "GSENetwork", "GSHIBA": "Gambler Shiba", "GSI": "Globex SCI", + "GSKY": "SKY FRONTIER", "GSM": "GSM Coin", "GSPI": "GSPI", "GSR": "GeyserCoin", + "GST": "CoinGhost", "GSTBSC": "Green Satoshi Token (BSC)", "GSTC": "GSTCOIN", "GSTETH": "Green Satoshi Token (ETH)", + "GSTOP": "GameStop", "GSTS": "Gunstar Metaverse", "GSTSOL": "Green Satoshi Token (SOL)", "GSTT": "GSTT", @@ -3741,10 +5472,17 @@ "GSWIFT": "GameSwift", "GSX": "Gold Secured Currency", "GSY": "GenesysCoin", + "GSYS": "Genesys", "GT": "Gatechain Token", + "GTA": "GTA Token", "GTA6": "GTA VI", + "GTAI": "GT Protocol", + "GTAVI": "GTAVI", + "GTBOT": "Gaming-T-Bot", "GTC": "Gitcoin", "GTCOIN": "Game Tree", + "GTE": "GreenTek", + "GTF": "GLOBALTRUSTFUND TOKEN", "GTFO": "DumpBuster", "GTH": "Gath3r", "GTIB": "Global Trust Coin", @@ -3752,33 +5490,57 @@ "GTN": "GlitzKoin", "GTON": "GTON Capital", "GTR": "Gturbo", + "GTRUMP": "Giga Trump", "GTSE": "Global Tourism Sharing Ecology", + "GTTM": "Going To The Moon", "GTX": "GALLACTIC", "GUAC": "Guacamole", + "GUAP": "Guapcoin", "GUAR": "Guarium", + "GUARD": "Guardian", + "GUARDAI": "GuardAI", + "GUC": "Green Universe Coin", "GUCCI": "GUCCI", "GUE": "GuerillaCoin", "GUESS": "Peerguess", + "GUI": "Gui Inu", "GUILD": "BlockchainSpace", + "GUISE": "GUISE", "GULF": "GulfCoin", "GUM": "Gourmet Galaxy", + "GUMMIES": "GUMMIES", + "GUMMY": "GUMMY", + "GUMSHOOS": "GUMSHOOS TRUMP", "GUN": "GunCoin", "GUNS": "GeoFunders", "GUP": "Guppy", + "GURL": "Gently Used Girl", "GUSD": "Gemini Dollar", "GUSDT": "Global Utility Smart Digital Token", + "GUT": "Genesis Universe", + "GUUFY": "Guufy", + "GVC": "Global Virtual Coin", "GVE": "Globalvillage Ecosystem", + "GVL": "Greever", "GVR": "Grove [OLD]", "GVT": "Genesis Vision", + "GW": "Gyrowin", + "GWD": "GreenWorld", + "GWGW": "GoWrap", "GWT": "Galaxy War", "GX": "GameX", "GXA": "Galaxia", "GXC": "GXChain", + "GXE": "XENO Governance", "GXT": "Gem Exchange And Trading", "GYEN": "GYEN", "GYM": "GYM Token", "GYMNET": "Gym Network", "GYMREW": "Gym Rewards", + "GYR": "Gyre Token", + "GYRO": "Gyro", + "GYROS": "Gyroscope GYD", + "GYSR": "GYSR", "GZB": "Gigzi", "GZE": "GazeCoin", "GZIL": "governance ZIL", @@ -3790,45 +5552,74 @@ "H2O": "H2O Dao", "H2ON": "H2O Securities", "H3O": "Hydrominer", + "H4TOKEN": "Hold Ignore Fud", + "HABIBI": "The Habibiz", "HAC": "Hackspace Capital", + "HACD": "Hacash Diamond", + "HACHI": "Hachi", "HACHIKO": "Hachiko Inu Token", + "HAHA": "Hasaki", "HAI": "Hacken Token", "HAIR": " HairDAO", "HAKA": "TribeOne", "HAKKA": "Hakka Finance", + "HAKU": "HakuSwap", "HAL": "Halcyon", "HALF": "0.5X Long Bitcoin Token", "HALFSHIT": "0.5X Long Shitcoin Index Token", "HALLO": "Halloween Coin", + "HALLOWEEN": "HALLOWEEN", "HALO": "Halo Coin", "HALOPLATFORM": "Halo Platform", "HAM": "Hamster", + "HAMI": "Hamachi Finance", + "HAMMY": "SAD HAMSTER", "HAMS": "HamsterCoin", + "HAMSTR": "Hamster Coin", + "HAN": "HanChain", "HANA": "Hanacoin", "HAND": "ShowHand", "HANDY": "Handy", + "HANK": "Hank", "HANU": "Hanu Yokia", "HAO": "HistoryDAO", + "HAP": "Happy Train", "HAPI": "HAPI", "HARAM": "HARAM", - "HARAMBE": "Harambe", + "HARAMBE": "Harambe on Solana", "HARD": "Kava Lend", + "HARE": "Hare Token", + "HAREPLUS": "Hare Plus", + "HAROLD": "Harold", "HARRYP": "HarryPotterObamaSonic10Inu (ERC20)", "HART": "HARA", + "HASBIK": "Hasbulla", "HASH": "Provenance Blockchain", + "HASHAI": "HashAI", "HASHT": "HASH Token", + "HASUI": "Haedal", "HAT": "Hawala.Exchange", + "HATAY": "Hatayspor Token", + "HATCHY": "Hatchyverse", "HATI": "Hati", "HAUS": "DAOhaus", + "HAVOC": "Havoc", + "HAVY": "Havy", "HAWK": "Hawksight", + "HAWKTUAH": "Hawk Tuah", + "HAY": "Destablecoin HAY", + "HAYYA": "GO HAYYA", "HAZ": "Hazza", "HAZE": "HazeCoin", "HB": "HeartBout", "HBAR": "Hedera Hashgraph", + "HBARX": "HBARX", "HBB": "Hubble", "HBC": "HBTC Captain Token", "HBD": "Hive Dollar", + "HBDC": "Happy Birthday Coin", "HBE": "healthbank", + "HBIT": "HashBit", "HBN": "HoboNickels", "HBO": "Hash Bridge Oracle", "HBOT": "Hummingbot", @@ -3846,6 +5637,7 @@ "HDG": "Hedge Token", "HDN": "Hydranet", "HDRN": "Hedron", + "HDRO": "Hydro Protocol", "HDV": "Hydraverse", "HDX": "HydraDX", "HE": "Heroes & Empires", @@ -3854,21 +5646,30 @@ "HEARTBOUT": "HeartBout Pay", "HEARTR": "Heart Rate", "HEAT": "Heat Ledger", + "HEAVEN": "Heaven Token", "HEC": "Hector Finance", + "HECT": "Hectic Turkey", "HECTA": "Hectagon", "HEDG": "HedgeTrade", "HEDGE": "Hedgecoin", "HEEL": "HeelCoin", + "HEFI": "HeFi", + "HEGE": "Hege", + "HEGG": "Hummingbird Egg", "HEGIC": "Hegic", "HELIOS": "Mission Helios", "HELL": "HELL COIN", "HELLO": "HELLO", "HELMET": "Helmet Insure", "HELPS": "HelpSeed", + "HEMAN": "HE-MAN", + "HEMULE": "Hemule", "HEP": "Health Potion", "HER": "Hero Node", "HERA": "Hero Arena", "HERB": "HerbCoin", + "HERBE": "Herbee", + "HERMES": "Hermes Protocol", "HERO": "Metahero", "HEROES": "Dehero Community Token", "HEROESC": "HeroesChained", @@ -3878,16 +5679,21 @@ "HEX": "HEX", "HEXC": "HexCoin", "HEZ": "Hermez Network Token", + "HF": "Have Fun", "HFI": "Holder Finance", "HFIL": "Huobi Fil", "HFT": "Hashflow", + "HGEN": "HGEN DAO", "HGET": "Hedget", + "HGHG": "HUGHUG Coin", "HGO": "HireGo", "HGOLD": "HollyGold", + "HGPT": "HyperGPT", "HGS": "HashGains", "HGT": "Hello Gold", "HH": "Holyheld", "HHEM": "Healthureum", + "HHGTTG": "Douglas Adams", "HI": "hi Dollar", "HIAZUKI": "hiAZUKI", "HIBAKC": "hiBAKC", @@ -3898,24 +5704,35 @@ "HICLONEX": "hiCLONEX", "HICOOLCATS": "hiCOOLCATS", "HID": "Hypersign Identity", + "HIDE": "Hide Coin", + "HIDOODLES": "hiDOODLES", "HIDU": "H-Education World", + "HIENS3": "hiENS3", "HIENS4": "hiENS4", "HIFI": "Hifi Finance", "HIFLUF": "hiFLUF", "HIFRIENDS": "hiFRIENDS", "HIGAZERS": "hiGAZERS", "HIGH": "Highstreet", + "HIGHER": "Higher", "HIH": "HiHealth", + "HIKARI": "Hikari Protocol", "HILL": "President Clinton", + "HIM": "Human Intelligence Machine", "HIMAYC": "hiMAYC", + "HIME": "Phantom of the Kill", "HIMEEBITS": "hiMEEBITS", "HIMFERS": "hiMFERS", + "HIMO": "Himo World", "HIMOONBIRDS": "hiMOONBIRDS", "HINA": "Hina Inu", "HINT": "Hintchain", "HINU": "HajiIni", + "HIOD": "hiOD", "HIODBS": "hiODBS", + "HIP": "HIPPOP", "HIPENGUINS": "hiPENGUINS", + "HIPP": "El Hippo", "HIPUNKS": "hiPUNKS", "HIRE": "HireMatch", "HIRENGA": "hiRENGA", @@ -3927,30 +5744,42 @@ "HITOP": "Hitop", "HIUNDEAD": "hiUNDEAD", "HIVE": "Hive", - "HIVEM": "Hivemapper", "HIX": "HELIX Orange", + "HK": "Hongkong", + "HKB": "HongKong BTC bank", "HKC": "HK Coin", + "HKDOGE": "HongKong Doge", "HKDX": "eToro Hong Kong Dollar", + "HKFLOKI": "hong kong floki", "HKG": "Hacker Gold", "HKN": "Hacken", "HLC": "HalalChain", "HLD": "HyperLending", "HLDY": "HOLIDAY", + "HLG": "Holograph", "HLM": "Helium", + "HLN": "Holonus", "HLP": "Purpose Coin", + "HLPR": "HELPER COIN", "HLPT": "HLP Token", + "HLS": "Halis", "HLT": "HyperLoot", "HLX": "Helex", "HMC": "Hi Mutual Society", "HMD": "Homelend", + "HMKR": "Hitmakr", + "HMM": "HMM", "HMN": "Harvest Masternode Coin", "HMND": "Humanode", + "HMNG": "HummingBirdFinance", "HMP": "HempCoin", "HMQ": "Humaniq", "HMR": "Homeros", "HMRN": "Homerun", "HMST": "Hamster Marketplace Token", "HMT": "HUMAN Token", + "HMTT": "Hype Meme Token", + "HMX": "HMX", "HNB": "HashNet BitEco", "HNC": "Hellenic Coin", "HNCN": "Huncoin", @@ -3958,32 +5787,61 @@ "HNST": "Honest", "HNT": "Helium", "HNTR": "Hunter", + "HNX": "HeartX Utility Token", "HNY": "Honey", "HNZO": "Hanzo Inu", + "HO": "HALO network", + "HOA": "Hex Orange Address", + "HOBA": "Honey Badger", + "HOBBES": "Hobbes", "HOBO": "HOBO THE BEAR", + "HOCAI": "Heroes of Crypto AI", "HOD": "HoDooi.com", "HODL": "HOdlcoin", + "HOG": "Hog", "HOGE": "Hoge Finance", + "HOHOHO": "Santa Floki v2.0", + "HOICHI": "Hoichi", + "HOKA": "Hokkaido Inu", "HOKK": "Hokkaidu Inu", + "HOL": "Hololoot", "HOLA": "Hola Token", "HOLD": "HOLD", + "HOLDEX": "Holdex Finance", "HOLY": "Holy Trinity", + "HOM": "Homeety", + "HOME": "OtterHome", + "HOMER": "Homer Simpson", "HOMI": "HOMIHELP", + "HOMIECOIN": "Homie Wars", + "HOMMIES": "HOMMIES", "HON": "SoulSociety", + "HONEY": "Hivemapper", "HONEYCOIN": "Honey", + "HONG": "HongKongDAO", + "HONK": "Honk", "HONOR": "HonorLand", + "HOOF": "Metaderby Hoof", "HOOK": "Hooked Protocol", + "HOOP": "Chibi Dinos", "HOP": "Hop Protocol", + "HOPPY": "Hoppy", "HOPR": "HOPR", "HORD": "Hord", "HORSE": "Ethorse", "HORUS": "HorusPay", "HOS": "Hotel of Secrets", + "HOSHI": "Dejitaru Hoshi", "HOSKY": "Hosky", + "HOSTAI": "Host AI", "HOT": "Holo", "HOTCROSS": "Hot Cross", + "HOTKEY": "HotKeySwap", + "HOTMOON": "HotMoon Token", "HOTN": "HotNow", "HOTT": "HOT Token", + "HOUSE": "Klaymore Stakehouse", + "HOW": "HowInu", "HOWL": "Coyote", "HP": "HeroPark", "HPAD": "HarmonyPad", @@ -3995,29 +5853,41 @@ "HPO": "Hippocrat", "HPT": "Huobi Pool Token", "HPX": "HUPAYX", + "HPY": "Hyper Pay", + "HPYPEPE": "Happy Pepe Token", + "HQR": "Hayya Qatar", "HQT": "HyperQuant", "HQX": "HOQU", "HRB": "Harbour DAO", "HRBE": "Harambee Token", + "HRCC": "HRC Crypto", "HRD": "Hoard", "HRDG": "HRDGCOIN", + "HRM": "Honorarium", "HRO": "HEROIC.com", + "HRT": "HIRO", "HRTS": "YellowHeart Protocol", + "HRX": "HorusLayer", + "HSAI": "HealthSci.AI", "HSC": "HashCoin", "HSF": "Hillstone Finance", "HSN": "Hyper Speed Network", "HSP": "Horse Power", "HSS": "Hashshare", "HST": "Decision Token", + "HSUI": "Suicune", + "HSUITE": "HbarSuite", "HT": "Huobi Token", "HTA": "Historia", "HTB": "Hotbit", "HTC": "Hitcoin", "HTD": "HeroesTD", "HTDF": "Orient Walt", + "HTE": "Hepton", "HTER": "Biogen", "HTM": "Hatom", "HTML": "HTML Coin", + "HTMOON": "HTMOON", "HTN": "Heart Number", "HTO": "Heavenland HTO", "HTR": "Hathor", @@ -4028,8 +5898,16 @@ "HUB": "Hub Token", "HUBII": "Hubii Network", "HUC": "HunterCoin", + "HUDI": "Hudi", "HUGE": "BigCoin", + "HUGO": "Hugo Inu", + "HUH": "HUH Token", + "HUHCAT": "huhcat", "HUM": "Humanscape", + "HUMAI": "Humanoid AI", + "HUMP": "Hump", + "HUND": "HUND MEME COIN", + "HUNDRED": "HUNDRED", "HUNNY": "Pancake Hunny", "HUNT": "HUNT", "HUR": "Hurify", @@ -4038,23 +5916,30 @@ "HUSH": "Hush", "HUSKY": "Husky", "HUSL": "Hustle Token", + "HUT": "Hibiki Run", "HVC": "HeavyCoin", "HVCO": "High Voltage Coin", "HVE": "UHIVE", "HVE2": "Uhive", "HVH": "HAVAH", + "HVI": "Hungarian Vizsla Inu", "HVN": "Hiveterminal Token", "HVNT": "HiveNet Token", "HVT": "HyperVerse", "HWC": "HollyWoodCoin", + "HWL": "Howl City", + "HWT": "Honor World Token", "HXA": "HXAcoin", "HXC": "HexanCoin", + "HXD": "Honeyland", "HXRO": "Hxro", "HXT": "HextraCoin", "HXX": "HexxCoin", + "HXXH": "Pioneering D. UTXO-Based NFT Social Protocol", "HYBN": "Hey Bitcoin", "HYBRID": "Hybrid Bank Cash", "HYC": "HYCON", + "HYCO": "HYPERCOMIC", "HYDRA": "Hydra", "HYDRO": "Hydro", "HYDROMINER": "Hydrominer", @@ -4062,16 +5947,20 @@ "HYGH": "HYGH", "HYN": "Hyperion", "HYP": "HyperStake", + "HYPC": "HyperCycle", "HYPE": "Hype", "HYPER": "HyperChainX", + "HYPERAI": "HyperHash AI", "HYPERCOIN": "HyperCoin", "HYPERD": "HyperDAO", "HYPERS": "HyperSpace", + "HYPES": "Supreme Finance", "HYPR": "Hypr Network", "HYS": "Heiss Shares", "HYT": "HoryouToken", "HYVE": "Hyve", "HZ": "Horizon", + "HZD": "HorizonDollar", "HZM": "HZM Coin", "HZN": "Horizon Protocol", "HZT": "HazMatCoin", @@ -4079,6 +5968,7 @@ "I7": "ImpulseVen", "I9C": "i9 Coin", "IAG": "IAGON", + "IAI": "inheritance Art", "IAM": "IAME Identity", "IB": "Iron Bank", "IBANK": "iBankCoin", @@ -4089,21 +5979,28 @@ "IBFN": "IBF Net", "IBFR": "iBuffer Token", "IBG": "iBG Token", + "IBIT": "InfinityBit Token", "IBNB": "iBNB", "IBP": "Innovation Blockchain Payment", "IBS": "Irbis Network", + "IBTC": "Indigo Protocol - iBTC", "IC": "Ignition", "ICA": "Icarus Network", "ICAP": "ICAP Token", "ICASH": "ICASH", "ICB": "IceBergCoin", "ICC": "Insta Cash Coin", - "ICE": "Decentral Games ICE", + "ICE": "Ice Open Network", + "ICELAND": "ICE LAND", + "ICETH": "Interest Compounding ETH Index", + "ICG": "Invest Club Global", "ICH": "IdeaChain", "ICHI": "ICHI", "ICHN": "i-chain", "ICHX": "IceChain", + "ICLICK": "Iclick inu", "ICN": "Iconomi", + "ICNX": "Icon.X World", "ICOB": "Icobid", "ICOM": "iCommunity", "ICON": "Iconic", @@ -4111,6 +6008,8 @@ "ICOO": "ICO OpenLedger", "ICOS": "ICOBox", "ICP": "Internet Computer", + "ICPX": "Icrypex token", + "ICS": " ICPSwap Token", "ICSA": "Icosa", "ICST": "ICST", "ICT": "Intrachain", @@ -4120,6 +6019,7 @@ "IDAP": "IDAP", "IDC": "IdealCoin", "IDEA": "Ideaology", + "IDEAL": "Ideal Opportunities", "IDEFI": "Inverse DeFi Index", "IDEX": "IDEX", "IDH": "IndaHash", @@ -4131,18 +6031,23 @@ "IDM": "IDM", "IDNA": "Idena", "IDO": "Idexo", + "IDOL": "IDOLINU", "IDORU": "Vip2Fan", "IDRT": "Rupiah Token", + "IDRX": "IDRX", "IDT": "InvestDigital", "IDTT": "Identity", "IDV": "Idavoll DAO", + "IDVV1": "Idavoll DAO v1", "IDX": "Index Chain", "IDXM": "IDEX Membership", "IDXS": "In-Dex Sale", + "IDYP": "iDypius", "IEC": "IvugeoEvolutionCoin", "IETH": "iEthereum", "IF": "Impossible Finance", "IFC": "Infinite Coin", + "IFIT": "CALO INDOOR", "IFLT": "InflationCoin", "IFT": "InvestFeed", "IFUM": "Infleum", @@ -4155,30 +6060,38 @@ "IGNIS": "Ignis", "IGTT": "IGT", "IGU": "IguVerse", + "IGUP": "IguVerse", "IHC": "Inflation Hedging Coin", "IHF": "Invictus Hyperion Fund", "IHT": "I-House Token", "IIC": "Intelligent Investment Chain", "IJC": "IjasCoin", "IJZ": "iinjaz", + "IKI": "ikipay", "ILA": "Infinite Launch", "ILC": "ILCOIN", "ILCT": "ILCoin Token", "ILK": "Inlock", "ILT": "iOlite", "ILV": "Illuvium", + "IMARO": "IMARO", + "IMAYC": "IMAYC", "IMBTC": "The Tokenized Bitcoin", "IMC": "i Money Crypto", "IME": "Imperium Empires", "IMG": "ImageCoin", + "IMGNAI": "Image Generation AI", "IMGZ": "Imigize", "IMI": "Influencer", "IML": "IMMLA", + "IMMO": "ImmortalDAO Finance", + "IMO": "IMO", "IMP": "CoinIMP", "IMPACT": "Impact", "IMPACTXP": "ImpactXP", "IMPCH": "Impeach", "IMPCN": "Brain Space", + "IMPCOIN": "IMPERIUM", "IMPER": "Impermax", "IMPS": "Impulse Coin", "IMPT": "IMPT", @@ -4198,12 +6111,16 @@ "INCORGNITO": "Incorgnito", "INCP": "InceptionCoin", "IND": "Indorse", + "INDAY": "Independence Day", + "INDEPENDENCEDAY": "Independence Day", "INDEX": "Index Cooperative", "INDI": "IndiGG", "INDIA": "Indiacoin", "INDICOIN": "IndiCoin", + "INDU": "INDU4.0", "INDY": "Indigo Protocol", "INE": "IntelliShare", + "INEDIBLE": "INEDIBLE", "INERY": "Inery", "INES": "Inescoin", "INET": "Insure Network", @@ -4225,23 +6142,37 @@ "INNBC": "Innovative Bioresearch Coin", "INNOU": "Innou", "INO": "Ino Coin", + "INOVAI": "INOVAI", + "INP": "Ionic Pocket Token", "INRT": "INRToken", "INS": "Insolar (Old Chain)", "INSANE": "InsaneCoin", "INSANITY": "Insanity Coin", + "INSC": "INSC (Ordinals)", "INSN": "Insane Coin", "INSP": "Inspect", + "INSR": "Insurabler", "INST": "Instadapp", "INSTAMINE": "Instamine Nuggets", "INSTAR": "Insights Network", "INSUR": "InsurAce", "INSURC": "InsurChain Coin", "INT": "Internet Node token", + "INTD": "INTDESTCOIN", + "INTE": "InteractWith", + "INTELLIQUE": "KARASOU", "INTER": "Inter Milan Fan Token", + "INTERN": "Interns", + "INTL": "Intelly", "INTO": "Influ Token", "INTR": "Interlay", + "INTRO": "1INTRO", + "INTX": "Intexcoin", "INU": "INU Token", + "INUGA": "INUGAMI", + "INUINU": "Inu Inu", "INUKO": "Inuko Finance", + "INUS": "MultiPlanetary Inus", "INUYASHA": "Inuyasha", "INV": "Inverse Finance", "INVC": "Invacio", @@ -4254,9 +6185,10 @@ "INXM": "InMax", "INXT": "Internxt", "INXTOKEN": "INX Token", - "IO": "Ideal Opportunities", + "IO": "io.net", "IOC": "IOCoin", "IOEN": "Internet of Energy Network", + "IOETH": "ioETH", "IOEX": "ioeX", "IOI": "IOI Token", "ION": "Ionomy", @@ -4264,6 +6196,7 @@ "IONX": "Charged Particles", "IONZ": "IONZ", "IOP": "Internet of People", + "IOSHIB": "IoTexShiba", "IOST": "IOS token", "IOT": "Helium IOT", "IOTW": "IOTW", @@ -4278,6 +6211,8 @@ "IPC": "IPChain", "IPDN": "IPDnetwork", "IPL": "VouchForMe", + "IPMB": "IPMB", + "IPOR": "IPOR", "IPSX": "IP Exchange", "IPT": "Crypt-ON", "IPUX": "IPUX", @@ -4285,20 +6220,27 @@ "IPVOLD": "IPVERSE (Klaytn)", "IPX": "InpulseX", "IQ": "IQ", + "IQ50": "IQ50", "IQC": "IQ.cash", + "IQG": "IQ Global", "IQN": "IQeon", "IQQ": "Iqoniq", "IQT": "IQ Protocol", "IRA": "Diligence", "IRC": "IRONCOIN", + "IRENA": "Irena Coin Apps", "IRIS": "IRIS Network", + "IRISTOKEN": "Iris Ecosystem", "IRL": "IrishCoin", - "IRON": "Iron BSC", + "IRON": "Iron Fish", + "IRONBSC": "Iron BSC", "IRT": "Infinity Rocket", + "IRYDE": "iRYDE COIN", "ISA": "Islander", "ISDT": "ISTARDUST", "ISG": "ISG", "ISH": "Interstellar Holdings", + "ISHI": "Ishi", "ISHND": "StrongHands Finance", "ISIKC": "Isiklar Coin", "ISKR": "ISKRA Token", @@ -4306,10 +6248,12 @@ "ISL": "IslaCoin", "ISLAMI": "ISLAMICOIN", "ISLM": "Islamic Coin", + "ISME": "Root Protocol", "ISP": "Ispolink", "ISR": "Insureum", "ISRG.CUR": "Intuitive Surgical, Inc.", "ISSP": "ISSP", + "IST": "Inter Stable Token", "ISTEP": "iSTEP", "ITA": "Italian National Football Team Fan Token", "ITALOCOIN": "Italocoin", @@ -4320,26 +6264,39 @@ "ITF": "Intelligent Trading", "ITG": "iTrust Governance", "ITGR": "Integral", + "ITHEUM": "Itheum", "ITL": "Italian Lira", + "ITLR": "MiTellor", "ITM": "intimate.io", "ITOC": "ITOChain", "ITR": "INTRO", + "ITSB": "ITSBLOC", "ITU": "iTrue", + "ITX": "Intellix", "ITZ": "Interzone", "IUNGO": "Iungo", + "IUS": "Iustitia Coin", + "IUSD": "Indigo Protocol - iUSD", "IUX": "GeniuX", + "IVANKA": "IVANKA TRUMP", "IVAR": "Ivar Coin", "IVC": "Investy Coin", + "IVEX": "IVEX Financial", + "IVI": "IVIRSE", + "IVIP": "iVipCoin", "IVN": "IVN Security", + "IVPAY": "ivendPay", "IVY": "IvyKoin", "IVZ": "InvisibleCoin", "IW": "iWallet", + "IWFT": "İstanbul Wild Cats", "IWT": "IwToken", "IX": "X-Block", "IXC": "IXcoin", "IXP": "IMPACTXPRIME", "IXS": "IX Swap", "IXT": "iXledger", + "IYKYK": "IYKYK", "IZA": "Inzura", "IZE": "IZE", "IZER": "IZEROIUM", @@ -4351,117 +6308,201 @@ "J": "JoinCoin", "J8T": "JET8", "J9BC": "J9CASINO", + "JACK": "Jack Token", "JACS": "JACS", "JACY": "JACY", "JADE": "Jade Protocol", "JADEC": "Jade Currency", + "JAIHO": "Jaiho Crypto", + "JAKE": "Jake The Dog", "JAM": "Tune.Fm", + "JAN": "Storm Warfare", "JANE": "JaneCoin", "JAR": "Jarvis+", "JARED": "Jared From Subway", + "JARY": "JeromeAndGary", "JASMY": "JasmyCoin", + "JAWS": "AutoShark", + "JAY": "Jaypeggers", + "JBOT": "JACKBOT", "JBS": "JumBucks Coin", "JBX": "Juicebox", "JC": "JesusCoin", "JCB": "Wine Chain", "JCC": "Junca Cash", "JCG": "JustCarbon Governance", + "JCO": "JennyCo", "JCR": "JustCarbon Removal", "JCT": "Japan Content Token", "JDC": "JustDatingSite", "JED": "JEDSTAR", + "JEDALS": "Yoda Coin Swap", + "JEET": "Jeet", + "JEETOLAX": "Jeetolax", + "JEFE": "JEFE TOKEN", "JEFF": "Jeff in Space", "JEJUDOGE": "Jejudoge", + "JELLI": "JELLI", + "JELLY": "Jelly eSports", "JEM": "Jem", + "JEN": "JEN COIN", + "JENNER": "Caitlyn Jenner", + "JENSEN": "Jensen Huang", + "JERRY": "Jerry Inu", + "JERRYINU": "JERRYINU", + "JES": "Jesus", "JESUS": "Jesus Coin", "JET": "Jet Protocol", "JETCOIN": "Jetcoin", + "JETTON": "JetTon Game", + "JEUR": "Jarvis Synthetic Euro", "JEW": "Shekel", "JEWEL": "DeFi Kingdoms", "JEX": "JEX Token", + "JF": "Jswap.Finance", "JFI": "JackPool.finance", "JFIN": "JFIN Coin", + "JFIVE": "Jonny Five", + "JGLP": "Jones GLP", "JGN": "Juggernaut", "JIAOZI": "Jiaozi", "JIB": "Jibbit", "JIF": "JiffyCoin", + "JIG": "Jigen", + "JIM": "Jim", + "JIND": "JINDO INU", "JINDOGE": "Jindoge", "JIO": "JIO Token", + "JITOSOL": "Jito Staked SOL", + "JIZZ": "JizzRocket", + "JIZZLORD": "JizzLord", + "JIZZUS": "JIZZUS CHRIST", + "JJ": "JEJE", + "JK": "JK Coin", "JKC": "JunkCoin", + "JKL": "Jackal Protocol", + "JLP": "Jupiter Perps LP", "JM": "JustMoney", "JMC": "Junson Ming Chan Coin", "JMPT": "JumpToken", "JMT": "JMTIME", + "JMZ": "Jimizz", + "JNB": "Jinbi Token", + "JNGL": "Jungle Labz", "JNS": "Janus", "JNT": "Jibrel Network Token", + "JNX": "Janex", "JNY": "JNY", "JOB": "Jobchain", "JOBS": "JobsCoin", + "JOC": "Speed Star JOC", "JOE": "JOE", + "JOEBIDEN2024 ": "JOEBIDEN2024", + "JOEY": "Joey Inu", + "JOGECO": "Jogecodog", + "JOHM": "Johm lemmon", + "JOHNNY": "Johnny The Bull", "JOINT": "Joint Ventures", - "JOK": "JokerCoin", + "JOJO": "JOJO", + "JOK": "JokInTheBox", + "JOKER": "JOKER", + "JOKERCOIN": "JokerCoin", + "JOL": "Jolofcoin", + "JOLT": "Joltify", "JONES": "Jones DAO", + "JOOPS": "JOOPS", "JOTCHUA": "Perro Dinero", + "JOWNES": "Alux Jownes", "JOY": "Joystream", "JOYS": "JOYS", "JOYT": "JoyToken", "JOYTOKEN": "Joycoin", "JP": "JP", "JPAW": "Jpaw Inu", + "JPD": "JackpotDoge", "JPEG": "JPEG'd", + "JPGC": "JPGold Coin", "JPYC": "JPYC", "JPYX": "eToro Japanese Yen", "JRIT": "JERITEX", "JRT": "Jarvis Reward Token", "JSE": "JSEcoin", + "JSM": "Joseon Mun", + "JSOL": "JPool Staked SOL", "JST": "JUST", "JT": "Jubi Token", + "JTC": "Jurat", "JTO": "Jito", "JTS": "Jetset", + "JTT": "Justus", "JTX": "Project J", "JUDGE": "JudgeCoin", + "JUGNI": "JUGNI", "JUI": "Juiice", + "JUICE": "Juice Finance", "JUL": "Joule", "JULB": "JustLiquidity Binance", "JULD": "JulSwap", "JUMBO": "Jumbo Exchange", "JUMP": "Jumpcoin", "JUN": "Jun \"M\" Coin", + "JUNGLE": "JUNGLEDOGE", + "JUNKIE": "Junkie Cats", "JUNO": "JUNO", "JUP": "Jupiter", + "JUPI": "Jupiter", + "JUPSOL": "Jupiter Staked SOL", "JUR": "Jur", + "JUSD": "JUSD Stable Token", + "JUSDC": "Jones USDC", + "JUSDT": "TON Bridged USDT", "JUSTICE": "AssangeDAO", "JUV": "Juventus Fan Token", "JVL": "Javelin", "JVY": "Javvy", "JW": "Jasan Wellness", + "JWIF": "Jerrywifhat", "JWL": "Jewels", + "JYC": "Joe-Yo Coin", "K21": "K21", "K2G": "Kasko2go", "KAAS": "KAASY.AI", + "KABOSU": "Kabosu Family", "KABY": "Kaby Arena", "KAC": "KACO Finance", + "KACY": "Kassandra", + "KAF": "KAIF Platform", "KAG": "Silver", "KAI": "KardiaChain", + "KAID": "KAIDEX", + "KAIJU": "KAIJUNO8", "KAIKEN": "Kaiken Shiba", "KAINET": "KAINET", + "KAKA": "KAKA NFT World", + "KAKAXA": "KAKAXA", + "KAKI": "Doge KaKi", "KAL": "Kaleido", "KALA": "Kalata Protocol", "KALAM": "Kalamint", + "KALDI": "Kaldicoin", "KALI": "Kalissa", + "KALIS": "KALICHAIN", "KALLY": "Polkally", "KALM": "KALM", "KALYCOIN": "KalyCoin", "KAM": "BitKAM", "KAMPAY": "KamPay", "KAN": "Bitkan", + "KANG3N": "Kang3n", "KANGAL": "Kangal", + "KAP": "KAP Games", "KAPU": "Kapu", "KAR": "Karura", "KARATE": "Karate Combat", "KAREN": "KarenCoin", "KARMA": "Karma", "KARMAD": "Karma DAO", + "KARRAT": "KARRAT", "KART": "Dragon Kart", "KAS": "Kaspa", "KASSIAHOME": "Kassia Home", @@ -4469,6 +6510,9 @@ "KAT": "Kambria", "KATA": "Katana Inu", "KATANA": "Katana Finance", + "KATCHU": "Katchu Coin", + "KATT": "Katt Daddy", + "KATYCAT": "Katy Perry Fans", "KATZ": "KATZcoin", "KAU": "Kinesis Gold", "KAVA": "Kava", @@ -4485,8 +6529,10 @@ "KBTC": "Klondike BTC", "KBX": "KuBitX", "KC": "Kernalcoin", + "KCAKE": "KittyCake", "KCAL": "Phantasma Energy", "KCASH": "Kcash", + "KCAT": "KING OF CATS", "KCCM": "KCC MemePad", "KCCPAD": "KCCPad", "KCH": "Keep Calm and Hodl", @@ -4497,42 +6543,66 @@ "KDC": "Klondike Coin", "KDG": "Kingdom Game 4.0", "KDIA": "KDIA COIN", + "KDOE": "Kudoe", "KDOGE": "KingDoge", + "KDX": "eckoDAO", "KEANU": "Keanu Inu", "KEC": "KEYCO", "KED": "Klingon Empire Darsek", + "KEEMJONG": "KEEM JONG UNN", "KEEP": "Keep Network", "KEES": "Korea Entertainment Education & Shopping", "KEI": "Keisuke Inu", "KEK": "KekCoin", "KEKE": "KEK", + "KEKEC": "THE BALKAN DWARF", "KEL": "KelVPN", + "KELP": "KELP", + "KELPE": "Kelp Earned Points", "KELPIE": "Kelpie Inu", "KEMA": "Kemacoin", "KEN": "Kencoin", + "KENDU": "Kendu Inu", + "KENKA": "KENKA METAVERSE", + "KENNEL": "Kennel Locker", + "KENSHI": "Kenshi", "KEP": "Kepler", + "KEPT": "KeptChain", "KERMIT": "KermitTheCoin", + "KETAMINE": "Ketamine", "KETAN": "Ketan", "KEX": "Kira Network", "KEXCOIN": "KexCoin", "KEY": "SelfKey", "KEYC": "KeyCoin", + "KEYCAT": "Keyboard Cat", "KEYFI": "KeyFi", + "KEYT": "REBIT", "KFC": "Chicken", "KFI": "Klever Finance", + "KFR": "KING FOREVER", "KFT": "Knit Finance", "KFX": "KnoxFS", + "KGB": "KGB protocol", "KGC": "Krypton Galaxy Coin", "KGO": "Kiwigo", + "KGT": "Kaby Gaming Token", + "KHAI": "khai", "KHM": "Kohima", + "KI": "Genopets KI", "KIAN": "Porta", "KIBA": "Kiba Inu", + "KIBSHI": "KiboShib", "KICK": "Kick", "KICKS": "GetKicks", "KIF": "KittenFinance", + "KIKO": "KIKO", + "KILLA": "The Bitcoin Killa", "KILLER": "Fat Cat Killer", + "KILLSOLANA": "KillSolana", "KILT": "KILT Protocol", "KIM": "King Money", + "KIMBO": "Kimbo", "KIMCHI": "KIMCHI.finance", "KIN": "Kin", "KIND": "Kind Ads", @@ -4540,23 +6610,42 @@ "KING": "KING", "KING93": "King93", "KINGB": "King Bean", + "KINGBONK": "King Bonk", + "KINGCAT": "King Cat", + "KINGDOG": "King Dog Inu", "KINGDOMQUEST": "Kingdom Quest", "KINGF": "King Finance", "KINGGROK": "King Grok", + "KINGPEPE": "KING PEPE", "KINGSHIB": "King Shiba", + "KINGSLERF": "King Slerf", + "KINGSORA": "King Sora", "KINGSWAP": "KingSwap", + "KINGTRUMP": "King Trump", "KINGU": "KINGU", + "KINGWIF": "King WIF", + "KINGY": "KINGYTON", + "KINIC": "Kinic", "KINT": "Kintsugi", + "KINU": "Kragger Inu", + "KIRA": "Kira the Injective Cat", "KIRBY": "Kirby Inu", + "KIRBYCEO": "Kirby CEO", + "KIRBYINU": "Kirby Inu", "KIRBYRELOADED": "Kirby Reloaded", "KIRO": "Kirobo", "KISC": "Kaiser", "KISHIMOTO": "Kishimoto Inu", "KISHU": "Kishu Inu", + "KIT": "Kitsune", "KITA": "KITA INU", "KITSU": "Kitsune Inu", + "KITTENS": "Kitten Coin", + "KITTENWIF": "KittenWifHat", + "KITTI": "KITTI TOKEN", "KITTY": "Kitty Inu", "KIWI": "kiwi", + "KIZUNA": "KIZUNA", "KKO": "Kineko", "KKT": "Kingdom Karnage", "KLAP": "Klap Finance", @@ -4565,52 +6654,82 @@ "KLD": "Koduck", "KLEE": "KleeKai", "KLEVA": "KLEVA Protocol", + "KLIMA": "KlimaDAO", "KLK": "Klickzie", "KLKS": "Kalkulus", "KLO": "Kalao", "KLON": "Klondike Finance", "KLP": "Kulupu", + "KLS": "Karlsen", "KLT": "Kamaleont", + "KLUB": "KlubCoin", "KLV": "Klever", "KMA": "Calamari Network", + "KMC": "Kitsumon", "KMD": "Komodo", "KML": "KinkyMilady", + "KMNO": "Kamino", "KMON": "Kryptomon", "KMX": "KiMex", + "KNB": "Kronobit Networks Blockchain", "KNC": "Kyber Network Crystal v2", "KNCL": "Kyber Network Crystal Legacy", + "KNDC": "KanadeCoin", + "KNDM": "Kingdom", + "KNDX": "Kondux", + "KNFT": "KStarNFT", "KNG": "BetKings", "KNGN": "KingN Coin", "KNIGHT": "Forest Knight", + "KNINE": "K9 Finance", + "KNJ": "Kunji Finance", + "KNOB": "KNOB", "KNOT": "Karmaverse", "KNOW": "KNOW", + "KNOX": "KnoxDAO", + "KNS": "Kenshi", "KNT": "Knekted", "KNTO": "Kento", "KNW": "Knowledge", "KOBE": "Shabu Shabu", "KOBO": "KoboCoin", + "KODA": "Koda Cryptocurrency", "KODACHI": "Kodachi Token", - "KOI": "Koi Network", + "KOGE": "BNB48 Club Token", + "KOGECOIN": "KogeCoin.io", + "KOI": "Koi", "KOIN": "Koinos", + "KOINB": "KoinBülteni Token", + "KOINETWORK": "Koi Network", "KOIP": "KoiPond", + "KOJI": "Koji", "KOK": "KOK Coin", "KOKO": "KokoSwap", "KOL": "Kollect", "KOLION": "Kolion", "KOM": "Kommunitas", + "KOMO": "Komoverse", "KOMP": "Kompass", + "KOMPETE": "KOMPETE", "KON": "KonPay", "KONG": "KONG", "KONO": "Konomi Network", + "KORA": "Kortana", "KORE": "KORE Vault", "KOREC": "Kore", + "KORRA": "KORRA", + "KOSS": "Koss", "KOTO": "Koto", + "KOY": "Koyo", "KOZ": "Kozjin", "KP3R": "Keep3rV1", "KP4R": "Keep4r", "KPAD": "KickPad", + "KPAPA": "KPAPA", "KPC": "KEEPs Coin", + "KPHI": "Kephi Gallery", "KPL": "Kepple", + "KPN": "KonnektVPN", "KPOP": "KPOP Coin", "KRAK": "Kraken", "KRATOS": "KRATOS", @@ -4618,32 +6737,39 @@ "KRC": "KRCoin", "KRD": "Krypton DAO", "KREDS": "KREDS", + "KREST": "krest Network", + "KRIDA": "KridaFans", "KRIPTO": "Kripto", "KRL": "Kryll", "KRM": "Karma", "KRN": "KRYZA Network", "KRO": "Betoken", "KROM": "Kromatika", + "KROME": "KROME Shares", "KRONE": "Kronecoin", "KRP": "Kryptoin", "KRRX": "Kyrrex", + "KRS": "Kingdom Raids", "KRT": "TerraKRW", "KRU": "Kingaru", "KRUGERCOIN": "KrugerCoin", "KRX": "RAVN Korrax", + "KRY": "Krypdraw", "KS2": "Kingdomswap", "KSC": "KStarCoin", "KSH": "Kahsh", "KSHIB": "Kilo Shiba Inu", - "KSK.BITCI": "Karsiyaka Taraftar Token", + "KSK": "Karsiyaka Taraftar Token", "KSM": "Kusama", "KSN": "KISSAN", "KSP": "KlaySwap Protocol", "KSS": "Krosscoin", "KST": "StarKST", - "KSTT.BITCI": "Kocaelispor Fan Token", + "KSTT": "Kocaelispor Fan Token", + "KSWAP": "KyotoSwap", "KSYS": "K-Systems", "KT": "Kuai Token", + "KTC": "KTX.Finance", "KTK": "KryptCoin", "KTN": "Kattana", "KTO": "Kounotori", @@ -4659,17 +6785,23 @@ "KUJI": "Kujira", "KUKU": "KuKu", "KUMA": "Kuma Inu", + "KUMU": "Kumu Finance", + "KUNAI": "KunaiKash", "KUNCI": "Kunci Coin", "KUR": "Kuro", + "KURO": "Kurobi", "KURT": "Kurrent", + "KUS": "KuSwap", "KUSA": "Kusa Inu", "KUSD": "Kowala", "KUSH": "KushCoin", + "KUSUNOKI": "Kusunoki Samurai", "KUV": "Kuverit", "KVERSE": "KEEPs Coin", "KVI": "KVI Chain", "KVNT": "KVANT", "KVT": "Kinesis Velocity Token", + "KWAI": "KWAI", "KWATT": "4New", "KWD": "KIWI DEFI", "KWENTA": "Kwenta", @@ -4677,75 +6809,107 @@ "KWIK": "KwikSwap", "KWS": "Knight War Spirits", "KWT": "Kawaii Island", + "KXA": "Kryxivia", + "KXC": "KingXChain", "KXUSD": "kxUSD", "KYCC": "KYCCOIN", "KYL": "Kylin Network", "KYOKO": "Kyoko", "KYTE": "Kambria Yield Tuning Engine", + "KYUB": "Kyuubi", "KYVE": "KYVE Network", "KZC": "KZCash", "KZEN": "Kaizen", "L": "L inu", "L2": "Leverj Gluon", + "L2DAO": "Layer2DAO", "L3P": "Lepricon", + "L3USD": "L3USD", "L7": "L7", "LA": "LATOKEN", "LAB": "Labrys", "LABRA": "LabraCoin", "LABS": "LABS Group", "LABX": "Stakinglab", + "LABZ": "Insane Labz", "LACCOIN": "LocalAgro", "LACE": "Lovelace World", + "LAD": "LADA", + "LADA": "LadderCaster", + "LADYF": "Milady Wif Hat", "LADYS": "Milady Meme Coin", "LAEEB": "LaEeb", "LAELAPS": "Laelaps", "LAI": "LayerAI", "LAIKA": "Laika Protocol", + "LAINESOL": "Laine Staked SOL", + "LAKE": "Data Lake", "LALA": "LaLa World", "LAMB": "Lambda", + "LAMBO": "LAMBO", + "LAN": "Lanify", "LANA": "LanaCoin", "LANC": "Lanceria", "LAND": "Landshare", "LANDB": "LandBox", "LANDS": "Two Lands", + "LANDV1": "Landshare v1", + "LANDWOLF": "LANDWOLF", + "LANDWU": "LandWu", "LANE": "LaneAxis", "LAO": "LC Token", + "LAPI": "Lapis Inu", + "LAPTOP": "Hunter Biden's Laptop", "LAR": "LinkArt", "LARIX": "Larix", + "LARO": "Anito Legends", + "LARR": "larrywifhat", + "LARRY": "LarryCoin", "LAS": "LNAsolution Coin", "LAT": "PlatON Network", + "LATOM": "Liquid ATOM", "LATTE": "LatteSwap", "LATX": "Latium", + "LAUGHCOIN": "Laughcoin", "LAUNCH": "Launchblock.com", "LAVA": "Lavaswap", "LAVAX": "LavaX Labs", + "LAVE": "Lavandos", + "LAVITA": "Lavita AI", "LAW": "Law Token", "LAX": "LAPO", "LAYER": "UniLayer", "LAZ": "Lazarus", "LAZIO": "Lazio Fan Token", + "LB": "LoveBit", "LBA": "Cred", "LBC": "LBRY Credits", "LBK": "LBK", "LBL": "LABEL Foundation", "LBLOCK": "Lucky Block", + "LBM": "Libertum", "LBR": "LaborCrypto", + "LBT": "Law Blocks", "LBTC": "LiteBitcoin", "LBXC": "LUX BIO EXCHANGE COIN", "LC": "Lotus Capital", "LC4": "LEOcoin", "LCASH": "LitecoinCash", "LCC": "LitecoinCash", + "LCD": "Lucidao", "LCG": "LCG", "LCK": "Luckbox", "LCMG": "ElysiumG", "LCMS": "LCMS", "LCP": "Litecoin Plus", "LCR": "Lucre", + "LCRO": "Liquid CRO", "LCS": "LocalCoinSwap", + "LCSN": "Lacostoken", "LCT": "LendConnect", "LCWP": "LiteCoinW Plus", "LCX": "LCX", + "LD": "Long Dragon", "LDC": "LeadCoin", "LDFI": "LenDeFi Token", "LDM": "Ludum token", @@ -4753,6 +6917,8 @@ "LDO": "Lido DAO", "LDOGE": "LiteDoge", "LDX": "Litedex", + "LDXG": "LondonCoinGold", + "LDZ": "Voodoo Token", "LEA": "LeaCoin", "LEAD": "Lead Wallet", "LEAF": "LeafCoin", @@ -4761,17 +6927,23 @@ "LEASH": "Doge Killer", "LED": "LEDGIS", "LEDU": "Education Ecosystem", + "LEE": "Love Earn Enjoy", "LEET": "LeetSwap", "LEG": "Legia Warsaw Fan Token", "LEGO": "Lego Coin", + "LEIA": "Leia", "LELE": "Lelecoin", "LEMC": "LemonChain", "LEMD": "Lemond", + "LEMN": "LEMON", "LEMO": "LemoChain", "LEMON": "LemonCoin", "LEN": "Liqnet", + "LENARD": "Lenard", "LEND": "Aave", "LENDA": "Lenda", + "LENDS": "Lends", + "LENFI": "Lenfi", "LENIN": "LeninCoin", "LEO": "LEO Token", "LEOPARD": "Leopard", @@ -4779,71 +6951,108 @@ "LEOX": "Galileo", "LEPA": "Lepasa", "LEPEN": "LePenCoin", + "LESBIAN": "Lesbian Inu", "LESS": "Less Network", "LESSF": "LessFnGas", "LET": "LinkEye", + "LETIT": "Letit", + "LETSGO": "Lets Go Brandon", "LEU": "CryptoLEU", "LEV": "Levante U.D. Fan Token", + "LEVE": "Leve Invest", + "LEVELG": "LEVELG", "LEVER": "LeverFi", + "LEVERA": "LeverageInu", "LEVERJ": "Leverj", "LEVL": "Levolution", "LEX": "Elxis", "LEXI": "LEXIT", "LEZ": "Peoplez", + "LEZGI": "LEZGI Token", "LF": "Linkflow", "LFC": "BigLifeCoin", "LFG": "Gamerse", + "LFGO": "Lets Fuckin Go", + "LFI": "LunaFi", + "LFNTY": "Lifinity", "LFT": "Lend Flare Dao", "LFW": "Linked Finance World", + "LGBT": "Let's Go Brandon Token", "LGBTQ": "LGBTQoin", + "LGC": "LiveGreen Coin", "LGCY": "LGCY Network", "LGD": "Legends Cryptocurrency", "LGO": "Legolas Exchange", + "LGOLD": "LYFE GOLD", "LGOT": "LGO Token", "LGR": "Logarithm", "LGX": "Legion Network", "LHB": "Lendhub", "LHC": "LHCoin", "LHD": "LitecoinHD", + "LHINU": "Love Hate Inu", "LHT": "LHT Coin", "LIB": "Libellum", "LIBERA": "Libera Financial", "LIBERO": "Libero Financial", + "LIBERTA": "The Libertarian Dog", "LIBFX": "Libfx", + "LIBRA": "0L Network", + "LIBRAP": "Libra Protocol", "LIBRE": "Libre", "LIC": "Ligercoin", + "LICK": "PetLFG", + "LICO": "Liquid Collectibles", "LID": "Liquidity Dividends Protocol", "LIDER": "Lider Token", "LIEN": "Lien", "LIF": "Winding Tree", + "LIF3": "LIF3", "LIFE": "Life Crypto", + "LIFEBIRD": "LIFEBIRD", "LIFETOKEN": "LIFE", + "LIFT": "Uplift", "LIGER": "Ligercoin", "LIGHT": "LightChain", + "LIGMA": "Ligma Node", "LIGO": "Ligo", "LIKE": "Only1", "LIKEC": "LikeCoin", + "LILA": "LiquidLayer", "LILFLOKI": "Lil Floki", + "LILPUMP": "lilpump", "LIME": "iMe Lab", + "LIMEX": "Limestone Network", + "LIMO": "Limoverse", "LIMX": "LimeCoinX", "LINA": "Linear", "LINANET": "Lina", "LINDA": "Metrix", "LINDACEO": "LindaYacc Ceo", + "LINEA": "Linea", "LING": "Lingose", "LINK": "Chainlink", "LINKA": "LINKA", "LINKC": "LINKCHAIN", + "LINKFI": "LinkFi", + "LINQ": "LINQ", + "LINSPIRIT": "linSpirit", "LINU": "Luna Inu", "LINX": "Linx", "LION": "Lion Token", "LIPC": "LIpcoin", "LIPS": "LipChain", "LIQ": "LIQ Protocol", + "LIQD": "Liquid Finance", + "LIQR": "Topshelf Finance", "LIQUI": "Liquidus", "LIR": "Let it Ride", + "LIS": "Realis Network", + "LISA": "Lisa Simpson", "LIST": "KList Protocol", + "LISTA": "Lista DAO", "LIT": "Litentry", + "LITE": "Lite USD", "LITENETT": "Litenett", "LITH": "Lithium Finance", "LITHIUM": "Lithium", @@ -4855,23 +7064,30 @@ "LIVESTARS": "Live Stars", "LIXX": "Libra Incentix", "LIZ": "Lizus Payment", + "LIZA": "Liza", + "LIZARD": "LIZARD", + "LIZD": "Dancing Lizard Coin", "LK": "Liker", "LK7": "Lucky7Coin", "LKC": "LuckyCoin", "LKD": "LinkDao", - "LKK": "Lykke", + "LKI": "Laika AI", "LKN": "LinkCoin Token", + "LKSM": "Liquid KSM", "LKT": "Locklet", "LKU": "Lukiu", "LKY": "LuckyCoin", + "LL": "LightLink", "LLAND": "Lyfe Land", "LLG": "Loligo", "LLION": "Lydian Lion", + "LLT": "LILLIUS", "LM": "LeisureMeta", "LMAO": "LMAO Finance", "LMC": "LomoCoin", "LMCH": "Latamcash", "LMCSWAP": "LimoCoin SWAP", + "LMEOW": "lmeow", "LMR": "Lumerin", "LMT": "Lympo Market Token", "LMTOKEN": "LM Token", @@ -4881,76 +7097,119 @@ "LN": "LINK", "LNC": "Blocklancer", "LND": "Lendingblock", + "LNDRR": "Lendr Network", + "LNDRY": "LNDRY", + "LNDX": "LandX Finance", "LNK": "Ethereum.Link", "LNKC": "Linker Coin", "LNL": "LunarLink", + "LNQ": "LinqAI", "LNR": "Lunar", "LNT": "Lottonation", "LNX": "Lunox Token", "LOA": "League of Ancients", + "LOAF": "LOAF CAT", "LOAN": "Lendoit", + "LOBO": "LOBO•THE•WOLF•PUP", "LOBS": "Lobstex", "LOC": "LockTrip", + "LOCAT": "LOVE CAT", "LOCC": "Low Orbit Crypto Cannon", "LOCG": "LOCGame", "LOCI": "LociCoin", "LOCK": "Contracto", "LOCO": "Loco", + "LOCOM": "Locomotir", "LOCUS": "Locus Chain", + "LODE": "Lodestar", + "LOE": "Legends of Elysium", "LOF": "Land of Fantasy", + "LOFI": "LOFI", "LOG": "Wood Coin", + "LOGO": "LOGOS", "LOIS": "Lois Token", "LOKA": "League of Kingdoms", "LOKR": "Polkalokr", "LOL": "EMOGI Network", + "LOLA": "Lola", "LOLC": "LOL Coin", "LON": "Tokenlon", "LONG": "Longdrink Finance", + "LONGFU": "LONGFU", + "LONGSHINE": "LongShine", "LOOK": "LookCoin", "LOOKS": "LooksRare", "LOOM": "Loom Network", + "LOOMV1": "Loom Network v1", "LOON": "Loon Network", + "LOONG": "PlumpyDragons", "LOOP": "LOOP", + "LOOPY": "Loopy", "LOOT": "LootBot", "LOOTEX": "Lootex", + "LOPES": "Leandro Lopes", "LORD": "MEMELORD", "LORDS": "LORDS", "LORDZ": "Meme Lordz", + "LORGY": "Memeolorgy", + "LORY": "Yield Parrot", + "LOS": "Lord Of SOL", + "LOST": "Lost Worlds", "LOT": "Lukki Operating Token", "LOTES": "Loteo", "LOTEU": "Loteo", + "LOTT": "Beauty bakery lott", "LOTTO": "LottoCoin", + "LOTTY": "Lotty", + "LOTUS": "The White Lotus", "LOUD": "Loud Market", "LOV": "LoveChain", "LOVE": "Deesse", - "LOVELY": "Lovely Inu finance", + "LOVELY": "Lovely finance", + "LOVELYV1": "Lovely Inu Finance", + "LOVESNOOPY": "I LOVE SNOOPY", "LOWB": "Loser Coin", + "LOWQ": "lowq frends", "LOX": "Lox Network", + "LOYAL": "Loyalty Labs", "LP": "Liquid Protocol", "LPC": "Little Phil", "LPI": "LPI DAO", "LPK": "Kripton", + "LPL": "LinkPool", "LPNT": "Luxurious Pro Network Token", "LPOOL": "Launchpool", "LPT": "Livepeer", + "LPV": "Lego Pepe Vision", "LPY": "LeisurePay", + "LQ": "Liqwid Finance", "LQ8": "Liquid8", "LQBTC": "Liquid Bitcoin", "LQD": "Liquid", "LQDN": "Liquidity Network", "LQDR": "LiquidDriver", + "LQDX": "Liquid Crypto", "LQR": "Laqira Protocol", "LQTY": "Liquity", "LRC": "Loopring", "LRG": "Largo Coin", "LRN": "Loopring [NEO]", + "LSC": "LS Coin", "LSD": "LightSpeedCoin", + "LSDOGE": "LSDoge", "LSETH": "Liquid Staked ETH", + "LSHARE": "LSHARE", + "LSILVER": "Lyfe Silver", "LSK": "Lisk", "LSP": "Lumenswap", "LSPHERE": "Lunasphere", + "LSR": "LaserEyes", "LSS": "Lossless", "LST": "Lendroid Support Token", + "LSTAR": "Learning Star", + "LSTV1": "Lovely Swap Token", + "LSV": "Litecoin SV", + "LSWAP": "LoopSwap", "LT": "Loctite Assets Token", "LTA": "Litra", "LTB": "Litebar", @@ -4974,31 +7233,44 @@ "LTK": "LinkToken", "LTNM": "Bitcoin Latinum", "LTO": "LTO Network", + "LTOV1": "LTO Network v1", + "LTOV2": "LTO Network v2", "LTPC": "Lightpaycoin", "LTR": "LogiTron", "LTRBT": "Little Rabbit", "LTS": "Litestar Coin", + "LTT": "LocalTrade", "LTX": "Lattice Token", "LTZ": "Litecoinz", "LUA": "Lua Token", + "LUAUSD": "Lumi Finance", + "LUBE": "Joe Lube Coin", "LUC": "Play 2 Live", "LUCA": "LUCA", + "LUCHOW": "LunaChow", "LUCK": "Lucky Cat", "LUCKY": "Lucky Lion", "LUCKYB": "LuckyBlocks", + "LUCKYS": "LuckyStar", + "LUCKYSLP": "LuckysLeprecoin", "LUCY": "Lucy", "LUDO": "Ludo", "LUFC": "Leeds United Fan Token", "LUFFY": "Luffy", "LUFFYG": "Luffy G5", "LUFFYOLD": "Luffy", + "LUIGI": "Luigi Inu", + "LUIS": "Tongue Cat", + "LULU": "LULU", "LUM": "Illuminates", "LUMA": "LUMA Token", "LUMI": "LUMI Credits", "LUN": "Lunyr", "LUNA": "Terra", + "LUNAR": "Lunar", "LUNAT": "Lunatics", "LUNC": "Terra Classic", + "LUNCARMY": "LUNCARMY", "LUNCH": "LunchDAO", "LUNE": "Luneko", "LUNES": "Lunes", @@ -5007,15 +7279,20 @@ "LUPIN": "LUPIN", "LUS": "Luna Rush", "LUSD": "Liquity USD", + "LUSH": "Lush AI", "LUT": "Cinemadrom", "LUTETIUM": "Lutetium Coin", "LUX": "LUXCoin", "LUXO": "Luxo", + "LUXU": "Luxury Travel Token", "LUXY": "Luxy", "LVG": "Leverage Coin", "LVIP": "Limitless VIP", + "LVL": "Level", + "LVM": "LakeViewMeta", "LVN": "LivenPay", "LVX": "Level01", + "LWC": "Linework Coin", "LWF": "Local World Forwarders", "LX": "Moonlight", "LXC": "LibrexCoin", @@ -5024,6 +7301,7 @@ "LXTO": "LuxTTO", "LYB": "LyraBar", "LYC": "LycanCoin", + "LYF": "Lillian Token", "LYFE": "Lyfe", "LYK": "Loyakk Vega", "LYL": "LoyalCoin", @@ -5033,46 +7311,96 @@ "LYNX": "Lynx", "LYO": "LYO Credit", "LYQD": "eLYQD", + "LYR": "Lyra", "LYRA": "Lyra", "LYTX": "LYTIX", + "LYUM": "Layerium", + "LYVE": "Lyve Finance", "LYX": "LUKSO", "LYXE": "LUKSO", + "LYZI": "Lyzi", "LZ": "LaunchZone", + "LZM": "LoungeM", + "M": "MetaVerse-M", "M1": "SupplyShock", "M2O": "M2O Token", + "M87": "MESSIER", + "MAAL": "Maal Chain", + "MABA": "Make America Based Again", "MAC": "MachineCoin", + "MACHO": "macho", + "MADA": "MilkADA", "MADANA": "MADANA", "MADC": "MadCoin", "MADOG": "MarvelDoge", + "MADPEPE": "Mad Pepe", "MAEP": "Maester Protocol", + "MAF": "MetaMAFIA", "MAG": "Magnet", + "MAGA": "MAGA Hat", + "MAGA2024": "MAGA2024", + "MAGAA": "MAGA AGAIN", + "MAGACAT": "MAGACAT", + "MAGADOGE": "MAGA DOGE", + "MAGAIBA": "Magaiba", + "MAGANOMICS": "Maganomics", + "MAGAPEPE": "MAGA PEPE", + "MAGASHIB": "MAGA SHIB", + "MAGATRUMP": "MAGA Trump", + "MAGE": "MetaBrands", "MAGIC": "Magic", "MAGICF": "MagicFox", + "MAGICK": "Cosmic Universe Magick", + "MAGICV": "Magicverse", + "MAGIK": "Magik Finance", + "MAGNET": "Yield Magnet", "MAHA": "MahaDAO", "MAI": "Mindsync", + "MAIA": "Maia", "MAID": "MaidSafe Coin", "MAIL": "CHAINMAIL", + "MAINSTON": "Ston", + "MAJO": "Majo", "MAKE": "MAKE", "MAKI": "MakiSwap", + "MALGO": "milkALGO", + "MALL": "Metamall", "MALLY": "Malamute Finance", + "MAMAI": "MammothAI", + "MAMBA": "Mamba", "MAN": "Matrix AI Network", "MANA": "Decentraland", "MANC": "Mancium", "MANDALA": "Mandala Exchange Token", "MANDOX": "MandoX", + "MANE": "MANE", + "MANEKI": "MANEKI", "MANGA": "Manga Token", + "MANIA": "ScapesMania", "MANNA": "Manna", + "MANORUKA": "ManoRuka", + "MANT": "Mantle USD", "MANTA": "Manta Network", "MANTLE": "Mantle", + "MANYU": "Little Manyu", + "MAO": "Mao", "MAP": "MAP Protocol", "MAPC": "MapCoin", "MAPE": "Mecha Morphing", "MAPR": "Maya Preferred 223", "MAPS": "MAPS", + "MAR3": "Mar3 AI", + "MARCO": "MELEGA", + "MARE": "Mare Finance", + "MARGA": "Margaritis", "MARGINLESS": "Marginless", "MARI": "MarijuanaCoin", + "MARIC": "Maricoin", + "MARIO": "MARIO CEO", "MARK": "Benchmark Protocol", + "MARKE": "Market Ledger", "MARLEY": "Marley Token", + "MARMAJ": "marmaj", "MARO": "Maro", "MARS": "MarsCoin", "MARS4": "MARS4", @@ -5080,53 +7408,82 @@ "MARSH": "Unmarshal", "MARSRISE": "MarsRise", "MARSUPILAMI": "MARSUPILAMI INU", + "MARSW": "Marswap", "MART": "Monart", + "MARTIA": "Colonize Mars", "MARTK": "Martkist", + "MARTY": "Marty Inu", + "MARU": "marumaruNFT", "MARV": "Marvelous", + "MARVIN": "Marvin", + "MARVINB": "Marvin on Base", "MARX": "MarX", "MARXCOIN": "MarxCoin", "MARYJ": "MaryJane Coin", "MAS": "Midas Protocol", + "MASA": "Masa", "MASK": "Mask Network", "MASP": "Market.space", "MASQ": "MASQ", "MASS": "MASS", + "MASSA": "Massa", "MASTER": "Mastercoin", "MASTERCOIN": "MasterCoin", "MASTERMINT": "MasterMint", + "MASYA": "MASYA", "MAT": "MiniApps", + "MATA": "Ninneko", + "MATAR": "MATAR AI", + "MATCH": "Matching Game", "MATE": "Mate", "MATH": "MATH", "MATIC": "Polygon", + "MATICX": "Stader MaticX", "MATPAD": "MaticPad", - "MATTER": "AntiMatter", + "MATRIX": "Matrix Labs", + "MAU": "MAU", + "MAUW": "MAUW", "MAV": "Maverick Protocol", + "MAVIA": "Heroes of Mavia", "MAW": "Mountain Sea World", "MAX": "MaxCoin", + "MAXI": "Maximus", + "MAXL": "Maxi protocol", "MAXR": "Max Revive", + "MAXX": "MAXX Finance", "MAY": "Theresa May Coin", "MAYACOIN": "MayaCoin", + "MAYP": "Maya Preferred", "MAZC": "MyMazzu", + "MAZI": "MaziMatic", + "MAZZE": "Mazze", "MB": "MineBee", + "MB4": "Matthew Box 404", "MB8": "MB8 Coin", + "MBAPEPE": "MBAPEPE", "MBASE": "Minebase", + "MBC": "MicroBitcoin", "MBCASH": "MBCash", "MBCC": "Blockchain-Based Distributed Super Computing Platform", + "MBD": "MBD Financials", + "MBE": "MxmBoxcEus Token", "MBET": "MoonBet", "MBF": "MoonBear.Finance", "MBI": "Monster Byte Inc", "MBIT": "Mbitbooks", "MBL": "MovieBloc", "MBLC": "Mont Blanc", + "MBLK": "Magical Blocks", "MBM": "MobileBridge Momentum", "MBN": "Mobilian Coin", "MBONK": "megaBonk", + "MBOT": "MoonBot", "MBOX": "MOBOX", + "MBOYS": "MoonBoys", "MBP": "MobiPad", "MBRS": "Embers", "MBS": "MonkeyBall", "MBT": "Metablackout", - "MBTC": "MicroBitcoin", "MBTX": "MinedBlock", "MBX": "Marblex", "MC": "Merit Circle", @@ -5139,42 +7496,62 @@ "MCB": "MCDEX", "MCC": "Magic Cube Coin", "MCD": "CDbio", + "MCELO": "Moola Celo", + "MCEUR": "Moola Celo EUR", "MCF": "MCFinance", "MCG": "MicroChains Gov Token", "MCH": "Meconcash", "MCHC": "My Crypto Heroes", "MCI": "Musiconomi", + "MCIV": "Mars Civ Project", + "MCL": "McLaren F1", + "MCLB": "Millennium Club Coin", "MCN": "mCoin", "MCO": "Crypto.com", "MCO2": "Moss Carbon Credit", + "MCOIN": "MCOIN", "MCONTENT": "MContent", "MCP": "My Crypto Play", + "MCPC": "Mobile Crypto Pay Coin", "MCRC": "MyCreditChain", "MCRN": "MacronCoin", "MCRT": "MagicCraft", "MCS": "MCS Token", "MCT": "MyConstant", + "MCTP": "Metacraft", "MCU": "MediChain", + "MCUSD": "Moola Celo USD", "MCV": "MCV Token", "MDA": "Moeda", + "MDAI": "MindAI", "MDAO": "MarsDAO", "MDB": "Million Dollar Baby", "MDC": "MedicCoin", "MDCL": "Medicalchain", + "MDDN": "Modden", "MDF": "MatrixETF", "MDH": "Telemedicoin", + "MDI": "Medicle", "MDICE": "Multidice", "MDM": "Medium", "MDN": "Modicoin", + "MDR": "Mudra MDR", "MDS": "MediShares", "MDT": "Measurable Data Token", + "MDTK": "MDtoken", "MDU": "MDUKEY", + "MDUS": "MEDIEUS", "MDX": "Mdex (BSC)", "MDXH": "Mdex (HECO)", "ME": "All.me", "MEAN": "Meanfi", + "MEB": "Meblox Protocol", "MEC": "MegaCoin", + "MECH": "Mech Master", + "MECHA": "Mechanium", + "MECI": "Meta Game City", "MED": "Medibloc", + "MEDAMON": "Medamon", "MEDI": "MediBond", "MEDIA": "Media Network", "MEDIC": "MedicCoin", @@ -5182,95 +7559,155 @@ "MEDIT": "MediterraneanCoin", "MEE": "Medieval Empires", "MEED": "Meeds DAO", + "MEER": "Qitmeer Network", "MEET": "CoinMeet", "MEETONE": "MEET.ONE", + "MEF": "MEFLEX", "MEFA": "Metaverse Face", "MEGA": "MegaFlash", "MEGABOT": "Megabot", + "MEGAHERO": "MEGAHERO", + "MEGALAND": "Metagalaxy Land", + "MEGALANDV1": "Metagalaxy Land v1", + "MEGE": "MEGE", + "MEH": "meh", "MEL": "MELX", + "MELANIA": "Melania Trump", "MELB": "Minelab", - "MELD": "Melodity", + "MELD": "MELD", "MELI": "Meli Games", "MELLO": "Mello Token", + "MELLSTROY": "MELLSTROY", + "MELO": "Melo Token", + "MELODITY": "Melodity", + "MELON": "cocomELON", "MELOS": "Melos Studio", "MELT": "Defrost Finance", "MEM": "Memecoin", + "MEMAGX": "Meta Masters Guild Games", + "MEMD": "MemeDAO", "MEME": "Memecoin", "MEMEAI": "Meme Ai", + "MEMECUP": "Meme Cup", + "MEMEETF": "Meme ETF", + "MEMEFI": "MemeFi", "MEMEINU": "Meme Inu", + "MEMEME": "MEMEME", + "MEMEMINT": "MEME MINT", + "MEMEMUSK": "MEME MUSK", + "MEMERUNE": "MEME•ECONOMICS", + "MEMES": "MemeCoinDAO", "MEMET": "MEMETOON", "MEMETIC": "Memetic", "MEMORYCOIN": "MemoryCoin", + "MENDI": "Mendi Finance", "MENGO": "Flamengo Fan Token", "MENLO": "Menlo One", "MEOW": "Zero Tech", + "MEOWG": "MeowGangs", + "MEOWIF": "Meowifhat", + "MEOWM": "Meow Meow Coin", + "MEOWME": "MEOW MEOW", "MEPAD": "MemePad", "MER": "Mercurial Finance", "MERCE": "MetaMerce", "MERCU": "Merculet", "MERCURY": "Mercury", + "MEREDITH": "Taylor Swift's Cat MEREDITH", + "MERGE": "Merge", "MERI": "Merebel", "MERIDIAN": "Meridian Network LOCK", "MERKLE": "Merkle Network", + "MERL": "Merlin Chain", + "MERY": "Mistery On Cro", "MESA": "MetaVisa", "MESG": "MESG", "MESH": "MeshBox", "MESSI": "MESSI COIN", + "MESSU": "Loinel Messu", "MET": "Metronome", "META": "Metadium", + "METABOT": "Robot Warriors", "METAC": "Metacoin", "METACAT": "MetaCat", "METACR": "Metacraft", + "METADOGE": "MetaDoge", "METADOGEV2": "MetaDoge V2", "METAF": "MetaFastest", "METAG": "MetagamZ", + "METAGEAR": "MetaGear", "METAL": "Metal Blockchain", "METALCOIN": "MetalCoin", "METAMEME": "met a meta metameme", + "METAMUSK": "Musk Metaverse", "METAN": "Metan Evolutions", + "METANO": "Metano", "METAPK": "Metapocket", + "METAQ": "MetaQ", "METAS": "Metaseer", "METAT": "MetaTrace", + "METAUFO": "MetaUFO", "METAV": "MetaVPad", "METAVIE": "Metavie", + "METAW": "MetaWorth", "METAX": "MetaverseX", "METEOR": "Meteorite Network", "METER": "Meter Stable", "METFI": "MetFi", - "METH": "Farming Bad", + "METH": "Mantle Staked Ether", "METI": "Metis", "METIS": "Metis Token", "METM": "MetaMorph", "METO": "Metafluence", "METOLD": "Metronome", + "METRO": "Metropoly", + "MEU": "MetaUnit", "MEV": "MEVerse", "MEVR": "Metaverse VR", + "MEW": "cat in a dogs world", + "MEWC": "Meowcoin", + "MEWING": "MEWING", + "MEWSWIFHAT": "cats wif hats in a dogs world", "MEWTWO": "Mewtwo Inu", "MEX": "MEX", "MEXC": "MEXC Token", "MEXP": "MOJI Experience Points", + "MEZZ": "MEZZ Token", "MF": "MetaFighter", "MF1": "Meta Finance", + "MFAM": "Moonwell Apollo", "MFC": "MFCoin", + "MFER": "mfercoin", + "MFERS": "MFERS", + "MFET": "MultiFunctional Environmental Token", "MFG": "SyncFab", "MFI": "Marginswap", + "MFO": "Moonfarm Finance", + "MFPS": "Meta FPS", "MFS": "Moonbase File System", "MFT": "Hifi Finance (Old)", + "MFTU": "Mainstream For The Underground", "MFUND": "Memefund", "MFX": "MFChain", "MG": "MinerGate Token", "MGAMES": "Meme Games", + "MGAR": "Metagame Arena", + "MGC": "Meta Games Coin", "MGD": "MassGrid", "MGG": "MetaGaming Guild", "MGGT": "Maggie Token", + "MGKL": "MAGIKAL.ai", + "MGLC": "MetaverseMGL", "MGLD": "Metallurgy", "MGN": "MagnaCoin", "MGO": "MobileGo", + "MGOD": "MetaGods", "MGP": "MangoChain", "MGPT": "MotoGP Fan Token", "MGT": "Megatech", "MGUL": "Mogul Coin", "MGX": "MargiX", + "MHAM": "Metahamster", "MHC": "MetaHash", "MHLX": "HelixNetwork", "MHP": "MedicoHealth", @@ -5282,21 +7719,33 @@ "MIBO": "miBoodle", "MIBR": "MIBR Fan Token", "MIC": "Mithril Cash", + "MICE": "Mice", + "MICHI": "michi", + "MICKEY": "Steamboat Willie", + "MICRO": "Micromines", + "MIDAI": "Midway AI", "MIDAS": "Midas", "MIDASDOLLAR": "Midas Dollar Share", "MIDN": "Midnight", "MIE": "MIE Network", + "MIF": "monkeywifhat", "MIG": "Migranet", + "MIGMIG": "MigMig Swap", + "MIIDAS": "Miidas NFT", "MIININGNFT": "MiningNFT", "MIKS": "MIKS COIN", "MIL": "Milllionaire Coin", + "MILE": "milestoneBased", "MILEI": "MILEI", "MILK": "Milkshake Swap", "MILK2": "Spaceswap MILK2", "MILKYWAY": "MilkyWayZone", "MILLI": "Million", + "MILLY": "milly", "MILO": "Milo Inu", + "MILOCEO": "Milo CEO", "MILOCOIN": "MiloCoin", + "MILODOG": "MILO DOG", "MIM": "Magic Internet Money", "MIMATIC": "MAI", "MIMI": "MIMI Money", @@ -5305,17 +7754,27 @@ "MIN": "MINDOL", "MINA": "Mina Protocol", "MINC": "MinCoin", + "MIND": "Morpheus Labs", "MINDCOIN": "MindCoin", "MINDEX": "Mindexcoin", "MINDGENE": "Mind Gene", "MINDS": "Minds", "MINE": "SpaceMine", + "MINER": "MINER", + "MINERALS": "Minerals Coin", + "MINES": "MINESHIELD", + "MINETTE": "Vibe Cat", "MINEX": "Minex", "MINI": "Mini", + "MINIBNBTIGER": "MiniBNBTiger", "MINIDOGE": "MiniDOGE", "MINIFOOTBALL": "Minifootball", + "MINIMYRO": "Mini Myro", + "MINION": "Minions INU", + "MINIPEPE": "MiniPepe", "MINISHIB": "miniSHIB ETH", - "MINRL": "Minerals Coin", + "MINO": "MINO INU", + "MINS": "Minswap", "MINT": "Mint Club", "MINTCOIN": "MintCoin", "MINTME": "MintMe.com Coin", @@ -5327,6 +7786,7 @@ "MIODIO": "MIODIOCOIN", "MIOTA": "IOTA", "MIR": "Mirror Protocol", + "MIRA": "Chains of War", "MIRACLE": "MIRACLE", "MIRC": "MIR COIN", "MIS": "Mithril Share", @@ -5335,44 +7795,68 @@ "MISHKA": "Mishka Token", "MISS": "MISS", "MIST": "Mist", + "MISTRAL": "Mistral AI", "MIT": "Galaxy Blitz", "MITC": "MusicLife", "MITH": "Mithril", + "MITHRIL": "CLIMBERS", + "MITTENS": "Mittens", "MITX": "Morpheus Infrastructure Token", "MIV": "MakeItViral", + "MIVA": "Minerva Wallet", "MIVRS": "Minionverse", "MIX": "MIXMARVEL", + "MIXAI": "Mixcash AI", "MIXCOIN": "Mixaverse", + "MIXER": "TON Mixer", + "MIY": "Icel Idman Yurdu Token", "MJT": "MojitoSwap", + "MK": "Meme Kombat", + "MKC": "Meta Kongz", "MKEY": "MEDIKEY", + "MKONG": "MEME KONG", "MKR": "Maker", + "MKT": "MikeToken", "MKUSD": "Prisma mkUSD", - "ML": "Market Ledger", + "ML": "Mintlayer", "MLA": "Moola", + "MLD": "MonoLend", "MLGC": "Marshal Lion Group Coin", "MLITE": "MeLite", "MLK": "MiL.k", "MLN": "Enzyme", + "MLNK": "Malinka", + "MLOKY": "MLOKY", "MLS": "CPROP", "MLT": "MIcro Licensing Coin", "MLTC": "MultiWallet Coin", + "MLTPX": "MoonLift Capital", + "MLXC": "Marvellex Classic", "MM": "Millimeter", + "MMA": "Meme Alliance", "MMAI": "MetamonkeyAi", + "MMAON": "MMAON", "MMAPS": "MapMetrics", "MMC": "Monopoly Millionaire Control", "MMETA": "Duckie Land Multi Metaverse", "MMF": "MMFinance", "MMG": "Monopoly Millionaire Game", + "MMIP": "Memes Make It Possible", + "MMIT": "MangoMan Intelligent", "MMNXT": "MMNXT", "MMO": "MMOCoin", "MMPRO": "Market Making Pro", "MMS": "Marsverse", + "MMSC": "MMSC PLATFORM", + "MMSS": "MMSS (Ordinals)", "MMT": "Master MIX Token", "MMTM": "Momentum", "MMUI": "MetaMUI", "MMVG": "MEMEVENGERS", + "MMX": "MMX", "MMXIV": "MaieutiCoin", "MMXVI": "MMXVI", + "MMY": "Mummy Finance", "MN": "Cryptsy Mining Contract", "MNB": "MoneyBag", "MNC": "MainCoin", @@ -5385,14 +7869,19 @@ "MNFTS": "Marvelous NFTs", "MNG": "Moon Nation Game", "MNGO": "Mango protocol", + "MNI": "Map Node", "MNM": "Mineum", "MNR": "Mineral", "MNRB": "MoneyRebel", + "MNRCH": "Monarch", "MNS": "Monnos", "MNST": "MoonStarter", + "MNTA": "MantaDAO", "MNTC": "Manet Coin", "MNTG": "Monetas", + "MNTIS": "Mantis", "MNTL": "AssetMantle", + "MNTO": "Minato", "MNTP": "GoldMint", "MNV": "MonetaVerde", "MNVM": "Novam", @@ -5406,23 +7895,39 @@ "MOAT": "Mother Of All Tokens", "MOB": "MobileCoin", "MOBI": "Mobius", + "MOBIC": "Mobility Coin", "MOBIE": "MobieCoin", "MOBILE": "Helium Mobile", "MOBU": "MOBU", + "MOBX": "MOBIX", + "MOBY": "Moby", "MOC": "Mossland", "MOCHI": "Mochiswap", + "MOCHICAT": "MochiCat", "MOCO": "MoCo", "MOD": "Modefi", "MODA": "MODA DAO", + "MODAI": "Modai", + "MODC": "Modclub", + "MODE": "Mode", "MODEL": "Model Labs", - "MODEX": "MODEX Token", + "MODEX": "Modex", + "MODU": "Modular Wallet", "MODUM": "Modum", + "MODX": "MODEL-X-coin", + "MOE": "Merchant Moe", + "MOETA": "Moeta", + "MOEW": "donotfomoew", "MOF": "Molecular Future (TRC20)", "MOFI": "MobiFi", "MOFOLD": "Molecular Future (ERC20)", "MOG": "Mog Coin", + "MOGE": "Moge", + "MOGGO": "MOGGO", "MOGU": "Mogu", + "MOGUL": "Mogul Productions", "MOGX": "Mogu", + "MOH": "Medal of Honour", "MOI": "MyOwnItem", "MOIN": "MoinCoin", "MOJO": "Mojocoin", @@ -5430,80 +7935,119 @@ "MOL": "Molecule", "MOLA": "MoonLana", "MOLK": "Mobilink Token", + "MOLLARS": "MollarsToken", + "MOLLY": "Molly", "MOM": "Mother of Memes", "MOMA": "Mochi Market", - "MON": "Medamon", + "MOMIJI": "MAGA Momiji", + "MOMO": "MOMO 2.0", + "MON": "MON Protocol", "MONA": "MonaCoin", "MONAIZE": "Monaize", "MONARCH": "TRUEMONARCH", "MONAV": "Monavale", + "MONB": "MonbaseCoin", "MONETA": "Moneta", "MONEY": "MoneyCoin", + "MONEYBEE": "MONEYBEE", "MONEYBYTE": "MoneyByte", "MONEYIMT": "MoneyToken", "MONF": "Monfter", "MONG": "MongCoin", "MONG20": "Mongoose 2.0", + "MONGBNB": "MongBNB", + "MONGOOSE": "Mongoose", "MONI": "Monsta Infinite", + "MONIE": "Infiblue World", "MONK": "Monkey Project", + "MONKAS": "Monkas", + "MONKE": "Monkecoin", "MONKEY": "Monkey", "MONKEYS": "Monkeys Token", + "MONKU": "Monku", "MONO": "MonoX", "MONONOKEINU": "Mononoke Inu", "MONS": "Monsters Clan", "MONST": "Monstock", + "MONSTA": "Cake Monster", "MONT": "Monarch Token", + "MONTE": "Monte", "MOO": "MooMonster", + "MOOCAT": "MooCat", "MOOI": "Moonai", + "MOOLA": "Degen Forest", "MOOLYA": "moolyacoin", "MOON": "r/CryptoCurrency Moons", "MOONARCH": "Moonarch", "MOONC": "MoonCoin", "MOOND": "Dark Moon", "MOONDAY": "Moonday Finance", + "MOONED": "MoonEdge", "MOONER": "CoinMooner", "MOONEY": "Moon DAO", + "MOONION": "Moonions", + "MOONKIZE": "MoonKize", "MOONLIGHT": "Moonlight Token", + "MOONR": "PulseMoonR", + "MOONS": "Sailor Moons", "MOONSHOT": "Moonshot", + "MOONSTAR": "MoonStar", "MOOO": "Hashtagger", "MOOV": "dotmoovs", "MOOX": "Moox Protocol", "MOPS": "Mops", + "MOR": "Morpheus", "MORA": "Meliora", "MORE": "More Coin", + "MORFEY": "Morfey", + "MOROS": "MOROS NET", + "MORPH": "Morpheus Token", + "MORRA": "Morra", "MORSE": "Morse", "MOS": "MOS Coin", + "MOST": "MOST Global", "MOT": "Olympus Labs", "MOTA": "MotaCoin", "MOTG": "MetaOctagon", + "MOTHER": "Mother Iggy", "MOTI": "Motion", "MOTO": "Motocoin", + "MOUTAI": "Moutai", "MOV": "MovieCoin", "MOVD": "MOVE Network", "MOVE": "MarketMove", + "MOVER": "Mover", "MOVEY": "Movey", "MOVEZ": "MoveZ", "MOVON": "MovingOn Finance", "MOVR": "Moonriver", + "MOW": "mouse in a cats world", "MOWA": "Moniwar", "MOZ": "Mozik", "MP": "Membership Placeholders", "MP3": "MP3", + "MPAD": "MultiPad", "MPAY": "Menapay", "MPC": "Metaplace", "MPD": "Metapad", "MPG": "Max Property Group", "MPH": "Morpher", + "MPI": "MetaPioneers", + "MPIX": "Megapix", "MPL": "Maple", "MPLUS": "M+Plus", "MPLX": "Metaplex", "MPM": "Monopoly Meta", "MPRO": "MediumProject", + "MPS": "Mt Pelerin Shares", "MPT": "Meetple", + "MPWR": "Empower", + "MPX": "Morphex", "MPXT": "Myplacex", "MQL": "MiraQle", "MQST": "MonsterQuest", "MR": "Meta Ruffy", + "MRBASED": "MrBased", "MRBOB": "MR BOB COIN", "MRCH": "MerchDAO", "MRCR": "Mercor Finance", @@ -5515,17 +8059,28 @@ "MRN": "Mercoin", "MRNA": "Moderna", "MRP": "MorpheusCoin", + "MRPEPE": "Pepe Potato", "MRS": "Metars Genesis", "MRSA": "MrsaCoin", "MRT": "MinersReward", + "MRUN": "Metarun", "MRV": "Macroverse", "MRX": "Metrix Coin", - "MRXB": "Wrapped Metrix", + "MRXB": "Wrapped BNB Metrix", + "MRXE": "Wrapped ETH Metrix", "MRY": "MurrayCoin", "MSA": "My Shiba Academia", "MSB": "Misbloc", "MSC": "Miningwatchdog Smartchain", + "MSCP": "Moonscape", + "MSCT": "MUSE ENT NFT", "MSD": "MSD", + "MSG": "MsgSender", + "MSGO": "MetaSetGO", + "MSHD": "MASHIDA", + "MSHEESHA": "Sheesha Finance Polygon", + "MSHIB": "Magic Shiba Starter", + "MSHIP": "MetaShipping", "MSN": "Manson Coin", "MSOL": "Marinade Staked SOL", "MSOT": "BTour Chain", @@ -5533,6 +8088,8 @@ "MSQ": "MSquare Global", "MSR": "Masari", "MST": "Idle Mystic", + "MSTAR": "MerlinStarter", + "MSTETH": "Eigenpie mstETH", "MSTO": "Millennium Sapphire", "MSU": "MetaSoccer", "MSWAP": "MoneySwap", @@ -5546,70 +8103,106 @@ "MTD": "Minted", "MTEL": "MEDoctor", "MTG": "MagnetGold", + "MTGT": "MTG Token", + "MTGX": "Montage Token", "MTH": "Monetha", "MTHD": "Method Finance", + "MTHN": "MTH Network", + "MTIK": "MatikaToken", + "MTIX": "Matrix Token", "MTK": "Moya Token", "MTL": "Metal", "MTLM3": "Metal Music v3", "MTLX": "Mettalex", "MTN": "TrackNetToken", + "MTO": "Merchant Token", + "MTP": "Macro Protocol", "MTR": "MasterTraderCoin", + "MTRA": "MetaRare", "MTRC": "ModulTrade", "MTRG": "Meter", + "MTRK": "Matrak Fan Token", "MTRM": "Materium", "MTRX": "Metarix", "MTS": "Metastrike", "MTSH": "Mitoshi", + "MTSP": "Metasphere", "MTT": "MulTra", "MTTCOIN": "Money of Tommorow, Today", "MTV": "MultiVAC", "MTVT": "Metaverser", + "MTW": "Meta Space 2045", "MTX": "Matryx", "MTXLT": "Tixl", + "MTY": "Viddli", "MTZ": "Monetizr", "MU": "Miracle Universe", "MUBI": "Multibit", + "MUC": "Multi Universe Central", + "MUDOL2": "Hero Blaze: Three Kingdoms", "MUDRA": "MudraCoin", "MUE": "MonetaryUnit", "MULTI": "Multichain", "MULTIBOT": "Multibot", "MULTIGAMES": "MultiGames", "MULTIV": "Multiverse", + "MUMU": "Mumu", "MUN": "MUNcoin", "MUNCH": "Munch Token", + "MUNCHY": "Boys Club Munchy", + "MUNITY": "Metahorse Unity", + "MUNK": "Dramatic Chipmunk", "MURA": "Murasaki", + "MURATIAI": "MuratiAI", + "MUSCAT": "MusCat", "MUSD": "mStable USD", "MUSDCOIN": "MUSDcoin", "MUSE": "Muse DAO", "MUSIC": "Gala Music", + "MUSICAI": "MusicAI", "MUSICOIN": "Musicoin", "MUSK": "Musk", + "MUSKMEME": "MUSK MEME", + "MUSKVSZUCK": "Cage Match", "MUST": "MUST Protocol", "MUSTANGC": "MustangCoin", "MUT": "Mutual Coin", "MUTE": "Mute", "MUU": "MilkCoin", + "MUZKI": "Muzki", + "MUZZ": "MuzzleToken", "MV": "GensoKishi Metaverse", "MVC": "MileVerse", "MVD": "Metavault", "MVDG": "MetaVerse Dog", "MVEDA": "MedicalVeda", + "MVERSE": "MindVerse", + "MVG": "Mad Viking Games", "MVI": "Metaverse Index", "MVL": "MVL", - "MVP": "MVP Coin", + "MVP": "MAGA VP", + "MVPC": "MVP Coin", "MVRS": "Meta MVRS", + "MVS": "Multiverse", "MVU": "meVu", + "MVX": "Metavault Trade", "MW": "MasterWin Coin", "MWAR": "MemeWars (MWAR)", "MWAT": "RED MegaWatt", + "MWAVE": "MeshWave", "MWC": "MimbleWimbleCoin", + "MWCC": "Metaworld", "MX": "MX Token", "MXC": "Machine Xchange Coin", + "MXGP": "MXGP Fan Token", "MXM": "Maximine", + "MXNT": "Tether MXNt", + "MXRP": "Monsta XRP", "MXT": "MixTrust", "MXTC": "MartexCoin", "MXW": "Maxonrow", "MXX": "Multiplier", + "MXZ": "Maximus Coin", "MYB": "MyBit", "MYC": "Mycelium", "MYCE": "MY Ceremonial Event", @@ -5617,20 +8210,27 @@ "MYDFS": "MyDFS", "MYID": "My Identity Coin", "MYL": "MyLottoCoin", + "MYLO": "MYLOCAT", "MYNE": "ITSMYNE", "MYO": "Mycro", "MYOBU": "Myōbu", "MYRA": "Mytheria", "MYRIA": "Myria", "MYRO": "Myro", + "MYRODRAGON": "MYRO DRAGON", + "MYROO": "Myro Dog", + "MYROWIF": "MYROWIF", "MYST": "Mysterium", "MYT": "Mytrade", "MYTH": "Mythos", "MYTHTOKEN": "Myth Token", "MYTV": "MyTVchain", "MZC": "MazaCoin", + "MZERO": "MetaZero", "MZG": "Moozicore", "MZK": "Muzika Network", + "MZM": "MetaZooMee", + "MZR": "Mazuri GameFi", "MZX": "Mosaic Network", "N0031": "nYFI", "N1": "NFTify", @@ -5638,9 +8238,14 @@ "N7": "Number7", "N8V": "NativeCoin", "NABOX": "Nabox", + "NAC": "Nirvana Chain", + "NADA": "NADA Protocol Token", "NAFT": "Nafter", "NAH": "Strayacoin", + "NAI": "Nuklai", "NAKA": "Nakamoto Games", + "NALA": "Not a lion, a...", + "NALS": "NALS (Ordinals)", "NAM": "Namacoin", "NAME": "PolkaDomain", "NAMEC": "Name Change Token", @@ -5651,15 +8256,20 @@ "NANAS": "BananaBits", "NANJ": "NANJCOIN", "NANO": "Nano", + "NAO": "Nettensor", "NAOS": "NAOS Finance", "NAP": "Napoli Fan Token", + "NARCO": "Mr. Narco", + "NARS": "Num ARS v2", "NAS": "Nebulas", "NAS2": "Nas2Coin", "NASADOGE": "Nasa Doge", "NASH": "NeoWorld Cash", "NASSR": "Alnassr FC Fan Token", + "NASTR": "Liquid ASTR", "NAT": "Natmin", "NATION": "Nation3", + "NATIX": "NATIX Network", "NAUSICAA": "Nausicaa-Inu", "NAUT": "Nautilus Coin", "NAV": "NavCoin", @@ -5667,11 +8277,18 @@ "NAVI": "Atlas Navi", "NAVIA": "NaviAddress", "NAVIB": "Navibration", + "NAVX": "NAVI Protocol", + "NAVY": "BoatPilot Token", "NAWA": "Narwhale.finance", "NAX": "NextDAO", + "NAZ": "NAZDAQ", + "NAZA": "NAZA", + "NAZAR": "NAZAR PROTOCOL", + "NBABSC": "NBA BSC", "NBAI": "Nebula AI", "NBAR": "NOBAR", "NBC": "Niobium", + "NBD": "Never Back Down", "NBIT": "NetBit", "NBL": "Nobility", "NBLU": "NuriTopia", @@ -5682,23 +8299,36 @@ "NBR": "Niobio Cash", "NBS": "New BitShares", "NBT": "NanoByte", + "NBXC": "Nibble", "NC": "Nayuta Coin", + "NCA": "NeuroCrypto Ads", "NCASH": "Nucleus Vision", + "NCAT": "Neuracat", "NCC": "NeuroChain", "NCDT": "Nuco.Cloud", + "NCO": "Nexacore", + "NCOP": "NCOP", + "NCOR": "NovaCore", + "NCORAI": "NeoCortexAI", "NCOV": "CoronaCoin", "NCP": "Newton Coin", "NCR": "Neos Credits", "NCT": "PolySwarm", + "ND": "Nemesis Downfall", "NDAU": "ndau", + "NDB": "NDB", "NDC": "NeverDie", "NDLC": "NeedleCoin", "NDN": "NDN Link", "NDOGE": "NinjaDoge", "NDR": "Node Runners", + "NDS": "NodeStation AI", "NDX": "Indexed Finance", + "NEADRAM": "The Ennead", "NEAL": "Coineal Token", "NEAR": "Near", + "NEARX": "Stader NearX", + "NEAT": "NEAT", "NEBL": "Neblio", "NEBU": "Nebuchadnezzar", "NEC": "Nectar", @@ -5707,58 +8337,98 @@ "NEETCOIN": "Neetcoin", "NEF": "NefariousCoin", "NEFTIPEDIA": "NEFTiPEDiA", + "NEFTY": "NeftyBlocks", + "NEGED": "Neged", "NEI": "Neurashi", "NEKI": "Neki Token", "NEKO": "The Neko", "NEKOIN": "Nekoin", + "NEKOS": "Nekocoin", + "NEMO": "NEMO", + "NEMS": "The Nemesis", "NEO": "NEO", "NEOG": "NEO Gold", + "NEOK": "NEOKingdom DAO", "NEOM": "New Earth Order Money", "NEON": "Neon EVM", + "NEONAI": "NeonAI", "NEOS": "NeosCoin", + "NEOX": "Neoxa", "NEPT": "Metanept", "NERD": "Nerd Bot", + "NERDS": "NERDS", + "NERF": "Neural Radiance Field", + "NERO": "Nero Token", "NERVE": "NERVE", + "NESS": "Ness LAB", "NEST": "Nest Protocol", "NESTREE": "Nestree", + "NESTV1": "Nest Protocol v1", "NET": "NetCoin", + "NETA": "Negative Tax", "NETC": "NetworkCoin", "NETCOIN": "Netcoincapital", "NETKO": "Netko", + "NETRUM": "Netrum", + "NETT": "Netswap", + "NETVR": "Netvrk", "NETZ": "MainnetZ", "NETZ1": "NETZERO", "NEU": "Neumark", + "NEURA": "Neurahub", + "NEURAL": "NeuralAI", "NEURALINK": "Neuralink", + "NEURON": "Cerebrum DAO", + "NEURONI": "Neuroni AI", + "NEUROS": "Shockwaves", + "NEUTR": "Neutrinos", "NEUTRO": "Neutro Protocol", "NEUTRON": "Neutron", "NEVA": "NevaCoin", + "NEVER": "neversol", "NEW": "Newton", "NEWB": "Newbium", + "NEWG": "NewGold", + "NEWM": "NEWM", "NEWO": "New Order", "NEWOS": "NewsToken", - "NEWS": "NewsTokens", + "NEWS": "PUBLISH", + "NEWSTOKENS": "NewsTokens", "NEWTON": "Newtonium", "NEX": "Nash Exchange", + "NEXA": "Nexa", + "NEXAI": "NexAI", "NEXBOX": "NexBox", "NEXBT": "Native XBTPro Exchange Token", "NEXM": "Nexum", + "NEXMS": "NexMillionaires", "NEXO": "NEXO", "NEXT": "Connext Network", "NEXTEX": "Next.exchange Token", + "NEXTEXV1": "Next.exchange Token v1", + "NEXUSAI": "NexusAI", "NEXXO": "Nexxo", "NEZHA": "NezhaToken", + "NFAI": "Not Financial Advice", "NFCR": "NFCore", "NFD": "Feisty Doge NFT", + "NFE": "Edu3Labs", "NFN": "Nafen", "NFP": "NFPrompt", "NFT": "APENFT", + "NFT11": "NFT11", "NFTART": "NFT Art Finance", "NFTB": "NFTb", + "NFTBS": "NFTBooks", "NFTD": "NFTrade", + "NFTE": "NFTEarthOFT", "NFTI": "NFT Index", "NFTL": "NFTLaunch", "NFTLOOT": "NFTLootBox", + "NFTM": "NFTMart Token", + "NFTN": "NFTNetwork", "NFTP": "NFT", + "NFTS": "NFT STARS", "NFTT": "NFT", "NFTX": "NFTX", "NFTXHI": "NFTX Hashmasks Index", @@ -5767,27 +8437,46 @@ "NFUP": "Natural Farm Union Protocol", "NFXC": "NFX Coin", "NFY": "Non-Fungible Yearn", + "NGA": "NGA Tiger", "NGC": "NagaCoin", "NGIN": "Ngin", - "NGL": "Gold Fever", + "NGL": "Entangle", "NGM": "e-Money", "NGMI": "NGMI Coin", "NHCT": "Nano Healthcare Token", + "NHI": "Non Human Intelligence", + "NHT": "Neighbourhoods", + "NIAO": "NIAO", + "NIBI": "Nibiru Chain", "NIC": "NewInvestCoin", "NICE": "Nice", "NICEC": "NiceCoin", "NIF": "Unifty", "NIFT": "Niftify", "NIFTSY": "Envelop", + "NIGELLA": "Nigella coin", + "NIGHT": "Midnight", + "NIGI": "Nigi", + "NIHAO": "NiHao", "NII": "nahmii", "NIIFI": "NiiFi", + "NIK": "NIKPLACE", "NIM": "Nimiq", "NIMFA": "Nimfamoney", "NIN": "Next Innovation", + "NINJA": "Dog Wif Nunchucks", + "NINJAZ": "Danketsu", "NINKY": "Ninky", "NINO": "Ninneko", + "NINU": "Nvidia Inu", + "NIOB": "Niob Finance", + "NIOCTIB": "nioctiB", "NIOX": "Autonio", + "NIPPY": "Cat On Catnip", + "NIRV": "Nirvana NIRV", "NIT": "Nesten", + "NITEFEEDER": "Nitefeeder", + "NITO": "Nitroken", "NITRO": "Nitro League", "NITROE": "NitroEX", "NITROG": "Nitro", @@ -5798,41 +8487,63 @@ "NKCLC": "NKCL Classic", "NKN": "NKN", "NKT": "NakomotoDark", + "NKYC": "NKYC Token", "NLC": "NoLimitCoin", "NLC2": "NoLimitCoin", "NLG": "Gulden", + "NLINK": "Neuralink", + "NLK": "NuLink", + "NLS": "Nolus", "NLX": "Nullex", "NMB": "Nimbus Coin", + "NMBTC": "NanoMeter Bitcoin", "NMC": "Namecoin", + "NMD": "Nexusmind", "NMH": "Namahe", "NMK": "Namek", + "NMKR": "NMKR", "NMR": "Numeraire", "NMS": "Numus", - "NMT": "NFTMart Token", + "NMSP": "Nemesis PRO", + "NMT": "NetMind Token", "NMX": "Nominex Token", "NNB": "NNB Token", "NNC": "NEO Name Credit", "NNI": "NeoNomad Exchange", "NNN": "Novem Gold", + "NNT": "Nunu Spirits", "NOA": "NOA PLAY", "NOAH": "NOAHCOIN", "NOBL": "NobleCoin", "NOBS": "No BS Crypto", + "NOCHILL": "AVAX HAS NO CHILL", "NODE": "Whole Network", "NODIS": "Nodis", "NODL": "Nodle Network", + "NOGS": "Noggles", + "NOHAT": "DogWifNoHat", "NOIA": "Syntropy", + "NOIS": "Nois Network", "NOIZ": "NOIZ", + "NOKA": "Noka Solana AI", "NOKU": "NOKU Master token", + "NOLA": "Nola", "NOM": "Finom NOM Token", "NONE": "None Trading", "NOO": "Noocoin", + "NOOT": "NOOT (Ordinals)", "NOR": "Noir", "NORA": "SnowCrash Token", "NORD": "Nord Finance", + "NORMIE": "Normie", + "NORMUS": "NORMUS", "NOS": "Nosana", "NOSN": "nOS", + "NOSO": "Noso", + "NOT": "Notcoin", "NOTE": "Notional Finance", + "NOTHING": "NOTHING", + "NOTINU": "NOTCOIN INU", "NOV": "Novara Calcio Fan Token", "NOVA": "Nova Finance", "NOW": "NOW Token", @@ -5841,14 +8552,18 @@ "NPAS": "New Paradigm Assets Solution", "NPC": "NPCcoin", "NPER": "NPER", + "NPICK": "NPICK BLOCK", "NPLC": "Plus Coin", + "NPM": "Neptune Mutual", "NPT": "Neopin", + "NPTX": "NeptuneX", "NPX": "Napoleon X", "NPXS": "Pundi X", "NPXSXEM": "Pundi X NEM", - "NR1.BITCI": "Number 1 Token", + "NR1": "Number 1 Token", "NRB": "NoirBits", "NRC": "Neurocoin", + "NRCH": "EnreachDAO", "NRFB": "NuriFootBall", "NRG": "Energi", "NRGY": "NRGY Defi", @@ -5861,11 +8576,17 @@ "NRV": "Nerve Finance", "NRVE": "Narrative", "NRX": "Neironix", + "NS": "NodeSynapse", "NS2DRP": "New Silver Series 2 DROP", "NSBT": "Neutrino Token", "NSD": "Nasdacoin", "NSDX": "NASDEX", "NSFW": "xxxNifty", + "NSH": "NOSHIT", + "NSI": "nSights DeFi Trader", + "NSIMPSON": "NeuraSimpson", + "NSK": "NSKSwap", + "NSO": "NeverSurrenderOne's", "NSP": "NOMAD.space", "NSR": "NuShares", "NSS": "NSS Coin", @@ -5877,6 +8598,8 @@ "NTBC": "Note Blockchain", "NTC": "NineElevenTruthCoin", "NTCC": "NeptuneClassic", + "NTD": "Neural Tensor Dynamics", + "NTG": "NEWTOWNGAMING", "NTK": "Neurotoken", "NTM": "NetM", "NTO": "Neton", @@ -5889,35 +8612,49 @@ "NTY": "Nexty", "NU": "NuCypher", "NUA": "Neulaut Token", + "NUB": " nubcat", "NUBIS": "NubisCoin", + "NUC": "NuCoin", "NUDE": "0xNude", + "NUDES": "NUDES", "NUKE": "NukeCoin", "NULS": "Nuls", "NUM": "Numbers Protocol", "NUMBERS": "NumbersCoin", "NUMI": "Numitor", + "NUR": "Nurcoin", + "NUSA": "Nusa", "NUSD": "Nomin USD", "NUT": "Native Utility Token", + "NUTGV2": "NUTGAIN", "NUTS": "Squirrel Finance", + "NUUM": "MNet", "NUX": "Peanut", "NVA": "Neeva Defi", "NVC": "NovaCoin", "NVDX": "Nodvix", + "NVG": "NightVerse Game", "NVIR": "NvirWorld", "NVL": "Nevula", "NVOY": "Envoy", + "NVS": "Navis", "NVST": "NVO", "NVT": "NerveNetwork", "NVX": "Novax Coin", "NVZN": "INVIZION", "NWC": "Newscrypto Coin", "NWCN": "NowCoin", + "NWG": "NotWifGary", "NWP": "NWPSolution", + "NWS": "Nodewaves", "NXC": "Nexium", "NXD": "Nexus Dubai", + "NXDT": "NXD Next", "NXE": "NXEcoin", "NXM": "Nexus Mutual", "NXMC": "NextMindCoin", + "NXN": "Naxion", + "NXQ": "NexQloud", "NXRA": "AllianceBlock Nexera", "NXS": "Nexus", "NXT": "Nxt", @@ -5925,6 +8662,7 @@ "NXTT": "Next Earth", "NXTTY": "NXTTY", "NYAN": "NyanCoin", + "NYANDOGE": "NyanDOGE International", "NYANTE": "Nyantereum International", "NYBBLE": "Nybble", "NYC": "NewYorkCoin", @@ -5934,65 +8672,97 @@ "NYM": "Nym Token", "NYN": "NYNJA", "NYX": "NYXCOIN", + "NYXC": "Nyxia AI", "NYZO": "Nyzo", "NZC": "NewZealandCoin", "NZDX": "eToro New Zealand Dollar", "NZE": "Nagezeni", "NZL": "Zealium", "NZO": "NonZero", + "O": "Childhoods End", "O3": "O3 Swap", + "O4DX": "O4DX", "OAK": "Acorn Collective", + "OAS": "Oasis City", "OASC": "Oasis City", + "OASIS": "Oasis", + "OAT": "OAT Network", "OATH": "OATH Protocol", "OAX": "Oax", + "OBEMA": "burek obema", + "OBI": "Orbofi AI", + "OBICOIN": "OBI Real Estate", "OBITS": "Obits Coin", "OBOT": "Obortech", "OBROK": "OBRok", "OBS": "One Basis Cash", "OBSCURE": "Obscurebay", "OBSR": "OBSERVER Coin", + "OBT": "Oobit", + "OBTC": "Obitan Chain", "OBX": "OpenBlox", "OC": "OrangeCoin", + "OCAI": "Onchain AI", + "OCAVU": "Ocavu Network Token", "OCB": "BLOCKMAX", "OCC": "OccamFi", + "OCD": "On-Chain Dynamics", "OCE": "OceanEX Token", "OCEAN": "Ocean Protocol", "OCEANT": "Poseidon Foundation", + "OCH": "Orchai", "OCICAT": "OciCat", "OCL": "Oceanlab", "OCN": "Odyssey", + "OCP": "Omni Consumer Protocols", "OCT": "Octopus Network", "OCTA": "OctaSpace", + "OCTAGON": "POLYDeFI", + "OCTAVUS": "Octavus Prime", "OCTAX": "OctaX", "OCTI": "Oction", "OCTO": "OctoFi", "OCTOC": "OctoCoin", "OCTOIN": "Octoin Coin", + "OCW": "Online Cold Wallet", "OCX": "Original Crypto Coin", "ODC": "Overseas Direct Certification", "ODDZ": "Oddz", "ODE": "ODEM", + "ODGN": "OrdiGen", "ODIN": "Odin Protocol", "ODMC": "ODMCoin", "ODN": "Obsidian", "ODNT": "Old Dogs New Tricks", + "ODS": "Odesis", "ODX": "ODX Token", + "ODYS": "OdysseyWallet", "OF": "OFCOIN", "OFBC": "OneFinBank Coin", "OFC": "$OFC Coin", "OFCR": "CryptoPolice", + "OFE": "Ofero", + "OFF": "BlastOff", "OFN": "Openfabric AI", "OG": "OG Fan Token", + "OGCINU": "The OG Cheems Inu", + "OGD": "OLYMPIC GAMES DOGE", "OGGY": "Oggy Inu", "OGN": "Origin Protocol", "OGO": "Origo", "OGOD": "GOTOGOD", + "OGPU": "OPEN GPU", + "OGSM": "OGSMINEM", "OGSP": "OriginSport", "OGT": "One Game", "OGV": "Origin Dollar Governance", + "OGY": "ORIGYN", + "OGZ": "OGzClub", "OH": "Oh! Finance", + "OHANDY": "Orbit Bridge Klaytn Handy", "OHM": "Olympus", "OHMV2": "Olympus v2", + "OHO": "OHO", "OICOIN": "Osmium Investment Coin", "OIL": "Oiler", "OILD": "OilWellCoin", @@ -6001,15 +8771,21 @@ "OJA": "Ojamu", "OJX": "Ojooo", "OK": "OKCash", + "OKAYEG": "Okayeg", "OKB": "OKB", "OKG": "Ookeenga", + "OKINAMI": "Kanagawa Nami", + "OKLP": "OkLetsPlay", "OKOIN": "OKOIN", "OKS": "Oikos", "OKSE": "Okse", "OKT": "OKT Chain", "OLAND": "Oceanland", + "OLAS": "Autonolas", "OLDSF": "OldSafeCoin", "OLE": "OpenLeverage", + "OLEA": "Olea Token", + "OLEV1": "OpenLeverage v1", "OLIVE": "Olive", "OLOID": "OLOID", "OLT": "OneLedger", @@ -6020,15 +8796,26 @@ "OM": "MANTRA", "OMA": "OmegaCoin", "OMAX": "Omax", + "OMAXV1": "Omax v1", "OMC": "Omchain", + "OMD": "OneMillionDollars", + "OME": "o-mee", "OMEGA": "OMEGA", "OMG": "OMG Network", "OMGC": "OmiseGO Classic", "OMI": "ECOMI", "OMIC": "Omicron", + "OMIKAMI": "Amaterasu Omikami", + "OMMI": "Ommniverse", "OMNI": "Omni", "OMNIA": "OmniaVerse", + "OMNIC": "OmniCat", "OMNICRON": "OmniCron", + "OMNINET": "Omni Network", + "OMNIR": "Omni Real Estate Token", + "OMNIX": "OmniBotX", + "OMNOM": "Doge Eat Doge", + "OMNOMN": "Omega Network", "OMT": "Mars Token", "OMV1": "OM Token (v1)", "OMX": "Project Shivom", @@ -6038,6 +8825,7 @@ "ONDO": "Ondo", "ONE": "Harmony", "ONES": "OneSwap DAO", + "ONET": "ONE Token", "ONEX": "ONE TECH", "ONG": "SoMee.Social", "ONGAS": "Ontology Gas", @@ -6048,6 +8836,7 @@ "ONLINE": "Onlinebase", "ONLY": "OnlyCam", "ONLYCUMIES": "OnlyCumies", + "ONNO": "Onno Vault", "ONOT": "ONO", "ONS": "One Share", "ONSTON": "Onston", @@ -6057,47 +8846,78 @@ "OOE": "OpenOcean", "OOGI": "OOGI", "OOKI": "Ooki", + "OOKS": "Onooks", + "OORC": "Orbit Bridge Klaytn Orbit Chain", + "OORT": "OORT", "OOT": "Utrum", "OOW": "OPP Open WiFi", "OP": "Optimism", "OPA": "Option Panda Platform", + "OPAIG": "OvalPixel", "OPC": "OP Coin", + "OPCAT": "OPCAT", "OPCT": "Opacity", - "OPEN": "Open Platform", + "OPEN": "Open Custody Protocol", + "OPENAI": "OpenAI ERC", + "OPENCHAT": "OpenChat", "OPENDAO": "OpenDAO", "OPENGO": "OPEN Governance Token", + "OPENP": "Open Platform", "OPENRI": "Open Rights Exchange", + "OPENSOURCE": "Open Source Network", + "OPENX": "OpenSwap Optimism Token", + "OPEPE": "Optimism PEPE", "OPES": "Opes", "OPET": "ÕpetFoundation", "OPEX": "Optherium Token", + "OPHX": "Operation Phoenix", + "OPINU": "Optimus Inu", "OPIUM": "Opium", + "OPMND": "Open Mind Network", + "OPN": "OPEN Ticketing Ecosystem", "OPNN": "Opennity", "OPP": "Opporty", "OPS": "Octopus Protocol", "OPSC": "OpenSourceCoin", + "OPSEC": "OpSec", "OPT": "Opus", + "OPTA": "Opta Global", "OPTC": "Open Predict Token", "OPTCM": "Optimus", + "OPTI": "Optimus AI", + "OPTIG": "Catgirl Optimus", "OPTIMOUSE": "Optimouse", "OPTION": "OptionCoin", "OPU": "Opu Coin", "OPUL": "Opulous", "OPV": "OpenLive NFT", + "OPXVEVELO": "OpenX Locked Velo", + "ORACLE": "Oracle AI", "ORACLECHAIN": "OracleChain", + "ORACUL": "Oracul Ai", "ORAI": "Oraichain Token", + "ORAIX": "OraiDEX", "ORAO": "ORAO Network", "ORARE": "OneRare", "ORB": "KlayCity ORB", "ORBIS": "Orbis", + "ORBIT": "Orbit Protocol", "ORBITCOIN": "Orbitcoin", + "ORBK": "Ordibank", "ORBR": "Orbler", "ORBS": "Orbs", + "ORBT": "Orbitt Pro", "ORC": "Orbit Chain", "ORCA": "Orca", + "ORD": "ordinex", "ORDI": "Ordinals ", + "ORDI2": "ORDI 2.0", "ORDIFI": "OrdinalsFi", + "ORDS": "Ordiswap", "ORE": "Galactrum", + "OREO": "OreoFi", "ORET": "ORET Token", + "ORFY": "Ordify", "ORGA": "Organicco", "ORGT": "Organic Token", "ORI": "Origami", @@ -6107,34 +8927,56 @@ "ORM": "ORIUM", "ORME": "Ormeus Coin", "ORN": "Orion Protocol", + "ORNJ": "Orange", "ORO": "Operon Origins", "OROC": "Orocrypt", "OROCOIN": "OroCoin", "OROP": "ORO", "OROX": "Cointorox", "ORS": "ORS Group", - "ORT": "Omni Real Estate Token", + "ORT": "Okratech Token", "ORV": "Orvium", "ORYX": "OryxCoin", + "OS": "Ethereans", "OS76": "OsmiumCoin", "OSA": "OSA Token", + "OSAK": "Osaka Protocol", "OSC": "iOscar", + "OSEA": "Omnisea", + "OSEAN": "OSEAN", + "OSETH": "StakeWise Staked ETH", "OSF": "One Solution", + "OSH": "OSHI", + "OSHI": "Oshi Token", + "OSIS": "OSIS", + "OSK": "OSK", + "OSKDAO": "OSK DAO", + "OSL": "OSL AI", "OSMO": "Osmosis", "OSQTH": "Opyn Squeeth", + "OSS": "OSSChain", "OST": "OST", "OSWAP": "OpenSwap", + "OT": "Onchain Trade", + "OTB": "OTCBTC Token", "OTHR": "OtherDAO", "OTK": "Octokn", "OTN": "Open Trading Network", "OTO": "OTOCASH", + "OTSEA": "OTSea", + "OTT": "Coost", + "OTTERSPACE": "Otter Space", "OTX": "Octanox", + "OUCHI": "OUCHI", "OUD": "OUD", "OUR": "Our Pay", "OUSD": "Origin Dollar", + "OUSDC": "Orbit Bridge Klaytn USDC", + "OUSE": "OUSE Token", "OUT": "Netscouters", "OVC": "OVCODE", "OVERLORD": "Overlord", + "OVN": "Overnight", "OVO": "Ovato", "OVR": "Ovr", "OWC": "Oduwa", @@ -6143,10 +8985,14 @@ "OWN": "Ownly", "OWNDATA": "OWNDATA", "OX": "Open Exchange Token", + "OXAI": "OxAI.com", "OXB": "Oxbull Tech", "OXBT": "OXBT (Ordinals)", "OXD": "0xDAO", "OXEN": "Oxen", + "OXM": "OXM Protocol", + "OXN": "0xNumber", + "OXO": "OXO Network", "OXS": "0xS", "OXT": "Orchid Protocol", "OXY": "Oxygen", @@ -6154,42 +9000,74 @@ "OXYC": "Oxycoin", "OYS": "Oyster Platform", "OZG": "Ozagold", + "OZK": "OrdiZK", + "OZMPC": "Ozempic", "OZO": "Ozone Chain", + "OZONE": "Ozone metaverse", "OZP": "OZAPHYRE", "P202": "Project 202", "P2PS": "P2P Solutions Foundation", + "P3D": "3DPass", + "P404": "Potion 404", "PAAL": "PAAL AI", - "PAC": "PAC Protocol", + "PAALV1": "PAAL AI v1", + "PABLO": "PABLO DEFI", + "PAC": "PacMoon", "PACE": "3space Art", + "PACM": "Pacman Blastoff", "PACMAN": "Pac Man", "PACOCA": "Pacoca", + "PACP": "PAC Protocol", + "PACT": "impactMarket", "PAD": "NearPad", "PAF": "Pacific", + "PAGE": "Page", "PAI": "Project Pai", "PAID": "PAID Network", + "PAIDV1": "PAID Network v1", "PAINT": "MurAll", + "PAIRED": "PairedWorld", + "PAJAMAS": "The First Youtube Cat", "PAK": "Pakcoin", "PAL": "PolicyPal Network", + "PALAI": "PaladinAI", "PALET": "Palette", + "PALG": "PalGold", "PALLA": "Pallapay", - "PALM": "PalmPay", + "PALM": "PaLM AI", + "PALMP": "PalmPay", + "PALMV1": "PaLM AI v1", + "PALMY": "Palmy", + "PAM": "PAM", + "PAMBI": "Pambicoin", "PAMP": "PAMP Network", "PAN": "Pantos", "PAND": "Panda Finance", "PANDA": "PandaDAO", + "PANDAI": "PandAI", "PANDO": "Pando", "PANDOP": "PandoProject", + "PANDORA": "Pandora", "PANGEA": "PANGEA", + "PANIC": "PanicSwap", + "PANO": "PanoVerse", + "PAPA": "Papa Bear", "PAPADOGE": "Papa Doge", "PAPER": "Dope Wars Paper", "PAPI": "Papi", + "PAPPAY": "PAPPAY", + "PAPU": "Papu Token", "PAPUSHA": "Papusha", "PAR": "Parachute", "PARA": "Paralink Network", "PARAB": "Parabolic", + "PARADOX": "The Paradox Metaverse", + "PARAG": "Paragon Network", "PARAL": "Parallel", "PARALL": "Parallel Finance", + "PARAM": "Param", "PARANOIA": "ParanoiaCoin", + "PARAS": "Paras", "PARAW": "Para", "PARETO": "Pareto Network Token", "PARI": "Paribus", @@ -6200,39 +9078,58 @@ "PART": "Particl", "PAS": "Passive Coin", "PASC": "Pascal Coin", + "PASG": "Passage", "PASL": "Pascal Lite", "PASS": "Blockpass", "PAT": "PATRON", + "PATEK": "Silly Patek", + "PATEX": "Patex", "PATH": "PathDAO", + "PATTON": "Patton", + "PAVIA": "Pavia", "PAVO": "Pavocoin", + "PAW": "PAWSWAP", "PAWS": "PawStars", + "PAWSTA": "dogeatingpasta", "PAWTH": "Pawthereum", "PAXEX": "PAXEX", "PAXG": "PAX Gold", + "PAXU": "Pax Unitas", "PAXW": "pax.world", "PAY": "TenX", "PAYB": "Paybswap", "PAYCON": "Paycon", + "PAYN": "PayNet Coin", "PAYP": "PayPeer", + "PAYS": "Payslink", "PAYT": "PayAccept", + "PAYU": "Platform of meme coins", + "PAYX": "Paypex", "PAZZI": "Paparazzi", + "PBAR": "Pangolin Hedera", "PBASE": "Polkabase", "PBC": "PabyosiCoin", "PBET": "PBET", + "PBIRB": "Parrotly", "PBL": "Pebbles", "PBLK": "PayBlock", "PBQ": "PUBLIQ", "PBR": "PolkaBridge", + "PBRV1": "PolkaBridge v1", "PBT": "Primalbase", "PBTC35A": "pBTC35A", + "PBUX": "Playbux", "PBX": "Probinex", + "PBXV1": "Probinex v1", "PC": "Promotion Coin", "PCC": "PCORE", "PCCM": "Poseidon Chain", + "PCD": " Phecda", "PCE": "PEACE COIN", "PCH": "POPCHAIN", "PCHS": "Peaches.Finance", "PCI": "PayProtocol Paycoin", + "PCKB": "pCKB (via Godwoken Bridge from CKB)", "PCL": "Peculium", "PCM": "Procom", "PCN": "PeepCoin", @@ -6241,79 +9138,164 @@ "PCOIN": "Pioneer Coin", "PCR": "Paycer Protocol", "PCS": "Pabyosi Coin", + "PCSP": "GenomicDao G-Stroke", "PCX": "ChainX", + "PD": "PUDEL", + "PDA": "PlayDapp", "PDATA": "PDATA", "PDC": "Project Decorum", + "PDD": "PDDOLLAR", "PDEX": "Polkadex", "PDF": "Port of DeFi Network", "PDOG": "Polkadog", "PDOGE": "PolkaDoge", "PDRAGON": "Phoenix Dragon", + "PDRIP": "Pulse Drip", "PDT": "ParagonsDAO", "PDX": "PDX Coin", "PEA": "Pea Farm", + "PEACH": "Based Peaches", + "PEACHY": "Peachy", "PEAK": "PEAKDEFI", + "PEANIE": "Peanie", + "PEAR": "Pear Swap", "PEARL": "Pearl Finance", + "PEAS": "Peapods Finance", "PEC": "PeaceCoin", + "PECL": "PECland", + "PED": "PEDRO", + "PEDRO": "Pedro The Raccoon", "PEEL": "Meta Apes", + "PEEP": "Peepo", "PEEPA": "Peepa", + "PEEPEE": "Peepee", + "PEEPO": "PEEPO", "PEEPS": "The People’s Coin", + "PEEZY": "Young Peezy AKA Pepe", + "PEFI": "Penguin Finance", "PEG": "PegNet", "PEGS": "PegShares", "PEIPEI": "PEIPEI", + "PEKA": "PEKA", "PEKC": "Peacock Coin", + "PEKINU": "PEKI INU", + "PEKO": "Pepe Neko", "PEL": "Propel Token", + "PELF": "PELFORT", + "PEM": "Pembrock", "PENC": "PenCoin", "PENDLE": "Pendle", - "PENG": "PENG", + "PENG": "Peng", + "PENGCOIN": "PENG", "PENGYX": "PengyX", + "PENIS": "PenisGrow", + "PENP": "Penpie", + "PENR": "Penrose Finance", "PENTA": "Penta", "PEOPLE": "ConstitutionDAO", - "PEOS": "pEOS", + "PEOSONE": "pEOS", + "PEP": "Pepechain", + "PEPA": "Pepa Inu", + "PEPC": "Pepe Classic", "PEPE": "Pepe", - "PEPE20": "Pepe 2.0", + "PEPE2": "Pepe 2.0", + "PEPE20V1": "Pepe 2.0 v1", + "PEPEA": "Pepeandybrettlandwolf", "PEPEAI": "Pepe Analytics", + "PEPEARMY": "PEPEARMY", + "PEPEB": "PEPEBOMB", + "PEPEBNB": "Pepe The Frog", + "PEPEBRC": "PEPE (Ordinals)", + "PEPEBURN": "Pepeburn", + "PEPEC": "Pepe Chain", "PEPECASH": "Pepe Cash", + "PEPECAT": "PEPE CAT", "PEPECEO": "REAL PEPE CEO", + "PEPECHAIN": "PEPE Chain", + "PEPECOIN": "PepeCoin", + "PEPED": "PepeDAO Coin", + "PEPEDAO": "PEPE DAO", "PEPEDERP": "PepeDerp", + "PEPEE": "Pepe the pepe", + "PEPEF": "PEPEFLOKI", + "PEPEFC": "Pepe FC", + "PEPEFLOKI": "PEPE FLOKI", + "PEPEG": "Pepe Girl", + "PEPEGA": "Pepe GEM AI", "PEPEGAINS": "PepeGains", + "PEPEGOAT": "pepeGOAT", "PEPEGRINCH": "Pepe Grinch", "PEPEINU": "PEPE inu", "PEPEKING": "PEPEKING", + "PEPELON": "Pepelon", + "PEPEMAGA": "Trump Pepe", + "PEPEMO": "PepeMo", + "PEPEMOON": "PEPEMOON", "PEPEPI": "PEPEPi", + "PEPER": "Baby Pepe", + "PEPERA": "PEPERA", + "PEPESOL": "PEPE SOL", + "PEPESORA": "Pepe Sora AI", + "PEPESWAP": "PEPE Swap", + "PEPET": "PepeTrump", + "PEPEW": "PEPEPOW", + "PEPEWIFHAT": "Pepewifhat", + "PEPEWO": "PEPE World", + "PEPEX": "pepeX", + "PEPEYE2": "PEPEYE 2.0", "PEPEZILLA": "PEPEZilla", + "PEPI": "PEPI", + "PEPINU": "Pepinu", + "PEPIT": "Pepito", + "PEPLO": "Peplo Escobar", + "PEPO": "Peepo", + "PEPPA": "PEPPA", "PEPPER": "Pepper Token", "PEPS": "PEPS Coin", "PEPURAI": "PEPURAI", + "PEPVERS": "PepVerse", + "PEPY": "Pepy", + "PER": "Perproject", "PERA": "Pera Finance", "PERC": "Perion", "PERI": "PERI Finance", "PERL": "PERL.eco", "PERMIAN": "Permian", "PERP": "Perpetual Protocol", + "PERRY": "Perry The BNB", "PERU": "PeruCoin", "PERX": "PeerEx Network", "PESA": "Credible", + "PESHI": "PESHI", "PESOBIT": "PesoBit", "PET": "Hello Pets", + "PETF": "PEPE ETF", "PETG": "Pet Games", + "PETH": "pETH", "PETL": "Petlife", "PETN": "Pylon Eco Token", "PETO": "Petoverse", + "PETOSHI": "Petoshi", "PETS": "PolkaPets", "PETT": "Pett Network", + "PEUSD": "peg-eUSD", + "PEW": "pepe in a memes world", "PEX": "Pexcoin", + "PFI": "PrimeFinance", "PFID": "Pofid Dao", "PFL": "Professional Fighters League Fan Token", "PFR": "PayFair", "PFT": "Pitch Finance Token", "PFY": "Portify", + "PG": "Pepe Grow", "PGALA": "pGALA", "PGC": "Pegascoin", + "PGEN": "Polygen", "PGF7T": "PGF500", "PGL": "Prospectors", "PGN": "Pigeoncoin", "PGOLD": " Polkagold", + "PGPT": "PrivateAI", "PGROK": "Papa Grok", "PGT": "Polyient Games Governance Token", "PGTS": "Puregold token", @@ -6322,6 +9304,8 @@ "PHA": "Phala Network", "PHAE": "Phaeton", "PHALA": "Phalanx", + "PHAME": "PHAME", + "PHAUNTEM": "Phauntem", "PHB": "Phoenix Global [v2]", "PHBD": "Polygon HBD", "PHC": "Profit Hunters Coin", @@ -6338,47 +9322,69 @@ "PHOON": "Typhoon Cash", "PHORE": "Phore", "PHR": "Phreak", + "PHRYGE": "PHRYGES", "PHS": "PhilosophersStone", "PHT": "Photon Token", "PHTC": "Photochain", + "PHTR": "Phuture", "PHV": "PATHHIVE", "PHX": "Phoenix Finance", "PI": "Plian", + "PIA": "Olympia AI", "PIAS": "PIAS", "PIB": "Pibble", "PICA": "PicaArtMoney", "PICKLE": "Pickle Finance", "PICO": "PicoGo", "PIE": "Persistent Information Exchange", + "PIF": "Pepe Wif Hat", "PIG": "Pig Finance", + "PIGE": "Pige", + "PIGEON": "Pigeon In Yellow Boots", "PIGGY": "Piggy", "PIGGYCOIN": "Piggy Coin", + "PIGONK": "PIGONK", + "PIGS": "Elon Vitalik Pigs", + "PIIN": "piin (Ordinals)", "PIKA": "Pikaboss", "PIKACHU": "Pikachu Inu", + "PIKO": "Pinnako", "PILOT": "Unipilot", "PIN": "Pin", + "PINCHI": "Da Pinchi", + "PINE": "Pine", + "PINETWORKDEFI": "Pi Network DeFi", "PING": "CryptoPing", "PINK": "PinkCoin", + "PINKSALE": "PinkSale", "PINKX": "PantherCoin", "PINMO": "Pinmo", "PINO": "Pinocchu", "PINU": "Piccolo Inu", + "PINU100X": "Pi INU 100x", "PIO": "Pioneershares", "PIP": "Pip", "PIPA": "Pipa Coin", "PIPI": "Pippi Finance", "PIPL": "PiplCoin", "PIPT": "Power Index Pool Token", - "PIRATE": "PirateCash", + "PIRATE": "Pirate Nation", + "PIRATECASH": "PirateCash", + "PIRATECOIN": "Pirate Coin Games", + "PIRB": "PIRB", + "PIRI": "Pirichain", "PIRL": "Pirl", "PIS": "Polkainsure Finance", "PIST": "Pist Trust", "PIT": "Pitbull", "PITCH": "PITCH", + "PIVN": "PIVN", "PIVX": "Private Instant Verified Transaction", "PIX": "Lampix", - "PIXEL": "PixelVerse", + "PIXEL": "Pixels", + "PIXELV": "PixelVerse", "PIZA": "Half Pizza", + "PIZPEPE": "Pepe Pizzeria", "PIZZA": "PizzaSwap", "PIZZACOIN": "PizzaCoin", "PJM": "Pajama.Finance", @@ -6386,6 +9392,7 @@ "PKC": "Pikciochain", "PKD": "PetKingdom", "PKF": "PolkaFoundry", + "PKG": "PKG Token", "PKN": "Poken", "PKOIN": "Pocketcoin", "PKT": "PKT", @@ -6395,7 +9402,9 @@ "PLACE": "PlaceWar Governance", "PLAI": "Plair", "PLAN": "Plancoin", - "PLANET": "PlanetCoin", + "PLANE": "Paper Plane", + "PLANET": "PLANET", + "PLANETCOIN": "PlanetCoin", "PLANETS": "PlanetWatch", "PLASTIK": "Plastiks", "PLAT": "BitGuild PLAT", @@ -6406,18 +9415,24 @@ "PLAYC": "PlayChip", "PLAYCOIN": "PlayCoin", "PLAYKEY": "Playkey", + "PLB": "Paladeum", "PLBT": "Polybius", "PLC": "PlusCoin", "PLCU": "PLC Ultima", + "PLCUC": "PLC Ultima Classic", "PLD": "Plutonian DAO", "PLE": "Plethori", + "PLEB": "PLEBToken", + "PLENTY": "Plenty DeFi", "PLEO": "Empleos", + "PLERF": "Plerf", "PLEX": "PLEX", "PLF": "PlayFuel", "PLG": "Pledgecamp", "PLGR": "Pledge Finance", "PLI": "Plugin", "PLM": "Plasmonics", + "PLMC": "Polimec", "PLMT": "Pallium", "PLNC": "PLNCoin", "PLNX": "Planumex", @@ -6425,8 +9440,11 @@ "PLQ": "Planq", "PLR": "Pillar", "PLS": "Pulsechain", + "PLSARB": "Plutus ARB", + "PLSB": "PulseBitcoin", "PLSD": "PulseDogecoin", "PLSPAD": "PulsePad", + "PLSRDNT": "Plutus RDNT", "PLSX": "PulseX", "PLT": "Poollotto.finance", "PLTC": "PlatonCoin", @@ -6435,23 +9453,37 @@ "PLU": "Pluton", "PLUG": "PL^Gnet", "PLUGCN": "Plug Chain", + "PLUP": "PoolUp", "PLURA": "PluraCoin", "PLUS1": "PlusOneCoin", "PLUTUS": "PlutusDAO", "PLX": "PlexCoin", + "PLXY": "Plxyer", "PLY": "Aurigami", + "PLZ": "PLUNZ", "PMA": "PumaPay", + "PMD": "Pandemic Multiverse", + "PME": "DogePome", "PMEER": "Qitmeer", "PMG": "Pomerium Ecosystem Token", "PMGT": "Perth Mint Gold Token", + "PMM": "Perpetual Motion Machine", "PMNT": "Paymon", "PMON": "Polkamon", + "PMOON": "Pookimoon", + "PMPY": "Prometheum Prodigy", + "PMR": "Pomerium Utility Token", + "PMT": "POWER MARKET", "PMTN": "Peer Mountain", + "PNB": "Pink BNB", "PNC": "PlatiniumCoin", "PND": "PandaCoin", + "PNDC": "Pond Coin", + "PNDR": "Pandora Finance", "PNFT": "Pawn My NFT", "PNG": "Pangolin", "PNGN": "SpacePenguin", + "PNIC": "Phoenic", "PNK": "Kleros", "PNL": "True PNL", "PNODE": "Pinknode", @@ -6459,22 +9491,34 @@ "PNX": "PhantomX", "PNY": "Peony Coin", "POA": "Poa Network", + "POAI": "Port AI", "POC": "POC Blockchain", + "POCAT": "Polite Cat", "POCC": "POC Chain", "POCKET": "XPocket", "POCO": "Pocoland", "POD": "Podo Point", + "PODFAST": "PodFast", "PODIUM": "Smart League", + "PODO": "Power Of Deep Ocean", "POE": "Po.et", "POG": "PolygonumOnline", + "POGAI": "POGAI", + "POGS": "POG", + "POINT": "SportPoint", "POINTS": "Cryptsy Points", "POK": "Pokmonsters", + "POKEGROK": "PokeGROK", "POKEM": "Pokemonio", "POKEMON": "Pokemon", "POKER": "PokerCoin", + "POKERFI": "PokerFi", + "POKKY": "Pokky Cat", + "POKO": "POKOMON", "POKT": "Pocket Network", "POL": "Polygon Ecosystem Token", "POLA": "Polaris Share", + "POLAO": "Pola On Base", "POLAR": "Polaris", "POLC": "Polka City", "POLI": "Polinate", @@ -6483,38 +9527,60 @@ "POLK": "Polkamarkets", "POLKER": "Polker", "POLL": "Pollchain", + "POLLUK": "Jasse Polluk", "POLNX": "eToro Polish Zloty", + "POLO": "NftyPlay", "POLS": "Polkastarter", "POLVEN": "Polka Ventures", "POLX": "Polylastic", "POLY": "Polymath Network", + "POLYCUB": "PolyCub", "POLYDOGE": "PolyDoge", "POLYPAD": "PolyPad", "POLYX": "Polymesh", + "POM": "Proof Of Memes", "PON": "Ponder", + "PONCHO": "Poncho", "POND": "Marlin", + "PONGO": "Pongo", + "PONKE": "Ponke", + "PONKEBNB": "Ponke BNB", "PONYO": "Ponyo Impact", + "PONZI": "Ponzi", "PONZU": "Ponzu Inu", "POO": "POOMOON", + "POOCOIN": "PooCoin", "POODL": "Poodl", + "POODOGE": "Poo Doge", + "POOH": "POOH", "POOL": "PoolTogether", "POOLX": "Poolz Finance", "POOLXT": "Pool-X", "POOLZ": "Poolz Finance", + "POOP": "Poopsicle", + "POOWEL": "Joram Poowel", "POP": "PopularCoin", "POP!": "POP", "POPC": "PopChest", "POPCAT": "Popcat", + "POPDOG": "PopDog", + "POPE": "Popecoin", "POPK": "POPKON", + "POPO": "popo", "POPSICLE": "Popsicle Finance", "POR": "Portugal National Team Fan Token", + "PORA": "PORA AI", "PORK": "PepeFork", + "PORKE": "PONKE FORK", + "PORKINU": "PepeFork INU", "PORNROCKET": "PornRocket", "PORT": "Port Finance", "PORT3": "Port3 Network", "PORTAL": "Portal", + "PORTALTOKEN": "Portal", "PORTO": "FC Porto", "PORTU": "Portuma", + "PORTX": "ChainPort", "POS": "PoSToken", "POSEX": "PosEx", "POSI": "Position Token", @@ -6522,18 +9588,28 @@ "POSS": "Posschain", "POST": "InterPlanetary Search Engine", "POSTC": "PostCoin", + "POSW": "PoSW Coin", "POT": "PotCoin", + "POTATO": "Potato", "POTS": "Moonpot", + "POTTER": "POTTER", + "POU": "Pou", + "POW": "PowBlocks", "POWELL": "Jerome Powell", "POWER": "UniPower", "POWR": "Power Ledger", + "POWSCHE": "Powsche", "PP": "ProducePay Chain", "PPAD": "PlayPad", "PPALPHA": "Phoenix Protocol", "PPAY": "Plasma Finance", "PPBLZ": "Pepemon Pepeballs", "PPC": "PeerCoin", + "PPFT": "Papparico Finance", "PPI": "Primpy", + "PPIZZA": "P Pizza", + "PPL": "Pink Panther Lovers", + "PPM": "Punk Panda Messenger", "PPN": "Puppies Network", "PPOVR": "POVR", "PPP": "PayPie", @@ -6544,27 +9620,40 @@ "PQT": "Prediqt", "PRA": "ProChain", "PRARE": "Polkarare", + "PRB": "Paribu Net", "PRC": "ProsperCoin", + "PRCH": "Power Cash", + "PRCL": "Parcl", "PRCM": "Precium", "PRCY": "PRivaCY Coin", "PRDS": "Brise Paradise", "PRDX": "ParamountDax Token", "PRE": "Presearch", + "PREAI": "Predict Crypto", + "PRED": "Predictcoin", "PREM": "Premium", + "PREME": "PREME Token", "PREMIA": "Premia", "PRES": "President Trump", + "PRESALE": "Presale.World", + "PRESI": "Turbo Trump", + "PRESID": "President Ron DeSantis", "PRFT": "Proof Suite Token", "PRG": "Paragon", "PRI": "PRIVATEUM INITIATIVE", "PRIA": "PRIA", + "PRICK": "Pickle Rick", "PRIDE": "Nomad Exiles", + "PRIMAL": "PRIMAL", "PRIMATE": "Primate", "PRIME": "Echelon Prime", "PRIMECHAIN": "PrimeChain", + "PRIMEETH": "Prime Staked ETH", "PRINT": "Printer.Finance", "PRINTERIUM": "Printerium", "PRINTS": "FingerprintsDAO", "PRISM": "Prism", + "PRISMA": "Prisma Finance", "PRIX": "Privatix", "PRL": "Oyster Pearl", "PRM": "PrismChain", @@ -6579,10 +9668,15 @@ "PROOF": "PROVER", "PROP": "Propeller", "PROPC": "Propchain", - "PROPS": "Props", + "PROPEL": "PayRue (Propel)", + "PROPHET": "PROPHET", + "PROPS": "Propbase", + "PROPSPROJECT": "Props", "PROS": "Prosper", "PROT": "PROT", + "PROTEO": "Proteo DeFi", "PROTO": "Protocon", + "PROTOCOLZ": "Protocol Zero", "PROTON": "Proton", "PROUD": "PROUD Money", "PROXI": "PROXI", @@ -6592,6 +9686,8 @@ "PRQ": "PARSIQ", "PRS": "PressOne", "PRT": "Parrot Protocol", + "PRTC": "Protectorate Protocol", + "PRTCLE": "Particle", "PRTG": "Pre-Retogeum", "PRV": "PrivacySwap", "PRVC": "PrivaCoin", @@ -6599,6 +9695,8 @@ "PRX": "Parex", "PRXY": "Proxy", "PRY": "PRIMARY", + "PRZS": "Perezoso", + "PS1": "POLYSPORTS", "PSB": "Planet Sandbox", "PSC": "PSC Token", "PSD": "Poseidon", @@ -6613,51 +9711,87 @@ "PSM": "Prasm", "PSOL": "Parasol Finance", "PSP": "ParaSwap", + "PSPS": "BobaCat", + "PSSYMONSTR": "PSSYMONSTR", "PST": "Primas", "PSTAKE": "pSTAKE Finance", "PSTN": "Piston", + "PSUB": "Payment Swap Utility Board", "PSWAP": "Polkaswap", "PSY": "PsyOptions", + "PSYOP": "PSYOP", + "PT": "Phemex", "PTA": "PentaCoin", + "PTAS": "La Peseta", "PTC": "PesetaCoin", "PTD": "Pilot", "PTERIA": "Pteria", "PTF": "PowerTrade Fuel", + "PTH": "PlasticHero", "PTI": "Paytomat", + "PTM": "Potentiam", "PTN": "PalletOneToken", "PTO": "Patentico", "PTON": "Foresting", "PTOY": "Patientory", "PTP": "Platypus Finance", "PTR": "Petro", + "PTRUMP": "Pepe Trump", "PTT": "Pink Taxi Token", "PTU": "Pintu Token", "PTX": "PlatinX", + "PUCA": "Puss Cat", + "PUFETH": "pufETH", + "PUFF": "Puff", + "PUFFIN": "Puffin Global", + "PUFFT": "Puff The Dragon", + "PUGAI": "PUG AI", "PUGL": "PugLife", "PULI": "Puli", "PULSE": "Pulse", + "PUMA": "Puma", "PUMBAA": "Pumbaa", "PUMLX": "PUMLx", + "PUMP": "PUMP", + "PUN": "Punkko", + "PUNCH": "PUNCHWORD", "PUNDIX": "Pundi X", - "PUNK": "SteamPunk", + "PUNDU": "Pundu", + "PUNK": "PunkCity", + "PUNKAI": "PunkAI", + "PUNKV": "Punk Vault (NFTX)", + "PUP": "Puppy Coin", "PUPA": "PupaCoin", + "PUPPER": "Pupper", "PUPPETS": "Puppets Coin", + "PUPPIES": "I love puppies", + "PUPS": "PUPS (Ordinals)", "PURA": "Pura", "PURE": "Puriever", "PUREALT": "Pure", + "PURR": "SpartaCats", + "PURSE": "Pundi X PURSE", + "PUS": "Pussy Cat", "PUSD": "PegsUSD", "PUSH": "Ethereum Push Notification Service", "PUSHI": "Pushi", "PUSSY": "Pussy Financial", + "PUSSYINBIO": "Pussy In Bio", "PUT": "PutinCoin", + "PUUSH": "puush da button", "PUX": "pukkamex", + "PVC": "PVC Meta", "PVFYBO": "JRVGCUPVSC", "PVP": "PVPChain", "PVT": "Pivot Token", "PVU": "Plant vs Undead Token", "PWAR": "PolkaWar", + "PWC": "PixelWorldCoin", + "PWH": "pepewifhat", + "PWINGS": "JetSwap pWings", "PWON": "Personal Wager", "PWR": "PWR Coin", + "PWT": "PANDAINU", "PX": "PXcoin", "PXB": "PixelBit", "PXC": "PhoenixCoin", @@ -6668,6 +9802,7 @@ "PXT": "Pixer Eternity", "PYC": "PayCoin", "PYE": "CreamPYE", + "PYI": "PYRIN", "PYLNT": "Pylon Network", "PYLON": "Pylon Finance", "PYM": "Playermon", @@ -6677,17 +9812,21 @@ "PYQ": "PolyQuity", "PYR": "Vulcan Forged", "PYRAM": "Pyram Token", + "PYRAMID": "Pyramid", "PYRK": "Pyrk", "PYRO": "PYRO Network", + "PYRV1": "Vulcan Forged v1", "PYT": "Payther", "PYTH": "Pyth Network", "PYUSD": "PayPal USD", "PZM": "Prizm", "PZP": "PlayZap", + "PZT": "Pizon", "Q1S": "Quantum1Net", "Q2C": "QubitCoin", "QA": "Quantum Assets", "QAC": "Quasarcoin", + "QAI": "QuantixAI", "QANX": "QANplatform", "QARK": "QANplatform", "QASH": "Quoine Liquid", @@ -6705,14 +9844,21 @@ "QCN": "Quazar Coin", "QCO": "Qravity", "QCX": "QuickX Protocol", + "QDC": "Quadrillion Coin", + "QDFI": "Qudefi", + "QDROP": "QuizDrop", + "QDT": "QCHAIN", "QDX": "Quidax", "QFI": "QFinance", "QI": "BENQI", "QIE": "QI Blockchain", + "QINGWA": "ShangXin QingWa", "QISWAP": "QiSwap", "QKC": "QuarkChain", + "QKNTL": "Quick Intel", "QLC": "Kepple [OLD]", "QLINDO": "QLINDO", + "QLIX": "QLix", "QMALL": "QMALL TOKEN", "QNT": "Quant", "QNTR": "Quantor", @@ -6722,21 +9868,27 @@ "QOM": "Shiba Predator", "QOOB": "QOOBER", "QORA": "QoraCoin", + "QORPO": "QORPO WORLD", "QQBC": "QQBC IPFS BLOCKCHAIN", "QQQ": "Poseidon Network", "QQQF": "Standard Crypto Fund", - "QRDO": "Qredo", + "QR": "Qrolli", "QRK": "QuarkCoin", "QRL": "Quantum Resistant Ledger", + "QRO": "Querio", "QRP": "Cryptics", "QRT": "Qrkita Token", "QRX": "QuiverX", "QSLV": "Quicksilver coin", "QSP": "Quantstamp", + "QSR": "Quasar", + "QSWAP": "Quantum Network", "QTC": "Qitcoin", "QTCON": "Quiztok", "QTF": "Quantfury", + "QTK": "QuantCheck", "QTL": "Quatloo", + "QTO": "QToken", "QTUM": "QTUM", "QTZ": "Quartz", "QUA": "Quantum Tech", @@ -6744,36 +9896,60 @@ "QUAM": "Quam Network", "QUANT": "Quant Finance", "QUARASHI": "Quarashi Network", + "QUARK": "Quark", "QUARTZ": "Sandclock", "QUASA": "Quasacoin", "QUB": "Qubism", "QUBE": "Qube", + "QUBIC": "Qubic", "QUBITICA": "Qubitica", + "QUEEN": "Queen of Engrand", "QUICK": "Quickswap", "QUICKOLD": "Quickswap", "QUIDD": "Quidd", + "QUIL": "Wrapped QUIL", + "QUIN": "QUINADS", "QUINT": "Quint", + "QUIPU": "QuipuSwap Governance Token", "QUIZ": "Quizando", + "QUNT": "Quants", + "QUO": "Quoll Finance", "QUROZ": "Qfora", "QUSD": "QUSD", "QVT": "Qvolta", "QWAN": "The QWAN", "QWARK": "Qwark", "QWC": "Qwertycoin", + "QWLA": "Qawalla", + "QWT": "QoWatt", + "QXC": "QuantumXC", "R1": "Recast1", + "R2R": "CitiOs", "R34P": "R34P", "R3FI": "r3fi.finance", + "R3T": "Real Estate Token", + "R4RE": "R4RE Token", + "RAB": "Rabbit", + "RABB": "Rabbit INU", "RABBIT": "Rabbit Finance", + "RABI": "Rabi", "RAC": "RAcoin", "RACA": "Radio Caca", "RACEFI": "RaceFi", + "RACING": "Racing Club Fan Token", "RAD": "Radworks", "RADAR": "DappRadar", "RADI": "RadicalCoin", "RADIO": "RadioShack", "RADR": "CoinRadr", + "RADX": "Radx AI", + "RAFF": "Ton Raffles", + "RAFFLES": "Degen Raffles", + "RAFL": "RAFL", + "RAFT": "Raft", "RAGE": "Rage Fan", "RAI": "Rai Reflex Index", + "RAID": "Raid Token", "RAIDER": "Crypto Raiders", "RAIF": "RAI Finance", "RAIL": "Railgun", @@ -6782,9 +9958,15 @@ "RAINC": "RainCheck", "RAINI": "Rainicorn", "RAISE": "Raise Token", + "RAIT": "Rabbitgame", "RAIZER": "RAIZER", + "RAK": "Rake Finance", + "RAKE": "Rake Coin", "RAKU": "RAKUN", + "RALLY": "Trump Rally", "RAM": "Ramifi Protocol", + "RAMA": "Ramestta", + "RAMEN": "RamenSwap", "RAMP": "RAMP", "RANKER": "RankerDao", "RAP": "Philosoraptor", @@ -6792,10 +9974,13 @@ "RAPTOR": "Jesus-Raptor", "RARE": "SuperRare", "RARI": "Rarible", + "RASTA": "ZionLabs Token", + "RAT": "RatCoin", "RATECOIN": "Ratecoin", "RATING": "DPRating", "RATIO": "Ratio", "RATS": "Rats", + "RATWIF": "RatWifHat", "RAVE": "Ravendex", "RAVELOUS": "Ravelous", "RAVEN": "Raven Protocol", @@ -6806,6 +9991,7 @@ "RAZE": "Raze Network", "RAZOR": "Razor Network", "RB": "REBorn", + "RBBT": "RabbitCoin", "RBC": "Rubic", "RBD": "Rubidium", "RBDT": "RoBust Defense Token", @@ -6814,8 +10000,12 @@ "RBIS": "ArbiSmart", "RBIT": "ReturnBit", "RBLS": "Rebel Bots", + "RBLZ": "RebelSatoshi", "RBN": "Ribbon Finance", + "RBNB": "StaFi Staked BNB", + "RBP": "Rare Ball Potion", "RBR": "Ribbit Rewards", + "RBRETT": "ROARING BRETT", "RBT": "Rabet", "RBTC": "Smart Bitcoin", "RBUNNY": "Rocket Bunny", @@ -6829,6 +10019,8 @@ "RCCC": "RCCC", "RCG": "Recharge", "RCH": "Rich", + "RCKT": "RocketSwap", + "RCM": "READ2N", "RCN": "Ripio", "RCOIN": "RCoin", "RCT": "RealChain", @@ -6836,45 +10028,70 @@ "RD": "Round Dollar", "RDC": "Ordocoin", "RDD": "Reddcoin", + "RDDT": "Reddit", + "RDEX": "Orders.Exchange", + "RDF": "ReadFi", + "RDGX": "R-DEE Protocol", "RDN": "Raiden Network Token", "RDNT": "Radiant Capital", + "RDNTV1": "Radiant Capital v1", + "RDO": "Rodeo Finance", "RDPX": "Dopex Rebate Token", "RDR": "Rise of Defenders", "RDS": "Reger Diamond", "RDT": "Ridotto", "RDX": "Redux Protocol", "REA": "Realisto", + "REACH": "/Reach", "REAL": "RealLink", "REALM": "Realm", "REALMS": "Realms of Ethernity", "REALPLATFORM": "REAL", "REALY": "Realy Metaverse", "REAP": "ReapChain", + "REAPER": "Grim Finance", + "REAU": "Vira-lata Finance", "REBL": "REBL", + "REBUS": "Rebuschain", "REC": "Rec Token (REC)", + "RECA": "The Resistance Cat", "RECKOON": "Reckoon", "RECOM": "Recom", "RED": "RED TOKEN", "REDC": "RedCab", "REDCO": "Redcoin", "REDDIT": "Reddit", + "REDFEG": "RedFEG", + "REDFLOKI": "Red Floki", "REDI": "REDi", "REDLANG": "RED", "REDLC": "Redlight Chain", + "REDLUNA": "Redluna", "REDN": "Reden", + "REDO": "Resistance Dog", + "REDP": "Red Ponzi Gud", + "REDPEPE": "Red Pepe", + "REDZILLA": "REDZILLA COIN", "REE": "ReeCoin", + "REEE": "REEE", "REEF": "Reef", "REELT": "Reel Token", "REF": "Ref Finance", "REFI": "Realfinance Network", + "REFLECT": "REFLECT", "REFLECTO": "Reflecto", "REFTOKEN": "RefToken", "REFUND": "Refund", "REGALCOIN": "Regalcoin", + "REGEN": "Regen Network", + "REGENT": "REGENT COIN", + "REHA": "Resistance Hamster", "REHAB": "NFT Rehab", "REI": "REI Network", + "REIGN": "Reign of Terror", "REINDEER": "Reindeer", "REKT": "REKT", + "REKT2": "REKT 2.0", "REL": "Reliance", "RELAY": "Relay Token", "RELI": "Relite Finance", @@ -6890,40 +10107,59 @@ "RENC": "RENC", "RENDOGE": "renDOGE", "RENE": "Renewable Energy", + "RENEC": "RENEC", + "RENQ": "Renq Finance", "RENS": "Rens", + "RENT": "Rent AI", "RENTBE": "Rentberry", "REP": "Augur", "REPO": "Repo Coin", "REPUX": "Repux", "REQ": "Request Network", "RES": "Resistance", + "RESCUE": "Rescue", "REST": "Restore", "RET": "RealTract", + "RETA": "Realital Metaverse", "RETAIL": "Retail.Global", + "RETARDIO": "RETARDIO", "RETH": "Rocket Pool ETH", "RETH2": "rETH2", + "RETIK": "Retik Finance", "RETIRE": "Retire Token", "REU": "REUCOIN", + "REUNI": "Reunit Wallet", "REV": "Revain", + "REV3L": "REV3AL", "REVA": "Revault Network", "REVE": "Revenu", "REVO": "Revomon", + "REVOAI": "revoAI", "REVOLAND": "Revoland Governance Token", "REVON": "RevoNetwork", "REVU": "Revuto", "REVV": "REVV", "REW": "Review.Network", "REX": "Imbrex", + "REZ": "Renzo", "RF": "Raido Financial", "RFCTR": "Reflector.Finance", + "RFD": "RefundCoin", + "RFDB": "Refund", "RFG": "Refugees Token", "RFI": "reflect.finance", + "RFKJ": "Independence Token", "RFL": "RAFL", "RFOX": "RedFOX Labs", "RFR": "Refereum", "RFT": "Rangers Fan Token", "RFUEL": "Rio DeFi", + "RFX": "Reflex", + "RGAME": "RGAMES", "RGC": "RG Coin", + "RGEN": "Paragen", + "RGOAT": "RealGOAT", + "RGOLD": "Royal Gold", "RGP": "Rigel Protocol", "RGT": "Rari Governance Token", "RHEA": "Rhea", @@ -6931,23 +10167,33 @@ "RHINOMARS": "RhinoMars", "RHOC": "RChain", "RHP": "Rhypton Club", + "RIA": "aRIA Currency", + "RIB": "Ribus", + "RIBBIT": "Ribbit", "RIC": "Riecoin", "RICE": "RiceFarm", "RICECOIN": "RiceCoin", "RICH": "Richie", + "RICHOFME": "Rich Of Memes", + "RICK": "Infinite Ricks", "RICKMORTY": "Rick And Morty", "RIDE": "Holoride", "RIDEMY": "Ride My Car", "RIF": "RIF Token", + "RIF3": "MetaTariffv3", "RIFI": "Rikkei Finance", "RIGEL": "Rigel Finance", + "RIK": "RIKEZA", "RIL": "Rilcoin", + "RIM": "MetaRim", "RIMBIT": "Rimbit", "RIN": "Aldrin", "RING": "Darwinia Network", "RINGX": "RING X PLATFORM", + "RINIA": "Rinia Inu", "RINU": "Raichu Inu", "RIO": "Realio Network", + "RIOT": "Riot Racers", "RIP": "Fantom Doge", "RIPAX": "RipaEx", "RIPO": "RipOffCoin", @@ -6956,103 +10202,155 @@ "RISE": "EverRise", "RISEP": "Rise Protocol", "RISEVISION": "Rise", + "RISITA": "Risitas", "RITE": "ritestream", "RITO": "Ritocoin", + "RITZ": "Ritz.Game", + "RIVUS": "RivusDAO", "RIYA": "Etheriya", + "RIZE": "Rizespor Token", + "RIZO": "Rizo", + "RJV": "Rejuve.AI", "RKC": "Royal Kingdom Coin", "RKI": "RAKHI", "RKN": "RAKON", + "RKR": "REAKTOR", "RKT": "Rock Token", "RLB": "Rollbit Coin", "RLC": "iExec", + "RLM": "MarbleVerse", "RLOOP": "rLoop", "RLT": "Runner Land", "RLX": "Relex", "RLY": "Rally", + "RMATIC": "StaFi Staked MATIC", "RMBCASH": "RMBCASH", "RMC": "Russian Mining Coin", "RMESH": "RightMesh", + "RMK": "KIM YONG EN", "RMOB": "RewardMob", "RMPL": "RMPL", "RMRK": "RMRK.app", "RMS": "Resumeo Shares", "RMT": "SureRemit", + "RMV": "Reality Metaverse", "RNB": "Rentible", "RNBW": "Rainbow Token", "RNC": "ReturnCoin", "RND": "The RandomDAO", "RNDR": "Render Token", + "RNDX": "Round X", "RNS": "RenosCoin", "RNT": "OneRoot Network", "RNTB": "BitRent", "RNX": "ROONEX", "ROAD": "ROAD", "ROAR": "Alpha DEX", + "ROARINGCAT": "Roaring Kitty", + "ROB": "ROB", "ROBET": "RoBet", + "ROBIN": "Robin of Da Hood", + "ROBINH": "ROBIN HOOD", + "ROBO": "RoboHero", "ROC": "Rasputin Online Coin", + "ROCCO": "Just A Rock", "ROCK": "Bedrock", "ROCK2": "Ice Rock Mining", + "ROCKET": "Team Rocket", "ROCKETCOIN": "RocketCoin", + "ROCKETFI": "RocketFi", "ROCKI": "Rocki", + "ROCKY": "Rocky", "ROCO": "ROCO FINANCE", + "RODAI": "ROD.AI", "ROE": "Rover Coin", "ROG": "ROGin AI", "ROI": "ROIcoin", "ROK": "Rockchain", + "ROKM": "Rocket Ma", + "ROKO": "Roko", "ROLS": "RollerSwap", + "ROM": "ROMCOIN", + "ROME": "Rome", "RONALDINHO": "Ronaldinho Soccer Coin", "RONCOIN": "RON", + "ROND": "ROND", "RONIN": "Ronin", + "ROO": "Lucky Roo", "ROOBEE": "ROOBEE", "ROOK": "KeeperDAO", "ROOM": "OptionRoom", - "ROOT": "RootCoin", + "ROON": "Raccoon", + "ROOST": "Roost Coin", + "ROOSTV1": "Roost Coin v1", + "ROOT": "The Root Network", + "ROOTCOIN": "RootCoin", "ROOTS": "RootProject", "ROS": "ROS Coin", + "ROSA": "Rosa Inu", "ROSE": "Oasis Labs", "ROSN": "Roseon Finance", + "ROSX": "Roseon", "ROT": "Rotten", "ROTTY": "ROTTYCOIN", "ROUND": "RoundCoin", "ROUP": "Roup (Ordinals)", "ROUSH": "Roush Fenway Racing Fan Token", "ROUTE": "Router Protocol", + "ROVI": "ROVI", + "ROW": "Rage On Wheels", "ROWAN": "Sifchain", "ROX": "Robotina", + "ROXY": "ROXY FROG", + "ROY": "Crypto Royale", "ROYA": "Royale", "ROYAL": "RoyalCoin", "RPB": "Republia", "RPC": "RonPaulCoin", "RPD": "Rapids", + "RPEPEc": "RoaringPepe", "RPG": "Rangers Protocol", "RPILL": "Red Pill", "RPK": "RepubliK", "RPL": "RocketPool", + "RPLAY": "Replay", "RPM": "Render Payment", + "RPR": "The Reaper", "RPS": "Rps League", "RPT": "Rug Proof", + "RPTR": "Raptor Finance", "RPUT": "Robin8 Profile Utility Token", "RPZX": "Rapidz", "RRB": "Renrenbit", "RRC": "Recycling Regeneration Chain", "RRT": "Recovery Right Tokens", + "RS": "ReadySwap", "RSC": "ResearchCoin", + "RSETH": "Kelp DAO Restaked ETH", "RSF": "Royal Sting", + "RSG": "RSG TOKEN", + "RSIC": "RSIC•GENESIS•RUNE", "RSIN": "Roketsin", + "RSPN": "Respan", "RSR": "Reserve Rights", "RSRV": "Reserve", + "RSRV1": "Reserve Rights v1", "RSS3": "RSS3", "RST": "REGA Risk Sharing Token", "RSTK": "Restake Finance", "RSUN": "RisingSun", "RSV": "Reserve", + "RSWETH": "Restaked Swell Ethereum", "RT2": "RotoCoin", "RTB": "AB-CHAIN", "RTC": "Reltime", "RTE": "Rate3", + "RTF": "Ready to Fight", "RTH": "Rotharium", + "RTK": "RetaFi", "RTM": "Raptoreum", "RTT": "Restore Truth Token", + "RU": "RIFI United", "RUBCASH": "RUBCASH", "RUBIT": "Rublebit", "RUBX": "eToro Russian Ruble", @@ -7061,19 +10359,29 @@ "RUC": "Rush", "RUFF": "Ruff", "RUG": "Rug", + "RUGA": "RUGAME", "RUGZ": "pulltherug.finance", "RULER": "Ruler Protocol", + "RUM": "RUM Pirates of The Arrland Token", + "RUN": "Run", "RUNE": "Thorchain", + "RUNY": "Runy", "RUP": "Rupee", "RUPX": "Rupaya", + "RUSD": "Reflecto USD", "RUSH": "RUSH COIN", + "RUSHCMC": "RUSHCMC", "RUST": "RustCoin", "RUSTBITS": "Rustbits", + "RUTH": "RUTH", + "RUUF": "RuufCoin", "RUX": "Gacrux NFT", "RVC": "Revenue Coin", "RVF": "RocketX exchange", + "RVL": "Revolotto", "RVLNG": "RevolutionGames", "RVLT": "Revolt 2 Earn", + "RVM": "Realvirm", "RVN": "Ravencoin", "RVO": "AhrvoDEEX", "RVP": "Revolution Populi", @@ -7081,43 +10389,65 @@ "RVST": "Revest Finance", "RVT": "Rivetz", "RVX": "Rivex", + "RWA": "Xend Finance", + "RWAS": "RWA Finance", + "RWB": "RawBlock", "RWD": "Reward Vision", "RWE": "Real-World Evidence", "RWN": "Rowan Token", "RWS": "Robonomics Web Services", + "RWT": "RWT TOKEN", + "RXCG": "RXCGames", "RXD": "Radiant", + "RXO": "RocketXRP Official", "RXT": "RIMAUNANGIS", "RYC": "RoyalCoin", "RYCN": "RoyalCoin 2.0", + "RYIU": "RYI Unity", "RYO": "Ryo", "RYOMA": "Ryoma", "RYOSHI": "Ryoshis Vision", + "RYU": "The Blue Dragon", "RYZ": "Anryze", "RZR": "RazorCoin", + "RedFlokiCEO": "Red Floki CEO", "S2K": "Sports 2K75", + "S315": "SWAP315", "S4F": "S4FE", "S8C": "S88 Coin", + "SA": "Superalgos", + "SABAI": "Sabai Ecoverse", "SABLE": "Sable Finance", "SABR": "SABR Coin", "SAC1": "Sable Coin", "SAF": "Safinus", - "SAFE": "SafeCoin", + "SAFE": "Safe", "SAFEBTC": "SafeBTC", "SAFEBULL": "SafeBull", + "SAFECOIN": "SafeCoin", "SAFEGROK": "SafeGrok", "SAFEHAMSTERS": "SafeHamsters", + "SAFELIGHT": "SafeLight", "SAFELUNAR": "SafeLunar", "SAFEMARS": "Safemars", + "SAFEMOO": "SafeMoo", "SAFEMOON": "SafeMoon", + "SAFEMOONCASH": "SafeMoonCash", + "SAFEMUUN": "Safemuun", "SAFEREUM": "Safereum", "SAFES": "SafeSwap", "SAFESTAR": "Safe Star", + "SAFET": "SafemoonTon", "SAFEX": "SafeExchangeCoin", + "SAFLE": "Safle", + "SAFTP": "Simple Agreement for Future Tokens", "SAFUU": "SAFUU", - "SAGA": "SagaCoin", + "SAGA": "Saga", + "SAGACOIN": "SagaCoin", "SAI": "SAI", "SAIL": "SAIL", "SAITA": "SaitaChain", + "SAITABIT": "SaitaBit", "SAITAMA": "Saitama Inu", "SAITAMAV1": "Saitama v1", "SAITANOBI": "Saitanobi", @@ -7130,6 +10460,7 @@ "SALD": "Salad", "SALE": "DxSale Network", "SALLY": "SALAMANDER", + "SALMAN": "Mohameme Bit Salman", "SALMON": "Salmon", "SALT": "Salt Lending", "SAM": "Samsunspor Fan Token", @@ -7140,56 +10471,92 @@ "SAND": "The Sandbox", "SANDG": "Save and Gain", "SANDWICH": " Sandwich Network", + "SANDY": "Sandy", + "SANI": "Sanin Inu", + "SANJI": "Sanji Inu", "SANSHU": "Sanshu Inu", + "SANTA": "SANTA CHRISTMAS INU", "SANTAGROK": "Santa Grok", "SANTOS": "Santos FC Fan Token", "SAO": "Sator", "SAP": "SwapAll", + "SAPE": "SolanaApe", "SAPP": "Sapphire", + "SAPPC": "SappChat", "SAR": "Saren", "SARCO": "Sarcophagus", + "SAROS": "Saros", "SAS": "Stand Share", "SASHIMI": "Sashimi", "SAT": "Satisfaction Token", "SAT2": "Saturn2Coin", "SATA": "Signata", + "SATOSHINAKAMOTO": "Satoshi Nakamoto", "SATOX": "Satoxcoin", + "SATOZ": "Satozhi", "SATS": "SATS (Ordinals)", + "SATSALL": "ALL BEST ICO SATOSHI", "SATT": "SaTT", "SATX": "SatoExchange Token", "SAUBER": "Alfa Romeo Racing ORLEN Fan Token", "SAUCE": "SaucerSwap", + "SAUCEINU": "SAUCEINU", + "SAUDIBONK": "Saudi Bonk", + "SAUDIPEPE": "SAUDI PEPE", + "SAUDISHIB": "Saudi Shiba Inu", "SAUNA": "SaunaFinance Token", "SAV3": "SAV3", "SAVG": "SAVAGE", + "SAVM": "SatoshiVM", "SB": "DragonSB", "SBA": "simplyBrand", + "SBABE": "SNOOPYBABE", + "SBAE": "Salt Bae For The People", "SBC": "StableCoin", "SBCC": "Smart Block Chain City", "SBE": "Sombe", + "SBEFE": "BEFE", + "SBET": "SBET", + "SBF": "SBF In Jail", "SBGO": "Bingo Share", + "SBIO": "Vector Space Biosciences, Inc.", + "SBOX": "SUIBOXER", "SBR": "Saber", "SBRT": "SaveBritney", "SBSC": "Subscriptio", "SBT": "SOLBIT", "SBTC": "Super Bitcoin", "SC": "Siacoin", + "SC20": "Shine Chain", "SCA": "SiaClassic", + "SCALE": "Scalia Infrastructure", "SCAM": "Scam Coin", + "SCAMP": "ScamPump", + "SCANS": "0xScans", "SCAP": "SafeCapital", + "SCAPE": "Etherscape", "SCAR": "Velhalla", + "SCARAB": "Scarab Finance", "SCASH": "SpaceCash", "SCAT": "Sad Cat Token", "SCC": "StockChain Coin", "SCCP": "S.C. Corinthians Fan Token", "SCDS": "Shrine Cloud Storage Network", + "SCFX": "Shui CFX", "SCH": "SoccerHub", "SCHO": "Scholarship Coin", + "SCHR": "Schrodinger", + "SCHRODI": "Schrödi", "SCIA": "Stem Cell", + "SCIVIVE": "sciVive", "SCIX": "Scientix", + "SCK": "Space Corsair Key", "SCL": "Sociall", + "SCLASSIC": "Solana Classic", "SCLP": "Scallop", + "SCM": "ScamFari token", "SCN": "Swiscoin", + "SCNR": "Swapscanner", "SCNSOL": "Socean Staked Sol", "SCO": "SCOPE", "SCOIN": "ShinCoin", @@ -7198,8 +10565,13 @@ "SCOR": "Scorista", "SCORE": "Scorecoin", "SCOT": "Scotcoin", + "SCOTTY": "Scotty Beam", "SCP": "ScPrime", "SCPT": "Script Network", + "SCRAP": "Scrap", + "SCRAPPY": "Scrappy", + "SCRAT": "Scrat", + "SCRATCH": "Scratch", "SCREAM": "Scream", "SCRIBE": "Scribe Network", "SCRIV": "SCRIV", @@ -7209,6 +10581,8 @@ "SCRPT": "ScryptCoin", "SCRT": "Secret", "SCRYPTA": "Scrypta", + "SCS": "Solcasino Token", + "SCSX": "Secure Cash", "SCT": "ScryptToken", "SCTK": "SharesChain", "SCY": "Synchrony", @@ -7217,12 +10591,16 @@ "SDAI": "Savings Dai", "SDAO": "SingularityDAO", "SDC": "ShadowCash", + "SDCRV": "Stake DAO CRV", "SDEX": "SmarDex", "SDL": "Saddle Finance", "SDN": "Shiden Network", + "SDO": "TheSolanDAO", "SDOG": "Small Doge", "SDOGE": "SpaceXDoge", + "SDOPE": "SHIBADOGEPEPE", "SDP": "SydPakCoin", + "SDR": "SedraCoin", "SDRN": "Senderon", "SDS": "Alchemint Standards", "SDT": "TerraSDT", @@ -7232,56 +10610,90 @@ "SEAL": "Seal Finance", "SEALN": "Seal Network", "SEAM": "Seamless Protocol", + "SEAMLESS": "SeamlessSwap", + "SEAN": "Starfish Finance", "SEAT": "SeatlabNFT", + "SEBA": "Seba", "SEC": "SecureCryptoPayments", "SECO": "Serum Ecosystem Token", + "SECOND": "MetaDOS", "SECRT": "SecretCoin", + "SECT": "SECTBOT", + "SECTO": "Sector Finance", + "SEDA": "SEDA Protocol", "SEED": "Superbloom", "SEEDS": "SeedShares", "SEEDV": "Seed Venture", + "SEEDX": "SEEDx", "SEELE": "Seele", "SEEN": "SEEN", "SEER": "SEER", + "SEFA": "Mesefa", + "SEG": "Solar Energy", "SEI": "Sei", "SEILOR": "Kryptonite", + "SEKAI": "Sekai DAO", "SEL": "SelenCoin", "SELF": "SELFCrypto", + "SELFIE": "SelfieDogCoin", + "SELLC": "Sell Token", "SEM": "Semux", "SEN": "Sentaro", "SENATE": "SENATE", "SENC": "Sentinel Chain", "SEND": "Social Send", + "SENK": "Senk", "SENNO": "SENNO", "SENSE": "Sense Token", + "SENSI": "Sensi", "SENSO": "SENSO", + "SENSOR": "Sensor Protocol", "SENT": "Sentinel", "SEON": "Seedon", "SEOR": "SEOR Network", "SEOS": "Smart Eye Operating System", "SEPA": "Secure Pad", "SEQ": "Sequence", + "SER": "Secretum", "SERG": "Seiren Games Network", "SERO": "Super Zero", + "SERP": "Shibarium Perpetuals", + "SERSH": "Serenity Shield", "SERV": "Serve", + "SERVE": "Metavice", + "SESE": "Simpson Pepe", "SESSIA": "SESSIA", "SETH": "sETH", "SETH2": "sETH2", "SETHER": "Sether", "SETS": "Sensitrust", + "SEW": "simpson in a memes world", + "SEX": "SEX Odyssey", + "SEXY": "EthXY", + "SFARM": "SolFarm", "SFC": "Solarflarecoin", + "SFCP": "SF Capital", "SFD": "SafeDeal", + "SFEX": "SafeLaunch", + "SFF": "Sunflower Farm", "SFG": "S.Finance", "SFI": "Saffron.finance", + "SFIN": "Songbird Finance", + "SFIT": "Sense4FIT", "SFL": "Sunflower Land", "SFLOKI": "SuiFloki-Inu", "SFM": "SafeMoon V2", "SFP": "SafePal", "SFR": "SaffronCoin", "SFRC": "Safari Crush", + "SFRXETH": "Frax Staked Ether", "SFT": "SportsFix", + "SFTMX": "Stader sFTMX", + "SFTY": "Stella Fantasy", "SFU": "Saifu", "SFUEL": "SparkPoint Fuel", "SFUND": "Seedify.fund", + "SFV2": "ShibaFameV2", "SFX": "SUBX FINANCE LAB", "SG": "SocialGood", "SGB": "Songbird", @@ -7294,75 +10706,135 @@ "SGP": "SGPay", "SGPT": "ShitGPT", "SGR": "Sogur Currency", + "SGROK": "Super Grok", "SGT": "SharedStake Governance Token", "SHA": "Safe Haven", "SHACK": "Shackleford", "SHACOIN": "Shacoin", "SHADE": "ShadeCoin", + "SHAK": "Shakita Inu", "SHAKE": "Spaceswap SHAKE", "SHAMAN": "Shaman King Inu", + "SHAN": "Shanum", + "SHANG": "Shanghai Inu", + "SHARBI": "SHARBI", "SHARD": "ShardCoin", + "SHARDS": "SolChicks Shards", "SHARE": "Seigniorage Shares", "SHARECHAIN": "ShareChain", "SHARES": "shares.finance", + "SHARK": "Sharky", + "SHARKC": "Shark Cat", "SHARPE": "Sharpe Capital", + "SHAUN": "SHAUN INU", "SHB4": "Super Heavy Booster 4", "SHD": "ShardingDAO", + "SHDW": "Shadow Token", "SHE": "Shine Chain", + "SHEB": "SHEBOSHIS", "SHEEESH": "Secret Gem", "SHEESH": "Sheesh it is bussin bussin", "SHEESHA": "Sheesha Finance", "SHEI": "SheikhSolana", "SHELL": "Shell Token", + "SHEN": "Shen", + "SHEPE": "Shiba V Pepe", "SHERA": "Shera Tokens", - "SHFL": "SHUFFLE!", + "SHEZMU": "Shezmu", + "SHFL": "Shuffle", "SHFT": "Shyft Network", + "SHG": "Shib Generating", "SHI": "Shirtum", "SHIA": "Shiba Saga", "SHIB": "Shiba Inu", "SHIB05": "Half Shiba Inu", + "SHIB1": "Shib1", + "SHIB2": "SHIB2", + "SHIBA": "Shibaqua", + "SHIBAAI": "SHIBAAI", + "SHIBAC": "SHIBA CLASSIC", "SHIBACASH": "ShibaCash", + "SHIBAI": "AiShiba", + "SHIBAKEN": "Shibaken Finance", "SHIBAMOM": "Shiba Mom", + "SHIBAR": "Shibarium Name Service", + "SHIBARMY": "Shib Army", + "SHIBAY": "Shiba Inu Pay", + "SHIBAZILLA": "ShibaZilla2.0", + "SHIBCAT": "SHIBCAT", + "SHIBCEO": "ShibCEO", "SHIBDOGE": "ShibaDoge", "SHIBELON": "ShibElon", + "SHIBEMP": "Shiba Inu Empire", + "SHIBGF": "Shiba Girlfriend", + "SHIBIC": "SHIBIC", + "SHIBK": "ShibaKeanu", + "SHIBKILLER": "ShibKiller", + "SHIBKING": "Shibking Inu", + "SHIBL": "ShibLa", + "SHIBLITE": "Shiba Lite", "SHIBMERICAN": "Shibmerican", "SHIBO": "ShiBonk", "SHIBTC": "Shibabitcoin", + "SHIBU": "SHIBU INU", + "SHICO": "ShibaCorgi", "SHIDO": "Shido", "SHIELD": "Crypto Shield", "SHIELDNET": "Shield Network", "SHIFT": "Shift", "SHIH": "Shih Tzu", + "SHIK": "Shikoku", + "SHIL": "Shila Inu", "SHILL": "SHILL Token", "SHILLD": "SHILLD", + "SHILLG": "Shill Guard Token", + "SHIN": "Shin Chan", + "SHINA": "Shina Inu", "SHINJA": "Shibnobi", + "SHINO": "ShinobiVerse", + "SHINT": "Shiba Interstellar", "SHIP": "ShipChain", "SHIRYOINU": "Shiryo-Inu", "SHIT": "I will poop it NFT", + "SHIV": "Shiva Inu", + "SHK": "Shrike", "SHL": "Oyster Shell", "SHLD": "ShieldCoin", "SHND": "StrongHands", + "SHNT": "Sats Hunters", "SHO": "Showcase Token", "SHOE": "ShoeFy", + "SHOKI": "Shoki", "SHON": "ShonToken", + "SHOOT": "Mars Battle", + "SHOOTER": "Top Down Survival Shooter", "SHOP": "Shoppi Coin", "SHOPX": "Splyt", + "SHORK": "shork", "SHORTY": "ShortyCoin", "SHOW": "ShowCoin", "SHPING": "Shping Coin", "SHR": "ShareToken", + "SHRA": "Shrapnel", "SHRAP": "Shrapnel", "SHRED": "ShredN", "SHREK": "ShrekCoin", + "SHRIMP": "SHRIMP", "SHROOM": "Shroom.Finance", "SHROOMFOX": "Magic Shroom", + "SHRUB": "Shrub", "SHS": "SHEESH", + "SHUB": "SimpleHub", + "SHUFFLE": "SHUFFLE!", + "SHVR": "Shivers", "SHX": "Stronghold Token", "SHYTCOIN": "ShytCoin", "SI": "Siren", "SIB": "SibCoin", "SIBA": "SibaInu", "SIC": "Swisscoin", + "SID": "Sid", + "SIDE": "Side.xyz", "SIDESHIFT": "SideShift Token", "SIDUS": "Sidus", "SIERRA": "Sierracoin", @@ -7380,76 +10852,124 @@ "SILKT": "SilkChain", "SILLY": "Silly Dragon", "SILO": "Silo Finance", + "SILV2": "Escrowed Illuvium 2", "SILVA": "Silva Token", + "SILVER": "SILVER", "SILVERWAY": "Silverway", + "SIMP": "SO-COL", "SIMPLE": "SimpleChain", + "SIMPSON": "Homer", "SIMPSON6900": "Simpson6900 ", + "SIMPSONSINU": "The Simpsons Inu", + "SIMSOL": "SimSol", "SIN": "Sinverse", "SINE": "Sinelock", + "SING": "SingularFarm", "SINGLE": "Single Finance", "SINS": "SafeInsure", + "SINSO": "SINSO", "SINX": "SINX Token", + "SION": "FC Sion", "SIP": "Space SIP", "SIPHER": "Sipher", + "SIPHON": "Siphon Life Spell", + "SIR": "Sir", "SIS": "Symbiosis Finance", "SISA": "Strategic Investments in Significant Areas", + "SISC": "Shirushi Coin", "SISHI": "Sishi Finance", + "SIU": "Siu", + "SIUUU": "Crustieno Renaldo", + "SIV": "Sivasspor Token", "SIX": "SIX Network", + "SIXPACK": "SIXPACK", + "SIZ": "Sizlux", + "SIZE": "SIZE", "SJCX": "StorjCoin", + "SKAI": "Skillful AI", "SKB": "SkullBuzz", "SKC": "Skeincoin", + "SKCS": "Staked KCS", "SKEB": "Skeb", "SKET": "Sketch coin", "SKEY": "SmartKey", "SKI": "Skillchain", + "SKIBIDI": "Skibidi Toilet", + "SKID": "Success Kid", "SKILL": "CryptoBlades", "SKIN": "Skincoin", + "SKING": "Solo King", + "SKINS": "Coins & Skins", + "SKIPUP": "SKI MASK PUP", "SKL": "SKALE Network", "SKLAY": "sKLAY", "SKM": "Skrumble Network", + "SKO": "Sugar Kingdom Odyssey", + "SKOP": "Skulls of Pepe Token", "SKPEPE": "Sheikh Pepe", "SKR": "Sakuracoin", "SKRB": "Sakura Bloom", + "SKRIMP": "Skrimples", "SKRP": "Skraps", "SKRT": "Skrilla Token", + "SKRY": "Sakaryaspor Token", "SKT": "Sukhavati Network", "SKU": "Sakura", "SKULL": "Pirate Blocks", "SKY": "Skycoin", + "SKYA": "Sekuya Multiverse", "SKYFT": "SKYFchain", "SKYM": "SkyMap", "SKYRIM": "Skyrim Finance", "SKYX": "SKUYX", "SLA": "SUPERLAUNCH", "SLAM": "Slam Token", + "SLB": "Solberg", "SLC": "Solice", + "SLCL": "Solcial", "SLEEP": "Sleep Ecosystem", "SLEEPEE": "SleepFuture", + "SLEPE": "Slepe", + "SLERF": "SLERF", + "SLERF2": "SLERF 2.0", + "SLERFFORK": "SlerfFork", + "SLEX": "SLEX Token", "SLG": "Land Of Conquest", "SLICE": "Tranche Finance", "SLICEC": "SLICE", "SLIM": "Solanium", + "SLIME": "Snail Trail", "SLING": "Sling Coin", "SLINK": "Soft Link", + "SLISBNB": "Lista Staked BNB", "SLM": "SlimCoin", + "SLN": "Smart Layer Network", "SLND": "Solend", "SLNV2": "SLNV2", "SLOKI": "Super Floki", + "SLORK": "SLORK", "SLOTH": "Sloth", "SLP": "Smooth Love Potion", + "SLPV1": "Smooth Love Potion v1", "SLR": "SolarCoin", "SLRR": "Solarr", "SLRS": "Solrise Finance", "SLS": "SaluS", "SLST": "SmartLands", "SLT": "Social Lending Network", + "SLUMBO": "SLUMBO", "SLVX": "eToro Silver", "SLX": "Slate", + "SMA": "Soma Network", "SMAC": "Social Media Coin", + "SMAK": "Smartlink", + "SMARS": "SafeMars", "SMART": "SmartCash", "SMARTB": "Smart Coin", "SMARTCREDIT": "SmartCredit Token", "SMARTLOX": "SmartLOX", + "SMARTM": "SmartMesh", + "SMARTMEME": "SmartMEME", "SMARTNFT": "SmartNFT", "SMARTO": "smARTOFGIVING", "SMARTSHARE": "Smartshare", @@ -7463,92 +10983,195 @@ "SMETA": "StarkMeta", "SMF": "SmurfCoin", "SMG": "Smaugs NFT", + "SMH": "Spacemesh", "SMI": "SafeMoon Inu", + "SMIDGE": "Smidge", "SMILE": "Smile Token", + "SMILEK": "Smilek to the Bank", + "SMILEY": "SMILEY", + "SMILY": "Smily Trump", "SML": "Saltmarble", "SMLY": "SmileyCoin", + "SMM": "TrendingTool.io", + "SMOG": "Smog", "SMOKE": "Smoke", + "SMOL": "Smolcoin", + "SMOLE": "smolecoin", "SMON": "StarMon", "SMOON": "SaylorMoon", "SMPF": "SMP Finance", "SMPL": "SMPL Foundation", "SMR": "Shimmer", "SMRAT": "Secured MoonRat", + "SMRT": "SmartMoney", + "SMRTR": "SmarterCoin", "SMSR": "Samsara Coin", - "SMT": "SmartMesh", + "SMT": "Swarm Markets", "SMTF": "SmartFi", "SMTY": "Smoothy", + "SMU": "SafeMoneyUP", + "SMUDCAT": "Smudge Cat", + "SMUDGE": "Smudge Lord", + "SMURFCATBSC": "Real Smurf Cat", + "SMURFCATETH": "Real Smurf Cat", + "SMURFCATSOL": "Real Smurf Cat", + "SMX": "Snapmuse.io", + "SN": "SpaceN", + "SNA": "SUKUYANA", "SNACK": "Crypto Snack", + "SNAIL": "SnailBrook", + "SNAKES": "Snakes Game", "SNAP": "SnapEx", "SNB": "SynchroBitcoin", "SNC": "SunContract", + "SNCT": "SnakeCity", "SND": "Sandcoin", "SNE": "StrongNode", "SNEK": "Snek", "SNEKE": "Snek on Ethereum", "SNET": "Snetwork", - "SNFT.BITCI": "Spanish National Team Fan Token", + "SNFT": "Spanish National Team Fan Token", + "SNFTS": "Seedify NFT Space", + "SNG": "SINERGIA", "SNGLS": "SingularDTV", "SNIP": "LyrnAI", + "SNIPPEPE": "SNIPING PEPE", + "SNITCH": "Randall", "SNK": "Snake Token", "SNL": "Sport and Leisure", "SNM": "SONM", + "SNMT": "Satoshi Nakamoto Token", + "SNN": "SeChain", "SNOB": "Snowball", "SNOOP": "SnoopDAO", + "SNOOPY": "Snoopy", + "SNORK": "Snork", + "SNORT": "SNORT", "SNOV": "Snovio", "SNOW": "Snowswap", + "SNOWBALL": "Simpson Cat", + "SNOWMANTASTIC": "Snowmantastic", + "SNPAD": "SNP adverse", "SNPC": "SnapCoin", "SNPS": "Snaps", "SNRG": "Synergy", + "SNRK": "Snark Launch", "SNS": "Synesis One", + "SNST": "Smooth Network Solutions Token", + "SNSY": "Sensay", "SNT": "Status Network Token", "SNTR": "Silent Notary", "SNTVT": "Sentivate", "SNX": "Synthetix", "SNY": "Synthetify ", + "SO": "Shiny Ore", + "SOAI": "SOAI", "SOAK": "Soak Token", "SOAR": "Soarcoin", + "SOARX": "Soarx Coin", "SOBA": "SOBA Token", + "SOBB": "SoBit", + "SOBER": "Solabrador", "SOC": "All Sports Coin", + "SOCA": "Socaverse", "SOCC": "SocialCoin", + "SOCCER": "SoccerInu", "SOCKS": "Unisocks", + "SOCOLA": "SOCOLA INU", "SODA": "SODA Coin", "SODO": "Scooby Doo", "SOFI": "RAI Finance", + "SOFTCO": "SOFT COQ INU", + "SOH": "Stohn Coin", + "SOHOT": "SOHOTRN", "SOIL": "SoilCoin", "SOJ": "Sojourn Coin", "SOKU": "Soku Swap", "SOL": "Solana", + "SOL10": "SOLANA MEME TOKEN", "SOLA": "Sola", + "SOLAI": "Solana AI BNB", + "SOLALA": "Solala", + "SOLAMA": "Solama", + "SOLAMB": "SOLAMB", + "SOLAPE": "SolAPE Token", "SOLAR": "Solar", + "SOLARA": "Solara", "SOLARDAO": "Solar DAO", + "SOLARE": "Solareum", "SOLARFARM": "SolarFarm", "SOLARIX": "SOLARIX", + "SOLAV": "SOLAV TOKEN", + "SOLBET": "SOL STREET BETS", + "SOLBULL": "SOLBULL", + "SOLC": "SolCard", + "SOLCASH": "SOLCash", + "SOLCAT": "SOLCAT", + "SOLCEX": "SolCex", "SOLE": "SoleCoin", + "SOLETF": "SOL ETF", + "SOLEX": "Solex Launchpad", + "SOLFI": "SoliDefi", + "SOLGOAT": "SOLGOAT", + "SOLGUN": "Solgun", "SOLID": "Solidified", + "SOLIDSEX": "SOLIDsex: Tokenized veSOLID", + "SOLKIT": "Solana Kit", + "SOLLY": "Solly", + "SOLMATES": "SOLMATES", + "SOLMEME": "TrumpFFIEGMEBidenCAT2024AMC", + "SOLNAV": "SOLNAV AI", + "SOLNIC": "Solnic", "SOLO": "Sologenic", + "SOLPAD": "Solpad Finance", + "SOLPAKA": "Solpaka", + "SOLPENG": "SOLPENG", "SOLR": "SolRazr", + "SOLS": "sols", + "SOLSCC": "sols", + "SOLSPONGE": "Solsponge", "SOLVE": "SOLVE", + "SOLWIF": "Solwif", "SOLX": "SolarX", + "SOLY": "Solamander", + "SOLZILLA": "Solzilla", "SOM": "Souls of Meta", "SOMA": "Soma", + "SOMM": "Sommelier", "SOMNIUM": "Somnium Space CUBEs", "SON": "Simone", "SONAR": "SonarWatch", "SONG": "Song Coin", + "SONGOKU": "SONGOKU", "SONIC": "Sonic", + "SONICO": "Sonic", + "SONICWIF": "SonicWifHat", + "SONNE": "Sonne Finance", "SOON": "Soonaverse", "SOONCOIN": "SoonCoin", "SOP": "SoPay", + "SOPHON": "Sophon (Atomicals)", + "SOR": "Sorcery", "SORA": "Sora Validator Token", + "SORACEO": "SORA CEO", + "SORADOGE": "Sora Doge", + "SORAETH": "SORA", + "SORAI": "Sora AI", + "SORAPORN": "Sora Porn", "SOSNOVKINO": "Sosnovkino", + "SOT": "Soccer Crypto", "SOTA": "SOTA Finance", "SOUL": "Phantasma", + "SOULO": "SouloCoin", "SOULS": "Soulsaver", "SOUND": "Sound Coin", "SOURCE": "ReSource Protocol", + "SOUTH": "DeepSouth AI", "SOV": "Sovryn", "SOVE": "Soverain", + "SOVI": "Sovi Finance", + "SOWA": "Sowa AI", + "SOX": "Nobby Game", "SOY": "Soy Finance", "SP": "Sex Pistols", "SP8DE": "Sp8de", @@ -7556,6 +11179,8 @@ "SPACE": "Spacelens", "SPACECOIN": "SpaceCoin", "SPACEPI": "SpacePi", + "SPAD": "SolPad", + "SPAI": "Starship AI", "SPAIN": "SpainCoin", "SPANK": "SpankChain", "SPARKO": "Sparko", @@ -7565,31 +11190,50 @@ "SPC": "SpaceChain ERC20", "SPC.QRC": "SpaceChain (QRC-20)", "SPCIE": "Specie", + "SPCT": "Spectra Chain", "SPD": "Stipend", "SPDR": "SpiderDAO", + "SPDX": "Speedex", "SPE": "SavePlanetEarth", "SPEC": "SpecCoin", + "SPECT": "Spectral", + "SPECTRE": "SPECTRE AI", + "SPEEDY": "Speedy", "SPELL": "Spell Token", "SPELLFIRE": "Spellfire", "SPEND": "Spend", "SPENDC": "SpendCoin", + "SPENT": "Espento", + "SPEPE": "SolanaPepe", + "SPEX": "StepEx", "SPF": "SportyCo", "SPFC": "São Paulo FC Fan Token", "SPG": "Space Crypto", + "SPGBB": "SPGBB", "SPH": "Spheroid Universe", + "SPHERE": "Sphere Finance", "SPHR": "Sphere Coin", "SPHRI": "Spherium", "SPHTX": "SophiaTX", "SPHYNX": "Sphynx Token", + "SPHYNXV1": "Sphynx Token v1", + "SPHYNXV2": "Sphynx Token v2", + "SPHYNXV3": "Sphynx Token v3", + "SPHYNXV4": "Sphynx Token v4", "SPI": "Shopping.io", "SPICE": "Spice", "SPIDER": "Spider Man", + "SPIDERMAN": "SPIDERMAN BITCOIN", + "SPIDEY": "Spidey", "SPIKE": "Spiking", + "SPILLWAYS": "SpillWays", "SPIN": "SPIN Protocol", "SPIRIT": "SpiritSwap", + "SPIZ": "SPACE-iZ", "SPK": "SparksPay", "SPKL": "SpokLottery", "SPKTR": "Ghost Coin", + "SPL": "SocialPal", "SPLA": "SmartPlay", "SPM": "Supreme", "SPN": "Sapien Network", @@ -7597,37 +11241,60 @@ "SPO": "Spores Network", "SPOK": "Spock", "SPOL": "Starterpool", + "SPONGE": "Sponge", + "SPONGEBOB": "Spongebob Squarepants", + "SPOODY": "Spoody Man", + "SPOOF": "Spoofify", "SPOOL": "Spool DAO Token", "SPORE": "Enoki Finance", "SPORT": "SportsCoin", + "SPORTS": "ZenSports", "SPOTS": "Spots", + "SPOX": "Sports Future Exchange Token", + "SPRING": "Spring", + "SPRITZMOON": "SpritzMoon Crypto Token", "SPRKL": "Sparkle Loyalty", "SPRT": "Sportium", "SPRTS": "Sprouts", "SPRTZ": "SpritzCoin", + "SPRX": "Sprint Coin", "SPS": "Splinterlands", "SPT": "SPECTRUM", "SPUME": "Spume", + "SPURDO": "spurdo", "SPURS": "Tottenham Hotspur Fan Token", "SPWN": "Bitspawn", "SPX": "SPX6900", "SPXC": "SpaceXCoin", "SPY": "Smarty Pay", + "SPYRO": "SPYRO", "SQAT": "Syndiqate", "SQG": "Squid Token", "SQL": "Squall Coin", "SQR": "Magic Square", + "SQT": "SubQuery Network", + "SQTS": "Sqts (Ordinals)", + "SQUA": "Square Token", + "SQUAD": "Superpower Squad", "SQUATCH": "SASQUATCH", "SQUAWK": "Squawk", "SQUEEZER": "Squeezer", + "SQUIBONK": "SQUIBONK", "SQUID": "Squid Game", + "SQUID2": "Squid Game 2.0", "SQUIDGROW": "SquidGrow", + "SQUIDGROWV1": "SquidGrow v1", + "SQUOGE": "DogeSquatch", "SRBP": "Super Rare Ball Potion", "SRC": "SecureCoin", + "SRCH": "SolSrch", "SRCOIN": "SRCoin", + "SRCX": "Source Token", "SREUR": "SocialRemit", + "SRG": "Street Runner NFT", "SRK": "SparkPoint", "SRLTY": "SaitaRealty", + "SRLY": "Rally (Solana)", "SRM": "Serum", "SRN": "SirinLabs", "SRNT": "Serenity", @@ -7636,23 +11303,32 @@ "SRWD": "ShibRWD", "SRX": "StorX", "SS": "Sharder", + "SSB": "SatoshiStreetBets", "SSC": "SelfSell", "SSD": "Sonic Screw Driver Coin", + "SSE": "Soroosh Smart Ecosystem", "SSG": "Surviving Soldiers", "SSGT": "Safeswap", "SSH": "StreamSpace", + "SSHIB": "Solana Shib", "SSHIP": "SSHIP", + "SSLX": "StarSlax", + "SSNC": "SatoshiSync", "SSS": "StarSharks", "SST": "SIMBA Storage Token", "SSTC": "SunShotCoin", + "SSU": "Sunny Side up", "SSV": "ssv.network", "SSVCOIN": "SSVCoin", + "SSVV1": "Blox", "SSWP": "Suiswap", "SSX": "SOMESING", "ST": "Skippy Token", - "STA": "Defi STOA", + "STA": "STOA Network", + "STABLZ": "Stablz", "STAC": "STAC", "STACK": "StackOS", + "STACKS": "STACKS", "STACS": "STACS Token", "STAK": "Jigstack", "STAKE": "xDai Chain", @@ -7660,6 +11336,7 @@ "STALIN": "StalinCoin", "STAMP": "SafePost", "STANDARD": "Stakeborg DAO", + "STAPT": "Ditto Staked Aptos", "STAR": "FileStar", "STARAMBA": "Staramba", "STARBASE": "Starbase", @@ -7668,31 +11345,48 @@ "STARLAUNCH": "StarLaunch", "STARLY": "Starly", "STARP": "Star Pacific Coin", - "STARS": "Mogul Productions", + "STARRI": "starri", + "STARS": "Stargaze", "STARSH": "StarShip Token", "STARSHI": "Starship", "STARSHIP": "STARSHIP", + "STARSHIPDOGE": "Starship Doge", "START": "StartCoin", "STARTA": "Starta", + "STARWARS": "Star Wars", + "STARX": "STARX TOKEN", "STASH": "BitStash", "STAT": "STAT", + "STATE": "New World Order", "STATER": "Stater", "STATERA": "Statera", + "STATOM": "Stride Staked ATOM", + "STATS": "Stats", "STAX": "Staxcoin", + "STAY": "NFsTay", + "STBOT": "SolTradingBot", "STBU": "Stobox Token", "STC": "Satoshi Island", "STCN": "Stakecoin", + "STD": "STEED", + "STDYDX": "Stride Staked DYDX", + "STEAK": "SteakHut Finance", "STEALTH": "StealthPad", + "STEAMPUNK": "SteamPunk", "STEEM": "Steem", "STEEMD": "Steem Dollars", "STEEP": "SteepCoin", + "STELLA": "StellaSwap", + "STEMX": "STEMX", "STEN": "Steneum Coin", "STEP": "Step Finance", + "STEPG": "StepG", "STEPH": "Step Hero", "STEPR": "Step", "STEPS": "Steps", "STERLINGCOIN": "SterlingCoin", "STETH": "Staked Ether", + "STEVMOS": "Stride Staked EVMOS", "STEWIE": "Stewie Coin", "STEX": "STEX", "STF": "Structure Finance", @@ -7700,27 +11394,43 @@ "STG": "Stargate Finance", "STHR": "Stakerush", "STI": "Seek Tiger", + "STIC": "StickMan", "STIK": "Staika", + "STIMA": "STIMA", "STING": "Sting", + "STINJ": "Stride Staked INJ", + "STJUNO": "Stride Staked JUNO", "STK": "STK Token", "STKAAVE": "Staked Aave", "STKATOM": "pSTAKE Staked ATOM", + "STKBNB": "pSTAKE Staked BNB", + "STKC": "Streakk Chain", + "STKHUAHUA": "pSTAKE Staked HUAHUA", "STKK": "Streakk", + "STKSTARS": "pSTAKE Staked STARS", "STKXPRT": "pSTAKE Staked XPRT", + "STLE": "Saint Ligne", "STMAN": "Stickman Battleground", + "STMATIC": "Lido Staked Matic", "STMX": "StormX", "STND": "Standard Protocol", + "STNEAR": "Staked NEAR", "STO": "Save The Ocean", + "STOG": "Stooges", "STOGE": "Stoner Doge Finance", - "STON": "Ston", + "STOIC": "stoicDAO", + "STON": "STON", "STONE": "Stone Token", "STONK": "STONK", + "STONKS": "HarryPotterObamaWallStreetBets10Inu", "STOP": "SatoPay", "STOR": "Self Storage Coin", "STORE": "Bit Store", "STORJ": "Storj", "STORM": "Storm", + "STORY": "Story", "STOS": "Stratos", + "STOSMO": "Stride Staked OSMO", "STOX": "Stox", "STP": "StashPay", "STPL": "Stream Protocol", @@ -7731,36 +11441,62 @@ "STRAX": "Stratis", "STRAY": "Stray Dog", "STRD": "Stride", + "STRDY": "Sturdy", "STREAM": "STREAMIT COIN", + "STREETH": "STREETH", + "STRI": "Strite", + "STRIKE": "Strike", "STRIP": "Stripto", - "STRK": "Strike", + "STRK": "Starknet", "STRM": "StreamCoin", "STRNGR": "Stronger", "STRONG": "Strong", "STRONGX": "StrongX", "STRP": "Strips Finance", "STRS": "STARS", + "STRUMP": "Super Trump", "STRX": "StrikeX", "STS": "SBank", "STSOL": "Lido Staked SOL", + "STSOMM": "Stride Staked SOMM", "STSR": "SatelStar", + "STSTARS": "Stride Staked Stars", + "STSW": "Stackswap", "STT": "Statter Network ", + "STTAO": "Tensorplex Staked TAO", + "STTIA": "Stride Staked TIA", + "STTON": "bemo staked TON", "STU": "BitJob", "STUART": "Stuart Inu", + "STUCK": "mouse in pasta", + "STUD": "Studyum", "STUDENTC": "Student Coin", + "STUMEE": "Stride Staked UMEE", "STUSDT": "Staked USDT", "STV": "Sativa Coin", "STWEMIX": "Staked WEMIX", "STX": "Stacks", + "STYL": "Stylike Governance", + "STYLE": "Style", "STZ": "99Starz", "STZEN": "StakedZEN", - "SUB": "Substratum Network", + "STZETA": "ZetaEarn", + "STZU": "Shihtzu Exchange Token", + "SU": "Smol Su", + "SUBAWU": "Subawu Token", + "SUBF": "Super Best Friends", + "SUBS": "Substratum Network", "SUCR": "Sucre", "SUDO": "sudoswap", "SUGAR": "Sugar Exchange", "SUI": "Sui", + "SUIA": "SUIA", + "SUIP": "SuiPad", + "SUISHIB": "SuiShiba", "SUKU": "SUKU", + "SULFERC": "SULFERC", "SUM": "SumSwap", + "SUMMER": "Summer", "SUMO": "Sumokoin", "SUN": "Sun Token", "SUNC": "Sunrise", @@ -7769,6 +11505,7 @@ "SUNI": "SUNI", "SUNNY": "Sunny Aggregator", "SUNOLD": "Sun Token", + "SUNV1": "Sun Token v1", "SUP": "Supcoin", "SUP8EME": "SUP8EME Token", "SUPE": "Supe Infinity", @@ -7781,65 +11518,102 @@ "SURF": "Surf.Finance", "SURV": "Survival Game Online", "SUSD": "sUSD", + "SUSDE": "Ethena Staked USDe", "SUSHI": "Sushi", + "SUTEKU": "Suteku", "SUTER": "Suterusu", "SUZUME": "Shita-kiri Suzume", "SVD": "savedroid", + "SVL": "Slash Vision Labs", + "SVN": "Savanna", + "SVNN": "Savanna Haus", + "SVPN": "Shadow Node", "SVS": "GivingToServices SVS", "SVT": "Solvent", "SVX": "Savix", + "SVY": "Savvy", "SWA": "Swace", "SWACH": "Swachhcoin", "SWAG": "SWAG Finance", + "SWAI": "Safe Water AI", + "SWAMP": "Swampy", "SWAP": "Trustswap", "SWAPP": "SWAPP Protocol", "SWAPZ": "SWAPZ.app", "SWARM": "SwarmCoin", - "SWARMM": "Swarm Markets", "SWASH": "Swash", "SWAY": "Sway Social", "SWC": "Scanetchain Token", + "SWCH": "SwissCheese", "SWD": "SW DAO", "SWDAO": "Super Whale DAO", "SWEAT": "Sweat Economy", + "SWEEP": "Sweeptoken", "SWEET": "SweetStake", + "SWETH": "swETH", "SWFL": "Swapfolio", "SWFTC": "SWFTCoin", + "SWG": "Swirge", + "SWH": "simbawifhat", "SWIFT": "BitSwift", + "SWIFTIES": "Taylor Swift", + "SWIM": "SWIM - Spread Wisdom", + "SWIN": "SwinCoin", "SWING": "SwingCoin", "SWINGBY": "Swingby", + "SWIPES": "BNDR", + "SWIRL": "Swirl Social", "SWIRLX": "SwirlToken", "SWISE": "StakeWise", + "SWITCH": "Switch", "SWM": "Swarm Fund", + "SWOLE": "Swole Doge", "SWOP": "Swop", + "SWORD": "eZKalibur", + "SWOT": "Swot AI", "SWP": "Kava Swap", + "SWPR": "Swapr", + "SWPRS": "Maid Sweepers", "SWRV": "Swerve", "SWT": "Swarm City Token", "SWTH": "Carbon", + "SWTS": "SWEETS", + "SWU": "Smart World Union", + "SWY": "Swype", "SWYFTT": "SWYFT", "SX": "SX Network", "SXC": "SexCoin", + "SXCH": "SolarX", "SXDT": "SPECTRE Dividend Token", + "SXM": "saxumdao", "SXP": "SXP", "SXS": "Sphere", "SXUT": "SPECTRE Utility Token", + "SYA": "SaveYourAssets", "SYBC": "SYB Coin", "SYBL": "Sybulls", "SYBTC": "sBTC", "SYC": "SynchroCoin", "SYL": "XSL Labs", "SYLO": "Sylo", + "SYLV": "Sylvester", "SYM": "SymVerse", "SYN": "Synapse", - "SYNC": "Sync Network", + "SYNC": "Syncus", "SYNCC": "SyncCoin", + "SYNCN": "Sync Network", "SYNCO": "Synco", "SYNLEV": "SynLev", + "SYNO": "Synonym Finance", "SYNR": "MOBLAND", + "SYNT": "Synthetix Network", + "SYNTH": "Synthswap", "SYNX": "Syndicate", "SYPOOL": "Sypool", - "SYS": "SysCoin", + "SYS": "Syscoin", + "SZCB": "Zugacoin", "T": "Threshold Network Token", + "T23": "T23", "T99": "Tethereum", "TAAS": "Token as a Service", "TAB": "MollyCoin", @@ -7847,57 +11621,85 @@ "TAC": "Traceability Chain", "TACHYON": "Tachyon Protocol", "TAD": "Tadpole Finance", + "TADA": "Ta-da", "TAF": "TAF", "TAGR": "Think And Get Rich Coin", - "TAI": "tBridge Token", + "TAI": "TARS Protocol", + "TAIKO": "Taiko", + "TAIKULA": "TAIKULA COIN", + "TAIL": "Tail", "TAIYO": "Taiyo", "TAJ": "TajCoin", "TAK": "TakCoin", "TAKI": "Taki", "TALAO": "Talao", + "TALIS": "Talis Protocol", "TALK": "Talken", "TAMA": "Tamadoge", "TAN": "Taklimakan", + "TANG": "Tangent", "TANGO": "keyTango", + "TANGYUAN": "TangYuan", "TANK": "CryptoTanks", + "TANPIN": "Tanpin", + "TANUKI": "Tanuki", "TAO": "Bittensor", + "TAONU": "TAO INU", "TAP": "TAP FANTASY", "TAPC": "Tap Coin", "TAPPINGCOIN": "TappingCoin", + "TAPROOT": "Taproot Exchange", "TAPT": "Tortuga Staked Aptos", "TARA": "Taraxa", + "TARAL": "TARALITY", + "TARD": "Tard", "TARI": "Tari World", "TAROT": "Tarot", + "TAROTV1": "Tarot v1", "TARP": "Totally A Rug Pull", "TAS": "TARUSH", "TASH": "Smart Trip Platform", "TASTE": "TasteNFT", "TAT": "Tatcoin", + "TATA": "TATA Coin", + "TATE": "Tate", + "TATSU": "Taτsu", "TAU": "Lamden Tau", "TAUC": "Taurus Coin", "TAUM": "Orbitau Taureum", "TAUR": "Marnotaur", "TAVA": "ALTAVA", + "TAX": "MetaToll", "TBAC": "BlockAura", + "TBANK": "TaoBank", "TBAR": "Titanium BAR", "TBB": "Trade Butler Bot", "TBC": "Ten Best Coins", "TBCC": "TBCC", + "TBCI": "tbci", "TBCX": "TrashBurn", + "TBD": "THE BIG DEBATE", "TBE": "TrustBase", + "TBFT": "Türkiye Basketbol Federasyon Token", "TBIS": "TBIS token", "TBL": "Tombola", + "TBRIDGE": "tBridge Token", "TBT": "T-BOT", "TBTC": "tBTC", + "TBTCV1": "tBTC v1", "TBX": "Tokenbox", "TCANDY": "TripCandy", "TCAP": "Total Crypto Market Cap", + "TCAT": "The Currency Analytics", "TCC": "The ChampCoin", + "TCG": "Today's Crypto", "TCG2": "TCG Coin 2.0", + "TCGC": "TCG Verse", "TCH": "Thorecash", "TCHAIN": "Tchain", "TCHB": "Teachers Blockchain", "TCHTRX": "ThoreCashTRX", + "TCNH": "TrueCNH", "TCNX": "Tercet Network", "TCO": "ThinkCoin", "TCP": "The Crypto Prophecies", @@ -7907,7 +11709,9 @@ "TCX": "T-Coin", "TCY": "The Crypto You", "TD": "Trade Chain", + "TDAN": "TDAN", "TDE": "Trade Ecology Token", + "TDEFI": "Token Teknoloji A.S. Token DeFi", "TDFB": "TDFB", "TDFY": "Tidefi", "TDP": "TrueDeck", @@ -7917,27 +11721,37 @@ "TEA": "TeaDAO", "TEAM": "TeamUP", "TEC": "TeCoin", + "TECAR": "Tesla Cars", "TECH": "TechCoin", "TECRA": "TecraCoin", - "TEDDY": "Teddy Doge", - "TEDDYV2": "Teddy Doge v2", + "TED": "TED", + "TEDDY": "Teddy Doge v2", + "TEDDYV1": "Teddy Doge", "TEE": "Guarantee", "TEER": "Integritee", + "TEITEI": "TeiTei", "TEK": "TekCoin", "TEL": "Telcoin", "TELE": "Miracle Tele", + "TELEBTC": "teleBTC", "TELL": "Tellurion", + "TELLER": "Teller", + "TELO": "Telo Meme Coin", "TELOS": "Teloscoin", "TEM": "Temtum", "TEMCO": "TEMCO", + "TEMP": "Tempus", + "TEMPLE": "TempleDAO", "TEN": "Tokenomy", "TEND": "Tendies", "TENDIE": "TendieSwap", + "TENET": "TENET", "TENFI": "TEN", "TENNET": "Tennet", "TENSHI": "Tenshi", "TENT": "TENT", "TEP": "Tepleton", + "TEQ": "Teq Network", "TER": "TerraNovaCoin", "TERA": "TERA", "TERADYNE": "Teradyne", @@ -7945,13 +11759,21 @@ "TERAWATT": "Terawatt", "TERN": "Ternio", "TERN.ETH": "Ternio ERC20", - "TERRA": "TERRABYTE AI", + "TERR": "Terrier", + "TERRA": "Terraport", + "TERRAB": "TERRABYTE AI", + "TERZ": "SHELTERZ", "TES": "TeslaCoin", "TESLA": "TeslaCoilCoin", + "TESLAI": "Tesla AI", + "TEST": "Test", "TESTA": "Testa", "TET": "Tectum", "TETHER": "Hermione", + "TETHYS": "Tethys", "TETRA": "Tetra", + "TETU": "TETU", + "TEW": "Trump in a memes world", "TFBX": "Truefeedback Token", "TFC": "The Freedom Coin", "TFI": "TrustFi Network Token", @@ -7963,27 +11785,41 @@ "TGAME": "TrueGame", "TGC": "TigerCoin", "TGCC": "TheGCCcoin", + "TGPT": "Trading GPT", + "TGRAM": "TG20 TGram", "TGT": "TargetCoin", "TH": "Team Heretics Fan Token", "THALES": "Thales", + "THAVAGE": "Mike Tython", "THC": "The Hempcoin", "THE": "The Protocol", + "THE9": "THE9", "THEBLOX": "The Blox Project", + "THECA": "Theca", "THECITADEL": "The Citadel", "THEDAO": "The DAO", + "THEHARAMBE": "Harambe", "THEMIS": "Themis", "THEN": "THENA", + "THEO": "Theopetra", "THEOS": "Theos", "THETA": "Theta Network", - "THETRIBE": "The Tribe ()", + "THETRIBE": "The Tribe", "THEX": "Thore Exchange", "THG": "Thetan Arena", + "THIK": "ThikDik", + "THING": "Nothing", + "THINKWAREAI": "ThinkwareAI", + "THL": "Thala", "THN": "Throne", "THNX": "ThankYou", "THO": "Athero", + "THOL": "AngelBlock", "THOR": "THORSwap", + "THOREUM": "Thoreum V3", "THP": "TurboHigh Performance", "THR": "Thorecoin", + "THREE": "Three Protocol Token ", "THRT": "ThriveToken", "THRY": "THEORY", "THS": "TechShares", @@ -7991,15 +11827,20 @@ "THUG": "Thug Life", "THUNDER": "ThunderStake", "THX": "Thorenext", + "TI": "Titanium22", "TIA": "Celestia", "TIANHE": "Tianhe", "TIC": "TrueInvestmentCoin", "TIDAL": "Tidal Finance", + "TIDE": "Tidalflats", "TIE": "Ties Network", "TIFI": "TiFi Token", "TIG": "Tigereum", "TIGER": "JungleKing TigerCoin", "TIGERC": "TigerCash", + "TIGERMOON": "TigerMoon", + "TIGRA": "Tigra", + "TIGRES": "Tigres Fan Token", "TIIM": "TriipMiles", "TIK": "ChronoBase", "TIKI": "Tiki Token", @@ -8011,17 +11852,24 @@ "TINC": "Tiny Coin", "TINKU": "TinkuCoin", "TINU": "Telegram Inu", + "TINY": "TinyBits", "TIOX": "TIOx", "TIP": "Tip Blockchain", + "TIPINU": "Tip Inu", "TIPS": "FedoraCoin", + "TIPSY": "TipsyCoin", "TIT": "TittieCoin", "TITA": "Titan Hunters", "TITAN": "TitanSwap", "TITANO": "Titano", "TITANX": "TitanX", "TITC": "TitCoin", + "TITI": "TiTi Protocol", + "TITTY": "TamaKitty", + "TIUSD": "TiUSD", "TIX": "Blocktix", "TKA": "Tokia", + "TKAI": "TAIKAI", "TKB": "TokenBot", "TKC": "TurkeyChain", "TKG": "Takamaka Green Coin", @@ -8033,49 +11881,80 @@ "TKP": "TOKPIE", "TKR": "CryptoInsight", "TKS": "Tokes", + "TKST": "TokenSight", "TKT": "Crypto Tickets", + "TKX": "Tokenize Xchange", "TKY": "THEKEY Token", + "TLC": "Trillioner", "TLM": "Alien Worlds", "TLN": "Trustlines Network", "TLOS": "Telos", "TLP": "TulipCoin", + "TLW": "TILWIKI", + "TMANIA": "Trump Mania", "TME": "Timereum", "TMED": "MDsquare", + "TMFT": "Turkish Motorcycle Federation", "TMN": "TranslateMe", + "TMNG": "TMN Global", + "TMNT": "TMNT", "TMON": "Two Monkey Juice Bar", + "TMRW": "TMRW Coin", + "TMSH": "Bursaspor Fan Token", "TMT": "Tamy Token", "TMTG": "The Midas Touch Gold", + "TMWH": "Tom Wif Hat", "TN": "TurtleNetwork", "TNB": "Time New Bank", "TNC": "TNC Coin", + "TNDC": "TendaCoin", + "TNGBL": "Tangible", "TNS": "Transcodium", + "TNSR": "Tensor", "TNT": "Tierion", "TOA": "TOA Coin", "TOAD": "TOAD", + "TOB": "Tom On Base", + "TOBI": "MOTO DOG", + "TOBY": "toby", "TOC": "TouchCon", "TODAY": "TodayCoin", + "TODD": "TURBO TODD", "TOK": "Tokenplace", + "TOKA": "Tonka Finance", "TOKAU": "Tokyo AU", "TOKC": "Tokyo Coin", "TOKE": "Tokemak", "TOKEN": "TokenFi", "TOKENSTARS": "TokenStars", + "TOKERO": "TOKERO LevelUP Token", "TOKKI": "CRYPTOKKI", "TOKO": "ToKoin", "TOKU": "TokugawaCoin", "TOL": "Tolar", + "TOLO": "Tolo Yacoloco", + "TOLYCAT": "Toly's Cat", "TOM": "TOM Finance", "TOMAHAWKCOIN": "Tomahawkcoin", + "TOMAN": "IRR", "TOMB": "Tomb", "TOMI": "tomiNet", "TOMOE": "TomoChain ERC20", "TOMS": "TomTomCoin", "TON": "Tokamak Network", + "TONALD": "Tonald Trump", "TONCOIN": "The Open Network", "TONE": "TE-FOOD", + "TONI": "Daytona Finance", "TONIC": "Tectonic", + "TONK": "Tonk Inu", + "TONNEL": "TONNEL Network", + "TONS": "TONSniper", "TONTOKEN": "TONToken", + "TONUP": "TonUP", + "TONY": "TONY THE DUCK", "TOOB": "Toobcoin", + "TOOKER": "tooker kurlson", "TOOLS": "TOOLS", "TOON": "Pontoon", "TOONF": "Toon Finance", @@ -8084,19 +11963,28 @@ "TOPIA": "Hytopia", "TOPN": "TOP Network", "TOR": "TOR", + "TORE": "Toreus Finance", "TORG": "TORG", + "TORI": "Teritori", "TORII": "Torii Finance", "TORN": "Tornado Cash", "TOS": "ThingsOperatingSystem", "TOSA": "TosaInu BSC", "TOSC": "T.OS", + "TOSDIS": "TosDis", + "TOSHE": "Toshe", "TOSHI": "Toshi", "TOT": "TotCoin", + "TOTEM": "DragonMaster", "TOTM": "Totem", + "TOTO": "TOTO", "TOUCHFAN": "TouchFan", + "TOURISTS": "TOURIST SHIBA INU", "TOWER": "Tower", "TOWN": "Town Star", "TOX": "INTOverse", + "TOXI": "ToxicGarden.finance SEED", + "TOYBOX": "Memefi Toybox 404", "TOZ": "Tozex", "TP": "Token Swap", "TPAD": "TrustPad", @@ -8104,23 +11992,33 @@ "TPC": "Techpay", "TPCASH": "TPCash", "TPG": "Troll Payment", + "TPRO": "TPRO Network", "TPT": "Token Pocket", + "TPU": "TensorSpace", + "TPV": "TravGoPV", "TPY": "Thrupenny", + "TQRT": "TokoQrt", "TR3": "Tr3zor", "TRA": "Trabzonspor Fan Token", "TRAC": "OriginTrail", "TRACE": "Trace Network Labs", + "TRACEA": "Trace AI", "TRACN": "trac (Ordinals)", "TRADE": "Polytrade", + "TRADEBOT": "TradeBot", "TRADEX": "TradeX AI", "TRAID": "Traid", + "TRAIN": "Trump Train", "TRAK": "TrakInvest", + "TRANQ": "Tranquil Finance", + "TRANS": "Trans Pepe", "TRAT": "Tratok", "TRAVA": "Trava Finance", "TRAXIA": "Traxia Membership Token", "TRAXX": "Traxx", "TRB": "Tellor", "TRBT": "Tribute", + "TRBV1": "Tellor Tributes v1", "TRC": "TerraCoin", "TRCB": "TRCB Chain", "TRCL": "Treecle", @@ -8129,10 +12027,14 @@ "TRDL": "Strudel Finance", "TRDS": "Traders Token", "TRDT": "Trident", + "TREAT": "Treat", "TRECENTO": "Trecento Blockchain Capital", "TREE": "HyperionX", "TREEB": "Retreeb", + "TREMP": "Doland Tremp", + "TRESTLE": "TRESTLE", "TRET": "Tourist Review", + "TRG": "The Rug Game", "TRGI": "The Real Golden Inu", "TRI": "Triangles Coin", "TRIA": "Triaconta", @@ -8146,34 +12048,69 @@ "TRINI": "Trinity Network Credit", "TRIO": "Tripio", "TRIPAD": "TripAdvisor, Inc.", + "TRIVIA": "Trivians", "TRIX": "TriumphX", "TRK": "TruckCoin", "TRL": "Triall", + "TRMX": "TourismX Token", "TRNDZ": "Trendsy", + "TROG": "Trog", "TROLL": "Trollcoin", "TROLLHEIM": "Trollheim", + "TROLLMODE": "TROLL MODE", "TRONPAD": "TRONPAD", "TROP": "Interop", + "TROSS": "Trossard", + "TROVE": "Arbitrove Governance Token", "TROY": "Troy", "TRP": "Tronipay", "TRR": "Terran Coin", + "TRSCT": "Transactra Finance", "TRST": "TrustCoin", "TRTL": "TurtleCoin", "TRTT": "Trittium", "TRU": "TrueFi", + "TRUCE": "WORLD PEACE PROJECT", "TRUE": "True Chain", "TRUEBIT": "Truebit Protocol", - "TRUMP": "TrumpCoin", + "TRUF": "Truflation", + "TRUM": "TrumpBucks", + "TRUMAGA": "TrumpMAGA", + "TRUMATIC": "TruFin Staked MATIC", + "TRUMP": "MAGA", + "TRUMP2024": "Donald Trump", + "TRUMPARMY": "Trump Army", + "TRUMPBIDEN": "Trump vs Biden", + "TRUMPCAT": "TRUMPCAT", + "TRUMPCOIN": "TrumpCoin", + "TRUMPDO": "TRUMP", + "TRUMPDOGE": "Trump Doge", + "TRUMPE": "Trump Pepe", + "TRUMPEPE": "Trump Pepe", + "TRUMPHAT": "Trump Hat", + "TRUMPINU": "Trump Inu", + "TRUMPJR": "TrumpJr", + "TRUMPSHIBA": "Trump Shiba", + "TRUMPTECH": "Trump Tech", + "TRUMPTITANS": "TrumpTitans", + "TRUMPX": "Trump X-Maga", + "TRUMPZ": "Trump Zhong", + "TRUNK": "Elephant Money", "TRUST": "TrustDAO", + "TRUSTNFT": "TrustNFT", "TRUTH": "TruthGPT", "TRV": "TrustVerse", "TRVC": "Trivechain", "TRVL": "TRVL", + "TRWP": "Danol Tremp", "TRX": "TRON", + "TRXC": "TRONCLASSIC", "TRXDICE": "TRONdice", + "TRXS": "Staked TRX", "TRXWIN": "TronWin", "TRYB": "BiLira", "TRYC": "TRYC", + "TRYHARDS": "TryHards", "TRYX": "eToro Turkish Lira", "TSA": "Teaswap Art", "TSC": "TrusterCoin", @@ -8182,14 +12119,18 @@ "TSE": "TattooCoin", "TSF": "Transaction Service Fee", "TSG": "The Soldiers Gold", + "TSHARE": "Tomb Shares", "TSHP": "12Ships", "TSL": "Energo", + "TSN": "Tsunami Exchange Token", "TSR": "Tesra", + "TSUBASAUT": "TSUBASA Utility Token", "TSUGT": "Captain Tsubasa", "TSUKA": "Dejitaru Tsuka", "TSX": "TradeStars", "TT": "ThunderCore", "TTC": "TTC PROTOCOL", + "TTF": "TurboTrix Finance", "TTK": "The Three Kingdoms", "TTM": "To The Moon", "TTN": "Titan Coin", @@ -8197,48 +12138,85 @@ "TTU": "TaTaTu", "TTV": "TV-TWO", "TUBE": "BitTube", + "TUBES": "TUBES", + "TUCKER": "TUCKER CARLSON", "TUDA": "Tutor's Diary", + "TUF": "TUF Token", + "TUGOU": "TuGou", "TULIP": "Tulip Protocol", + "TUNA": "TUNACOIN", + "TUNE": "Bitune", "TUP": "Tenup", "TUR": "Turron", "TURBO": "Turbo Wallet", "TURBOS": "Turbos Finance", + "TURBOT": "Turbo", + "TURT": "TurtSat", + "TUS": "Treasure Under Sea", "TUSD": "True USD", + "TUSDV1": "True USD v1", "TUT": "Tutellus", + "TUTTER": "Tutter", + "TUZKI": "Tuzki", + "TUZLA": "Tuzlaspor Token", "TVK": "Terra Virtua Kolect", + "TVNT": "TravelNote", + "TVRS": "TiraVerse", + "TW": "Winners Coin", "TWC": "Twilight", "TWD": "Terra World Token", "TWEE": "TWEEBAA", + "TWEETY": "Tweety", + "TWELVE": "TWELVE ZODIAC", "TWEP": "The Web3 Project", + "TWIFB": "TrumpWifBiden", "TWIN": "Twinci", "TWIST": "TwisterCoin", "TWLV": "Twelve Coin", + "TWOCAT": "TwoTalkingCats", + "TWOGE": "Twoge Inu", + "TWP": "TrumpWifPanda", "TWT": "Trust Wallet Token", + "TX": "Tradix", + "TX20": "Trex20", "TXA": "TXA", + "TXAG": "tSILVER", + "TXAU": "tGOLD", + "TXBIT": "Txbit Token", "TXG": "TRUSTxGAMING", - "TXL": "Tixl", + "TXL": "Autobahn Network", "TXT": "TuneTrade", + "TYBENG": "TYBENG", + "TYBG": "Base God", + "TYBGSc": "Base Goddess", "TYC": "Tycoon", "TYCOON": "CryptoTycoon", - "TYPE": "Typerium", + "TYOGHOUL": "TYO GHOUL", + "TYPE": "TypeAI", + "TYPERIUM": "Typerium", "TYRANT": "Fable Of The Dragon", + "TYRION": "Tyrion", "TYT": "Tianya Token", "TZC": "TrezarCoin", "TZKI": "Tsuzuki Inu", "U": "Unidef", "U8D": "Universal Dollar", "UAEC": "United Arab Emirates Coin", + "UAHG": "UAHg", "UAT": "UltrAlpha", + "UB": "UBit Token", "UBA": "Unbox.Art", "UBC": "Ubcoin", "UBDN": "UBD Network", "UBEX": "Ubex", "UBI": "Universal Basic Income", "UBIQ": "Ubiqoin", + "UBIT": "UBIT", "UBQ": "Ubiq", "UBT": "UniBright", "UBTC": "UnitedBitcoin", "UBX": "UBIX Network", + "UBXN": "UpBots Token", "UBXS": "UBXS", "UBXT": "UpBots", "UC": "YouLive Coin", @@ -8247,12 +12225,20 @@ "UCASH": "U.CASH", "UCG": "Universe Crystal Gene", "UCH": "UChain", + "UCJL": "Utility Cjournal", + "UCM": "UCROWDME", "UCN": "UC Coin", "UCO": "Uniris", "UCOIN": "Ucoin", + "UCON": "YouCoin Metaverse", + "UCORE": "UnityCore Protocol", + "UCR": "Ultra Clear", "UCT": "UnitedCrowd", + "UCX": "UCX", + "UDAO": "UDAO", "UDO": "Unido", "UDOO": "Hyprr", + "UDS": "Undeads Games", "UDT": "Unlock Protocol", "UEC": "United Emirates Coin", "UEDC": "United Emirate Decentralized Coin", @@ -8270,18 +12256,26 @@ "UFT": "UniLend Finance", "UGAS": "Ultrain", "UGC": "ugChain", + "UGOLD": "UGOLD Inc.", "UGT": "Universal Games Token", "UHP": "Ulgen Hash Power", + "UIBT": "Unibit", + "UIM": "UNIVERSE ISLAND", "UIN": "Alliance Chain", "UIP": "UnlimitedIP", "UIS": "Unitus", + "UJENNY": "Jenny Metaverse DAO Token", "UKG": "UnikoinGold", + "ULD": "Unlighted", "ULT": "Ultiledger", "ULTC": "Umbrella", "ULTGG": "UltimoGG", + "ULTI": "Ultiverse", "ULTIMA": "Ultima", "ULTIMATEBOT": "Ultimate Tipbot", "ULTRA": "Ultra", + "ULTRAP": "ULTRA Prisma Finance", + "ULX": "ULTRON", "UM": "UncleMine", "UMA": "UMA", "UMAD": "MADworld", @@ -8291,8 +12285,11 @@ "UMC": "Umbrella Coin", "UMI": "Universal Money Instrument", "UMK": "UMKA", + "UMMA": "UMMA Token", "UMO": "Universal Molecule", + "UMT": "UnityMeta", "UMX": "UniMex Network", + "UMY": "KaraStar UMY", "UNAT": "Unattanium", "UNB": "Unbound Finance", "UNBNK": "Unbanked", @@ -8304,34 +12301,44 @@ "UNDB": "unibot.cash", "UNDEAD": "Undead Blocks", "UNDG": "UniDexGas", + "UNDX": "UNODEX", "UNF": "Unfed Coin", "UNFI": "Unifi Protocol DAO", "UNI": "Uniswap Protocol Token", "UNIBOT": "Unibot", "UNIC": "Unicly", + "UNICE": "UNICE", "UNICORN": "UNICORN Token", + "UNIDEXAI": "UniDexAI", "UNIDX": "UniDex", + "UNIETH": "Universal ETH", "UNIFY": "Unify", "UNIM": "Unicorn Milk", "UNIQ": "Uniqredit", "UNIQUE": "Unique One", "UNISTAKE": "Unistake", "UNIT": "Universal Currency", + "UNITARYSTATUS": "UnitaryStatus Dollar", "UNITED": "UnitedCoins", "UNITRADE": "UniTrade", "UNITS": "GameUnits", "UNITY": "SuperNET", "UNIVRS": "Universe", "UNIX": "UniX", + "UNIXCOIN": "UNIX", "UNLEASH": "UnleashClub", + "UNM": "UNIUM", + "UNMD": "Utility Nexusmind", "UNN": "UNION Protocol Governance Token", "UNO": "UnoRe", "UNOB": "Unobtanium", + "UNP": "UNIPOLY", "UNQ": "UNQ", "UNQT": "Unique Utility Token", "UNR": "Unirealchain", "UNRC": "UniversalRoyalCoin", "UNS": "UNS TOKEN", + "UNSHETH": "unshETH Ether", "UNW": "UniWorld", "UOP": "Utopia Genesis Foundation", "UOS": "UOS", @@ -8340,12 +12347,16 @@ "UPCG": "Upcomings", "UPCO2": "Universal Carbon", "UPCOIN": "UPcoin", + "UPDOG": "What's Updog", "UPEUR": "Universal Euro", "UPI": "Pawtocol", + "UPLOAD": "Upload Token", "UPO": "UpOnly", "UPP": "Sentinel Protocol", "UPR": "Upfire", "UPRO": "ULTRAPRO", + "UPS": "UPFI Network", + "UPTOS": "UPTOS", "UPUNK": "Unicly CryptoPunks Collection", "UPUSD": "Universal US Dollar", "UPX": "uPlexa", @@ -8353,20 +12364,27 @@ "UR": "UR", "URAC": "Uranus", "URALS": "Urals Coin", + "URFA": "Urfaspor Token", "URO": "UroCoin", "URQA": "UREEQA", + "URS": "URUS", "URUS": "Urus Token", "URX": "URANIUMX", + "USA": "DEDPRZ", "USAT": "USAT", "USC": "Ultimate Secure Cash", "USCC": "USC", "USCOIN": "USCoin", "USDA": "USDA", "USDAP": "Bond Appetite USD", + "USDB": "USD Bancor", + "USDBC": "Bridged USDC", + "USDBLAST": "USDB Blast", "USDC": "USD Coin", "USDCASH": "USDCASH", "USDD": "USDD", - "USDE": "UnitaryStatus Dollar", + "USDE": "Ethena USDe", + "USDEBT": "USDEBT", "USDEX": "eToro US Dollar", "USDFL": "USDFreeLiquidity", "USDG": "USDG", @@ -8374,27 +12392,47 @@ "USDI": "Interest Protocol USDi", "USDJ": "USDJ", "USDK": "USDK", + "USDM": "Mountain Protocol", "USDN": "Neutrino USD", + "USDO": "USD Open Dollar", "USDP": "Pax Dollar", + "USDPLUS": "Overnight.fi USD+", "USDQ": "USDQ", + "USDR": "Real USD", "USDS": "StableUSD", "USDSB": "USDSB", "USDT": "Tether", + "USDTV": "TetherTV", + "USDTZ": "USDtez", "USDU": "Upper Dollar", "USDV": "Verified USD", "USDX": "USDX Stablecoin", + "USDY": "Ondo US Dollar Yield", "USDZ": "Zedxion USDZ", "USE": "Usechain Token", "USEDCAR": "A Gently Used 2001 Honda", "USG": "USGold", + "USH": "unshETHing_Token", + "USHARK": "uShark", + "USHI": "Ushi", "USHIBA": "American Shiba", + "USK": "USK", + "USN": "USN", "USNBT": "NuBits", "USNOTA": "NOTA", + "USP": "USP Token", + "USPLUS": "Fluent Finance", + "UST": "Wrapped UST Token", + "USTB": "Superstate Short Duration U.S. Government Securities Fund", "USTC": "TerraClassicUSD", + "USTCW": "TerraClassicUSD Wormhole", + "USTX": "UpStableToken", + "USV": "Universal Store of Value", "USX": "USX Quantum", "UT": "Ulord", "UTBAI": "UTB.ai", "UTC": "UltraCoin", + "UTG": "UltronGlow", "UTH": "Uther", "UTI": "Unicorn Technology International", "UTIL": "Utility Coin", @@ -8403,35 +12441,63 @@ "UTNP": "Universa", "UTT": "United Traders Token", "UTU": "UTU Protocol", + "UTX": "UTIX", + "UTYAB": "Utya Black", + "UUSD": "Utopia USD", "UUU": "U Network", - "UWU": "uwu", + "UVT": "UvToken", + "UW3S": "Utility Web3Shot", + "UWU": "UwU Lend", + "UWUCOIN": "uwu", "UX": "Umee", + "UXOS": "UXOS", + "UXP": "UXD Protocol", "UZUMAKI": "Uzumaki Inu", + "UZX": "UZX", + "VAAVE": "Venus AAVE", "VAB": "Vabble", + "VADA": "Venus Cardano", "VADER": "Vader Protocol", "VAI": "Vai", "VAIOT": "VAIOT", + "VAIOTV1": "VAIOT v1", "VAL": "Validity", + "VALAS": "Valas Finance", + "VALI": "VALIMARKET", "VALID": "Validator Token", "VALOR": "Valor Token", "VALORBIT": "Valorbit", "VALUE": "Value Liquidity", + "VAMPIRE": "Vampire Inu", + "VAN": "Vanspor Token", + "VANA": "Nirvana", + "VANCAT": "Vancat", "VANRY": "Vanar Chain", "VANT": "Vanta Network", "VANY": "Vanywhere", + "VAPE": "VAPE", "VAPOR": "Vaporcoin", "VARA": "Vara Network", "VARIUS": "Varius", + "VARK": "Aardvark", + "VATR": "Vatra INU", + "VATRENI": "Croatian FF Fan Token", + "VAULT": "Vault Tech", "VAULTCOIN": "VaultCoin", "VBCH": "Venus BCH", + "VBETH": "Venus BETH", "VBG": "Vibing", "VBIT": "Valobit", "VBK": "VeriBlock", "VBNB": "Venus BNB", + "VBNT": "Bancor Governance Token", "VBSC": "Votechain", + "VBSWAP": "vBSWAP", "VBT": "VB Token", "VBTC": "Venus BTC", "VC": "VinuChain", + "VCAKE": "Venus CAKE", + "VCAT": "Vibing Cat", "VCF": "Valencia CF Fan Token", "VCG": "VCGamers", "VCHF": "VNX Swiss Franc", @@ -8439,21 +12505,32 @@ "VCNT": "ViciCoin", "VCORE": "VCORE", "VCX": "VaultCraft", + "VDA": "Verida", "VDG": "VeriDocGlobal", "VDL": "Vidulum", "VDO": "VidioCoin", + "VDOGE": "Venus Dogecoin", "VDOT": "Venus DOT", "VDR": "Vodra", + "VDT": "Vendetta", "VDV": "VDV Token", "VDX": "Vodi X", + "VDZ": "Voidz", + "VEC": "VECTOR", "VEC2": "VectorCoin 2.0", + "VECT": "Vectorium", "VEE": "BLOCKv", "VEED": "VEED", "VEEN": "LIVEEN", "VEG": "BitVegan", "VEGA": "Vega Protocol", + "VEGAS": "Vegasino", + "VEGASI": "Vegas Inu Token", + "VEGE": "Vege Token", "VEIL": "VEIL", + "VEKTOR": "VEKTOR", "VELA": "Vela Token", + "VELAR": "Velar", "VELO": "Velo", "VELOD": "Velodrome Finance", "VELOX": "Velox", @@ -8461,7 +12538,10 @@ "VEMP": "vEmpire DDAO", "VEN": "VeChain Old", "VENA": "Vena Network", + "VENOM": "Venom", + "VENOMAI": "VENOM", "VENT": "Vent Finance", + "VENTI": "VentiSwap", "VENTION": "Vention", "VENUS": "VenusEnergy", "VEO": "Amoveo", @@ -8469,18 +12549,26 @@ "VERA": "Vera", "VERI": "Veritaseum", "VERIC": "VeriCoin", + "VERO": "VEROPAD", "VERSA": "Versa Token", "VERSACE": "VERSACE", "VERSE": "Verse", "VERTEX": "Vertex", + "VERUM": "Verum Coin", + "VERVE": "Verve", "VEST": "VestChain", - "VESTA": "Vestarin", + "VESTARIN": "Vestarin", + "VESTATE": "Vestate", "VET": "VeChain", "VETH": "Venus ETH", + "VETME": "VetMe", + "VETTER": "Vetter Token", "VEUR": "VNX Euro", "VEX": "Vexanium", "VEXT": "Veloce", + "VFIL": "Venus Filecoin", "VFOX": "VFOX", + "VFT": "Value Finance", "VGO": "Vagabond", "VGX": "Voyager Token", "VHC": "Vault Hill City", @@ -8492,8 +12580,11 @@ "VIC": "Viction", "VICA": "ViCA Token", "VICEX": "ViceToken", + "VICS": "RoboF", + "VICT": "Victory Impact Coin", "VICTORIUM": "Victorium", "VID": "VideoCoin", + "VIDA": "Vidiachange", "VIDT": "VIDT Datalink", "VIDY": "Vidy", "VIDYA": "Vidya", @@ -8501,44 +12592,72 @@ "VIDZ": "PureVidz", "VIEW": "Viewly", "VIG": "TheVig", + "VIK": "VIKTAMA", + "VIKKY": "VikkyToken", + "VIM": "VicMove", "VIN": "VinChain", "VINCI": "VINCI", "VINU": "Vita Inu", "VIOR": "ViorCoin", "VIP": "VIP Tokens", + "VIPER": "Viper Protocol", "VIPS": "Vipstar Coin", "VIRAL": "Viral Coin", + "VIRES": "Vires Finance", + "VIRTU": "VIRTUCLOUD", + "VIRTUAL": "Virtual Protocol", + "VIRTUM": "VIRTUMATE", + "VIS": "Vigorus", "VISIO": "Visio", "VISION": "VisionGame", "VISR": "Visor", "VIT": "Vision Industry Token", + "VITA": "VitaDAO", "VITAE": "Vitae", "VITAFAST": "Molecules of Korolchuk IP-NFT", + "VITAL": "Vital Network", "VITE": "VITE", + "VITRA": "Vitra Studios", + "VITY": "Vitteey", "VIU": "Viuly", "VIVID": "Vivid Coin", "VIVO": "VIVO Coin", + "VIX": "VIXCO", + "VIXV1": "VIXCO v1", + "VIZ": "Vision City", + "VIZION": "ViZion Protocol", + "VIZSLASWAP": "VizslaSwap", "VKNF": "VKENAF", "VLC": "Volcano Uni", + "VLK": "Vulkania", "VLS": "Veles", "VLT": "Veltor", "VLTC": "Venus LTC", "VLTX": "Volentix", "VLTY": "Vaulty", + "VLUNA": "Venus Luna", "VLX": "Velas", "VLXPAD": "VelasPad", + "VMATIC": "Venus MATIC", "VMC": "VirtualMining Coin", "VME": "TrueVett", "VMINT": "Volumint", + "VMPX": "VMPX (Ordinals)", + "VMS": "Vehicle Mining System", + "VMT": "Vemate", "VNDC": "VNDC", + "VNDT": "Vendit ", "VNES": "Vanesse", + "VNLNK": "VINLINK", "VNM": "Venom", + "VNN": "VINU Network", "VNO": "Veno Finance", "VNT": "VNT Chain", "VNTW": "Value Network Token", "VNX": "VisionX", "VNXAU": "VNX Gold", "VNXLU": "VNX Exchange", + "VNY": "Vanity", "VOCARE": "Vocare ex Machina", "VOCO": "Provoco", "VODKA": "Vodka Token", @@ -8547,38 +12666,63 @@ "VOLLAR": "Vollar", "VOLR": "Volare Network", "VOLT": "Volt Inu", + "VOLTA": "Volta Club", "VOLTOLD": "Volt Inu (Old)", + "VOLTV1": "Volt Inu v1", + "VOLTV2": "Volt Inu v2", + "VOLTZ": "Voltz", + "VOLX": "VolumeX", + "VONE": "Vone", + "VONSPEED": "Andrea Von Speed", "VOOT": "VootCoin", + "VOPO": "VOPO", "VOT": "Votecoin", "VOW": "Vow", "VOX": "Vox.Finance", "VOXEL": "Voxies", "VOY": "enVoy DeFi", "VOYA": "Voyacoin", + "VP": "Torah Network", "VPAD": "VLaunch", + "VPK": "Vulture Peak", + "VPND": "VaporNodes", "VPP": "Virtue Poker Points", + "VPR": "VaporWallet", "VPRC": "VapersCoin", + "VPS": "VPS AI", "VR": "Victoria", "VRA": "Verasity", "VRC": "Virtual Coin", "VRGW": "Virtual Reality Game World", + "VRH": "Versailles Heroes", + "VRL": "Virtual X", "VRM": "Verium", "VRN": "Varen", "VRO": "VeraOne", + "VROOM": "TurboPepe", "VRP": "Prosense.tv", "VRS": "Veros", "VRSC": "Verus Coin", + "VRSE": "CronosVerse", + "VRSW": "VirtuSwap", "VRT": "Venus Reward Token", "VRTX": "Vertex Protocol", "VRTY": "Verity", "VRX": "Verox", + "VS": "veSync", + "VSC": "Vyvo Coin", "VSD": "Value Set Dollar", + "VSG": "Vitalik Smart Gas", + "VSHARE": "V3S Share", "VSL": "vSlice", "VSO": "Verso", + "VSOL": "VSolidus", "VSP": "Vesper Finance", - "VST": "VentiSwap", + "VSTA": "Vesta Finance", + "VSUI": "Volo Staked SUI", "VSX": "Vsync", "VSYS": "V Systems", + "VT": "Virtual Tourist", "VTC": "Vertcoin", "VTG": "Victory Gem", "VTHO": "VeChainThor", @@ -8586,39 +12730,65 @@ "VTM": "Victorieum", "VTN": "Voltroon", "VTOS": "VTOS", + "VTRA": " E.C. Vitoria Fan Token", + "VTRO": "Vitruveo DEX", + "VTRUMP": "Vote Trump", + "VTRX": "Venus TRX", "VTS": "Veritise", + "VTU": "Virtu", + "VTUSD": "Venus TUSD", "VTX": "Vortex DeFi", "VTY": "Victoriouscoin", "VUC": "Virta Unique Coin", "VULC": "Vulcano", + "VUNI": "Venus UNI", + "VUZZ": "Vuzz AI", "VV": "Virtual Versions", "VVI": "VV Coin", "VVS": "VVS Finance", + "VX": "ViteX Coin", "VXL": "Voxel X Network", + "VXRP": "Venus XRP", + "VXT": "Voxto Amplify", "VXV": "Vectorspace AI", "VYBE": "Vybe", + "VYFI": "VyFinance", "VYNC": "VYNK Chain", "VZT": "Vezt", + "W": "Wormhole", "W1": "W1", "W12": "W12 Protocol", + "W2E": "Walk To Earn", "W3C": "W3Coin", + "W3M": "Web3Met", + "W3S": "Web3Shot", + "W3W": "Web3 Whales", + "W8BIT": "8Bit Chain", "WAB": "WABnetwork", "WABI": "WABI", + "WACME": "Wrapped Accumulate", "WACO": "Waste Digital Coin", + "WAD": "WardenSwap", + "WADA": "Wrapped Cardano", + "WAFFLES": "Waffles Davincij15's Cat", "WAG": "WagyuSwap", "WAGE": "Digiwage", "WAGG": "Waggle Network", + "WAGIE": "Wagie", + "WAGIEBOT": "Wagie Bot", "WAGMI": "Wagmi Coin", "WAGMIGAMES": "WAGMI Game", "WAGMIT": "Wagmi", "WAGON": "Wagon Network", "WAI": "Wanaka Farm WAIRERE Token", "WAIF": "Waifu Token", + "WAIFU": "Waifu", "WAIT": "Hourglass", "WAL": "The Wasted Lands", "WALK": "Walk Token", "WALLET": "Ambire Wallet", "WALLY": "Wally Bot", + "WALTER": "walter", "WALV": "Alvey Chain", "WAM": "Wam", "WAMPL": "Wrapped Ampleforth", @@ -8626,92 +12796,169 @@ "WANA": "Wanaka Farm", "WANATHA": "Wrapped ANATHA", "WAND": "WandX", + "WANKO": "WANKO•MANKO•RUNES", + "WANNA": "Wanna Bot", + "WANUSDT": "wanUSDT", "WAR": "WeStarter", "WARP": "WarpCoin", + "WARPED": "Warped Games", + "WARPIE": "Warpie", "WARS": "MetaWars", "WAS": "Wasder", "WASABI": "WasabiX", + "WASD": "WASD Studios", "WASH": "WashingtonCoin", + "WASSIE": "WASSIE", + "WASTR": "Wrapped Astar", + "WAT": "Wat", + "WATER": "doginwotah", + "WAVAX": "Wrapped AVAX", "WAVES": "Waves", "WAXE": "WAXE", "WAXL": "Wrapped Axelar", "WAXP": "Worldwide Asset eXchange", "WAY": "WayCoin", + "WAZ": "MikeAI", "WBB": "Wild Beast Coin", "WBBC": "Wibcoin", + "WBCH": "Wrapped Bitcoin Cash", + "WBESC": "Wrapped BESC", "WBET": "Wavesbet", "WBETH": "Wrapped Beacon ETH", "WBIND": "Wrapped BIND", + "WBLT": "Wrapped BMX Liquidity Token", "WBN": "Wisdom Bank Network", "WBNB": "Wrapped BNB", + "WBOND": "War Bond Token", + "WBONE": "Shibarium Wrapped BONE", + "WBONES": "Wrapped BONES", + "WBS": "Websea", "WBT": "WhiteBIT Token", "WBTC": "Wrapped Bitcoin", "WBTCWXG": "WBTC-WXG", "WBX": "WiBX", + "WCA": "WCAPES", + "WCANTO": "Wrapped CANTO", + "WCAT": "Sol Cat Warrior", "WCC": "Wincash Coin", "WCCX": "Wrapped Conceal", + "WCDONALDS": "WC Donalds", + "WCELL": "Wrapped CellMates", "WCELO": "Wrapped Celo", "WCFG": "Wrapped Centrifuge", + "WCFX": "Wrapped Conflux", "WCG": "World Crypto Gold", + "WCKB": "Wrapped Nervos Network", "WCOIN": "WCoin", + "WCORE": "Wrapped Core", "WCS": "Weecoins", "WCSOV": "Wrapped CrownSterling", "WCT": "Waves Community Token", + "WCT1WCT1": "Wrapped Car Token 1", "WCUSD": "Wrapped Celo Dollar", "WDC": "WorldCoin", + "WDOG": "Winterdog", + "WDOGE": "Wrapped Dogecoin", + "WDOT": "WDOT", "WDR": "Wider Coin", "WDX": "WeiDex", "WE": "WeBuy", "WEALTH": "WealthCoin", + "WEAPON": "MEGAWEAPON", "WEAR": "MetaWear", "WEB": "Webcoin", "WEB3": "WEB3 Inu", + "WEB4": "WEB4 AI", + "WEB5": "WEB5 Inu", + "WEBAI": "Web Ai", "WEBC": "Webchain", + "WEBD": "WebDollar", + "WEBSS": "Websser", "WEC": "Whole Earth Coin", + "WECO": "WECOIN", + "WED": "Wednesday Inu", "WEETH": "Wrapped eETH", + "WEF": "DOG WIF CHINESE HAT", "WEFI": "WeFi", "WEGEN": "WeGen Platform", + "WEGI": "Wegie", + "WEGLD": "Wrapped EGLD", + "WEIRDO": "Weirdo", + "WELA": "Wrapped Elastos", "WELD": "Weld", "WELL": "Moonwell", "WELLTOKEN": "Well", + "WELSH": "Welshcorgicoin", "WELT": "Fabwelt", "WELUPS": "Welups Blockchain", "WEMIX": "WEMIX", + "WEMIXUSD": "WEMIX", "WEN": "Wen", "WENLAMBO": "Wenlambo", "WEOS": "Wrapped EOS", "WEST": "Waves Enterprise", "WET": "WeShow Token", "WETH": "WETH", + "WETHV1": "WETH v1", + "WETHW": "Wrapped EthereumPoW", "WEVE": "veDAO", + "WEVMOS": "Wrapped Evmos", "WEWE": "WEWE", "WEX": "WaultSwap", "WEXO": "Wexo", + "WEXPOLY": "WaultSwap Polygon", "WFAI": "WaifuAI", + "WFBTC": "Wrapped Fantom Bitcoin", "WFIL": "Wrapped Filecoin", + "WFLAMA": "WIFLAMA", "WFLOW": "Wrapped Flow", + "WFO": "WoofOracle", + "WFT": "Windfall Token", + "WFTN": "Wrapped FTN", + "WFUSE": "Wrapped Fuse", "WFX": "WebFlix", "WGC": "Green Climate World", + "WGHOST": "Wrapped GhostbyMcAfee", "WGL": "Wiggly Finance", + "WGLMR": "Wrapped Moonbeam", "WGO": "WavesGO", "WGP": "W Green Pay", "WGR": "Wagerr", "WGRT": "WaykiChain Governance Coin", + "WGT": "Web3Games.com", "WHALE": "WHALE", + "WHALES": "Whales Market", "WHBAR": "Wrapped HBAR", "WHEAT": "Wheat Token", + "WHEE": "WHEE (Ordinals)", "WHEEL": "Wheelers", "WHEN": "WhenHub", + "WHEX": "Whale Exploder", "WHIRL": "Whirl Finance", + "WHISK": "Whiskers", + "WHISKEY": "WHISKEY", "WHITE": "Whiteheart", "WHL": "WhaleCoin", "WHO": "Truwho", + "WHOREN": "elizabath whoren", + "WHT": "Wrapped Huobi Token", + "WHTETGRMOON": "WHITE TIGER MOON", + "WHTGRPXL": "White Tiger Pixel", + "WHX": "WHITEX", "WIB": "Wibson", "WIC": "Wi Coin", "WICC": "WaykiChain", "WIF": "dogwifhat", + "WIF2": "DogWif2.0", + "WIFB": "dogwifball", + "WIFE": "Wifejak", "WIFEDOGE": "Wifedoge", - "WIFI": "Wifi Coin", + "WIFI": "WiFi Map", + "WIFICOIN": "Wifi Coin", + "WIFS": "dogwifscarf", + "WIFSA": "dogwifsaudihat", + "WIGO": "WigoSwap", + "WIK": "Wicked Bet", "WIKEN": "Project WITH", "WIKI": "Wiki Token", "WILC": "Wrapped ILCOIN", @@ -8726,16 +12973,28 @@ "WINR": "JustBet", "WINRY": "Winry Inu", "WINT": "WinToken", + "WINTER": "Winter", + "WINU": "Walter Inu", + "WIOTX": "Wrapped IoTeX", "WIRTUAL": "Wirtual", "WIS": "Experty Wisdom Token", "WISC": "WisdomCoin", "WISE": "Wise Token", "WISH": "MyWish", + "WISP": "Whisper", + "WISTA": "Wistaverse", "WIT": "Witnet", "WITCH": "Witch", "WITCOIN": "Witcoin", "WIX": "Wixlar", + "WIZA": "Wizardia", + "WJD": "WJD", + "WJEWEL": "WJEWEL", + "WJXN": "Jax.Network", + "WKAI": "Wrapped KardiaChain", + "WKAS": "Wrapped Kaspa", "WKAVA": "Wrapped Kava", + "WKC": "Wiki Cat", "WKD": "Wakanda Inu", "WLD": "Worldcoin", "WLF": "Wolfs Group", @@ -8743,7 +13002,9 @@ "WLK": "Wolk", "WLKN": "Walken", "WLO": "WOLLO", + "WLTH": "Common Wealth", "WLUNA": "Wrapped LUNA Token", + "WLUNC": "Wrapped LUNA Classic", "WLXT": "Wallex Token", "WMATIC": "Wrapped Matic", "WMB": "WatermelonBlock", @@ -8751,13 +13012,23 @@ "WMEMO": "Wonderful Memories", "WMF": "Whale Maker Fund", "WMINIMA": "Wrapped Minima", + "WMLX": "Millix", + "WMN": "WebMind Network", + "WMNT": "Wrapped Mantle", + "WMOXY": "Moxy", "WMT": "World Mobile Token", + "WMW": "WoopMoney", "WMX": "Wombex Finance", + "WMXWOM": "Wombex WOM", "WNCG": "Wrapped NCG", "WND": "WonderHero", "WNDR": "Wonderman Nation", + "WNEAR": "Wrapped Near", + "WNEON": "Wrapped Neon EVM", "WNET": "Wavesnode.net", "WNK": "The Winkyverse", + "WNOW": "WalletNow", + "WNRG": "Wrapped-Energi", "WNRZ": "WinPlay", "WNT": "Wicrypt", "WNXM": "Wrapped NXM", @@ -8765,93 +13036,146 @@ "WNZ": "Winerz", "WOA": "Wrapped Origin Axie", "WOD": "World of Defish", + "WOETH": "Wrapped Origin Ether", + "WOFM": "World of Masters", "WOID": "WORLD ID", "WOJ": "Wojak Finance", + "WOJAK": "Wojak", "WOJAK2": "Wojak 2.0 Coin", + "WOKB": "Wrapped OKB", + "WOKT": "Wrapped OKT", + "WOL": "World of Legends", "WOLF": "Landwolf", "WOLFILAND": "Wolfiland", "WOLFOF": "Wolf of Wall Street", + "WOLFP": "Wolfpack Coin", "WOLFY": "WOLFY", "WOLVERINU": "WOLVERINU", "WOM": "WOM", "WOMB": "Wombat Exchange", "WOMBAT": "Wombat", + "WOME": "WAR OF MEME", "WOMEN": "WomenCoin", "WOMI": "Wrapped ECOMI", "WON": "WeBlock", "WONDER": "Wonderland", + "WONE": "Wrapped Harmony", "WOO": "WOO Network", + "WOOD": "Mindfolk Wood", "WOOF": "Shibance Token", "WOOFY": "Woofy", "WOOL": "Wolf Game Wool", "WOONK": "Woonkly", "WOOO": "wooonen", + "WOOOOO": "Wooooo! Coin", "WOOP": "Woonkly Power", "WOP": "WorldPay", + "WOR": "Hollywood Capital Group WARRIOR", + "WORK": "Work X", "WORLD": "World Token", "WORM": "HealthyWorm", "WORX": "Worx", + "WOS": "Wolf Of Solana", "WOW": "WOWswap", "WOWS": "Wolves of Wall Street", "WOZX": "Efforce", "WPC": "WePiggy Coin", "WPE": "OPES (Wrapped PE)", + "WPEPE": "Wrapped Pepe", + "WPI": "Wrapped Pi", + "WPKT": "Wrapped PKT", "WPLS": "Wrapped Pulse", + "WPOKT": "wrapped POKT", "WPP": "Green Energy Token", "WPR": "WePower", "WQT": "Work Quest", "WRC": "Worldcore", "WRK": "BlockWRK", + "WRKX": "NFT Workx", "WRLD": "NFT Worlds", "WRONG": "The Wrong Token", - "WRT": "WRTcoin", + "WROSE": "Wrapped Rose", + "WRT": "WRT Token", + "WRTCOIN": "WRTcoin", "WRX": "WazirX", "WRZ": "Weriz", "WSB": "WallStreetBets DApp", "WSBABY": "Wall Street Baby", + "WSBC": "WSB Coin", + "WSBS": "Wall Street Bets Solana", "WSCRT": "Secret ERC20", + "WSDM": "Wisdomise AI", "WSDOGE": "Doge of Woof Street", "WSG": "Wall Street Games", + "WSGV1": "Wall Street Games v1", + "WSHIB": "wShiba", "WSI": "WeSendit", "WSIENNA": "Sienna ERC20", "WSM": "Wall Street Memes", "WSPP": "Wolf Safe Poor People", + "WSTA": "Wrapped Statera", "WSTETH": "Lido wstETH", + "WSTOR": "StorageChain", "WSTR": "Wrapped Star", + "WSTUSDT": "wstUSDT", "WSX": "WeAreSatoshi", "WT": "WeToken", + "WTAO": "Wrapped TAO", "WTC": "Waltonchain", "WTF": "Waterfall Governance", "WTFT": "WTF Token", + "WTFUEL": "Wrapped TFUEL", + "WTG": "Watergate", "WTK": "WadzPay Token", "WTL": "Welltrado", "WTN": "Wateenswap", "WTON": "Wrapped TON Crystal", + "WTR": "Deepwaters", "WTT": "Giga Watt", + "WTWOOL": "Wolf Town Wool", + "WUF": "WUFFI", + "WUSD": "Worldwide USD", "WUST": "Wrapped UST Token", + "WVTRS": "Vitreus", + "WW3": "WW3", + "WWAN": "Wrapped WAN", "WWB": "Wowbit", + "WWD": "Wolf Works DAO", "WWDOGE": "Wrapped WDOGE", + "WWEMIX": "WWEMIX", + "WWF": "WWF", "WWY": "WeWay", + "WX": "WX Token", "WXDAI": "Wrapped XDAI", + "WXDC": "Wrapped XDC", "WXT": "WXT", "WXTZ": "Wrapped Tezos", + "WYNN": "Anita Max Wynn", "WYS": "Wysker", "WZEC": "Wrapped Zcash", + "WZEDX": "Wrapped Zedxion", "WZENIQ": "Wrapped Zeniq (ETH)", - "WZRD": "Wizardia", + "WZETA": "Wrapped Zeta", + "WZM": "Woozoo Music", + "WZRD": "Bitcoin Wizards", "X": "AI-X", "X2": "X2Coin", "X2Y2": "X2Y2", "X42": "X42 Protocol", + "X7C": "X7 Coin", "X7DAO": "X7DAO", "X7R": "X7R", "X8X": "X8Currency", "XACT": "XactToken", "XAEAXII": "XAEA-Xii Token", + "XAH": "Xahau", "XAI": "Xai", + "XALGO": "Wrapped ALGO", + "XALPHA": "XAlpha AI", "XAMP": "Antiample", "XANK": "Xank", "XAP": "Apollon", + "XAR": "Arcana Network", "XAS": "Asch", "XAT": "ShareAt", "XAUC": "XauCoin", @@ -8859,6 +13183,7 @@ "XAUT": "Tether Gold", "XAVA": "Avalaunch", "XAYA": "XAYA", + "XB": "XBANKING", "XBASE": "ETERBASE", "XBB": "BrickBlock", "XBC": "BitcoinPlus", @@ -8866,21 +13191,31 @@ "XBG": "BitGrin", "XBI": "Bitcoin Incognito", "XBL": "Billionaire Token", + "XBLAZE": "Trailblaze", "XBN": "Elastic BNB", + "XBNB": "PhoenixBNB", "XBOND": "Bitacium", "XBOT": "SocialXbotCoin", "XBP": "Black Pearl Coin", "XBS": "Bitstake", "XBT": "Xbit", + "XBTC": "XenBitcoin", + "XBTC21": "Bitcoin 21", "XBTS": "Beats", "XBX": "BiteX", "XBY": "XTRABYTES", "XC": "X11 Coin", "XCAD": "XCAD Network", + "XCAL": "3xcalibur", "XCASH": "X-CASH", + "XCASTR": "Astar", + "XCB": "Crypto Birds", + "XCDOT": "xcDOT", "XCE": "Cerium", "XCEL": "XcelTrip", + "XCEPT": "XCeption", "XCF": "Cenfura Token", + "XCFX": "Nucleon", "XCG": "Xchange", "XCH": "Chia", "XCHF": "CryptoFranc", @@ -8895,6 +13230,7 @@ "XCPO": "Copico", "XCR": "Crypti", "XCRE": "Creatio", + "XCRX": "xCRX", "XCT": "C-Bits", "XCUR": "Curate", "XCV": "XCarnival", @@ -8902,13 +13238,15 @@ "XD": "Data Transaction Token", "XDAG": "Dagger", "XDAI": "XDAI", + "XDAO": "XDAO", "XDATA": "Streamr XDATA", "XDB": "DigitalBits", - "XDC": "Xinfin Network", + "XDC": "XDC Network", "XDCE": "XinFin Coin", "XDEF2": "Xdef Finance", "XDEFI": "XDEFI", "XDEN": "Xiden", + "XDG": "Decentral Games Governance", "XDN": "DigitalNote", "XDNA": "XDNA", "XDOGE": "Xdoge", @@ -8917,33 +13255,44 @@ "XDQ": "Dirac Coin", "XEC": "eCash", "XED": "Exeedme", + "XEDO": "XedoAI", "XEL": "Xel", "XELS": "XELS Coin", "XEM": "NEM", "XEN": "XEN Crypto", "XEND": "Xend Finance", "XENIX": "XenixCoin", + "XENO": "Xeno", "XENOVERSE": "Xenoverse", "XEP": "Electra Protocol", + "XERS": "X Project", "XES": "Proxeus", "XET": "Xfinite Entertainment Token", "XETA": "Xana", "XETH": "Xplosive Ethereum", "XFC": "Football Coin", - "XFI": "Xfinance", + "XFI": "CrossFi", + "XFINANCE": "Xfinance", "XFIT": "Xfit", + "XFLOKI": "XFLOKI", "XFT": "Fantasy Cash", "XFTC": "Offshift", + "XFUEL": "XFUEL", "XFUND": "xFund", "XFYI": "XCredit", "XG": "XG Sports", "XGB": "GoldenBird", + "XGC": "Xiglute Coin", + "XGEM": "Exchange Genesis Ethlas Medium", "XGLI": "Glitter Finance", + "XGOLD": "XGOLD COIN", "XGOX": "Go!", "XGPT": "XGPT", "XGR": "GoldReserve", + "XGRO": "Growth DeFi", "XGT": "Xion Finance", "XHI": "HiCoin", + "XHP": "XHYPE", "XHT": "HollaEx", "XHV": "Haven Protocol", "XI": "Xi", @@ -8953,10 +13302,12 @@ "XIDR": "XIDR", "XIL": "Xillion", "XIN": "Mixin", + "XINU": "XINU", "XIO": "Blockzero Labs", "XIOS": "Xios", "XIOT": "Xiotri", "XIV": "Project Inverse", + "XJEWEL": "xJEWEL", "XJO": "JouleCoin", "XKI": "Ki", "XLA": "Scala", @@ -8964,14 +13315,19 @@ "XLB": "LibertyCoin", "XLC": "LeviarCoin", "XLD": "Xcel Defi", + "XLIST": "XList", "XLM": "Stellar", + "XLN": "LunaOne", "XLQ": "Alqo", "XLR": "Solaris", "XLT": "Nexalt", + "XM": "xMooney", "XMARK": "xMARK", + "XMAS": "Elon Xmas", "XMASGROK": "Xmas Grok", "XMC": "Monero Classic", "XMCC": "Monoeci", + "XMETA": "TTX METAVERSE", "XMG": "Coin Magi", "XMN": "Motion", "XMO": "Monero Original", @@ -8993,26 +13349,35 @@ "XNL": "Chronicle", "XNN": "Xenon", "XNO": "Xeno Token", + "XNODE": "XNODE", + "XNP": "ExenPay Token", "XNS": "Insolar", "XNT": "Exenium", + "XNV": "Nerva", "XNX": "XanaxCoin", "XODEX": "Xodex", + "XOLO": "Xoloitzcuintli", "XOR": "Sora", "XOT": "Okuru", "XOV": "XOVBank", + "XOX": "XOX Labs", "XP": "Experience Points", + "XPA": "XPA", "XPAT": "Bitnation Pangea", "XPAY": "Wallet Pay", "XPB": "Pebble Coin", "XPC": "eXPerience Chain", "XPD": "PetroDollar", + "XPE": "Xpense", "XPET": "XPET token", "XPH": "PharmaCoin", "XPHX": "PhoenixCo Token", "XPL": "Exclusive Platform", "XPLA": "XPLA", + "XPLL": "ParallelChain", "XPM": "PrimeCoin", "XPN": "PANTHEON X", + "XPND": "Time Raiders", "XPNET": "XP Network", "XPO": "Opair", "XPOKE": "PokeChain", @@ -9024,28 +13389,47 @@ "XPS": "PoisonIvyCoin", "XPST": "PokerSports", "XPT": "Cryptobuyer", + "XPTP": "xPTP", "XPTX": "PlatinumBAR", "XPX": "ProximaX", "XPY": "PayCoin", "XQC": "Quras Token", "XQN": "Quotient", "XQR": "Qredit", + "XQUOK": "XQUOK", "XRA": "Xriba", + "XRAI": "X-Ratio A", + "XRAY": "Ray Network", "XRC": "xRhodium", "XRD": "Radix", + "XRDOGE": "XRdoge", "XRE": "RevolverCoin", + "XRGB": "XRGB", + "XRISE": "Xrise", "XRL": "Rialto.AI", + "XRLM": "xRealm.ai", + "XROOTAI": "XRootAI", "XRP": "XRP", + "XRP2": "XRP2.0", + "XRP20": "XRP20", + "XRP8": "HarryPotterObamaPacMan8Inu", + "XRPAYNET": "XRPayNet", + "XRPC": "Xrp Classic", + "XRPCHAIN": "Ripple Chain", "XRPH": "XRP Healthcare", + "XRS": "Xrius", "XRT": "Robonomics Network", "XRUN": "XRun", "XRUNE": "Thorstarter", + "XSAUCE": "xSAUCE", "XSC": "Hyperspace", "XSD": "SounDAC", "XSEED": "BitSeeds", "XSGD": "XSGD", "XSH": "SHIELD", + "XSHIB": "XSHIB", "XSI": "Stability Shares", + "XSLR": "NovaXSolar", "XSN": "StakeNet", "XSP": "XSwap", "XSPC": "SpectreSecurityCoin", @@ -9056,19 +13440,28 @@ "XST": "StealthCoin", "XSTAR": "StarCurve", "XSTC": "Safe Trade Coin", + "XSTUSD": "SORA Synthetic USD", "XSUSHI": "xSUSHI", + "XSWAP": "XSwap", "XT": "XT.com Token", "XT3": "Xt3ch", "XTAG": "xHashtag", "XTAL": "XTAL", "XTC": "TileCoin", + "XTECH": "X-TECH", "XTK": "xToken", "XTM": "TORUM", "XTO": "Tao", "XTP": "Tap", + "XTR": "Xtreme", "XTRA": "ExtraCredit", + "XTRACK": "Xtrack AI", "XTREME": "ExtremeCoin", + "XTRM": "XTRM COIN", + "XTT": "XSwap Treasure", + "XTTB20": "XTblock", "XTUSD": "XT Stablecoin XTUSD", + "XTV": "XTV", "XTX": "Xtock", "XTZ": "Tezos", "XUC": "Exchange Union", @@ -9076,10 +13469,12 @@ "XUN": "UltraNote", "XUP": "UPGRADE", "XUPS": "Xups", + "XUV": "XUV Coin", "XVC": "Vcash", "XVE": "The Vegan Initiative", "XVG": "Verge", "XVP": "VirtacoinPlus", + "XVR": "Xover", "XVS": "Venus", "XWC": "WhiteCoin", "XWG": "X World Games", @@ -9093,14 +13488,24 @@ "XYM": "Symbol", "XYO": "XY Oracle", "XYZ": "Universe.XYZ", + "Y2K": "Y2K", + "Y8U": "Y8U", "YAC": "YAcCoin", + "YACHT": "YachtingVerse", "YAE": "Cryptonovae", "YAG": "Yaki Gold", + "YAI": "Ÿ", + "YAK": "Yield Yak", + "YAKS": "YakDAO", + "YAKU": "Yaku", "YAM": "YAM", + "YAMA": "YAMA Inu", "YAMV1": "YAM v1", "YAMV2": "YAM v2", + "YAOYAO": "Yaoyao's Cat", "YAP": "Yap Stone", "YARL": "Yarloo", + "YAW": "Yawww", "YAXIS": "yAxis", "YAY": "YAY Games", "YAYCOIN": "YAYcoin", @@ -9110,16 +13515,22 @@ "YCE": "MYCE", "YCO": "Y Coin", "YCT": "Youclout", + "YDA": "YadaCoin", "YDF": "Yieldification", + "YDOGE": "Yorkie Doge", "YDR": "YDragon", "YEC": "Ycash", "YEE": "Yeeco", "YEED": "Yggdrash", + "YEEHAW": "YEEHAW", "YEFI": "YeFi", "YEL": "Yel.Finance", "YEON": "Yeon", - "YES": "Yes World", + "YEPE": "Yellow Pepe", + "YES": "YES Money", "YESCOIN": "YesCoin", + "YESP": "Yesports", + "YESW": "Yes World", "YETI": "Yeti Finance", "YETU": "Yetucoin", "YFARM": "YFARM Token", @@ -9143,55 +13554,88 @@ "YFX": "Your Futures Exchange", "YGG": "Yield Guild Games", "YIELD": "Yield Protocol", + "YIELDX": "Yield Finance", + "YIKES": "Yikes Dog", "YIN": "YIN Finance", "YINBI": "Yinbi", + "YLC": "YoloCash", "YLD": "YIELD App", "YLDY": "Yieldly", "YMC": "YamahaCoin", + "YMS": "Yeni Malatyaspor Token", "YO": "Yobit Token", + "YOBASE": "All Your Base", "YOC": "YoCoin", "YOCO": "YocoinYOCO", + "YOD": "Year of the Dragon", + "YODE": "YodeSwap", + "YOLO": "YoloNolo", + "YOM": "YOM", "YOOSHI": "YooShi", "YOP": "Yield Optimization Platform & Protocol", + "YOSHI": "Yoshi.exchange", + "YOTD": "Year of the Dragon", + "YOTO": "yotoshi", "YOU": "YOU Chain", "YOUC": "yOUcash", + "YOURAI": "YOUR AI", "YOVI": "YobitVirtualCoin", "YOYOW": "Yoyow", + "YPC": "YoungParrot", "YPIE": "PieDAO Yearn Ecosystem Pie", "YSAFE": "yieldfarming.insure", "YSEC": "Yearn Secure", "YSR": "Ystar", "YTA": "YottaChain", + "YTJIA": "Jia Yueting", "YTN": "YENTEN", + "YTS": "YetiSwap", + "YU": "BOUNTYKINDS", "YUANG": "Yuang Coin", "YUCJ": "Yu Coin", "YUCT": "Yucreat", "YUDI": "Yudi", + "YUGE": "YUGE COIN", + "YUKI": "YUKI", "YUKKY": "YUKKY", "YUM": "Yumerium", + "YUMMI": "Yummi Universe", "YUMMY": "Yummy", "YUP": "Crowdholding", + "YURI": "YURI", + "YUSE": "Yuse Token", "YUSRA": "YUSRA", + "YUZU": "YuzuSwap", "YVBOOST": "Yearn Compounding veCRV yVault", "YVS": "YVS.Finance", + "YYAVAX": "Yield Yak AVAX", "YYE": "YYE Energy", "YYFI": "YYFI.Protocol", + "Z3": "Z-Cubed", "ZABAKU": "Zabaku Inu", + "ZACK": "Zack Morris", + "ZAFI": "ZakumiFi", "ZAI": "Zero Collateral Dai", "ZAIF": "Zaif Token", "ZAM": "Zamio", "ZAMZAM": "ZAMZAM", "ZANO": "Zano", + "ZAO": "zkTAO", "ZAP": "Zap", + "ZAPI": "Zapicorn", + "ZARP": "ZARP Stablecoin", "ZARX": "eToro South African Rand", "ZASH": "ZIMBOCASH", "ZAT": "ZatGo", "ZB": "ZB", "ZBC": "Zebec Protocol", + "ZBCN": "Zebec Network", + "ZBIT": "zbit", "ZBU": "Zeebu", "ZCC": "ZCC Coin", "ZCC1": "ZeroCarbon", "ZCG": "ZCashGOLD", + "ZCHF": "Frankencoin", "ZCHN": "Zichain", "ZCL": "ZClassic", "ZCN": "Züs", @@ -9199,59 +13643,111 @@ "ZCON": "Zcon Protocol", "ZCOR": "Zrocor", "ZCR": "ZCore", + "ZCULT": "Zkcult", "ZCX": "Unizen", + "ZDAI": "Zydio AI", "ZDEX": "Zeedex", "ZDR": "Zloadr", + "ZEBU": "ZEBU", "ZEC": "ZCash", "ZECD": "ZCashDarkCoin", "ZED": "ZedCoins", + "ZEDD": "ZedDex", "ZEDTOKEN": "Zed Token", + "ZEDX": "ZEDXION", "ZEDXION": "Zedxion", "ZEE": "ZeroSwap", + "ZEFI": "ZCore Finance", "ZEFU": "Zenfuse", "ZEIT": "ZeitCoin", "ZEL": "Zelcash", - "ZELIX ": "ZELIX", + "ZELIX": "ZELIX", "ZEN": "Horizen", + "ZENC": "Zenc Coin", + "ZEND": "zkLend", + "ZENF": "Zenland", "ZENI": "Zennies", "ZENIQ": "Zeniq Coin", "ZENITH": "Zenith Chain", + "ZENT": "Zentry", "ZEON": "Zeon Network", "ZEP": "Zeppelin Dao", - "ZEPH": "Zeph", + "ZEPH": "Zephyr Protocol", "ZER": "Zero", "ZEROB": "ZeroBank", "ZEROEX": "0.exchange", "ZES": "Zetos", + "ZESH": "Zesh", "ZEST": "ZestCoin", "ZET": "ZetaCoin", "ZET2": "Zeta2Coin", "ZETA": "ZetaChain", + "ZETH": "Zethan", + "ZETRIX": "Zetrix", "ZEUM": "Colizeum", + "ZEUS": "Zeus Network", + "ZEXI": "ZEXICON", + "ZF": "zkSwap Finance ", "ZFL": "Zuflo Coin", + "ZFLOKI": "zkFloki", "ZFM": "ZFMCOIN", "ZGD": "ZambesiGold", + "ZGEM": "GemSwap", + "ZHC": "ZHC : Zero Hour Cash", + "ZIBU": "Zibu", "ZIG": "Zignaly", + "ZIGAP": "ZIGAP", "ZIK": "Ziktalk", + "ZIKC": "Zik coin", "ZIL": "Zilliqa", "ZILBERCOIN": "Zilbercoin", + "ZILLIONXO": "ZILLION AAKAR XO", + "ZILPEPE": "ZilPepe", "ZINC": "ZINC", "ZINU": "Zombie Inu", "ZIP": "Zipper", + "ZIPPYSOL": "Zippy Staked SOL", "ZIPT": "Zippie", "ZIRVE": "Zirve Coin", + "ZIV4": "Ziv4 Labs", "ZIX": "ZIX Token", + "ZIZLE": "Zizle", + "ZIZY": "ZIZY", "ZJLT": "ZJLT Distributed Factoring Network", + "ZJOE": "zJOE", + "ZK": "zkSync", + "ZKARCH": "zkArchive", + "ZKB": "ZKBase", "ZKBOB": "BOB", + "ZKDOGE": "zkDoge", + "ZKDX": "ZKDX", + "ZKE": "zkEra Finance", + "ZKEVM": "zkEVMChain (BSC)", "ZKF": "ZKFair", + "ZKGROK": "ZKGROK", + "ZKGUN": "zkGUN", + "ZKHIVE": "zkHive", + "ZKID": "zkSync id", + "ZKIN": "zkInfra", + "ZKJ": "Polyhedra Network", + "ZKLAB": "zkSync Labs", + "ZKLK": "ZkLock", + "ZKML": "zKML", "ZKP": "Panther Protocol", + "ZKPAD": "zkLaunchpad", + "ZKPEPE": "ZKPEPEs", "ZKS": "ZKSpace", + "ZKSHIB": "zkShib", + "ZKSP": "zkSwap", "ZKT": "zkTube", "ZKVAULT": "zkVAULT", + "ZKX": "ZKX", + "ZKZ": "Zkzone", "ZLA": "Zilla", "ZLDA": "Zelda Inu", "ZLK": "Zenlink Network", "ZLOT": "zLOT Finance", + "ZLP": "ZilPay Wallet", "ZLQ": "ZLiteQubit", "ZLW": "Zelwin", "ZMBE": "RugZombie", @@ -9261,8 +13757,10 @@ "ZNE": "ZoneCoin", "ZNN": "Zenon", "ZNT": "Zenith Finance", + "ZNX": "ZENEX", "ZNY": "BitZeny", "ZNZ": "ZENZO", + "ZOA": "Zone of Avoidance", "ZOC": "01coin", "ZODI": "Zodium", "ZOE": "Zoe Cash", @@ -9271,32 +13769,48 @@ "ZONO": "Zono Swap", "ZONX": "METAZONX", "ZOO": "ZooKeeper", + "ZOOA": "Zoopia", "ZOOM": "ZoomCoin", + "ZOOMER": "Zoomer Coin", "ZOON": "CryptoZoon", "ZOOT": "Zoo Token", "ZORA": "Zoracles", + "ZORKSEES": "Zorksees", + "ZORO": "Zoro Inu", "ZORT": "Zort", "ZP": "Zen Protocol", "ZPAE": "ZelaaPayAE", "ZPAY": "ZoidPay", + "ZPC": "Zen Panda Coin", + "ZPET": "Zino Pet", "ZPR": "ZPER", + "ZPRO": "ZAT Project", "ZPT": "Zeepin", "ZPTC": "Zeptacoin", "ZRC": "ZrCoin", + "ZRO": "LayerZero", "ZRX": "0x", "ZSC": "Zeusshield", + "ZSD": "Zephyr Protocol Stable Dollar", "ZSE": "ZSEcoin", + "ZSH": "Ziesha", "ZT": "ZBG Token", "ZTC": "ZeTo", + "ZTG": "Zeitgeist", "ZTX": "ZTX", "ZUC": "Zeux", + "ZUCKPEPE": "ZuckPepe", "ZUKI": "Zuki Moba", "ZUM": "ZumCoin", "ZUNA": "ZUNA", + "ZUNUSD": "Zunami USD", "ZUR": "Zurcoin", "ZURR": "ZURRENCY", "ZUSD": "ZUSD", + "ZUSHI": "ZUSHI", "ZUT": "Zero Utility Token", + "ZUZALU": "Zuzalu Inu", + "ZUZU": "ZUZU", "ZUZUAI": "ZUZUAI", "ZVC": "ZVCHAIN", "ZWAP": "ZilSwap", @@ -9304,6 +13818,8 @@ "ZXT": "Zcrypt", "ZYD": "ZayedCoin", "ZYN": "Zynecoin", + "ZYNE": "Zynergy", + "ZYPTO": "Zypto Token", "ZYR": "Zyrri", "ZYRO": "Zyro", "ZYTARA": "Zytara dollar", @@ -9315,6 +13831,5 @@ "gOHM": "Governance OHM", "redBUX": "redBUX", "sOHM": "Staked Olympus", - "sols": "SOLS", "wsOHM": "Wrapped Staked Olympus" } diff --git a/apps/api/src/assets/cryptocurrencies/custom.json b/apps/api/src/assets/cryptocurrencies/custom.json index bb65ec03c..814aeec34 100644 --- a/apps/api/src/assets/cryptocurrencies/custom.json +++ b/apps/api/src/assets/cryptocurrencies/custom.json @@ -4,6 +4,9 @@ "LUNA1": "Terra", "LUNA2": "Terra", "SGB1": "Songbird", + "SMURFCAT": "Real Smurf Cat", + "TON11419": "Toncoin", "UNI1": "Uniswap", + "UNI7083": "Uniswap", "UST": "TerraUSD" } diff --git a/apps/api/src/assets/sitemap.xml b/apps/api/src/assets/sitemap.xml index 3aeadc035..860024c61 100644 --- a/apps/api/src/assets/sitemap.xml +++ b/apps/api/src/assets/sitemap.xml @@ -4,6 +4,12 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"> + https://ghostfol.io/de ${currentDate}T00:00:00+00:00 @@ -54,206 +60,6 @@ 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-8figures - ${currentDate}T00:00:00+00:00 - - - https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-allinvestview - ${currentDate}T00:00:00+00:00 - - - https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-allvue-systems - ${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-basil-finance - ${currentDate}T00:00:00+00:00 - - - https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-beanvest - ${currentDate}T00:00:00+00:00 - - - https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capitally - ${currentDate}T00:00:00+00:00 - - - https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capmon - ${currentDate}T00:00:00+00:00 - - - https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-compound-planning - ${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-de.fi - ${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-empower - ${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-fina - ${currentDate}T00:00:00+00:00 - - - https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finary - ${currentDate}T00:00:00+00:00 - - - https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finwise - ${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-intuit-mint - ${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-magnifi - ${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-monarch-money - ${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-rocket-money - ${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-stockle - ${currentDate}T00:00:00+00:00 - - - https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-stockmarketeye - ${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-tiller - ${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-vyzer - ${currentDate}T00:00:00+00:00 - - - https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-wealthfolio - ${currentDate}T00:00:00+00:00 - - - https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-wealthica - ${currentDate}T00:00:00+00:00 - - - https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-whal - ${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/ressourcen/personal-finance-tools/open-source-alternative-zu-ynab - ${currentDate}T00:00:00+00:00 - https://ghostfol.io/de/ueber-uns ${currentDate}T00:00:00+00:00 @@ -409,891 +215,312 @@ ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-8figures - ${currentDate}T00:00:00+00:00 - - - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-allinvestview + https://ghostfol.io/es ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-allvue-systems + https://ghostfol.io/es/funcionalidades ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo + https://ghostfol.io/es/mercados + daily ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-basil-finance + https://ghostfol.io/es/open + daily ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-beanvest + https://ghostfol.io/es/precios ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capitally + https://ghostfol.io/es/preguntas-mas-frecuentes ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capmon + https://ghostfol.io/es/recursos ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-compound-planning + https://ghostfol.io/es/recursos/personal-finance-tools ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-copilot-money + https://ghostfol.io/es/registro ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-de.fi + https://ghostfol.io/es/sobre ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-delta + https://ghostfol.io/es/sobre/changelog ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-divvydiary + https://ghostfol.io/es/sobre/licencia ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-empower + https://ghostfol.io/es/sobre/oss-friends ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio + https://ghostfol.io/es/sobre/politica-de-privacidad ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-fina + https://ghostfol.io/fr ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finary + https://ghostfol.io/fr/a-propos ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finwise + https://ghostfol.io/fr/a-propos/changelog ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare + https://ghostfol.io/fr/a-propos/licence ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-getquin + https://ghostfol.io/fr/a-propos/oss-friends ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-gospatz + https://ghostfol.io/fr/a-propos/politique-de-confidentialite ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-intuit-mint + https://ghostfol.io/fr/enregistrement ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-justetf + https://ghostfol.io/fr/fonctionnalites ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-kubera + https://ghostfol.io/fr/foire-aux-questions ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-magnifi + https://ghostfol.io/fr/marches + daily ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-markets.sh + https://ghostfol.io/fr/open + daily ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-maybe-finance + https://ghostfol.io/fr/prix ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-monarch-money + https://ghostfol.io/fr/ressources ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-monse + https://ghostfol.io/fr/ressources/personal-finance-tools ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-parqet + https://ghostfol.io/it ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-plannix + https://ghostfol.io/it/domande-piu-frequenti ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-portfolio-dividend-tracker + https://ghostfol.io/it/funzionalita ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-portseido + https://ghostfol.io/it/informazioni-su ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-projectionlab + https://ghostfol.io/it/informazioni-su/changelog ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-rocket-money + https://ghostfol.io/it/informazioni-su/informativa-sulla-privacy ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-seeking-alpha + https://ghostfol.io/it/informazioni-su/licenza ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sharesight + https://ghostfol.io/it/informazioni-su/oss-friends ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-simple-portfolio + https://ghostfol.io/it/iscrizione ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-snowball-analytics + https://ghostfol.io/it/mercati + daily ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stockle + https://ghostfol.io/it/open + daily ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stockmarketeye + https://ghostfol.io/it/prezzi ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio + https://ghostfol.io/it/risorse ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-tiller + https://ghostfol.io/it/risorse/personal-finance-tools ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-utluna + https://ghostfol.io/nl ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-vyzer + https://ghostfol.io/nl/bronnen ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-wealthfolio + https://ghostfol.io/nl/bronnen/personal-finance-tools ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-wealthica + https://ghostfol.io/nl/functionaliteiten ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-whal + https://ghostfol.io/nl/markten + daily ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-yeekatee + https://ghostfol.io/nl/open + daily ${currentDate}T00:00:00+00:00 - https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-ynab + https://ghostfol.io/nl/over ${currentDate}T00:00:00+00:00 - https://ghostfol.io/es + https://ghostfol.io/nl/over/changelog ${currentDate}T00:00:00+00:00 - https://ghostfol.io/es/funcionalidades + https://ghostfol.io/nl/over/licentie ${currentDate}T00:00:00+00:00 - https://ghostfol.io/es/mercados - daily + https://ghostfol.io/nl/over/oss-friends ${currentDate}T00:00:00+00:00 - https://ghostfol.io/es/open - daily + https://ghostfol.io/nl/over/privacybeleid ${currentDate}T00:00:00+00:00 - https://ghostfol.io/es/precios + https://ghostfol.io/nl/prijzen ${currentDate}T00:00:00+00:00 - https://ghostfol.io/es/preguntas-mas-frecuentes + https://ghostfol.io/nl/registratie ${currentDate}T00:00:00+00:00 - https://ghostfol.io/es/recursos + https://ghostfol.io/nl/veelgestelde-vragen ${currentDate}T00:00:00+00:00 + - https://ghostfol.io/es/registro + https://ghostfol.io/pt ${currentDate}T00:00:00+00:00 - https://ghostfol.io/es/sobre + https://ghostfol.io/pt/blog ${currentDate}T00:00:00+00:00 - https://ghostfol.io/es/sobre/changelog + https://ghostfol.io/pt/funcionalidades ${currentDate}T00:00:00+00:00 - https://ghostfol.io/es/sobre/licencia + https://ghostfol.io/pt/mercados ${currentDate}T00:00:00+00:00 - https://ghostfol.io/es/sobre/oss-friends + https://ghostfol.io/pt/open ${currentDate}T00:00:00+00:00 - https://ghostfol.io/es/sobre/politica-de-privacidad + https://ghostfol.io/pt/perguntas-mais-frequentes ${currentDate}T00:00:00+00:00 - https://ghostfol.io/fr + https://ghostfol.io/pt/precos ${currentDate}T00:00:00+00:00 - https://ghostfol.io/fr/a-propos + https://ghostfol.io/pt/recursos ${currentDate}T00:00:00+00:00 - https://ghostfol.io/fr/a-propos/changelog + https://ghostfol.io/pt/recursos/personal-finance-tools ${currentDate}T00:00:00+00:00 - https://ghostfol.io/fr/a-propos/licence + https://ghostfol.io/pt/registo ${currentDate}T00:00:00+00:00 - https://ghostfol.io/fr/a-propos/oss-friends + https://ghostfol.io/pt/sobre ${currentDate}T00:00:00+00:00 - https://ghostfol.io/fr/a-propos/politique-de-confidentialite + https://ghostfol.io/pt/sobre/changelog ${currentDate}T00:00:00+00:00 - https://ghostfol.io/fr/enregistrement + https://ghostfol.io/pt/sobre/licenca ${currentDate}T00:00:00+00:00 - https://ghostfol.io/fr/fonctionnalites + https://ghostfol.io/pt/sobre/oss-friends ${currentDate}T00:00:00+00:00 - https://ghostfol.io/fr/foire-aux-questions + https://ghostfol.io/pt/sobre/politica-de-privacidade ${currentDate}T00:00:00+00:00 - https://ghostfol.io/fr/marches - daily + https://ghostfol.io/tr ${currentDate}T00:00:00+00:00 + + ${personalFinanceTools} diff --git a/apps/api/src/environments/environment.prod.ts b/apps/api/src/environments/environment.prod.ts index bc8aa65a4..81b324963 100644 --- a/apps/api/src/environments/environment.prod.ts +++ b/apps/api/src/environments/environment.prod.ts @@ -1,4 +1,4 @@ export const environment = { production: true, - version: `v${require('../../../../package.json').version}` + version: `${require('../../../../package.json').version}` }; diff --git a/apps/api/src/events/events.module.ts b/apps/api/src/events/events.module.ts new file mode 100644 index 000000000..0e6b25ba4 --- /dev/null +++ b/apps/api/src/events/events.module.ts @@ -0,0 +1,11 @@ +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; + +import { Module } from '@nestjs/common'; + +import { PortfolioChangedListener } from './portfolio-changed.listener'; + +@Module({ + imports: [RedisCacheModule], + providers: [PortfolioChangedListener] +}) +export class EventsModule {} diff --git a/apps/api/src/events/portfolio-changed.event.ts b/apps/api/src/events/portfolio-changed.event.ts new file mode 100644 index 000000000..a3b0710fb --- /dev/null +++ b/apps/api/src/events/portfolio-changed.event.ts @@ -0,0 +1,15 @@ +export class PortfolioChangedEvent { + private userId: string; + + public constructor({ userId }: { userId: string }) { + this.userId = userId; + } + + public static getName() { + return 'portfolio.changed'; + } + + public getUserId() { + return this.userId; + } +} diff --git a/apps/api/src/events/portfolio-changed.listener.ts b/apps/api/src/events/portfolio-changed.listener.ts new file mode 100644 index 000000000..d12b9558d --- /dev/null +++ b/apps/api/src/events/portfolio-changed.listener.ts @@ -0,0 +1,23 @@ +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; + +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; + +import { PortfolioChangedEvent } from './portfolio-changed.event'; + +@Injectable() +export class PortfolioChangedListener { + public constructor(private readonly redisCacheService: RedisCacheService) {} + + @OnEvent(PortfolioChangedEvent.getName()) + handlePortfolioChangedEvent(event: PortfolioChangedEvent) { + Logger.log( + `Portfolio of user '${event.getUserId()}' has changed`, + 'PortfolioChangedListener' + ); + + this.redisCacheService.removePortfolioSnapshotsByUserId({ + userId: event.getUserId() + }); + } +} diff --git a/apps/api/src/helper/object.helper.ts b/apps/api/src/helper/object.helper.ts index 1538228b8..c6d825598 100644 --- a/apps/api/src/helper/object.helper.ts +++ b/apps/api/src/helper/object.helper.ts @@ -1,4 +1,4 @@ -import Big from 'big.js'; +import { Big } from 'big.js'; import { cloneDeep, isArray, isObject } from 'lodash'; export function hasNotDefinedValuesInObject(aObject: Object): boolean { diff --git a/apps/api/src/helper/portfolio.helper.ts b/apps/api/src/helper/portfolio.helper.ts new file mode 100644 index 000000000..6ebe48d3c --- /dev/null +++ b/apps/api/src/helper/portfolio.helper.ts @@ -0,0 +1,19 @@ +import { Type as ActivityType } from '@prisma/client'; + +export function getFactor(activityType: ActivityType) { + let factor: number; + + switch (activityType) { + case 'BUY': + factor = 1; + break; + case 'SELL': + factor = -1; + break; + default: + factor = 0; + break; + } + + return factor; +} diff --git a/apps/api/src/interceptors/redact-values-in-response.interceptor.ts b/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts similarity index 82% rename from apps/api/src/interceptors/redact-values-in-response.interceptor.ts rename to apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts index c1df8157e..cae4f22ed 100644 --- a/apps/api/src/interceptors/redact-values-in-response.interceptor.ts +++ b/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts @@ -1,6 +1,9 @@ -import { UserService } from '@ghostfolio/api/app/user/user.service'; import { redactAttributes } from '@ghostfolio/api/helper/object.helper'; import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; +import { + hasReadRestrictedAccessPermission, + isRestrictedView +} from '@ghostfolio/common/permissions'; import { UserWithSettings } from '@ghostfolio/common/types'; import { @@ -16,7 +19,7 @@ import { map } from 'rxjs/operators'; export class RedactValuesInResponseInterceptor implements NestInterceptor { - public constructor(private userService: UserService) {} + public constructor() {} public intercept( context: ExecutionContext, @@ -29,15 +32,13 @@ export class RedactValuesInResponseInterceptor const impersonationId = headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()]; - const hasReadRestrictedPermission = - this.userService.hasReadRestrictedAccessPermission({ - impersonationId, - user - }); if ( - hasReadRestrictedPermission || - this.userService.isRestrictedView(user) + hasReadRestrictedAccessPermission({ + impersonationId, + user + }) || + isRestrictedView(user) ) { data = redactAttributes({ object: data, @@ -49,10 +50,11 @@ export class RedactValuesInResponseInterceptor 'dividendInBaseCurrency', 'fee', 'feeInBaseCurrency', - 'filteredValueInBaseCurrency', 'grossPerformance', + 'grossPerformanceWithCurrencyEffect', 'investment', 'netPerformance', + 'netPerformanceWithCurrencyEffect', 'quantity', 'symbolMapping', 'totalBalanceInBaseCurrency', diff --git a/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.module.ts b/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.module.ts new file mode 100644 index 000000000..90cf254b3 --- /dev/null +++ b/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.module.ts @@ -0,0 +1,4 @@ +import { Module } from '@nestjs/common'; + +@Module({}) +export class RedactValuesInResponseModule {} diff --git a/apps/api/src/interceptors/transform-data-source-in-request.interceptor.ts b/apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts similarity index 100% rename from apps/api/src/interceptors/transform-data-source-in-request.interceptor.ts rename to apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts diff --git a/apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.module.ts b/apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.module.ts new file mode 100644 index 000000000..4a7d23803 --- /dev/null +++ b/apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.module.ts @@ -0,0 +1,11 @@ +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; + +import { Module } from '@nestjs/common'; + +@Module({ + exports: [ConfigurationService], + imports: [ConfigurationModule], + providers: [ConfigurationService] +}) +export class TransformDataSourceInRequestModule {} diff --git a/apps/api/src/interceptors/transform-data-source-in-response.interceptor.ts b/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts similarity index 100% rename from apps/api/src/interceptors/transform-data-source-in-response.interceptor.ts rename to apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts diff --git a/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.module.ts b/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.module.ts new file mode 100644 index 000000000..fadf0bd80 --- /dev/null +++ b/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.module.ts @@ -0,0 +1,11 @@ +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; + +import { Module } from '@nestjs/common'; + +@Module({ + exports: [ConfigurationService], + imports: [ConfigurationModule], + providers: [ConfigurationService] +}) +export class TransformDataSourceInResponseModule {} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 016f82473..abb27b7cd 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -2,7 +2,7 @@ import { Logger, ValidationPipe, VersioningType } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; import type { NestExpressApplication } from '@nestjs/platform-express'; -import * as bodyParser from 'body-parser'; +import { json } from 'body-parser'; import helmet from 'helmet'; import { AppModule } from './app/app.module'; @@ -34,7 +34,7 @@ async function bootstrap() { ); // Support 10mb csv/json files for importing activities - app.use(bodyParser.json({ limit: '10mb' })); + app.use(json({ limit: '10mb' })); if (configService.get('ENABLE_FEATURE_SUBSCRIPTION') === 'true') { app.use( diff --git a/apps/api/src/models/order.ts b/apps/api/src/models/order.ts index 214f7e56a..6e6762101 100644 --- a/apps/api/src/models/order.ts +++ b/apps/api/src/models/order.ts @@ -1,6 +1,6 @@ import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces'; -import { Account, SymbolProfile, Type as TypeOfOrder } from '@prisma/client'; +import { Account, SymbolProfile, Type as ActivityType } from '@prisma/client'; import { v4 as uuidv4 } from 'uuid'; export class Order { @@ -14,7 +14,7 @@ export class Order { private symbol: string; private symbolProfile: SymbolProfile; private total: number; - private type: TypeOfOrder; + private type: ActivityType; private unitPrice: number; public constructor(data: IOrder) { diff --git a/apps/api/src/models/rule.ts b/apps/api/src/models/rule.ts index 171da810d..8397f3e46 100644 --- a/apps/api/src/models/rule.ts +++ b/apps/api/src/models/rule.ts @@ -1,25 +1,34 @@ import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { groupBy } from '@ghostfolio/common/helper'; -import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces'; +import { UserSettings } from '@ghostfolio/common/interfaces'; +import { TimelinePosition } from '@ghostfolio/common/models'; import { EvaluationResult } from './interfaces/evaluation-result.interface'; import { RuleInterface } from './interfaces/rule.interface'; export abstract class Rule implements RuleInterface { + private key: string; private name: string; public constructor( protected exchangeRateDataService: ExchangeRateDataService, { + key, name }: { + key: string; name: string; } ) { + this.key = key; this.name = name; } + public getKey() { + return this.key; + } + public getName() { return this.name; } diff --git a/apps/api/src/models/rules/account-cluster-risk/current-investment.ts b/apps/api/src/models/rules/account-cluster-risk/current-investment.ts index 23d3307de..e25bb2f08 100644 --- a/apps/api/src/models/rules/account-cluster-risk/current-investment.ts +++ b/apps/api/src/models/rules/account-cluster-risk/current-investment.ts @@ -15,6 +15,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule { accounts: PortfolioDetails['accounts'] ) { super(exchangeRateDataService, { + key: AccountClusterRiskCurrentInvestment.name, name: 'Investment' }); @@ -35,7 +36,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule { }; } - let maxItem; + let maxItem: (typeof accounts)[0]; let totalInvestment = 0; for (const account of Object.values(accounts)) { @@ -52,12 +53,12 @@ export class AccountClusterRiskCurrentInvestment extends Rule { } } - const maxInvestmentRatio = maxItem.investment / totalInvestment; + const maxInvestmentRatio = maxItem?.investment / totalInvestment || 0; - if (maxInvestmentRatio > ruleSettings.threshold) { + if (maxInvestmentRatio > ruleSettings.thresholdMax) { return { evaluation: `Over ${ - ruleSettings.threshold * 100 + ruleSettings.thresholdMax * 100 }% of your current investment is at ${maxItem.name} (${( maxInvestmentRatio * 100 ).toPrecision(3)}%)`, @@ -69,7 +70,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule { evaluation: `The major part of your current investment is at ${ maxItem.name } (${(maxInvestmentRatio * 100).toPrecision(3)}%) and does not exceed ${ - ruleSettings.threshold * 100 + ruleSettings.thresholdMax * 100 }%`, value: true }; @@ -78,13 +79,13 @@ export class AccountClusterRiskCurrentInvestment extends Rule { public getSettings(aUserSettings: UserSettings): Settings { return { baseCurrency: aUserSettings.baseCurrency, - isActive: true, - threshold: 0.5 + isActive: aUserSettings.xRayRules[this.getKey()].isActive, + thresholdMax: 0.5 }; } } interface Settings extends RuleSettings { baseCurrency: string; - threshold: number; + thresholdMax: number; } diff --git a/apps/api/src/models/rules/account-cluster-risk/single-account.ts b/apps/api/src/models/rules/account-cluster-risk/single-account.ts index b5028228a..1f61b9659 100644 --- a/apps/api/src/models/rules/account-cluster-risk/single-account.ts +++ b/apps/api/src/models/rules/account-cluster-risk/single-account.ts @@ -11,6 +11,7 @@ export class AccountClusterRiskSingleAccount extends Rule { accounts: PortfolioDetails['accounts'] ) { super(exchangeRateDataService, { + key: AccountClusterRiskSingleAccount.name, name: 'Single Account' }); @@ -35,7 +36,7 @@ export class AccountClusterRiskSingleAccount extends Rule { public getSettings(aUserSettings: UserSettings): RuleSettings { return { - isActive: true + isActive: aUserSettings.xRayRules[this.getKey()].isActive }; } } diff --git a/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts b/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts index a23a208c3..1258eb889 100644 --- a/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts +++ b/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts @@ -1,7 +1,8 @@ import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { Rule } from '@ghostfolio/api/models/rule'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; -import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces'; +import { UserSettings } from '@ghostfolio/common/interfaces'; +import { TimelinePosition } from '@ghostfolio/common/models'; export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule { private positions: TimelinePosition[]; @@ -11,6 +12,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule { private positions: TimelinePosition[]; @@ -11,6 +12,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule { positions: TimelinePosition[] ) { super(exchangeRateDataService, { + key: CurrencyClusterRiskCurrentInvestment.name, name: 'Investment' }); @@ -37,12 +39,12 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule { } }); - const maxValueRatio = maxItem.value / totalValue; + const maxValueRatio = maxItem?.value / totalValue || 0; - if (maxValueRatio > ruleSettings.threshold) { + if (maxValueRatio > ruleSettings.thresholdMax) { return { evaluation: `Over ${ - ruleSettings.threshold * 100 + ruleSettings.thresholdMax * 100 }% of your current investment is in ${maxItem.groupKey} (${( maxValueRatio * 100 ).toPrecision(3)}%)`, @@ -52,9 +54,9 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule { return { evaluation: `The major part of your current investment is in ${ - maxItem.groupKey + maxItem?.groupKey ?? ruleSettings.baseCurrency } (${(maxValueRatio * 100).toPrecision(3)}%) and does not exceed ${ - ruleSettings.threshold * 100 + ruleSettings.thresholdMax * 100 }%`, value: true }; @@ -63,13 +65,13 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule { public getSettings(aUserSettings: UserSettings): Settings { return { baseCurrency: aUserSettings.baseCurrency, - isActive: true, - threshold: 0.5 + isActive: aUserSettings.xRayRules[this.getKey()].isActive, + thresholdMax: 0.5 }; } } interface Settings extends RuleSettings { baseCurrency: string; - threshold: number; + thresholdMax: number; } diff --git a/apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts b/apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts index b6248ab51..0ba7a109c 100644 --- a/apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts +++ b/apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts @@ -11,6 +11,7 @@ export class EmergencyFundSetup extends Rule { emergencyFund: number ) { super(exchangeRateDataService, { + key: EmergencyFundSetup.name, name: 'Emergency Fund: Set up' }); @@ -18,29 +19,29 @@ export class EmergencyFundSetup extends Rule { } public evaluate(ruleSettings: Settings) { - if (this.emergencyFund > ruleSettings.threshold) { + if (this.emergencyFund < ruleSettings.thresholdMin) { return { - evaluation: 'An emergency fund has been set up', - value: true + evaluation: 'No emergency fund has been set up', + value: false }; } return { - evaluation: 'No emergency fund has been set up', - value: false + evaluation: 'An emergency fund has been set up', + value: true }; } public getSettings(aUserSettings: UserSettings): Settings { return { baseCurrency: aUserSettings.baseCurrency, - isActive: true, - threshold: 0 + isActive: aUserSettings.xRayRules[this.getKey()].isActive, + thresholdMin: 0 }; } } interface Settings extends RuleSettings { baseCurrency: string; - threshold: number; + thresholdMin: number; } diff --git a/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts b/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts index 0ba70d23c..09029fd3e 100644 --- a/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts +++ b/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts @@ -13,6 +13,7 @@ export class FeeRatioInitialInvestment extends Rule { fees: number ) { super(exchangeRateDataService, { + key: FeeRatioInitialInvestment.name, name: 'Fee Ratio' }); @@ -25,10 +26,10 @@ export class FeeRatioInitialInvestment extends Rule { ? this.fees / this.totalInvestment : 0; - if (feeRatio > ruleSettings.threshold) { + if (feeRatio > ruleSettings.thresholdMax) { return { evaluation: `The fees do exceed ${ - ruleSettings.threshold * 100 + ruleSettings.thresholdMax * 100 }% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`, value: false }; @@ -36,7 +37,7 @@ export class FeeRatioInitialInvestment extends Rule { return { evaluation: `The fees do not exceed ${ - ruleSettings.threshold * 100 + ruleSettings.thresholdMax * 100 }% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`, value: true }; @@ -45,13 +46,13 @@ export class FeeRatioInitialInvestment extends Rule { public getSettings(aUserSettings: UserSettings): Settings { return { baseCurrency: aUserSettings.baseCurrency, - isActive: true, - threshold: 0.01 + isActive: aUserSettings.xRayRules[this.getKey()].isActive, + thresholdMax: 0.01 }; } } interface Settings extends RuleSettings { baseCurrency: string; - threshold: number; + thresholdMax: number; } diff --git a/apps/api/src/services/api/api.service.ts b/apps/api/src/services/api/api.service.ts index a469254f7..e961ec037 100644 --- a/apps/api/src/services/api/api.service.ts +++ b/apps/api/src/services/api/api.service.ts @@ -10,18 +10,21 @@ export class ApiService { filterByAccounts, filterByAssetClasses, filterByAssetSubClasses, + filterByHoldingType, filterBySearchQuery, filterByTags }: { filterByAccounts?: string; filterByAssetClasses?: string; filterByAssetSubClasses?: string; + filterByHoldingType?: string; filterBySearchQuery?: string; filterByTags?: string; }): Filter[] { const accountIds = filterByAccounts?.split(',') ?? []; const assetClasses = filterByAssetClasses?.split(',') ?? []; const assetSubClasses = filterByAssetSubClasses?.split(',') ?? []; + const holdingType = filterByHoldingType; const searchQuery = filterBySearchQuery?.toLowerCase(); const tagIds = filterByTags?.split(',') ?? []; @@ -52,6 +55,13 @@ export class ApiService { }) ]; + if (holdingType) { + filters.push({ + id: holdingType, + type: 'HOLDING_TYPE' + }); + } + if (searchQuery) { filters.push({ id: searchQuery, diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index 61a54daa1..6a50766d2 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -3,7 +3,8 @@ 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'; +import { bool, cleanEnv, host, json, num, port, str, url } from 'envalid'; +import ms from 'ms'; @Injectable() export class ConfigurationService { @@ -20,7 +21,7 @@ export class ConfigurationService { API_KEY_FINANCIAL_MODELING_PREP: str({ default: '' }), API_KEY_OPEN_FIGI: str({ default: '' }), API_KEY_RAPID_API: str({ default: '' }), - CACHE_QUOTES_TTL: num({ default: 1 }), + CACHE_QUOTES_TTL: num({ default: ms('1 minute') / 1000 }), CACHE_TTL: num({ default: 1 }), DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }), DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }), @@ -41,20 +42,21 @@ export class ConfigurationService { HOST: host({ default: '0.0.0.0' }), JWT_SECRET_KEY: str({}), MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }), + MAX_CHART_ITEMS: num({ default: 365 }), MAX_ITEM_IN_CACHE: num({ default: 9999 }), PORT: port({ default: 3333 }), + REDIS_DB: num({ default: 0 }), REDIS_HOST: str({ default: 'localhost' }), REDIS_PASSWORD: str({ default: '' }), REDIS_PORT: port({ default: 6379 }), REQUEST_TIMEOUT: num({ default: 2000 }), - ROOT_URL: str({ default: DEFAULT_ROOT_URL }), + ROOT_URL: url({ default: DEFAULT_ROOT_URL }), STRIPE_PUBLIC_KEY: str({ default: '' }), STRIPE_SECRET_KEY: str({ default: '' }), TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }), TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }), TWITTER_API_KEY: str({ default: 'dummyApiKey' }), - TWITTER_API_SECRET: str({ default: 'dummyApiSecret' }), - WEB_AUTH_RP_ID: host({ default: 'localhost' }) + TWITTER_API_SECRET: str({ default: 'dummyApiSecret' }) }); } diff --git a/apps/api/src/services/cron.service.ts b/apps/api/src/services/cron.service.ts index d74ad6a94..864891c6a 100644 --- a/apps/api/src/services/cron.service.ts +++ b/apps/api/src/services/cron.service.ts @@ -1,4 +1,5 @@ import { + DATA_GATHERING_QUEUE_PRIORITY_LOW, GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS_OPTIONS, PROPERTY_IS_DATA_GATHERING_ENABLED @@ -44,10 +45,11 @@ export class CronService { @Cron(CronService.EVERY_SUNDAY_AT_LUNCH_TIME) public async runEverySundayAtTwelvePm() { if (await this.isDataGatheringEnabled()) { - const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); + const assetProfileIdentifiers = + await this.dataGatheringService.getAllAssetProfileIdentifiers(); await this.dataGatheringService.addJobsToQueue( - uniqueAssets.map(({ dataSource, symbol }) => { + assetProfileIdentifiers.map(({ dataSource, symbol }) => { return { data: { dataSource, @@ -56,7 +58,8 @@ export class CronService { name: GATHER_ASSET_PROFILE_PROCESS, opts: { ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, - jobId: getAssetProfileIdentifier({ dataSource, symbol }) + jobId: getAssetProfileIdentifier({ dataSource, symbol }), + priority: DATA_GATHERING_QUEUE_PRIORITY_LOW } }; }) 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 bf960048c..d8a6a7644 100644 --- a/apps/api/src/services/data-gathering/data-gathering.processor.ts +++ b/apps/api/src/services/data-gathering/data-gathering.processor.ts @@ -7,7 +7,7 @@ import { GATHER_HISTORICAL_MARKET_DATA_PROCESS } from '@ghostfolio/common/config'; import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper'; -import { UniqueAsset } from '@ghostfolio/common/interfaces'; +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { Process, Processor } from '@nestjs/bull'; import { Injectable, Logger } from '@nestjs/common'; @@ -35,9 +35,19 @@ export class DataGatheringProcessor { ) {} @Process({ concurrency: 1, name: GATHER_ASSET_PROFILE_PROCESS }) - public async gatherAssetProfile(job: Job) { + public async gatherAssetProfile(job: Job) { try { + Logger.log( + `Asset profile data gathering has been started for ${job.data.symbol} (${job.data.dataSource})`, + `DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS})` + ); + await this.dataGatheringService.gatherAssetProfiles([job.data]); + + Logger.log( + `Asset profile data gathering has been completed for ${job.data.symbol} (${job.data.dataSource})`, + `DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS})` + ); } catch (error) { Logger.error( error, @@ -62,11 +72,11 @@ export class DataGatheringProcessor { `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})` ); - const historicalData = await this.dataProviderService.getHistoricalRaw( - [{ dataSource, symbol }], - currentDate, - new Date() - ); + const historicalData = await this.dataProviderService.getHistoricalRaw({ + dataGatheringItems: [{ dataSource, symbol }], + from: currentDate, + to: new Date() + }); const data: Prisma.MarketDataUpdateInput[] = []; let lastMarketPrice: number; 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 6dccd645e..8b8c65a21 100644 --- a/apps/api/src/services/data-gathering/data-gathering.service.ts +++ b/apps/api/src/services/data-gathering/data-gathering.service.ts @@ -8,6 +8,9 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { DATA_GATHERING_QUEUE, + DATA_GATHERING_QUEUE_PRIORITY_HIGH, + DATA_GATHERING_QUEUE_PRIORITY_LOW, + DATA_GATHERING_QUEUE_PRIORITY_MEDIUM, GATHER_HISTORICAL_MARKET_DATA_PROCESS, GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS, PROPERTY_BENCHMARKS @@ -17,7 +20,10 @@ import { getAssetProfileIdentifier, resetHours } from '@ghostfolio/common/helper'; -import { BenchmarkProperty, UniqueAsset } from '@ghostfolio/common/interfaces'; +import { + AssetProfileIdentifier, + BenchmarkProperty +} from '@ghostfolio/common/interfaces'; import { InjectQueue } from '@nestjs/bull'; import { Inject, Injectable, Logger } from '@nestjs/common'; @@ -60,25 +66,49 @@ export class DataGatheringService { } public async gather7Days() { - const dataGatheringItems = await this.getSymbols7D(); - await this.gatherSymbols(dataGatheringItems); + await this.gatherSymbols({ + dataGatheringItems: await this.getCurrencies7D(), + priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH + }); + + await this.gatherSymbols({ + dataGatheringItems: await this.getSymbols7D({ + withUserSubscription: true + }), + priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM + }); + + await this.gatherSymbols({ + dataGatheringItems: await this.getSymbols7D({ + withUserSubscription: false + }), + priority: DATA_GATHERING_QUEUE_PRIORITY_LOW + }); } public async gatherMax() { const dataGatheringItems = await this.getSymbolsMax(); - await this.gatherSymbols(dataGatheringItems); + await this.gatherSymbols({ + dataGatheringItems, + priority: DATA_GATHERING_QUEUE_PRIORITY_LOW + }); } - public async gatherSymbol({ dataSource, symbol }: UniqueAsset) { + public async gatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) { await this.marketDataService.deleteMany({ dataSource, symbol }); - const symbols = (await this.getSymbolsMax()).filter((dataGatheringItem) => { - return ( - dataGatheringItem.dataSource === dataSource && - dataGatheringItem.symbol === symbol - ); + const dataGatheringItems = (await this.getSymbolsMax()).filter( + (dataGatheringItem) => { + return ( + dataGatheringItem.dataSource === dataSource && + dataGatheringItem.symbol === symbol + ); + } + ); + await this.gatherSymbols({ + dataGatheringItems, + priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH }); - await this.gatherSymbols(symbols); } public async gatherSymbolForDate({ @@ -91,11 +121,11 @@ export class DataGatheringService { symbol: string; }) { try { - const historicalData = await this.dataProviderService.getHistoricalRaw( - [{ dataSource, symbol }], - date, - date - ); + const historicalData = await this.dataProviderService.getHistoricalRaw({ + dataGatheringItems: [{ dataSource, symbol }], + from: date, + to: date + }); const marketPrice = historicalData[symbol][format(date, DATE_FORMAT)].marketPrice; @@ -119,23 +149,29 @@ export class DataGatheringService { } } - public async gatherAssetProfiles(aUniqueAssets?: UniqueAsset[]) { - let uniqueAssets = aUniqueAssets?.filter((dataGatheringItem) => { - return dataGatheringItem.dataSource !== 'MANUAL'; - }); + public async gatherAssetProfiles( + aAssetProfileIdentifiers?: AssetProfileIdentifier[] + ) { + let assetProfileIdentifiers = aAssetProfileIdentifiers?.filter( + (dataGatheringItem) => { + return dataGatheringItem.dataSource !== 'MANUAL'; + } + ); - if (!uniqueAssets) { - uniqueAssets = await this.getUniqueAssets(); + if (!assetProfileIdentifiers) { + assetProfileIdentifiers = await this.getAllAssetProfileIdentifiers(); } - if (uniqueAssets.length <= 0) { + if (assetProfileIdentifiers.length <= 0) { return; } - const assetProfiles = - await this.dataProviderService.getAssetProfiles(uniqueAssets); - const symbolProfiles = - await this.symbolProfileService.getSymbolProfiles(uniqueAssets); + const assetProfiles = await this.dataProviderService.getAssetProfiles( + assetProfileIdentifiers + ); + const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( + assetProfileIdentifiers + ); for (const [symbol, assetProfile] of Object.entries(assetProfiles)) { const symbolMapping = symbolProfiles.find((symbolProfile) => { @@ -168,6 +204,7 @@ export class DataGatheringService { figi, figiComposite, figiShareClass, + holdings, isin, name, sectors, @@ -185,6 +222,7 @@ export class DataGatheringService { figi, figiComposite, figiShareClass, + holdings, isin, name, sectors, @@ -199,6 +237,7 @@ export class DataGatheringService { figi, figiComposite, figiShareClass, + holdings, isin, name, sectors, @@ -217,22 +256,23 @@ export class DataGatheringService { error, 'DataGatheringService' ); + + if (assetProfileIdentifiers.length === 1) { + throw error; + } } } - - Logger.log( - `Asset profile data gathering has been completed for ${uniqueAssets - .map(({ dataSource, symbol }) => { - return `${symbol} (${dataSource})`; - }) - .join(',')}.`, - 'DataGatheringService' - ); } - public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) { + public async gatherSymbols({ + dataGatheringItems, + priority + }: { + dataGatheringItems: IDataGatheringItem[]; + priority: number; + }) { await this.addJobsToQueue( - aSymbolsWithStartDate.map(({ dataSource, date, symbol }) => { + dataGatheringItems.map(({ dataSource, date, symbol }) => { return { data: { dataSource, @@ -242,6 +282,7 @@ export class DataGatheringService { name: GATHER_HISTORICAL_MARKET_DATA_PROCESS, opts: { ...GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS, + priority, jobId: `${getAssetProfileIdentifier({ dataSource, symbol @@ -252,7 +293,9 @@ export class DataGatheringService { ); } - public async getUniqueAssets(): Promise { + public async getAllAssetProfileIdentifiers(): Promise< + AssetProfileIdentifier[] + > { const symbolProfiles = await this.prismaService.symbolProfile.findMany({ orderBy: [{ symbol: 'asc' }] }); @@ -272,73 +315,83 @@ 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); - - const symbolProfiles = await this.prismaService.symbolProfile.findMany({ - orderBy: [{ symbol: 'asc' }], - select: { - dataSource: true, - scraperConfiguration: true, - symbol: true - } - }); - - // Only consider symbols with incomplete market data for the last - // 7 days - const symbolsWithCompleteMarketData = ( + private async getAssetProfileIdentifiersWithCompleteMarketData(): Promise< + AssetProfileIdentifier[] + > { + return ( await this.prismaService.marketData.groupBy({ _count: true, - by: ['symbol'], + by: ['dataSource', 'symbol'], orderBy: [{ symbol: 'asc' }], where: { - date: { gt: startDate }, + date: { gt: subDays(resetHours(new Date()), 7) }, state: 'CLOSE' } }) ) - .filter((group) => { - return group._count >= 6; + .filter(({ _count }) => { + return _count >= 6; }) - .map((group) => { - return group.symbol; + .map(({ dataSource, symbol }) => { + return { dataSource, symbol }; }); + } + + private async getCurrencies7D(): Promise { + const assetProfileIdentifiersWithCompleteMarketData = + await this.getAssetProfileIdentifiersWithCompleteMarketData(); + + return this.exchangeRateDataService + .getCurrencyPairs() + .filter(({ dataSource, symbol }) => { + return !assetProfileIdentifiersWithCompleteMarketData.some((item) => { + return item.dataSource === dataSource && item.symbol === symbol; + }); + }) + .map(({ dataSource, symbol }) => { + return { + dataSource, + symbol, + date: subDays(resetHours(new Date()), 7) + }; + }); + } - const symbolProfilesToGather = symbolProfiles + private getEarliestDate(aStartDate: Date) { + return min([aStartDate, subYears(new Date(), 10)]); + } + + private async getSymbols7D({ + withUserSubscription = false + }: { + withUserSubscription?: boolean; + }): Promise { + const symbolProfiles = + await this.symbolProfileService.getSymbolProfilesByUserSubscription({ + withUserSubscription + }); + + const assetProfileIdentifiersWithCompleteMarketData = + await this.getAssetProfileIdentifiersWithCompleteMarketData(); + + return symbolProfiles .filter(({ dataSource, scraperConfiguration, symbol }) => { const manualDataSourceWithScraperConfiguration = dataSource === 'MANUAL' && !isEmpty(scraperConfiguration); return ( - !symbolsWithCompleteMarketData.includes(symbol) && + !assetProfileIdentifiersWithCompleteMarketData.some((item) => { + return item.dataSource === dataSource && item.symbol === symbol; + }) && (dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration) ); }) .map((symbolProfile) => { return { ...symbolProfile, - date: startDate + date: subDays(resetHours(new Date()), 7) }; }); - - const currencyPairsToGather = this.exchangeRateDataService - .getCurrencyPairs() - .filter(({ symbol }) => { - return !symbolsWithCompleteMarketData.includes(symbol); - }) - .map(({ dataSource, symbol }) => { - return { - dataSource, - symbol, - date: startDate - }; - }); - - return [...currencyPairsToGather, ...symbolProfilesToGather]; } private async getSymbolsMax(): Promise { 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 ab8923035..4e93bc757 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 @@ -37,18 +37,22 @@ export class AlphaVantageService implements DataProviderInterface { return !!this.configurationService.get('API_KEY_ALPHA_VANTAGE'); } - public async getAssetProfile( - aSymbol: string - ): Promise> { + public async getAssetProfile({ + symbol + }: { + symbol: string; + }): Promise> { return { - dataSource: this.getName(), - symbol: aSymbol + symbol, + dataSource: this.getName() }; } public getDataProviderInfo(): DataProviderInfo { return { - isPremium: false + isPremium: false, + name: 'Alpha Vantage', + url: 'https://www.alphavantage.co' }; } 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 b05ffc02b..d673dd7aa 100644 --- a/apps/api/src/services/data-provider/coingecko/coingecko.service.ts +++ b/apps/api/src/services/data-provider/coingecko/coingecko.service.ts @@ -52,15 +52,17 @@ export class CoinGeckoService implements DataProviderInterface { return true; } - public async getAssetProfile( - aSymbol: string - ): Promise> { + public async getAssetProfile({ + symbol + }: { + symbol: string; + }): Promise> { const response: Partial = { - assetClass: AssetClass.CASH, + symbol, + assetClass: AssetClass.LIQUIDITY, assetSubClass: AssetSubClass.CRYPTOCURRENCY, currency: DEFAULT_CURRENCY, - dataSource: this.getName(), - symbol: aSymbol + dataSource: this.getName() }; try { @@ -70,7 +72,7 @@ export class CoinGeckoService implements DataProviderInterface { abortController.abort(); }, this.configurationService.get('REQUEST_TIMEOUT')); - const { name } = await got(`${this.apiUrl}/coins/${aSymbol}`, { + const { name } = await got(`${this.apiUrl}/coins/${symbol}`, { headers: this.headers, // @ts-ignore signal: abortController.signal @@ -81,7 +83,7 @@ export class CoinGeckoService implements DataProviderInterface { let message = error; if (error?.code === 'ABORT_ERR') { - message = `RequestError: The operation to get the asset profile for ${aSymbol} was aborted because the request to the data provider took more than ${this.configurationService.get( + message = `RequestError: The operation to get the asset profile for ${symbol} was aborted because the request to the data provider took more than ${this.configurationService.get( 'REQUEST_TIMEOUT' )}ms`; } @@ -241,7 +243,7 @@ export class CoinGeckoService implements DataProviderInterface { return { name, symbol, - assetClass: AssetClass.CASH, + assetClass: AssetClass.LIQUIDITY, assetSubClass: AssetSubClass.CRYPTOCURRENCY, currency: DEFAULT_CURRENCY, dataProviderInfo: this.getDataProviderInfo(), 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 index 5d79ea90a..aa0b3c597 100644 --- 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 @@ -36,6 +36,7 @@ export class DataEnhancerService { if ( (assetProfile.countries as unknown as Prisma.JsonArray)?.length > 0 && + (assetProfile.holdings as unknown as Prisma.JsonArray)?.length > 0 && (assetProfile.sectors as unknown as Prisma.JsonArray)?.length > 0 ) { return true; 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 63d785253..0e12b8f02 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,16 +1,17 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; +import { Holding } from '@ghostfolio/common/interfaces'; 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 { countries } from 'countries-list'; import got from 'got'; @Injectable() export class TrackinsightDataEnhancerService implements DataEnhancerInterface { private static baseUrl = 'https://www.trackinsight.com/data-api'; - private static countries = require('countries-list/dist/countries.json'); private static countriesMapping = { 'Russian Federation': 'Russia' }; @@ -131,20 +132,19 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { (response.countries as unknown as Country[]).length === 0 ) { response.countries = []; + for (const [name, value] of Object.entries( holdings?.countries ?? {} )) { let countryCode: string; - for (const [key, country] of Object.entries( - TrackinsightDataEnhancerService.countries - )) { + for (const [code, country] of Object.entries(countries)) { if ( country.name === name || country.name === TrackinsightDataEnhancerService.countriesMapping[name] ) { - countryCode = key; + countryCode = code; break; } } @@ -156,11 +156,30 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { } } + if ( + !response.holdings || + (response.holdings as unknown as Holding[]).length === 0 + ) { + response.holdings = []; + + for (const { label, weight } of holdings?.topHoldings ?? []) { + if (label?.toLowerCase() === 'other') { + continue; + } + + response.holdings.push({ + weight, + name: label + }); + } + } + if ( !response.sectors || (response.sectors as unknown as Sector[]).length === 0 ) { response.sectors = []; + for (const [name, value] of Object.entries( holdings?.sectors ?? {} )) { 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 ccb5e2a64..35fa9604a 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 @@ -196,7 +196,9 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { shortName: assetProfile.price.shortName, symbol: assetProfile.price.symbol }); - response.symbol = assetProfile.price.symbol; + response.symbol = this.convertFromYahooFinanceSymbol( + assetProfile.price.symbol + ); if (assetSubClass === AssetSubClass.MUTUALFUND) { response.sectors = []; @@ -264,7 +266,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { switch (quoteType?.toLowerCase()) { case 'cryptocurrency': - assetClass = AssetClass.CASH; + assetClass = AssetClass.LIQUIDITY; assetSubClass = AssetSubClass.CRYPTOCURRENCY; break; case 'equity': 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 e16c40a54..e5eda2d7e 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -14,13 +14,18 @@ import { DERIVED_CURRENCIES, PROPERTY_DATA_SOURCE_MAPPING } from '@ghostfolio/common/config'; -import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper'; -import { UniqueAsset } from '@ghostfolio/common/interfaces'; +import { + DATE_FORMAT, + getCurrencyFromSymbol, + getStartOfUtcDate, + isDerivedCurrency +} from '@ghostfolio/common/helper'; +import { AssetProfileIdentifier } 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'; -import Big from 'big.js'; +import { Big } from 'big.js'; import { eachDayOfInterval, format, isValid } from 'date-fns'; import { groupBy, isEmpty, isNumber, uniqWith } from 'lodash'; import ms from 'ms'; @@ -70,7 +75,7 @@ export class DataProviderService { return false; } - public async getAssetProfiles(items: UniqueAsset[]): Promise<{ + public async getAssetProfiles(items: AssetProfileIdentifier[]): Promise<{ [symbol: string]: Partial; }> { const response: { @@ -92,7 +97,9 @@ export class DataProviderService { for (const symbol of symbols) { const promise = Promise.resolve( - this.getDataProvider(DataSource[dataSource]).getAssetProfile(symbol) + this.getDataProvider(DataSource[dataSource]).getAssetProfile({ + symbol + }) ); promises.push( @@ -166,7 +173,7 @@ export class DataProviderService { } public async getHistorical( - aItems: UniqueAsset[], + aItems: AssetProfileIdentifier[], aGranularity: Granularity = 'month', from: Date, to: Date @@ -202,13 +209,14 @@ export class DataProviderService { }); try { - const queryRaw = `SELECT * - FROM "MarketData" - WHERE "dataSource" IN ('${dataSources.join(`','`)}') - AND "symbol" IN ('${symbols.join( - `','` - )}') ${granularityQuery} ${rangeQuery} - ORDER BY date;`; + const queryRaw = ` + SELECT * + FROM "MarketData" + WHERE "dataSource" IN ('${dataSources.join(`','`)}') + AND "symbol" IN ('${symbols.join( + `','` + )}') ${granularityQuery} ${rangeQuery} + ORDER BY date;`; const marketDataByGranularity: MarketData[] = await this.prismaService.$queryRawUnsafe(queryRaw); @@ -230,15 +238,17 @@ export class DataProviderService { } } - public async getHistoricalRaw( - aDataGatheringItems: UniqueAsset[], - from: Date, - to: Date - ): Promise<{ + public async getHistoricalRaw({ + dataGatheringItems, + from, + to + }: { + dataGatheringItems: AssetProfileIdentifier[]; + from: Date; + to: Date; + }): Promise<{ [symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; }> { - let dataGatheringItems = aDataGatheringItems; - for (const { currency, rootCurrency } of DERIVED_CURRENCIES) { if ( this.hasCurrency({ @@ -327,6 +337,8 @@ export class DataProviderService { } } catch (error) { Logger.error(error, 'DataProviderService'); + + throw error; } return result; @@ -335,11 +347,13 @@ export class DataProviderService { public async getQuotes({ items, requestTimeout, - useCache = true + useCache = true, + user }: { - items: UniqueAsset[]; + items: AssetProfileIdentifier[]; requestTimeout?: number; useCache?: boolean; + user?: UserWithSettings; }): Promise<{ [symbol: string]: IDataProviderResponse; }> { @@ -362,7 +376,7 @@ export class DataProviderService { } // Get items from cache - const itemsToFetch: UniqueAsset[] = []; + const itemsToFetch: AssetProfileIdentifier[] = []; for (const { dataSource, symbol } of items) { if (useCache) { @@ -390,7 +404,8 @@ export class DataProviderService { numberOfItemsInCache > 1 ? 's' : '' } from cache in ${((performance.now() - startTimeTotal) / 1000).toFixed( 3 - )} seconds` + )} seconds`, + 'DataProviderService' ); } @@ -405,13 +420,26 @@ export class DataProviderService { )) { const dataProvider = this.getDataProvider(DataSource[dataSource]); - const symbols = dataGatheringItems.map((dataGatheringItem) => { - return dataGatheringItem.symbol; - }); + if ( + dataProvider.getDataProviderInfo().isPremium && + this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + user?.subscription.type === 'Basic' + ) { + continue; + } + + const symbols = dataGatheringItems + .filter(({ symbol }) => { + return !isDerivedCurrency(getCurrencyFromSymbol(symbol)); + }) + .map(({ symbol }) => { + return symbol; + }); const maximumNumberOfSymbolsPerRequest = dataProvider.getMaxNumberOfSymbolsPerRequest?.() ?? Number.MAX_SAFE_INTEGER; + for ( let i = 0; i < symbols.length; @@ -488,7 +516,8 @@ export class DataProviderService { } from ${dataSource} in ${( (performance.now() - startTimeDataSource) / 1000 - ).toFixed(3)} seconds` + ).toFixed(3)} seconds`, + 'DataProviderService' ); try { @@ -497,7 +526,8 @@ export class DataProviderService { .filter((symbol) => { return ( isNumber(response[symbol].marketPrice) && - response[symbol].marketPrice > 0 + response[symbol].marketPrice > 0 && + response[symbol].marketState === 'open' ); }) .map((symbol) => { @@ -518,14 +548,15 @@ export class DataProviderService { await Promise.all(promises); - Logger.debug('------------------------------------------------'); + Logger.debug('--------------------------------------------------------'); Logger.debug( `Fetched ${items.length} quote${items.length > 1 ? 's' : ''} in ${( (performance.now() - startTimeTotal) / 1000 - ).toFixed(3)} seconds` + ).toFixed(3)} seconds`, + 'DataProviderService' ); - Logger.debug('================================================'); + Logger.debug('========================================================'); return response; } @@ -578,10 +609,14 @@ export class DataProviderService { return name1?.toLowerCase().localeCompare(name2?.toLowerCase()); }) .map((lookupItem) => { - if ( - !this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') || - user.subscription.type === 'Premium' - ) { + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + if (user.subscription.type === 'Premium') { + lookupItem.dataProviderInfo.isPremium = false; + } + + lookupItem.dataProviderInfo.name = undefined; + lookupItem.dataProviderInfo.url = undefined; + } else { lookupItem.dataProviderInfo.isPremium = false; } @@ -598,7 +633,7 @@ export class DataProviderService { dataGatheringItems }: { currency: string; - dataGatheringItems: UniqueAsset[]; + dataGatheringItems: AssetProfileIdentifier[]; }) { return dataGatheringItems.some(({ dataSource, symbol }) => { return ( 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 3d3bf39e5..1fe9e0ad1 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 @@ -11,6 +11,7 @@ import { IDataProviderHistoricalResponse, IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { DEFAULT_CURRENCY, REPLACE_NAME_PARTS @@ -35,7 +36,8 @@ export class EodHistoricalDataService implements DataProviderInterface { private readonly URL = 'https://eodhistoricaldata.com/api'; public constructor( - private readonly configurationService: ConfigurationService + private readonly configurationService: ConfigurationService, + private readonly symbolProfileService: SymbolProfileService ) { this.apiKey = this.configurationService.get('API_KEY_EOD_HISTORICAL_DATA'); } @@ -44,25 +46,29 @@ export class EodHistoricalDataService implements DataProviderInterface { return true; } - public async getAssetProfile( - aSymbol: string - ): Promise> { - const [searchResult] = await this.getSearchResult(aSymbol); + public async getAssetProfile({ + symbol + }: { + symbol: string; + }): Promise> { + const [searchResult] = await this.getSearchResult(symbol); return { + symbol, assetClass: searchResult?.assetClass, assetSubClass: searchResult?.assetSubClass, currency: this.convertCurrency(searchResult?.currency), dataSource: this.getName(), isin: searchResult?.isin, - name: searchResult?.name, - symbol: aSymbol + name: searchResult?.name }; } public getDataProviderInfo(): DataProviderInfo { return { - isPremium: true + isPremium: true, + name: 'EOD Historical Data', + url: 'https://eodhd.com' }; } @@ -228,51 +234,56 @@ export class EodHistoricalDataService implements DataProviderInterface { ? [realTimeResponse] : realTimeResponse; - const searchResponse = await Promise.all( - eodHistoricalDataSymbols - .filter((symbol) => { - return !symbol.endsWith('.FOREX'); - }) - .map((symbol) => { - return this.search({ query: symbol }); - }) + const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( + symbols.map((symbol) => { + return { + symbol, + dataSource: this.getName() + }; + }) ); - const lookupItems = searchResponse.flat().map(({ items }) => { - return items[0]; - }); - - response = quotes.reduce( - ( - result: { [symbol: string]: IDataProviderResponse }, - { close, code, timestamp } - ) => { - const currency = lookupItems.find((lookupItem) => { - return lookupItem.symbol === code; + for (const { close, code, timestamp } of quotes) { + let currency: string; + + if (this.isForex(code)) { + currency = this.convertFromEodSymbol(code)?.replace( + DEFAULT_CURRENCY, + '' + ); + } + + if (!currency) { + currency = symbolProfiles.find(({ symbol }) => { + return symbol === code; })?.currency; + } - if (isNumber(close)) { - result[this.convertFromEodSymbol(code)] = { - currency: - currency ?? - this.convertFromEodSymbol(code)?.replace(DEFAULT_CURRENCY, ''), - dataSource: this.getName(), - marketPrice: close, - marketState: isToday(new Date(timestamp * 1000)) - ? 'open' - : 'closed' - }; - } else { - Logger.error( - `Could not get quote for ${this.convertFromEodSymbol(code)} (${this.getName()})`, - 'EodHistoricalDataService' - ); + if (!currency) { + const { items } = await this.search({ query: code }); + + if (items.length === 1) { + currency = items[0].currency; } + } - return result; - }, - {} - ); + if (isNumber(close)) { + response[this.convertFromEodSymbol(code)] = { + currency, + dataSource: this.getName(), + marketPrice: close, + marketState: + this.isForex(code) || isToday(new Date(timestamp * 1000)) + ? 'open' + : 'closed' + }; + } else { + Logger.error( + `Could not get quote for ${this.convertFromEodSymbol(code)} (${this.getName()})`, + 'EodHistoricalDataService' + ); + } + } return response; } catch (error) { @@ -303,7 +314,7 @@ export class EodHistoricalDataService implements DataProviderInterface { items: searchResult .filter(({ currency, symbol }) => { // Remove 'NA' currency and exchange rates - return currency?.length === 3 && !symbol.endsWith('.FOREX'); + return currency?.length === 3 && !this.isForex(symbol); }) .map( ({ @@ -341,7 +352,7 @@ export class EodHistoricalDataService implements DataProviderInterface { private convertFromEodSymbol(aEodSymbol: string) { let symbol = aEodSymbol; - if (symbol.endsWith('.FOREX')) { + if (this.isForex(symbol)) { symbol = symbol.replace('GBX', 'GBp'); symbol = symbol.replace('.FOREX', ''); } @@ -443,6 +454,10 @@ export class EodHistoricalDataService implements DataProviderInterface { return searchResult; } + private isForex(aCode: string) { + return aCode?.endsWith('.FOREX') || false; + } + private parseAssetClass({ Exchange, Type @@ -462,7 +477,7 @@ export class EodHistoricalDataService implements DataProviderInterface { assetSubClass = AssetSubClass.STOCK; break; case 'currency': - assetClass = AssetClass.CASH; + assetClass = AssetClass.LIQUIDITY; if (Exchange?.toLowerCase() === 'cc') { assetSubClass = AssetSubClass.CRYPTOCURRENCY; 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 dcc2d3ae0..2faaf8db8 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 @@ -37,12 +37,14 @@ export class FinancialModelingPrepService implements DataProviderInterface { return true; } - public async getAssetProfile( - aSymbol: string - ): Promise> { + public async getAssetProfile({ + symbol + }: { + symbol: string; + }): Promise> { return { - dataSource: this.getName(), - symbol: aSymbol + symbol, + dataSource: this.getName() }; } diff --git a/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts b/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts index 9029e38f0..c8ff87719 100644 --- a/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts +++ b/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts @@ -33,18 +33,22 @@ export class GoogleSheetsService implements DataProviderInterface { return true; } - public async getAssetProfile( - aSymbol: string - ): Promise> { + public async getAssetProfile({ + symbol + }: { + symbol: string; + }): Promise> { return { - dataSource: this.getName(), - symbol: aSymbol + symbol, + dataSource: this.getName() }; } public getDataProviderInfo(): DataProviderInfo { return { - isPremium: false + isPremium: false, + name: 'Google Sheets', + url: 'https://docs.google.com/spreadsheets' }; } diff --git a/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts b/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts index 07fca64bd..3b3644473 100644 --- a/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts +++ b/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts @@ -11,7 +11,11 @@ import { DataSource, SymbolProfile } from '@prisma/client'; export interface DataProviderInterface { canHandle(symbol: string): boolean; - getAssetProfile(aSymbol: string): Promise>; + getAssetProfile({ + symbol + }: { + symbol: string; + }): Promise>; getDataProviderInfo(): DataProviderInfo; 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 2ac293384..0655d2318 100644 --- a/apps/api/src/services/data-provider/manual/manual.service.ts +++ b/apps/api/src/services/data-provider/manual/manual.service.ts @@ -43,16 +43,18 @@ export class ManualService implements DataProviderInterface { return true; } - public async getAssetProfile( - aSymbol: string - ): Promise> { + public async getAssetProfile({ + symbol + }: { + symbol: string; + }): Promise> { const assetProfile: Partial = { - dataSource: this.getName(), - symbol: aSymbol + symbol, + dataSource: this.getName() }; const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([ - { dataSource: this.getName(), symbol: aSymbol } + { symbol, dataSource: this.getName() } ]); if (symbolProfile) { @@ -89,7 +91,7 @@ export class ManualService implements DataProviderInterface { headers = {}, selector, url - } = symbolProfile.scraperConfiguration ?? {}; + } = symbolProfile?.scraperConfiguration ?? {}; if (defaultMarketPrice) { const historical: { @@ -164,13 +166,16 @@ export class ManualService implements DataProviderInterface { } }); - for (const symbolProfile of symbolProfiles) { - response[symbolProfile.symbol] = { - currency: symbolProfile.currency, + for (const { currency, symbol } of symbolProfiles) { + let marketPrice = + marketData.find((marketDataItem) => { + return marketDataItem.symbol === symbol; + })?.marketPrice ?? 0; + + response[symbol] = { + currency, + marketPrice, dataSource: this.getName(), - marketPrice: marketData.find((marketDataItem) => { - return marketDataItem.symbol === symbolProfile.symbol; - })?.marketPrice, marketState: 'delayed' }; } @@ -252,7 +257,7 @@ export class ManualService implements DataProviderInterface { signal: abortController.signal }); - if (headers['content-type'] === 'application/json') { + if (headers['content-type'].includes('application/json')) { const data = JSON.parse(body); const value = String( jsonpath.query(data, scraperConfiguration.selector)[0] 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 fd34abb92..7e866552e 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 @@ -30,18 +30,22 @@ export class RapidApiService implements DataProviderInterface { return !!this.configurationService.get('API_KEY_RAPID_API'); } - public async getAssetProfile( - aSymbol: string - ): Promise> { + public async getAssetProfile({ + symbol + }: { + symbol: string; + }): Promise> { return { - dataSource: this.getName(), - symbol: aSymbol + symbol, + dataSource: this.getName() }; } public getDataProviderInfo(): DataProviderInfo { return { - isPremium: false + isPremium: false, + name: 'Rapid API', + url: 'https://rapidapi.com' }; } 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 645ec133e..e0d88f0c6 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 @@ -33,25 +33,19 @@ export class YahooFinanceService implements DataProviderInterface { return true; } - public async getAssetProfile( - aSymbol: string - ): Promise> { - const { assetClass, assetSubClass, currency, name, symbol } = - await this.yahooFinanceDataEnhancerService.getAssetProfile(aSymbol); - - return { - assetClass, - assetSubClass, - currency, - name, - symbol, - dataSource: this.getName() - }; + public async getAssetProfile({ + symbol + }: { + symbol: string; + }): Promise> { + return this.yahooFinanceDataEnhancerService.getAssetProfile(symbol); } public getDataProviderInfo(): DataProviderInfo { return { - isPremium: false + isPremium: false, + name: 'Yahoo Finance', + url: 'https://finance.yahoo.com' }; } diff --git a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts index e32af51d3..59f5144d8 100644 --- a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts @@ -22,6 +22,14 @@ export const ExchangeRateDataServiceMock = { '2023-07-10': 0.8854 } }); + } else if (targetCurrency === 'USD') { + return Promise.resolve({ + USDUSD: { + '2018-01-01': 1, + '2021-11-16': 1, + '2023-07-10': 1 + } + }); } return Promise.resolve({}); 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 2d169737a..1f08034cd 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 @@ -73,7 +73,17 @@ export class ExchangeRateDataService { currencyTo: targetCurrency }); - let previousExchangeRate = 1; + const dateStrings = Object.keys( + exchangeRatesByCurrency[`${currency}${targetCurrency}`] + ); + const lastDateString = dateStrings.reduce((a, b) => { + return a > b ? a : b; + }, undefined); + + let previousExchangeRate = + exchangeRatesByCurrency[`${currency}${targetCurrency}`]?.[ + lastDateString + ] ?? 1; // Start from the most recent date and fill in missing exchange rates // using the latest available rate @@ -94,7 +104,7 @@ export class ExchangeRateDataService { exchangeRatesByCurrency[`${currency}${targetCurrency}`][dateString] = previousExchangeRate; - if (currency === DEFAULT_CURRENCY) { + if (currency === DEFAULT_CURRENCY && isBefore(date, new Date())) { Logger.error( `No exchange rate has been found for ${currency}${targetCurrency} at ${dateString}`, 'ExchangeRateDataService' @@ -351,13 +361,13 @@ export class ExchangeRateDataService { const symbol = `${currencyFrom}${currencyTo}`; const marketData = await this.marketDataService.getRange({ - dateQuery: { gte: startDate, lt: endDate }, - uniqueAssets: [ + assetProfileIdentifiers: [ { dataSource, symbol } - ] + ], + dateQuery: { gte: startDate, lt: endDate } }); if (marketData?.length > 0) { @@ -382,13 +392,13 @@ export class ExchangeRateDataService { } } else { const marketData = await this.marketDataService.getRange({ - dateQuery: { gte: startDate, lt: endDate }, - uniqueAssets: [ + assetProfileIdentifiers: [ { dataSource, symbol: `${DEFAULT_CURRENCY}${currencyFrom}` } - ] + ], + dateQuery: { gte: startDate, lt: endDate } }); for (const { date, marketPrice } of marketData) { @@ -405,16 +415,16 @@ export class ExchangeRateDataService { } } else { const marketData = await this.marketDataService.getRange({ - dateQuery: { - gte: startDate, - lt: endDate - }, - uniqueAssets: [ + assetProfileIdentifiers: [ { dataSource, symbol: `${DEFAULT_CURRENCY}${currencyTo}` } - ] + ], + dateQuery: { + gte: startDate, + lt: endDate + } }); for (const { date, marketPrice } of marketData) { @@ -433,15 +443,22 @@ export class ExchangeRateDataService { ]) * marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)]; - factors[format(date, DATE_FORMAT)] = factor; + if (isNaN(factor)) { + throw new Error('Exchange rate is not a number'); + } else { + factors[format(date, DATE_FORMAT)] = factor; + } } catch { - Logger.error( - `No exchange rate has been found for ${currencyFrom}${currencyTo} at ${format( - date, - DATE_FORMAT - )}`, - 'ExchangeRateDataService' - ); + let errorMessage = `No exchange rate has been found for ${currencyFrom}${currencyTo} at ${format( + date, + DATE_FORMAT + )}. Please complement market data for ${DEFAULT_CURRENCY}${currencyFrom}`; + + if (DEFAULT_CURRENCY !== currencyTo) { + errorMessage = `${errorMessage} and ${DEFAULT_CURRENCY}${currencyTo}`; + } + + Logger.error(`${errorMessage}.`, 'ExchangeRateDataService'); } } } @@ -451,7 +468,7 @@ export class ExchangeRateDataService { } private async prepareCurrencies(): Promise { - let currencies: string[] = []; + let currencies: string[] = [DEFAULT_CURRENCY]; ( await this.prismaService.account.findMany({ diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index 54abe22be..c0dfb1806 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -28,8 +28,10 @@ export interface Environment extends CleanedEnvAccessors { GOOGLE_SHEETS_PRIVATE_KEY: string; JWT_SECRET_KEY: string; MAX_ACTIVITIES_TO_IMPORT: number; + MAX_CHART_ITEMS: number; MAX_ITEM_IN_CACHE: number; PORT: number; + REDIS_DB: number; REDIS_HOST: string; REDIS_PASSWORD: string; REDIS_PORT: number; @@ -41,5 +43,4 @@ export interface Environment extends CleanedEnvAccessors { TWITTER_ACCESS_TOKEN_SECRET: string; TWITTER_API_KEY: string; TWITTER_API_SECRET: string; - WEB_AUTH_RP_ID: string; } diff --git a/apps/api/src/services/interfaces/interfaces.ts b/apps/api/src/services/interfaces/interfaces.ts index bfd29d991..fa7fc4d09 100644 --- a/apps/api/src/services/interfaces/interfaces.ts +++ b/apps/api/src/services/interfaces/interfaces.ts @@ -1,11 +1,14 @@ -import { DataProviderInfo, UniqueAsset } from '@ghostfolio/common/interfaces'; +import { + AssetProfileIdentifier, + DataProviderInfo +} from '@ghostfolio/common/interfaces'; import { MarketState } from '@ghostfolio/common/types'; import { Account, DataSource, SymbolProfile, - Type as TypeOfOrder + Type as ActivityType } from '@prisma/client'; export interface IOrder { @@ -18,7 +21,7 @@ export interface IOrder { quantity: number; symbol: string; symbolProfile: SymbolProfile; - type: TypeOfOrder; + type: ActivityType; unitPrice: number; } @@ -34,6 +37,6 @@ export interface IDataProviderResponse { marketState: MarketState; } -export interface IDataGatheringItem extends UniqueAsset { +export interface IDataGatheringItem extends AssetProfileIdentifier { date?: Date; } diff --git a/apps/api/src/services/market-data/market-data.service.ts b/apps/api/src/services/market-data/market-data.service.ts index faf429955..09f591b9e 100644 --- a/apps/api/src/services/market-data/market-data.service.ts +++ b/apps/api/src/services/market-data/market-data.service.ts @@ -3,7 +3,7 @@ import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.i import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { resetHours } from '@ghostfolio/common/helper'; -import { UniqueAsset } from '@ghostfolio/common/interfaces'; +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; import { @@ -17,7 +17,7 @@ import { export class MarketDataService { public constructor(private readonly prismaService: PrismaService) {} - public async deleteMany({ dataSource, symbol }: UniqueAsset) { + public async deleteMany({ dataSource, symbol }: AssetProfileIdentifier) { return this.prismaService.marketData.deleteMany({ where: { dataSource, @@ -40,7 +40,7 @@ export class MarketDataService { }); } - public async getMax({ dataSource, symbol }: UniqueAsset) { + public async getMax({ dataSource, symbol }: AssetProfileIdentifier) { return this.prismaService.marketData.findFirst({ select: { date: true, @@ -59,11 +59,11 @@ export class MarketDataService { } public async getRange({ - dateQuery, - uniqueAssets + assetProfileIdentifiers, + dateQuery }: { + assetProfileIdentifiers: AssetProfileIdentifier[]; dateQuery: DateQuery; - uniqueAssets: UniqueAsset[]; }): Promise { return this.prismaService.marketData.findMany({ orderBy: [ @@ -76,13 +76,13 @@ export class MarketDataService { ], where: { dataSource: { - in: uniqueAssets.map(({ dataSource }) => { + in: assetProfileIdentifiers.map(({ dataSource }) => { return dataSource; }) }, date: dateQuery, symbol: { - in: uniqueAssets.map(({ symbol }) => { + in: assetProfileIdentifiers.map(({ symbol }) => { return symbol; }) } diff --git a/apps/api/src/services/symbol-profile/symbol-profile.service.ts b/apps/api/src/services/symbol-profile/symbol-profile.service.ts index a87b00d95..50cb25000 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -1,9 +1,10 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { + AssetProfileIdentifier, EnhancedSymbolProfile, - ScraperConfiguration, - UniqueAsset + Holding, + ScraperConfiguration } from '@ghostfolio/common/interfaces'; import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; @@ -22,7 +23,7 @@ export class SymbolProfileService { return this.prismaService.symbolProfile.create({ data: assetProfile }); } - public async delete({ dataSource, symbol }: UniqueAsset) { + public async delete({ dataSource, symbol }: AssetProfileIdentifier) { return this.prismaService.symbolProfile.delete({ where: { dataSource_symbol: { dataSource, symbol } } }); @@ -35,7 +36,7 @@ export class SymbolProfileService { } public async getSymbolProfiles( - aUniqueAssets: UniqueAsset[] + aAssetProfileIdentifiers: AssetProfileIdentifier[] ): Promise { return this.prismaService.symbolProfile .findMany({ @@ -53,7 +54,7 @@ export class SymbolProfileService { SymbolProfileOverrides: true }, where: { - OR: aUniqueAssets.map(({ dataSource, symbol }) => { + OR: aAssetProfileIdentifiers.map(({ dataSource, symbol }) => { return { dataSource, symbol @@ -61,7 +62,9 @@ export class SymbolProfileService { }) } }) - .then((symbolProfiles) => this.getSymbols(symbolProfiles)); + .then((symbolProfiles) => { + return this.enhanceSymbolProfiles(symbolProfiles); + }); } public async getSymbolProfilesByIds( @@ -83,7 +86,43 @@ export class SymbolProfileService { } } }) - .then((symbolProfiles) => this.getSymbols(symbolProfiles)); + .then((symbolProfiles) => { + return this.enhanceSymbolProfiles(symbolProfiles); + }); + } + + public async getSymbolProfilesByUserSubscription({ + withUserSubscription = false + }: { + withUserSubscription?: boolean; + }) { + return this.prismaService.symbolProfile.findMany({ + include: { + Order: { + include: { + User: true + } + } + }, + orderBy: [{ symbol: 'asc' }], + where: { + Order: withUserSubscription + ? { + some: { + User: { + Subscription: { some: { expiresAt: { gt: new Date() } } } + } + } + } + : { + every: { + User: { + Subscription: { none: { expiresAt: { gt: new Date() } } } + } + } + } + } + }); } public updateSymbolProfile({ @@ -93,12 +132,15 @@ export class SymbolProfileService { countries, currency, dataSource, + holdings, name, scraperConfiguration, sectors, symbol, - symbolMapping - }: Prisma.SymbolProfileUpdateInput & UniqueAsset) { + symbolMapping, + SymbolProfileOverrides, + url + }: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) { return this.prismaService.symbolProfile.update({ data: { assetClass, @@ -106,16 +148,19 @@ export class SymbolProfileService { comment, countries, currency, + holdings, name, scraperConfiguration, sectors, - symbolMapping + symbolMapping, + SymbolProfileOverrides, + url }, where: { dataSource_symbol: { dataSource, symbol } } }); } - private getSymbols( + private enhanceSymbolProfiles( symbolProfiles: (SymbolProfile & { _count: { Order: number }; Order?: { @@ -132,6 +177,7 @@ export class SymbolProfileService { symbolProfile?.countries as unknown as Prisma.JsonArray ), dateOfFirstActivity: undefined, + holdings: this.getHoldings(symbolProfile), scraperConfiguration: this.getScraperConfiguration(symbolProfile), sectors: this.getSectors(symbolProfile), symbolMapping: this.getSymbolMapping(symbolProfile) @@ -159,6 +205,14 @@ export class SymbolProfileService { ); } + if ( + (item.SymbolProfileOverrides.holdings as unknown as Holding[]) + ?.length > 0 + ) { + item.holdings = item.SymbolProfileOverrides + .holdings as unknown as Holding[]; + } + item.name = item.SymbolProfileOverrides?.name ?? item.name; if ( @@ -189,13 +243,26 @@ export class SymbolProfileService { return { code, weight, - continent: - continents[countries[code as string]?.continent] ?? UNKNOWN_KEY, - name: countries[code as string]?.name ?? UNKNOWN_KEY + continent: continents[countries[code]?.continent] ?? UNKNOWN_KEY, + name: countries[code]?.name ?? UNKNOWN_KEY }; }); } + private getHoldings(symbolProfile: SymbolProfile): Holding[] { + return ((symbolProfile?.holdings as Prisma.JsonArray) ?? []).map( + (holding) => { + const { name, weight } = holding as Prisma.JsonObject; + + return { + allocationInPercentage: weight as number, + name: (name as string) ?? UNKNOWN_KEY, + valueInBaseCurrency: undefined + }; + } + ); + } + private getScraperConfiguration( symbolProfile: SymbolProfile ): ScraperConfiguration { 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 5a98afaed..163b8e9c3 100644 --- a/apps/api/src/services/twitter-bot/twitter-bot.service.ts +++ b/apps/api/src/services/twitter-bot/twitter-bot.service.ts @@ -70,7 +70,7 @@ export class TwitterBotService { await this.twitterClient.v2.tweet(status); Logger.log( - `Fear & Greed Index has been tweeted: https://twitter.com/ghostfolio_/status/${createdTweet.id}`, + `Fear & Greed Index has been posted: https://x.com/ghostfolio_/status/${createdTweet.id}`, 'TwitterBotService' ); } diff --git a/apps/api/src/validators/is-currency-code.ts b/apps/api/src/validators/is-currency-code.ts new file mode 100644 index 000000000..8e8530552 --- /dev/null +++ b/apps/api/src/validators/is-currency-code.ts @@ -0,0 +1,44 @@ +import { DERIVED_CURRENCIES } from '@ghostfolio/common/config'; + +import { + registerDecorator, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, + ValidationArguments +} from 'class-validator'; +import { isISO4217CurrencyCode } from 'class-validator'; + +export function IsCurrencyCode(validationOptions?: ValidationOptions) { + return function (object: Object, propertyName: string) { + registerDecorator({ + propertyName, + constraints: [], + options: validationOptions, + target: object.constructor, + validator: IsExtendedCurrencyConstraint + }); + }; +} + +@ValidatorConstraint({ async: false }) +export class IsExtendedCurrencyConstraint + implements ValidatorConstraintInterface +{ + public defaultMessage(args: ValidationArguments) { + return '$value must be a valid ISO4217 currency code'; + } + + public validate(currency: any) { + // Return true if currency is a standard ISO 4217 code or a derived currency + return ( + isISO4217CurrencyCode(currency) || + [ + ...DERIVED_CURRENCIES.map((derivedCurrency) => { + return derivedCurrency.currency; + }), + 'USX' + ].includes(currency) + ); + } +} diff --git a/apps/client/localhost.cert b/apps/client/localhost.cert new file mode 100644 index 000000000..12f31dd27 --- /dev/null +++ b/apps/client/localhost.cert @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC5TCCAc2gAwIBAgIJAJAMHOFnJ6oyMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV +BAMMCWxvY2FsaG9zdDAeFw0yNDAyMjcxNTQ2MzFaFw0yNDAzMjgxNTQ2MzFaMBQx +EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC +ggEBAJ0hRjViikEKVIyukXR4CfuYVvFEFzB6AwAQ9Jrz2MseqpLacLTXFFAS54mp +iDuqPBzs9ta40mlSrqSBuAWKikpW5kTNnmqUnDZ6iSJezbYWx9YyULGqqb1q3C4/ +5pH9m6NHJ+2uaUNKlDxYNKbntqs3drQEdxH9yv672Z53nvogTcf9jz6zjutEQGSV +HgVkCTTQmzf3+3st+VJ5D8JeYFR+tpZ6yleqgXFaTMtPZRfKLvTkQ+KeyCJLnsUJ +BQvdCKI0PGsG6d6ygXFmSePolD9KW3VTKKDPCsndID89vAnRWDj9UhzvycxmKpcF +GrUPH5+Pis1PM1R7OnAvnFygnyECAwEAAaM6MDgwFAYDVR0RBA0wC4IJbG9jYWxo +b3N0MAsGA1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATANBgkqhkiG9w0B +AQsFAAOCAQEAOdzrY3RTAPnBVAd3/GKIEkielYyOgKPnvd+RcOB+je8cXZY+vaxX +uEFP0526G00+kRZ2Tx9t0SmjoSpwg3lHUPMko0dIxgRlqISDAohdrEptHpcVujsD +ak88LLnAurr60JNjWX2wbEoJ18KLtqGSnATdmCgKwDPIN5a7+ssp44BGyzH6VYCg +wV3VjJl0pp4C5M0Jfu0p1FrQjzIVhgqR7JFYmvqIogWrGwYAQK/3XRXq8t48J5X3 +IsfWiLAA2ZdCoWAnZ6PAGBOoGivtkJm967pHjd/28qYY6mQo4sN2ksEOjx6/YslF +2mOJdLV/DzqoggUsTpPEG0dRhzQLTGHKDQ== +-----END CERTIFICATE----- diff --git a/apps/client/localhost.pem b/apps/client/localhost.pem new file mode 100644 index 000000000..dc2a7ff5a --- /dev/null +++ b/apps/client/localhost.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCdIUY1YopBClSM +rpF0eAn7mFbxRBcwegMAEPSa89jLHqqS2nC01xRQEueJqYg7qjwc7PbWuNJpUq6k +gbgFiopKVuZEzZ5qlJw2eokiXs22FsfWMlCxqqm9atwuP+aR/ZujRyftrmlDSpQ8 +WDSm57arN3a0BHcR/cr+u9med576IE3H/Y8+s47rREBklR4FZAk00Js39/t7LflS +eQ/CXmBUfraWespXqoFxWkzLT2UXyi705EPinsgiS57FCQUL3QiiNDxrBunesoFx +Zknj6JQ/Slt1UyigzwrJ3SA/PbwJ0Vg4/VIc78nMZiqXBRq1Dx+fj4rNTzNUezpw +L5xcoJ8hAgMBAAECggEAPU5YOEgELTA8oM8TjV+wdWuQsH2ilpVkSkhTR4nQkh+a +6cU0qDoqgLt/fySYNL9MyPRjso9V+SX7YdAC3paZMjwJh9q57lehQ1g33SMkG+Fz +gs0K0ucFZxQkaB8idN+ANAp1N7UO+ORGRe0cTeqmSNNRCxea5XgiFZVxaPS/IFOR +vVdXS1DulgwHh4H0ljKmkj7jc9zPBSc9ccW5Ml2q4a26Atu4IC/Mlp/DF4GNELbD +ebi9ZOZG33ip2bdhj3WX7NW9GJaaViKtVUpcpR6u+6BfvTXQ6xrqdoxXk9fnPzzf +sSoLPTt8yO4RavP1zQU22PhgIcWudhCiy/2Nv+uLqQKBgQDMPh1/xwdFl8yES8dy +f0xJyCDWPiB+xzGwcOAC90FrNZaqG0WWs3VHE4cilaWjCflvdR6aAEDEY68sZJhl +h+ChT/g61QLMOI+VIDQ1kJXKYgfS/B+BE1PZ0EkuymKFOvbNO8agHodB8CqnZaQh +bLuZaDnZ0JLK4im3KPt70+aKYwKBgQDE8s6xl0SLu+yJLo3VklCRD67Z8/jlvx2t +h3DF6NG8pB3QmvKdJWFKuLAWLGflJwbUW9Pt3hXkc0649KQrtiIYC0ZMh9KMaVCk +WmjS/n2eIUQZ7ZUlwFesi4p4iGynVBavIY8TJ6Y+K3TvsJgXP3IZ96r689PQJo8E +KbSeyYzFqwKBgGQTS4EAlJ+U8bEhMGj51veQCAbyChoUoFRD+n95h6RwbZKMKlzd +MenRt7VKfg6VJJNoX8Y1uYaBEaQ+5i1Zlsdz1718AhLu4+u+C9bzMXIo9ox63TTx +s3RWioVSxVNiwOtvDrQGQWAdvcioFPQLwyA34aDIgiTHDImimxbhjWThAoGAWOqW +Tq9QjxWk0Lpn5ohMP3GpK1VuhasnJvUDARb/uf8ORuPtrOz3Y9jGBvy9W0OnXbCn +mbiugZldbTtl8yYjdl+AuYSIlkPl2I3IzZl/9Shnqp0MvSJ9crT9KzXMeC8Knr6z +7Z30/BR6ksxTngtS5E5grzPt6Qe/gc2ich3kpEkCgYBfBHUhVIKVrDW/8a7U2679 +Wj4i9us/M3iPMxcTv7/ZAg08TEvNBQYtvVQLu0NfDKXx8iGKJJ6evIYgNXVm2sPq +VzSgwGCALi1X0443amZU+mnX+/9RnBqyM+PJU8570mM83jqKlY/BEsi0aqxTioFG +an3xbjjN+Rq9iKLzmPxIMg== +-----END PRIVATE KEY----- diff --git a/apps/client/project.json b/apps/client/project.json index 4eb03df3c..dd644f8c1 100644 --- a/apps/client/project.json +++ b/apps/client/project.json @@ -13,13 +13,13 @@ "build": { "executor": "@nx/angular:webpack-browser", "options": { + "deleteOutputPath": false, "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": [], "styles": [ "apps/client/src/assets/fonts/inter.css", "apps/client/src/styles/theme.scss", @@ -36,6 +36,10 @@ "ngswConfigPath": "apps/client/ngsw-config.json" }, "configurations": { + "development-ca": { + "baseHref": "/ca/", + "localize": ["ca"] + }, "development-de": { "baseHref": "/de/", "localize": ["de"] @@ -72,6 +76,10 @@ "baseHref": "/tr/", "localize": ["tr"] }, + "development-zh": { + "baseHref": "/zh/", + "localize": ["zh"] + }, "production": { "fileReplacements": [ { @@ -108,13 +116,22 @@ "options": { "commands": [ { - "command": "shx mkdir -p dist/apps/client" + "command": "shx rm -rf dist/apps/client" + }, + { + "command": "shx mkdir -p dist/apps/client/.well-known" + }, + { + "command": "shx mkdir -p dist/apps/client/assets" + }, + { + "command": "shx mkdir -p dist/apps/client/ionicons" }, { - "command": "shx cp -r apps/client/src/assets dist/apps/client" + "command": "shx cp -r apps/client/src/assets/* dist/apps/client/assets" }, { - "command": "shx cp -r apps/client/src/assets/.well-known dist/apps/client" + "command": "shx cp -r apps/client/src/assets/.well-known/* dist/apps/client/.well-known" }, { "command": "shx cp apps/client/src/assets/favicon.ico dist/apps/client" @@ -128,9 +145,6 @@ { "command": "shx cp apps/client/src/assets/site.webmanifest dist/apps/client" }, - { - "command": "shx cp -r apps/client/src/locales dist/apps/api/assets" - }, { "command": "shx cp node_modules/ionicons/dist/index.js dist/apps/client" }, @@ -138,7 +152,7 @@ "command": "shx cp node_modules/ionicons/dist/ionicons.js dist/apps/client" }, { - "command": "shx cp -r node_modules/ionicons/dist/ionicons dist/apps/client/ionicons" + "command": "shx cp -r node_modules/ionicons/dist/ionicons/* dist/apps/client/ionicons" }, { "command": "shx cp CHANGELOG.md dist/apps/client/assets" @@ -146,14 +160,18 @@ { "command": "shx cp LICENSE dist/apps/client/assets" } - ] + ], + "parallel": false } }, "serve": { "executor": "@nx/angular:dev-server", "options": { + "buildTarget": "client:build", "proxyConfig": "apps/client/proxy.conf.json", - "buildTarget": "client:build" + "ssl": true, + "sslCert": "apps/client/localhost.cert", + "sslKey": "apps/client/localhost.pem" }, "configurations": { "development-de": { @@ -183,6 +201,9 @@ "development-tr": { "buildTarget": "client:build:development-tr" }, + "development-zh": { + "buildTarget": "client:build:development-zh" + }, "production": { "buildTarget": "client:build:production" } @@ -195,6 +216,7 @@ "includeContext": true, "outputPath": "src/locales", "targetFiles": [ + "messages.ca.xlf", "messages.de.xlf", "messages.es.xlf", "messages.fr.xlf", @@ -202,7 +224,8 @@ "messages.nl.xlf", "messages.pl.xlf", "messages.pt.xlf", - "messages.tr.xlf" + "messages.tr.xlf", + "messages.zh.xlf" ] } }, @@ -222,6 +245,10 @@ }, "i18n": { "locales": { + "ca": { + "baseHref": "/ca/", + "translation": "apps/client/src/locales/messages.ca.xlf" + }, "de": { "baseHref": "/de/", "translation": "apps/client/src/locales/messages.de.xlf" @@ -253,6 +280,10 @@ "tr": { "baseHref": "/tr/", "translation": "apps/client/src/locales/messages.tr.xlf" + }, + "zh": { + "baseHref": "/zh/", + "translation": "apps/client/src/locales/messages.zh.xlf" } }, "sourceLocale": "en" diff --git a/apps/client/src/app/app-routing.module.ts b/apps/client/src/app/app-routing.module.ts index 8f7aaea4e..8a517c5fe 100644 --- a/apps/client/src/app/app-routing.module.ts +++ b/apps/client/src/app/app-routing.module.ts @@ -1,3 +1,5 @@ +import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; +import { paths } from '@ghostfolio/client/core/paths'; import { PageTitleStrategy } from '@ghostfolio/client/services/page-title.strategy'; import { NgModule } from '@angular/core'; @@ -5,18 +7,6 @@ import { RouterModule, Routes, TitleStrategy } from '@angular/router'; 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 = [ { path: paths.about, @@ -53,9 +43,12 @@ const routes: Routes = [ import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule) }, { - path: 'demo', - loadChildren: () => - import('./pages/demo/demo-page.module').then((m) => m.DemoPageModule) + canActivate: [AuthGuard], + loadComponent: () => + import('./pages/demo/demo-page.component').then( + (c) => c.GfDemoPageComponent + ), + path: 'demo' }, { path: paths.faq, @@ -63,11 +56,13 @@ const routes: Routes = [ import('./pages/faq/faq-page.module').then((m) => m.FaqPageModule) }, { + canActivate: [AuthGuard], + loadComponent: () => + import('./pages/features/features-page.component').then( + (c) => c.GfFeaturesPageComponent + ), path: paths.features, - loadChildren: () => - import('./pages/features/features-page.module').then( - (m) => m.FeaturesPageModule - ) + title: $localize`Features` }, { path: 'home', @@ -75,9 +70,13 @@ const routes: Routes = [ import('./pages/home/home-page.module').then((m) => m.HomePageModule) }, { + canActivate: [AuthGuard], + loadComponent: () => + import('./pages/i18n/i18n-page.component').then( + (c) => c.GfI18nPageComponent + ), path: 'i18n', - loadChildren: () => - import('./pages/i18n/i18n-page.module').then((m) => m.I18nPageModule) + title: $localize`Internationalization` }, { path: paths.markets, @@ -134,11 +133,12 @@ const routes: Routes = [ ) }, { + loadComponent: () => + import('./pages/webauthn/webauthn-page.component').then( + (c) => c.GfWebauthnPageComponent + ), path: 'webauthn', - loadChildren: () => - import('./pages/webauthn/webauthn-page.module').then( - (m) => m.WebauthnPageModule - ) + title: $localize`Sign in` }, { path: 'zen', diff --git a/apps/client/src/app/app.component.html b/apps/client/src/app/app.component.html index 713eb1a94..806360c0f 100644 --- a/apps/client/src/app/app.component.html +++ b/apps/client/src/app/app.component.html @@ -1,33 +1,31 @@
-
-
-
- -
- You are using the Live Demo. - Create Account -
-
- {{ user.systemMessage.message }} + @if (canCreateAccount || user?.systemMessage) { +
+
+
+ @if (canCreateAccount) { + +
+ You are using the Live Demo. + Create Account +
+ } + @if (!canCreateAccount && user?.systemMessage) { +
+ {{ user.systemMessage.message }} +
+ }
-
+ } - + +} diff --git a/apps/client/src/app/app.component.scss b/apps/client/src/app/app.component.scss index 21d33e3c9..6037e9639 100644 --- a/apps/client/src/app/app.component.scss +++ b/apps/client/src/app/app.component.scss @@ -1,5 +1,3 @@ -@import 'apps/client/src/styles/ghostfolio-style'; - :host { display: block; min-height: 100svh; @@ -48,7 +46,7 @@ } } -:host-context(.is-dark-theme) { +:host-context(.theme-dark) { footer { background-color: rgba(var(--palette-foreground-text-dark), 0.05); } diff --git a/apps/client/src/app/app.component.ts b/apps/client/src/app/app.component.ts index 64b25ed79..fbc358590 100644 --- a/apps/client/src/app/app.component.ts +++ b/apps/client/src/app/app.component.ts @@ -1,3 +1,6 @@ +import { GfHoldingDetailDialogComponent } from '@ghostfolio/client/components/holding-detail-dialog/holding-detail-dialog.component'; +import { HoldingDetailDialogParams } from '@ghostfolio/client/components/holding-detail-dialog/interfaces/interfaces'; +import { getCssVariable } from '@ghostfolio/common/helper'; import { InfoItem, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { ColorScheme } from '@ghostfolio/common/types'; @@ -12,13 +15,22 @@ import { OnDestroy, OnInit } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; import { Title } from '@angular/platform-browser'; -import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router'; +import { + ActivatedRoute, + NavigationEnd, + PRIMARY_OUTLET, + Router +} from '@angular/router'; +import { DataSource } from '@prisma/client'; import { DeviceDetectorService } from 'ngx-device-detector'; import { Subject } from 'rxjs'; import { filter, takeUntil } from 'rxjs/operators'; +import { NotificationService } from './core/notification/notification.service'; import { DataService } from './services/data.service'; +import { ImpersonationStorageService } from './services/impersonation-storage.service'; import { TokenStorageService } from './services/token-storage.service'; import { UserService } from './services/user/user.service'; @@ -37,6 +49,7 @@ export class AppComponent implements OnDestroy, OnInit { public currentRoute: string; public currentYear = new Date().getFullYear(); public deviceType: string; + public hasImpersonationId: boolean; public hasInfoMessage: boolean; public hasPermissionForStatistics: boolean; public hasPermissionForSubscription: boolean; @@ -66,7 +79,11 @@ export class AppComponent implements OnDestroy, OnInit { private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, private deviceService: DeviceDetectorService, + private dialog: MatDialog, @Inject(DOCUMENT) private document: Document, + private impersonationStorageService: ImpersonationStorageService, + private notificationService: NotificationService, + private route: ActivatedRoute, private router: Router, private title: Title, private tokenStorageService: TokenStorageService, @@ -74,6 +91,21 @@ export class AppComponent implements OnDestroy, OnInit { ) { this.initializeTheme(); this.user = undefined; + + this.route.queryParams + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((params) => { + if ( + params['dataSource'] && + params['holdingDetailDialog'] && + params['symbol'] + ) { + this.openHoldingDetailDialog({ + dataSource: params['dataSource'], + symbol: params['symbol'] + }); + } + }); } public ngOnInit() { @@ -95,6 +127,13 @@ export class AppComponent implements OnDestroy, OnInit { permissions.enableFearAndGreedIndex ); + this.impersonationStorageService + .onChangeHasImpersonation() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((impersonationId) => { + this.hasImpersonationId = !!impersonationId; + }); + this.router.events .pipe(filter((event) => event instanceof NavigationEnd)) .subscribe(() => { @@ -162,7 +201,9 @@ export class AppComponent implements OnDestroy, OnInit { if (this.user.systemMessage.routerLink) { this.router.navigate(this.user.systemMessage.routerLink); } else { - alert(this.user.systemMessage.message); + this.notificationService.alert({ + title: this.user.systemMessage.message + }); } } @@ -187,20 +228,86 @@ export class AppComponent implements OnDestroy, OnInit { ? userPreferredColorScheme === 'DARK' : window.matchMedia('(prefers-color-scheme: dark)').matches; - this.toggleThemeStyleClass(isDarkTheme); + this.toggleTheme(isDarkTheme); window.matchMedia('(prefers-color-scheme: dark)').addListener((event) => { if (!this.user?.settings.colorScheme) { - this.toggleThemeStyleClass(event.matches); + this.toggleTheme(event.matches); } }); } - private toggleThemeStyleClass(isDarkTheme: boolean) { + private openHoldingDetailDialog({ + dataSource, + symbol + }: { + dataSource: DataSource; + symbol: string; + }) { + this.userService + .get() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((user) => { + this.user = user; + + const dialogRef = this.dialog.open(GfHoldingDetailDialogComponent, { + autoFocus: false, + data: { + dataSource, + symbol, + baseCurrency: this.user?.settings?.baseCurrency, + colorScheme: this.user?.settings?.colorScheme, + deviceType: this.deviceType, + hasImpersonationId: this.hasImpersonationId, + hasPermissionToCreateOrder: + !this.hasImpersonationId && + hasPermission(this.user?.permissions, permissions.createOrder) && + !this.user?.settings?.isRestrictedView, + hasPermissionToReportDataGlitch: hasPermission( + this.user?.permissions, + permissions.reportDataGlitch + ), + hasPermissionToUpdateOrder: + !this.hasImpersonationId && + hasPermission(this.user?.permissions, permissions.updateOrder) && + !this.user?.settings?.isRestrictedView, + locale: this.user?.settings?.locale + }, + height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', + maxWidth: this.deviceType === 'mobile' ? '95vw' : '50rem', + width: this.deviceType === 'mobile' ? '100vw' : '50rem' + }); + + dialogRef + .afterClosed() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.router.navigate([], { + queryParams: { + dataSource: null, + holdingDetailDialog: null, + symbol: null + }, + queryParamsHandling: 'merge', + relativeTo: this.route + }); + }); + }); + } + + private toggleTheme(isDarkTheme: boolean) { + const themeColor = getCssVariable( + isDarkTheme ? '--dark-background' : '--light-background' + ); + if (isDarkTheme) { - this.document.body.classList.add('is-dark-theme'); + this.document.body.classList.add('theme-dark'); } else { - this.document.body.classList.remove('is-dark-theme'); + this.document.body.classList.remove('theme-dark'); } + + this.document + .querySelector('meta[name="theme-color"]') + .setAttribute('content', themeColor); } } diff --git a/apps/client/src/app/app.module.ts b/apps/client/src/app/app.module.ts index f8b52faa9..04602dd2e 100644 --- a/apps/client/src/app/app.module.ts +++ b/apps/client/src/app/app.module.ts @@ -1,7 +1,10 @@ -import { GfLogoModule } from '@ghostfolio/ui/logo'; +import { GfLogoComponent } from '@ghostfolio/ui/logo'; import { Platform } from '@angular/cdk/platform'; -import { HttpClientModule } from '@angular/common/http'; +import { + provideHttpClient, + withInterceptorsFromDi +} from '@angular/common/http'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatChipsModule } from '@angular/material/chips'; @@ -30,6 +33,7 @@ import { GfSubscriptionInterstitialDialogModule } from './components/subscriptio import { authInterceptorProviders } from './core/auth.interceptor'; import { httpResponseInterceptorProviders } from './core/http-response.interceptor'; import { LanguageService } from './core/language.service'; +import { GfNotificationModule } from './core/notification/notification.module'; export function NgxStripeFactory(): string { return environment.stripePublicKey; @@ -43,9 +47,9 @@ export function NgxStripeFactory(): string { BrowserAnimationsModule, BrowserModule, GfHeaderModule, - GfLogoModule, + GfLogoComponent, + GfNotificationModule, GfSubscriptionInterstitialDialogModule, - HttpClientModule, MarkdownModule.forRoot(), MatAutocompleteModule, MatChipsModule, @@ -63,6 +67,7 @@ export function NgxStripeFactory(): string { authInterceptorProviders, httpResponseInterceptorProviders, LanguageService, + provideHttpClient(withInterceptorsFromDi()), { provide: DateAdapter, useClass: CustomDateAdapter, diff --git a/apps/client/src/app/components/access-table/access-table.component.html b/apps/client/src/app/components/access-table/access-table.component.html index 8112ca4ad..b1befc8c9 100644 --- a/apps/client/src/app/components/access-table/access-table.component.html +++ b/apps/client/src/app/components/access-table/access-table.component.html @@ -1,67 +1,71 @@ - - - - - +
+
Alias - {{ element.alias }} -
+ + + + - - - - + + + + - - - - + + + + - - - - + + + + - - + + - - + + + + + - - -
Alias + {{ element.alias }} + Grantee - {{ element.grantee }} - Grantee + {{ element.grantee }} + Permission -
- @if (element.permissions.includes('READ')) { - - View - } @else if (element.permissions.includes('READ_RESTRICTED')) { - - Restricted view - } -
-
Permission +
+ @if (element.permissions.includes('READ')) { + + View + } @else if (element.permissions.includes('READ_RESTRICTED')) { + + Restricted view + } +
+
Details - - Details + @if (element.type === 'PUBLIC') { + + } + - - - + - -
+ + + +
diff --git a/apps/client/src/app/components/access-table/access-table.component.scss b/apps/client/src/app/components/access-table/access-table.component.scss index f506edfc6..22a5d6732 100644 --- a/apps/client/src/app/components/access-table/access-table.component.scss +++ b/apps/client/src/app/components/access-table/access-table.component.scss @@ -1,5 +1,3 @@ -@import 'apps/client/src/styles/ghostfolio-style'; - :host { display: block; diff --git a/apps/client/src/app/components/access-table/access-table.component.ts b/apps/client/src/app/components/access-table/access-table.component.ts index 12412a3c2..7772451d4 100644 --- a/apps/client/src/app/components/access-table/access-table.component.ts +++ b/apps/client/src/app/components/access-table/access-table.component.ts @@ -1,3 +1,5 @@ +import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type'; +import { NotificationService } from '@ghostfolio/client/core/notification/notification.service'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { Access } from '@ghostfolio/common/interfaces'; @@ -29,7 +31,7 @@ export class AccessTableComponent implements OnChanges, OnInit { public defaultLanguageCode = DEFAULT_LANGUAGE_CODE; public displayedColumns = []; - public constructor() {} + public constructor(private notificationService: NotificationService) {} public ngOnInit() {} @@ -46,12 +48,12 @@ export class AccessTableComponent implements OnChanges, OnInit { } public onDeleteAccess(aId: string) { - const confirmation = confirm( - $localize`Do you really want to revoke this granted access?` - ); - - if (confirmation) { - this.accessDeleted.emit(aId); - } + this.notificationService.confirm({ + confirmFn: () => { + this.accessDeleted.emit(aId); + }, + confirmType: ConfirmationDialogType.Warn, + title: $localize`Do you really want to revoke this granted access?` + }); } } diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts index 0c61cf1d3..1cec23aba 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts @@ -1,3 +1,5 @@ +import { CreateAccountBalanceDto } from '@ghostfolio/api/app/account-balance/create-account-balance.dto'; +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { DataService } from '@ghostfolio/client/services/data.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { downloadAsFile } from '@ghostfolio/common/helper'; @@ -21,7 +23,8 @@ import { import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { Sort, SortDirection } from '@angular/material/sort'; import { MatTableDataSource } from '@angular/material/table'; -import Big from 'big.js'; +import { Router } from '@angular/router'; +import { Big } from 'big.js'; import { format, parseISO } from 'date-fns'; import { isNumber } from 'lodash'; import { Subject } from 'rxjs'; @@ -41,7 +44,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit { public activities: OrderWithAccount[]; public balance: number; public currency: string; - public dataSource: MatTableDataSource; + public dataSource: MatTableDataSource; public equity: number; public hasPermissionToDeleteAccountBalance: boolean; public historicalDataItems: HistoricalDataItem[]; @@ -64,6 +67,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit { @Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams, private dataService: DataService, public dialogRef: MatDialogRef, + private router: Router, private userService: UserService ) { this.userService.stateChanged @@ -83,75 +87,44 @@ export class AccountDetailDialog implements OnDestroy, OnInit { } public ngOnInit() { - this.dataService - .fetchAccount(this.data.accountId) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe( - ({ - balance, - currency, - name, - Platform, - transactionCount, - value, - valueInBaseCurrency - }) => { - this.balance = balance; - this.currency = currency; - - if (isNumber(balance) && isNumber(value)) { - this.equity = new Big(value).minus(balance).toNumber(); - } else { - this.equity = null; - } - - this.name = name; - this.platformName = Platform?.name ?? '-'; - this.transactionCount = transactionCount; - this.valueInBaseCurrency = valueInBaseCurrency; - - this.changeDetectorRef.markForCheck(); - } - ); - - this.dataService - .fetchPortfolioDetails({ - filters: [ - { - type: 'ACCOUNT', - id: this.data.accountId - } - ] - }) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(({ holdings }) => { - this.holdings = []; - - for (const [symbol, holding] of Object.entries(holdings)) { - this.holdings.push(holding); - } - - this.changeDetectorRef.markForCheck(); - }); - + this.fetchAccount(); this.fetchAccountBalances(); this.fetchActivities(); + this.fetchPortfolioHoldings(); this.fetchPortfolioPerformance(); } + public onCloneActivity(aActivity: Activity) { + this.router.navigate(['/portfolio', 'activities'], { + queryParams: { activityId: aActivity.id, createDialog: true } + }); + + this.dialogRef.close(); + } + public onClose() { this.dialogRef.close(); } + public onAddAccountBalance(accountBalance: CreateAccountBalanceDto) { + this.dataService + .postAccountBalance(accountBalance) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.fetchAccount(); + this.fetchAccountBalances(); + this.fetchPortfolioPerformance(); + }); + } + public onDeleteAccountBalance(aId: string) { this.dataService .deleteAccountBalance(aId) .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe({ - next: () => { - this.fetchAccountBalances(); - this.fetchPortfolioPerformance(); - } + .subscribe(() => { + this.fetchAccount(); + this.fetchAccountBalances(); + this.fetchPortfolioPerformance(); }); } @@ -184,6 +157,47 @@ export class AccountDetailDialog implements OnDestroy, OnInit { this.fetchActivities(); } + public onUpdateActivity(aActivity: Activity) { + this.router.navigate(['/portfolio', 'activities'], { + queryParams: { activityId: aActivity.id, editDialog: true } + }); + + this.dialogRef.close(); + } + + private fetchAccount() { + this.dataService + .fetchAccount(this.data.accountId) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe( + ({ + balance, + currency, + name, + Platform, + transactionCount, + value, + valueInBaseCurrency + }) => { + this.balance = balance; + this.currency = currency; + + if (isNumber(balance) && isNumber(value)) { + this.equity = new Big(value).minus(balance).toNumber(); + } else { + this.equity = null; + } + + this.name = name; + this.platformName = Platform?.name ?? '-'; + this.transactionCount = transactionCount; + this.valueInBaseCurrency = valueInBaseCurrency; + + this.changeDetectorRef.markForCheck(); + } + ); + } + private fetchAccountBalances() { this.dataService .fetchAccountBalances(this.data.accountId) @@ -215,6 +229,24 @@ export class AccountDetailDialog implements OnDestroy, OnInit { }); } + private fetchPortfolioHoldings() { + this.dataService + .fetchPortfolioHoldings({ + filters: [ + { + type: 'ACCOUNT', + id: this.data.accountId + } + ] + }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ holdings }) => { + this.holdings = holdings; + + this.changeDetectorRef.markForCheck(); + }); + } + private fetchPortfolioPerformance() { this.isLoadingChart = true; @@ -227,7 +259,8 @@ export class AccountDetailDialog implements OnDestroy, OnInit { } ], range: 'max', - withExcludedAccounts: true + withExcludedAccounts: true, + withItems: true }) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(({ chart }) => { @@ -235,11 +268,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit { ({ date, netWorth, netWorthInPercentage }) => { return { date, - value: - this.data.hasImpersonationId || - this.user.settings.isRestrictedView - ? netWorthInPercentage - : netWorth + value: isNumber(netWorth) ? netWorth : netWorthInPercentage }; } ); diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html index 9cae03e2c..9f55250ec 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html @@ -20,16 +20,20 @@
+
@@ -77,6 +81,7 @@ @@ -91,14 +96,24 @@ [dataSource]="dataSource" [deviceType]="data.deviceType" [hasPermissionToCreateActivity]="false" - [hasPermissionToExportActivities]="!data.hasImpersonationId && !user.settings.isRestrictedView" + [hasPermissionToDeleteActivity]="false" + [hasPermissionToExportActivities]=" + !data.hasImpersonationId && !user.settings.isRestrictedView + " [hasPermissionToFilter]="false" [hasPermissionToOpenDetails]="false" [locale]="user?.settings?.locale" - [showActions]="false" + [showActions]=" + !data.hasImpersonationId && + data.hasPermissionToCreateOrder && + user?.settings?.isExperimentalFeatures && + !user?.settings?.isRestrictedView + " [sortColumn]="sortColumn" [sortDirection]="sortDirection" [totalItems]="totalItems" + (activityToClone)="onCloneActivity($event)" + (activityToUpdate)="onUpdateActivity($event)" (export)="onExport()" (sortChanged)="onSortChanged($event)" /> @@ -110,9 +125,15 @@ diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts index faba1d6d2..e404aaad1 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts @@ -1,10 +1,10 @@ import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module'; import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module'; import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module'; -import { GfAccountBalancesModule } from '@ghostfolio/ui/account-balances/account-balances.module'; -import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module'; -import { GfHoldingsTableModule } from '@ghostfolio/ui/holdings-table/holdings-table.module'; -import { GfValueModule } from '@ghostfolio/ui/value'; +import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances'; +import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; +import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table'; +import { GfValueComponent } from '@ghostfolio/ui/value'; import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; @@ -19,13 +19,13 @@ import { AccountDetailDialog } from './account-detail-dialog.component'; declarations: [AccountDetailDialog], imports: [ CommonModule, - GfAccountBalancesModule, - GfActivitiesTableModule, + GfAccountBalancesComponent, + GfActivitiesTableComponent, GfDialogFooterModule, GfDialogHeaderModule, - GfHoldingsTableModule, + GfHoldingsTableComponent, GfInvestmentChartModule, - GfValueModule, + GfValueComponent, MatButtonModule, MatDialogModule, MatTabsModule, diff --git a/apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts index 016fc3b7d..9ad6a5ba4 100644 --- a/apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts +++ b/apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts @@ -2,4 +2,5 @@ export interface AccountDetailDialogParams { accountId: string; deviceType: string; hasImpersonationId: boolean; + hasPermissionToCreateOrder: boolean; } diff --git a/apps/client/src/app/components/accounts-table/accounts-table.component.html b/apps/client/src/app/components/accounts-table/accounts-table.component.html index 47473d303..f5039395b 100644 --- a/apps/client/src/app/components/accounts-table/accounts-table.component.html +++ b/apps/client/src/app/components/accounts-table/accounts-table.component.html @@ -1,301 +1,322 @@ -
- -
- - - - - - - +@if (showActions) { +
+ +
+} - - - + + +
-
- -
-
- Name - - - {{ element.name }} - (Default) + + + + - - +
+ @if (element.isExcluded) { + + } +
+ + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - + - - + + + + - - - + + + + + + + - - + + + + + + + - - - -
- Total - Currency - - {{ element.currency }} - - {{ baseCurrency }} - + Name + + @if (element.Platform?.url) { + + } + {{ element.name }} + Total - Platform - -
- - {{ element.Platform?.name }} -
-
+ Currency + + {{ element.currency }} + + {{ baseCurrency }} + - # - Activities - - {{ element.transactionCount }} - - {{ transactionCount }} - + Platform + +
+ @if (element.Platform?.url) { + + } + {{ element.Platform?.name }} +
+
- Cash Balance - - - - - + # + Activities + + {{ element.transactionCount }} + + {{ transactionCount }} + - Value - - - - - + Cash Balance + + + + + - Value - - - - - + Value + + + + + - + Value + - - - + + - - - - - + @if (element.comment) { + + } + - -
+
+
- +@if (isLoading) { + +} diff --git a/apps/client/src/app/components/accounts-table/accounts-table.component.scss b/apps/client/src/app/components/accounts-table/accounts-table.component.scss index 39e455dca..990b8b294 100644 --- a/apps/client/src/app/components/accounts-table/accounts-table.component.scss +++ b/apps/client/src/app/components/accounts-table/accounts-table.component.scss @@ -1,9 +1,7 @@ -@import 'apps/client/src/styles/ghostfolio-style'; - :host { display: block; - .mat-mdc-table { + .gf-table { th { ::ng-deep { .mat-sort-header-container { diff --git a/apps/client/src/app/components/accounts-table/accounts-table.component.ts b/apps/client/src/app/components/accounts-table/accounts-table.component.ts index d7c948adf..ff0358baa 100644 --- a/apps/client/src/app/components/accounts-table/accounts-table.component.ts +++ b/apps/client/src/app/components/accounts-table/accounts-table.component.ts @@ -1,3 +1,7 @@ +import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type'; +import { NotificationService } from '@ghostfolio/client/core/notification/notification.service'; +import { getLocale } from '@ghostfolio/common/helper'; + import { ChangeDetectionStrategy, Component, @@ -27,7 +31,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit { @Input() baseCurrency: string; @Input() deviceType: string; @Input() hasPermissionToOpenDetails = true; - @Input() locale: string; + @Input() locale = getLocale(); @Input() showActions: boolean; @Input() showBalance = true; @Input() showFooter = true; @@ -52,7 +56,10 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit { private unsubscribeSubject = new Subject(); - public constructor(private router: Router) {} + public constructor( + private notificationService: NotificationService, + private router: Router + ) {} public ngOnInit() {} @@ -95,13 +102,13 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit { } public onDeleteAccount(aId: string) { - const confirmation = confirm( - $localize`Do you really want to delete this account?` - ); - - if (confirmation) { - this.accountDeleted.emit(aId); - } + this.notificationService.confirm({ + confirmFn: () => { + this.accountDeleted.emit(aId); + }, + confirmType: ConfirmationDialogType.Warn, + title: $localize`Do you really want to delete this account?` + }); } public onOpenAccountDetailDialog(accountId: string) { @@ -113,7 +120,9 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit { } public onOpenComment(aComment: string) { - alert(aComment); + this.notificationService.alert({ + title: aComment + }); } public onTransferBalance() { diff --git a/apps/client/src/app/components/accounts-table/accounts-table.module.ts b/apps/client/src/app/components/accounts-table/accounts-table.module.ts index 259e839a4..879ca13ea 100644 --- a/apps/client/src/app/components/accounts-table/accounts-table.module.ts +++ b/apps/client/src/app/components/accounts-table/accounts-table.module.ts @@ -1,5 +1,5 @@ -import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module'; -import { GfValueModule } from '@ghostfolio/ui/value'; +import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component'; +import { GfValueComponent } from '@ghostfolio/ui/value'; import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; @@ -17,8 +17,8 @@ import { AccountsTableComponent } from './accounts-table.component'; exports: [AccountsTableComponent], imports: [ CommonModule, - GfSymbolIconModule, - GfValueModule, + GfAssetProfileIconComponent, + GfValueComponent, MatButtonModule, MatMenuModule, MatSortModule, diff --git a/apps/client/src/app/components/admin-jobs/admin-jobs.component.ts b/apps/client/src/app/components/admin-jobs/admin-jobs.component.ts index 5eff103df..820b3651d 100644 --- a/apps/client/src/app/components/admin-jobs/admin-jobs.component.ts +++ b/apps/client/src/app/components/admin-jobs/admin-jobs.component.ts @@ -1,6 +1,12 @@ +import { NotificationService } from '@ghostfolio/client/core/notification/notification.service'; import { AdminService } from '@ghostfolio/client/services/admin.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { QUEUE_JOB_STATUS_LIST } from '@ghostfolio/common/config'; +import { + DATA_GATHERING_QUEUE_PRIORITY_HIGH, + DATA_GATHERING_QUEUE_PRIORITY_LOW, + DATA_GATHERING_QUEUE_PRIORITY_MEDIUM, + QUEUE_JOB_STATUS_LIST +} from '@ghostfolio/common/config'; import { getDateWithTimeFormatString } from '@ghostfolio/common/helper'; import { AdminJobs, User } from '@ghostfolio/common/interfaces'; @@ -24,6 +30,11 @@ import { takeUntil } from 'rxjs/operators'; templateUrl: './admin-jobs.html' }) export class AdminJobsComponent implements OnDestroy, OnInit { + public DATA_GATHERING_QUEUE_PRIORITY_LOW = DATA_GATHERING_QUEUE_PRIORITY_LOW; + public DATA_GATHERING_QUEUE_PRIORITY_HIGH = + DATA_GATHERING_QUEUE_PRIORITY_HIGH; + public DATA_GATHERING_QUEUE_PRIORITY_MEDIUM = + DATA_GATHERING_QUEUE_PRIORITY_MEDIUM; public defaultDateTimeFormat: string; public filterForm: FormGroup; public dataSource: MatTableDataSource = @@ -33,6 +44,7 @@ export class AdminJobsComponent implements OnDestroy, OnInit { 'type', 'symbol', 'dataSource', + 'priority', 'attempts', 'created', 'finished', @@ -48,6 +60,7 @@ export class AdminJobsComponent implements OnDestroy, OnInit { private adminService: AdminService, private changeDetectorRef: ChangeDetectorRef, private formBuilder: FormBuilder, + private notificationService: NotificationService, private userService: UserService ) { this.userService.stateChanged @@ -98,12 +111,25 @@ export class AdminJobsComponent implements OnDestroy, OnInit { }); } + public onExecuteJob(aId: string) { + this.adminService + .executeJob(aId) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.fetchJobs(); + }); + } + public onViewData(aData: AdminJobs['jobs'][0]['data']) { - alert(JSON.stringify(aData, null, ' ')); + this.notificationService.alert({ + title: JSON.stringify(aData, null, ' ') + }); } public onViewStacktrace(aStacktrace: AdminJobs['jobs'][0]['stacktrace']) { - alert(JSON.stringify(aStacktrace, null, ' ')); + this.notificationService.alert({ + title: JSON.stringify(aStacktrace, null, ' ') + }); } public ngOnDestroy() { diff --git a/apps/client/src/app/components/admin-jobs/admin-jobs.html b/apps/client/src/app/components/admin-jobs/admin-jobs.html index 12b31dfc8..9ea2097e2 100644 --- a/apps/client/src/app/components/admin-jobs/admin-jobs.html +++ b/apps/client/src/app/components/admin-jobs/admin-jobs.html @@ -5,11 +5,14 @@ - {{ statusFilterOption }} + @for ( + statusFilterOption of statusFilterOptions; + track statusFilterOption + ) { + {{ + statusFilterOption + }} + } @@ -28,15 +31,11 @@ Type - - Asset Profile - - - Historical Market Data - + @if (element.name === 'GATHER_ASSET_PROFILE') { + Asset Profile + } @else if (element.name === 'GATHER_HISTORICAL_MARKET_DATA') { + Historical Market Data + } @@ -58,6 +57,25 @@ + + + Priority + + + @if (element.opts.priority === DATA_GATHERING_QUEUE_PRIORITY_LOW) { + + } @else if ( + element.opts.priority === DATA_GATHERING_QUEUE_PRIORITY_MEDIUM + ) { + + } @else if ( + element.opts.priority === DATA_GATHERING_QUEUE_PRIORITY_HIGH + ) { + + } + + + Attempts @@ -90,24 +108,29 @@ Status - - - - - - + @if (element.state === 'active') { + + } @else if (element.state === 'completed') { + + } @else if (element.state === 'delayed') { + + } @else if (element.state === 'failed') { + + } @else if (element.state === 'paused') { + + } @else if (element.state === 'waiting') { + + } @@ -147,6 +170,9 @@ > View Stacktrace + diff --git a/apps/client/src/app/components/admin-jobs/admin-jobs.scss b/apps/client/src/app/components/admin-jobs/admin-jobs.scss index b5b58f67e..5d4e87f30 100644 --- a/apps/client/src/app/components/admin-jobs/admin-jobs.scss +++ b/apps/client/src/app/components/admin-jobs/admin-jobs.scss @@ -1,5 +1,3 @@ -@import 'apps/client/src/styles/ghostfolio-style'; - :host { display: block; } diff --git a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html b/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html index f452dd1c9..617dd6962 100644 --- a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html +++ b/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html @@ -9,39 +9,38 @@ [showYAxis]="true" [symbol]="symbol" /> -
-
{{ itemByMonth.key }}
-
-
+ @for (itemByMonth of marketDataByMonth | keyvalue; track itemByMonth) { +
+
{{ itemByMonth.key }}
+
+ @for (dayItem of days; track dayItem; let i = $index) { +
+ } +
-
+ }
diff --git a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.scss b/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.scss index 8121fc119..a03533589 100644 --- a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.scss +++ b/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.scss @@ -1,5 +1,3 @@ -@import 'apps/client/src/styles/ghostfolio-style'; - :host { display: block; font-size: 0.9rem; diff --git a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts b/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts index 5a2ec5265..7e7168a6e 100644 --- a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts +++ b/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts @@ -19,15 +19,17 @@ import { MatDialog } from '@angular/material/dialog'; import { DataSource, MarketData } from '@prisma/client'; import { addDays, + addMonths, format, isBefore, isSameDay, isToday, isValid, + min, parse, parseISO } from 'date-fns'; -import { last } from 'lodash'; +import { first, last } from 'lodash'; import { DeviceDetectorService } from 'ngx-device-detector'; import { Subject, takeUntil } from 'rxjs'; @@ -135,6 +137,27 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit { marketPrice: marketDataItem.marketPrice }; } + + if (this.dateOfFirstActivity) { + // Fill up missing months + const dates = Object.keys(this.marketDataByMonth).sort(); + const startDate = min([ + parseISO(this.dateOfFirstActivity), + parseISO(first(dates)) + ]); + const endDate = parseISO(last(dates)); + + let currentDate = startDate; + + while (isBefore(currentDate, endDate)) { + const key = format(currentDate, 'yyyy-MM'); + if (!this.marketDataByMonth[key]) { + this.marketDataByMonth[key] = {}; + } + + currentDate = addMonths(currentDate, 1); + } + } } public isDateOfInterest(aDateString: string) { @@ -155,15 +178,14 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit { day: string; yearMonth: string; }) { - const date = parseISO(`${yearMonth}-${day}`); const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice; const dialogRef = this.dialog.open(MarketDataDetailDialog, { data: { - date, marketPrice, currency: this.currency, dataSource: this.dataSource, + dateString: `${yearMonth}-${day}`, symbol: this.symbol, user: this.user }, diff --git a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.module.ts b/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.module.ts index 9e742acba..9f4e1b3bc 100644 --- a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.module.ts +++ b/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.module.ts @@ -1,4 +1,4 @@ -import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module'; +import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; @@ -9,7 +9,7 @@ import { GfMarketDataDetailDialogModule } from './market-data-detail-dialog/mark @NgModule({ declarations: [AdminMarketDataDetailComponent], exports: [AdminMarketDataDetailComponent], - imports: [CommonModule, GfLineChartModule, GfMarketDataDetailDialogModule], + imports: [CommonModule, GfLineChartComponent, GfMarketDataDetailDialogModule], schemas: [CUSTOM_ELEMENTS_SCHEMA] }) export class GfAdminMarketDataDetailModule {} diff --git a/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/interfaces/interfaces.ts index 8f5447f9c..81188cd1f 100644 --- a/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/interfaces/interfaces.ts +++ b/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/interfaces/interfaces.ts @@ -5,7 +5,7 @@ import { DataSource } from '@prisma/client'; export interface MarketDataDetailDialogParams { currency: string; dataSource: DataSource; - date: Date; + dateString: string; marketPrice: number; symbol: string; user: User; diff --git a/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.component.ts b/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.component.ts index df8ac6067..6a44d0dfb 100644 --- a/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.component.ts +++ b/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.component.ts @@ -45,7 +45,7 @@ export class MarketDataDetailDialog implements OnDestroy { this.adminService .fetchSymbolForDate({ dataSource: this.data.dataSource, - date: this.data.date, + dateString: this.data.dateString, symbol: this.data.symbol }) .pipe(takeUntil(this.unsubscribeSubject)) @@ -63,7 +63,7 @@ export class MarketDataDetailDialog implements OnDestroy { marketData: { marketData: [ { - date: this.data.date.toISOString(), + date: this.data.dateString, marketPrice: this.data.marketPrice } ] diff --git a/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.html b/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.html index 5e16fc702..8e7e30649 100644 --- a/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.html +++ b/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.html @@ -9,7 +9,7 @@ matInput name="date" [matDatepicker]="date" - [(ngModel)]="data.date" + [(ngModel)]="data.dateString" /> 'PRESET_ID' + }, { id: 'CURRENCIES', label: $localize`Currencies`, @@ -78,44 +96,66 @@ export class AdminMarketDataComponent type: 'PRESET_ID' } ]); + public benchmarks: Partial[]; public currentDataSource: DataSource; public currentSymbol: string; public dataSource: MatTableDataSource = new MatTableDataSource(); public defaultDateFormat: string; public deviceType: string; - public displayedColumns = [ - 'nameWithSymbol', - 'dataSource', - 'assetClass', - 'assetSubClass', - 'date', - 'activitiesCount', - 'marketDataItemCount', - 'sectorsCount', - 'countriesCount', - 'comment', - 'actions' - ]; + public displayedColumns: string[] = []; public filters$ = new Subject(); + public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix; + public hasPermissionForSubscription: boolean; + public info: InfoItem; public isLoading = false; public isUUID = isUUID; public placeholder = ''; public pageSize = DEFAULT_PAGE_SIZE; + public selection: SelectionModel>; public totalItems = 0; public user: User; private unsubscribeSubject = new Subject(); public constructor( + public adminMarketDataService: AdminMarketDataService, private adminService: AdminService, private changeDetectorRef: ChangeDetectorRef, + private dataService: DataService, private deviceService: DeviceDetectorService, private dialog: MatDialog, private route: ActivatedRoute, private router: Router, private userService: UserService ) { + this.info = this.dataService.fetchInfo(); + + this.hasPermissionForSubscription = hasPermission( + this.info?.globalPermissions, + permissions.enableSubscription + ); + + this.displayedColumns = [ + 'select', + 'nameWithSymbol', + 'dataSource', + 'assetClass', + 'assetSubClass', + 'date', + 'activitiesCount', + 'marketDataItemCount', + 'sectorsCount', + 'countriesCount' + ]; + + if (this.hasPermissionForSubscription) { + this.displayedColumns.push('isUsedByUsersWithSubscription'); + } + + this.displayedColumns.push('comment'); + this.displayedColumns.push('actions'); + this.route.queryParams .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((params) => { @@ -169,7 +209,12 @@ export class AdminMarketDataComponent } public ngOnInit() { + const { benchmarks } = this.dataService.fetchInfo(); + + this.benchmarks = benchmarks; this.deviceType = this.deviceService.getDeviceInfo().deviceType; + + this.selection = new SelectionModel(true); } public onChangePage(page: PageEvent) { @@ -180,21 +225,16 @@ export class AdminMarketDataComponent }); } - public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) { - const confirmation = confirm( - $localize`Do you really want to delete this asset profile?` - ); + public onDeleteAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) { + this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol }); + } - if (confirmation) { - this.adminService - .deleteProfileData({ dataSource, symbol }) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(() => { - setTimeout(() => { - window.location.reload(); - }, 300); - }); - } + public onDeleteAssetProfiles() { + this.adminMarketDataService.deleteAssetProfiles( + this.selection.selected.map(({ dataSource, symbol }) => { + return { dataSource, symbol }; + }) + ); } public onGather7Days() { @@ -226,21 +266,27 @@ export class AdminMarketDataComponent .subscribe(() => {}); } - public onGatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) { + public onGatherProfileDataBySymbol({ + dataSource, + symbol + }: AssetProfileIdentifier) { this.adminService .gatherProfileDataBySymbol({ dataSource, symbol }) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(() => {}); } - public onGatherSymbol({ dataSource, symbol }: UniqueAsset) { + public onGatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) { this.adminService .gatherSymbol({ dataSource, symbol }) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(() => {}); } - public onOpenAssetProfileDialog({ dataSource, symbol }: UniqueAsset) { + public onOpenAssetProfileDialog({ + dataSource, + symbol + }: AssetProfileIdentifier) { this.router.navigate([], { queryParams: { dataSource, @@ -281,6 +327,8 @@ export class AdminMarketDataComponent this.placeholder = this.activeFilters.length <= 0 ? $localize`Filter by...` : ''; + this.selection.clear(); + this.adminService .fetchAdminMarketData({ sortColumn, @@ -293,7 +341,16 @@ export class AdminMarketDataComponent .subscribe(({ count, marketData }) => { this.totalItems = count; - this.dataSource = new MatTableDataSource(marketData); + this.dataSource = new MatTableDataSource( + marketData.map((marketDataItem) => { + return { + ...marketDataItem, + isBenchmark: this.benchmarks.some(({ id }) => { + return id === marketDataItem.id; + }) + }; + }) + ); this.dataSource.sort = this.sort; this.isLoading = false; @@ -320,6 +377,7 @@ export class AdminMarketDataComponent data: { dataSource, symbol, + colorScheme: this.user?.settings.colorScheme, deviceType: this.deviceType, locale: this.user?.settings?.locale }, diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.html b/apps/client/src/app/components/admin-market-data/admin-market-data.html index b2fa9a275..f3b2d8ddd 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.html +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.html @@ -11,209 +11,270 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + +
- Symbol - - {{ element.symbol }} - - Name - -
{{ element.name }}
-
- {{ element.symbol | gfSymbol }} -
-
- Data Source - - {{ element.dataSource }} - - Asset Class - - {{ element.assetClass }} - - Asset Sub Class - - {{ element.assetSubClass }} - - First Activity - - {{ (element.date | date: defaultDateFormat) ?? '' }} - - Activities Count - - {{ element.activitiesCount }} - - Historical Data - - {{ element.marketDataItemCount }} - - Sectors Count - - {{ element.sectorsCount }} - - Countries Count - - {{ element.countriesCount }} - - - - - - - - - - - + +
+
- + @if (isLoading && totalItems === 0) { + + }
diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.module.ts b/apps/client/src/app/components/admin-market-data/admin-market-data.module.ts index 7522a2fc9..224e3506b 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.module.ts +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.module.ts @@ -1,9 +1,11 @@ import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; -import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module'; +import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter'; +import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatMenuModule } from '@angular/material/menu'; import { MatPaginatorModule } from '@angular/material/paginator'; import { MatSortModule } from '@angular/material/sort'; @@ -12,6 +14,7 @@ import { RouterModule } from '@angular/router'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { AdminMarketDataComponent } from './admin-market-data.component'; +import { AdminMarketDataService } from './admin-market-data.service'; import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile-dialog.module'; import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/create-asset-profile-dialog.module'; @@ -19,11 +22,13 @@ import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/ declarations: [AdminMarketDataComponent], imports: [ CommonModule, - GfActivitiesFilterModule, + GfActivitiesFilterComponent, GfAssetProfileDialogModule, GfCreateAssetProfileDialogModule, + GfPremiumIndicatorComponent, GfSymbolModule, MatButtonModule, + MatCheckboxModule, MatMenuModule, MatPaginatorModule, MatSortModule, @@ -31,6 +36,7 @@ import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/ NgxSkeletonLoaderModule, RouterModule ], + providers: [AdminMarketDataService], schemas: [CUSTOM_ELEMENTS_SCHEMA] }) export class GfAdminMarketDataModule {} diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.scss b/apps/client/src/app/components/admin-market-data/admin-market-data.scss index b5b58f67e..5d4e87f30 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.scss +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.scss @@ -1,5 +1,3 @@ -@import 'apps/client/src/styles/ghostfolio-style'; - :host { display: block; } diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.service.ts b/apps/client/src/app/components/admin-market-data/admin-market-data.service.ts new file mode 100644 index 000000000..006bacb95 --- /dev/null +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.service.ts @@ -0,0 +1,81 @@ +import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type'; +import { NotificationService } from '@ghostfolio/client/core/notification/notification.service'; +import { AdminService } from '@ghostfolio/client/services/admin.service'; +import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config'; +import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; +import { + AssetProfileIdentifier, + AdminMarketDataItem +} from '@ghostfolio/common/interfaces'; + +import { Injectable } from '@angular/core'; +import { EMPTY, catchError, finalize, forkJoin, takeUntil } from 'rxjs'; + +@Injectable() +export class AdminMarketDataService { + public constructor( + private adminService: AdminService, + private notificationService: NotificationService + ) {} + + public deleteAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) { + this.notificationService.confirm({ + confirmFn: () => { + this.adminService + .deleteProfileData({ dataSource, symbol }) + .subscribe(() => { + setTimeout(() => { + window.location.reload(); + }, 300); + }); + }, + confirmType: ConfirmationDialogType.Warn, + title: $localize`Do you really want to delete this asset profile?` + }); + } + + public deleteAssetProfiles( + aAssetProfileIdentifiers: AssetProfileIdentifier[] + ) { + this.notificationService.confirm({ + confirmFn: () => { + const deleteRequests = aAssetProfileIdentifiers.map( + ({ dataSource, symbol }) => { + return this.adminService.deleteProfileData({ dataSource, symbol }); + } + ); + + forkJoin(deleteRequests) + .pipe( + catchError(() => { + this.notificationService.alert({ + title: $localize`Oops! Could not delete profiles.` + }); + + return EMPTY; + }), + finalize(() => { + window.location.reload(); + setTimeout(() => {}, 300); + }) + ) + .subscribe(() => {}); + }, + confirmType: ConfirmationDialogType.Warn, + title: $localize`Do you really want to delete these profiles?` + }); + } + + public hasPermissionToDeleteAssetProfile({ + activitiesCount, + isBenchmark, + symbol + }: Pick) { + return ( + activitiesCount === 0 && + !isBenchmark && + !isCurrency(getCurrencyFromSymbol(symbol)) && + !symbol.startsWith(ghostfolioScraperApiSymbolPrefix) + ); + } +} diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts index c0128f363..7cb3aac08 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts @@ -1,11 +1,15 @@ import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto'; +import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto'; +import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service'; +import { NotificationService } from '@ghostfolio/client/core/notification/notification.service'; import { AdminService } from '@ghostfolio/client/services/admin.service'; import { DataService } from '@ghostfolio/client/services/data.service'; -import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; +import { validateObjectForForm } from '@ghostfolio/client/util/form.util'; +import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config'; +import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { AdminMarketDataDetails, - Currency, - UniqueAsset + AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { translate } from '@ghostfolio/ui/i18n'; @@ -61,14 +65,16 @@ export class AssetProfileDialog implements OnDestroy, OnInit { name: ['', Validators.required], scraperConfiguration: '', sectors: '', - symbolMapping: '' + symbolMapping: '', + url: '' }); public assetProfileSubClass: string; public benchmarks: Partial[]; public countries: { [code: string]: { name: string; value: number }; }; - public currencies: Currency[] = []; + public currencies: string[] = []; + public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix; public isBenchmark = false; public marketDataDetails: MarketData[] = []; public sectors: { @@ -82,23 +88,22 @@ export class AssetProfileDialog implements OnDestroy, OnInit { private unsubscribeSubject = new Subject(); public constructor( + public adminMarketDataService: AdminMarketDataService, private adminService: AdminService, private changeDetectorRef: ChangeDetectorRef, @Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams, private dataService: DataService, public dialogRef: MatDialogRef, private formBuilder: FormBuilder, + private notificationService: NotificationService, private snackBar: MatSnackBar ) {} - public ngOnInit(): void { + public ngOnInit() { const { benchmarks, currencies } = this.dataService.fetchInfo(); this.benchmarks = benchmarks; - this.currencies = currencies.map((currency) => ({ - label: currency, - value: currency - })); + this.currencies = currencies; this.initialize(); } @@ -158,7 +163,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit { this.assetProfile?.scraperConfiguration ?? {} ), sectors: JSON.stringify(this.assetProfile?.sectors ?? []), - symbolMapping: JSON.stringify(this.assetProfile?.symbolMapping ?? {}) + symbolMapping: JSON.stringify(this.assetProfile?.symbolMapping ?? {}), + url: this.assetProfile?.url ?? '' }); this.assetProfileForm.markAsPristine(); @@ -167,18 +173,27 @@ export class AssetProfileDialog implements OnDestroy, OnInit { }); } - public onClose(): void { + public onClose() { this.dialogRef.close(); } - public onGatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) { + public onDeleteProfileData({ dataSource, symbol }: AssetProfileIdentifier) { + this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol }); + + this.dialogRef.close(); + } + + public onGatherProfileDataBySymbol({ + dataSource, + symbol + }: AssetProfileIdentifier) { this.adminService .gatherProfileDataBySymbol({ dataSource, symbol }) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(() => {}); } - public onGatherSymbol({ dataSource, symbol }: UniqueAsset) { + public onGatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) { this.adminService .gatherSymbol({ dataSource, symbol }) .pipe(takeUntil(this.unsubscribeSubject)) @@ -195,15 +210,13 @@ export class AssetProfileDialog implements OnDestroy, OnInit { header: true, skipEmptyLines: true } - ).data; + ).data as UpdateMarketDataDto[]; this.adminService .postMarketData({ dataSource: this.data.dataSource, marketData: { - marketData: marketData.map(({ date, marketPrice }) => { - return { marketPrice, date: parseDate(date).toISOString() }; - }) + marketData }, symbol: this.data.symbol }) @@ -234,7 +247,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit { } } - public onSetBenchmark({ dataSource, symbol }: UniqueAsset) { + public onSetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) { this.dataService .postBenchmark({ dataSource, symbol }) .pipe(takeUntil(this.unsubscribeSubject)) @@ -247,29 +260,29 @@ export class AssetProfileDialog implements OnDestroy, OnInit { }); } - public onSubmit() { + public async onSubmit() { let countries = []; let scraperConfiguration = {}; let sectors = []; let symbolMapping = {}; try { - countries = JSON.parse(this.assetProfileForm.controls['countries'].value); + countries = JSON.parse(this.assetProfileForm.get('countries').value); } catch {} try { scraperConfiguration = JSON.parse( - this.assetProfileForm.controls['scraperConfiguration'].value + this.assetProfileForm.get('scraperConfiguration').value ); } catch {} try { - sectors = JSON.parse(this.assetProfileForm.controls['sectors'].value); + sectors = JSON.parse(this.assetProfileForm.get('sectors').value); } catch {} try { symbolMapping = JSON.parse( - this.assetProfileForm.controls['symbolMapping'].value + this.assetProfileForm.get('symbolMapping').value ); } catch {} @@ -278,15 +291,25 @@ export class AssetProfileDialog implements OnDestroy, OnInit { scraperConfiguration, sectors, symbolMapping, - assetClass: this.assetProfileForm.controls['assetClass'].value, - assetSubClass: this.assetProfileForm.controls['assetSubClass'].value, - comment: this.assetProfileForm.controls['comment'].value ?? null, - currency: (( - (this.assetProfileForm.controls['currency'].value) - ))?.value, - name: this.assetProfileForm.controls['name'].value + assetClass: this.assetProfileForm.get('assetClass').value, + assetSubClass: this.assetProfileForm.get('assetSubClass').value, + comment: this.assetProfileForm.get('comment').value || null, + currency: this.assetProfileForm.get('currency').value, + name: this.assetProfileForm.get('name').value, + url: this.assetProfileForm.get('url').value || null }; + try { + await validateObjectForForm({ + classDto: UpdateAssetProfileDto, + form: this.assetProfileForm, + object: assetProfileData + }); + } catch (error) { + console.error(error); + return; + } + this.adminService .patchAssetProfile({ ...assetProfileData, @@ -302,31 +325,33 @@ export class AssetProfileDialog implements OnDestroy, OnInit { this.adminService .testMarketData({ dataSource: this.data.dataSource, - scraperConfiguration: - this.assetProfileForm.controls['scraperConfiguration'].value, + scraperConfiguration: this.assetProfileForm.get('scraperConfiguration') + .value, symbol: this.data.symbol }) .pipe( catchError(({ error }) => { - alert(`Error: ${error?.message}`); + this.notificationService.alert({ + message: error?.message, + title: $localize`Error` + }); return EMPTY; }), takeUntil(this.unsubscribeSubject) ) .subscribe(({ price }) => { - alert( - $localize`The current market price is` + + this.notificationService.alert({ + title: + $localize`The current market price is` + ' ' + price + ' ' + - (( - (this.assetProfileForm.controls['currency'].value) - ))?.value - ); + this.assetProfileForm.get('currency').value + }); }); } - public onUnsetBenchmark({ dataSource, symbol }: UniqueAsset) { + public onUnsetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) { this.dataService .deleteBenchmark({ dataSource, symbol }) .pipe(takeUntil(this.unsubscribeSubject)) diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html index 171b8065d..3e3910961 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html @@ -25,7 +25,9 @@ mat-menu-item type="button" [disabled]="assetProfileForm.dirty" - (click)="onGatherSymbol({dataSource: data.dataSource, symbol: data.symbol})" + (click)=" + onGatherSymbol({ dataSource: data.dataSource, symbol: data.symbol }) + " > Gather Historical Data @@ -33,10 +35,34 @@ mat-menu-item type="button" [disabled]="assetProfileForm.dirty" - (click)="onGatherProfileDataBySymbol({dataSource: data.dataSource, symbol: data.symbol})" + (click)=" + onGatherProfileDataBySymbol({ + dataSource: data.dataSource, + symbol: data.symbol + }) + " > Gather Profile Data + @@ -73,7 +99,12 @@ color="accent" mat-flat-button type="button" - [disabled]="!assetProfileForm.controls['historicalData']?.controls['csvString'].touched || assetProfileForm.controls['historicalData']?.controls['csvString']?.value === ''" + [disabled]=" + !assetProfileForm.controls['historicalData']?.controls['csvString'] + .touched || + assetProfileForm.controls['historicalData']?.controls['csvString'] + ?.value === '' + " (click)="onImportHistoricalData()" > Import @@ -86,11 +117,22 @@ >Symbol +
+ Data Source +
Currency
+
Activities
@@ -128,91 +170,99 @@ >Asset Sub Class - - @if (assetProfile?.countries?.length === 1 && - assetProfile?.sectors?.length === 1 ) { -
- Sector -
-
- Country -
+ @if ( + assetProfile?.countries?.length > 0 || assetProfile?.sectors?.length > 0 + ) { + @if ( + assetProfile?.countries?.length === 1 && + assetProfile?.sectors?.length === 1 + ) { + @if (assetProfile?.sectors?.length === 1) { +
+ Sector +
+ } + @if (assetProfile?.countries?.length === 1) { +
+ Country +
+ } } @else { -
-
Sectors
- -
-
-
Countries
- -
+
+
Sectors
+ +
+
+
Countries
+ +
} -
+ } -
+
Name
-
- - Currency - - -
-
+ @if (assetProfile?.dataSource === 'MANUAL') { +
+ + Currency + + +
+ } +
Asset Class - {{ assetClass.label }} + @for (assetClass of assetClasses; track assetClass) { + {{ + assetClass.label + }} + }
-
+
Asset Sub Class - {{ assetSubClass.label }} + @for (assetSubClass of assetSubClasses; track assetSubClass) { + {{ + assetSubClass.label + }} + }
@@ -222,7 +272,17 @@ color="primary" i18n [checked]="isBenchmark" - (change)="isBenchmark ? onUnsetBenchmark({dataSource: data.dataSource, symbol: data.symbol}) : onSetBenchmark({dataSource: data.dataSource, symbol: data.symbol})" + (change)=" + isBenchmark + ? onUnsetBenchmark({ + dataSource: data.dataSource, + symbol: data.symbol + }) + : onSetBenchmark({ + dataSource: data.dataSource, + symbol: data.symbol + }) + " >Benchmark
@@ -238,52 +298,69 @@ >
-
- - Scraper Configuration -
+ @if (assetProfile?.dataSource === 'MANUAL') { +
+ + Scraper Configuration +
+ + +
+
+
+
+ + Sectors - -
- -
-
- - Sectors - - -
-
- - Countries - + +
+
+ + Countries + + +
+ } +
+ + Url + + @if (assetProfileForm.get('url').value) { + + }
-
+
Note