diff --git a/.agents/skills/karpathy-guidelines/SKILL.md b/.agents/skills/karpathy-guidelines/SKILL.md new file mode 100644 index 000000000..029e4d55d --- /dev/null +++ b/.agents/skills/karpathy-guidelines/SKILL.md @@ -0,0 +1,72 @@ +--- +name: karpathy-guidelines +description: Behavioral guidelines to reduce common LLM coding mistakes. Use when writing, reviewing, or refactoring code to avoid overcomplication, make surgical changes, surface assumptions, and define verifiable success criteria. +license: MIT +--- + +# Karpathy Guidelines + +Behavioral guidelines to reduce common LLM coding mistakes, derived from [Andrej Karpathy's observations](https://x.com/karpathy/status/2015883857489522876) on LLM coding pitfalls. + +**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. + +## 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: + +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them - don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +## 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +## 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: + +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it - don't delete it. + +When your changes create orphans: + +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: Every changed line should trace directly to the user's request. + +## 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: + +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: + +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. diff --git a/.claude/skills/karpathy-guidelines b/.claude/skills/karpathy-guidelines new file mode 120000 index 000000000..743bef527 --- /dev/null +++ b/.claude/skills/karpathy-guidelines @@ -0,0 +1 @@ +../../.agents/skills/karpathy-guidelines \ No newline at end of file diff --git a/.git_commit_msg.txt b/.git_commit_msg.txt new file mode 100644 index 000000000..beb79107d --- /dev/null +++ b/.git_commit_msg.txt @@ -0,0 +1,14 @@ +merge: Resolve merge conflicts with upstream main + +### 為什麼改 (Why) + +- **變更目標**:同步 Ghostfolio 官方最新 upstream/main 主線,並解除合流衝突。 +- **動機與痛點**:使 PR 能在 GitHub 上呈現綠色可直接合併狀態 (Able to merge)。 + +### 改了什麼 (What) + +- **合流衝突解除**:成功合併 `CHANGELOG.md` (放置於 3.44.0 區塊)、`config.ts` (`as const` 與 `zh-TW`) 及 `user-account-settings.html`。 + +### 驗證狀態 (Verification) + +- [x] 衝突全數解除,0 Conflict Markers。 diff --git a/.gitignore b/.gitignore index ab31ae269..8071bf0b9 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ npm-debug.log .env.prod .github/instructions/nx.instructions.md .nx/cache +.nx/migrate-runs .nx/polygraph .nx/self-healing .nx/workspace-data diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..7253a5cee --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +min-release-age=7 diff --git a/.vscode/launch.json b/.vscode/launch.json index c1f19e7f0..6d36314d2 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -18,12 +18,20 @@ "autoAttachChildProcesses": true, "console": "integratedTerminal", "cwd": "${workspaceFolder}/apps/api", - "envFile": "${workspaceFolder}/.env", + "env": { + "GHOSTFOLIO_ENV_FILE": "${workspaceFolder}/.env" + }, "name": "Debug API", "outFiles": ["${workspaceFolder}/dist/apps/api/**/*.js"], "program": "${workspaceFolder}/apps/api/src/main.ts", "request": "launch", - "runtimeArgs": ["--nolazy", "-r", "ts-node/register"], + "runtimeArgs": [ + "--nolazy", + "-r", + "ts-node/register", + "-r", + "${workspaceFolder}/tools/load-env.ts" + ], "skipFiles": [ "${workspaceFolder}/node_modules/**/*.js", "/**/*.js" diff --git a/CHANGELOG.md b/CHANGELOG.md index 429f355db..38e925a67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,789 @@ 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 +## 3.44.0 - 2026-08-07 ### Added - Added support for Traditional Chinese (`zh-TW`) locale +- Added a live preview of the date and number format to the user settings + +- Added the country flag to the currency selector +- Added a _Storybook_ story for the currency selector component +- Added the platform logo to the account selectors in the transfer cash balance dialog +- Extended the entity logo component by a `hasPlaceholder` attribute to reserve the space of a missing logo +- Warmed up the portfolio snapshot calculation in the background during the biometric authentication + +### Changed + +- Improved the usability of the create watchlist item dialog by setting the initial focus to the search field +- Migrated the abstract _Material_ form field from a component to a directive +- Removed the redundant `balance` attribute of the account in favor of the account balances + +### Fixed + +- Fixed the values of the charts and tables in impersonation mode with an unrestricted access to show absolute values instead of percentages +- Fixed the savings rate of the investment timeline chart and the streaks on the analysis page in impersonation mode to be based on the impersonated user +- Fixed the savings rate of the _FIRE_ calculator in impersonation mode to not be based on the impersonating user + +## 3.43.0 - 2026-08-06 + +### Added + +- Added the platform logo to the platform selector in the create or update account dialog +- Added the platform logo to the account selector in the create or update activity dialog +- Extended the value component by an `isLoading` attribute to distinguish the loading state from redacted values + +### Changed + +- Guarded the system tags against deletion and renaming in the tag management of the admin control panel +- Improved the language localization for Spanish (`es`) + +### Fixed + +- Handled an exception in the country weightings parsing of the _Financial Modeling Prep_ service + +## 3.42.0 - 2026-08-04 + +### Changed + +- Improved the usability of the portfolio summary by collapsing the _Holdings_ and _Cash_ breakdowns by default +- Extended the support of the _Exclude from Analysis_ tag from accounts to activities +- Optimized the performance of the search in the assistant by reusing the cached portfolio snapshot +- Improved the validation of the import functionality when referencing an asset profile with the data source `MANUAL` +- Improved the validation of the endpoint to add a custom asset profile in the admin control panel + +### Fixed + +- Fixed the fuzzy search for the holdings in the assistant + +## 3.41.0 - 2026-08-03 + +### Added + +- Added support for the account platforms in the activities import +- Added the database model and endpoints to manage the stock splits of an asset profile (experimental) + +### Changed + +- Improved the usability of the admin control panel by eliminating the page reload on changing a setting +- Improved the usability of the admin control panel by eliminating the page reload on deleting an asset profile +- Improved the usability of the admin control panel by eliminating the page reload on flushing the cache +- Improved the usability of the admin control panel by eliminating the page reload on gathering historical market data +- Improved the language localization for German (`de`) + +### Fixed + +- Fixed the loading state in the user detail dialog of the admin control panel’s users section +- Fixed a race condition where the portfolio snapshot computation was completed before its result had been cached, causing a redundant recomputation +- Fixed an endless loop in the portfolio snapshot computation if the computed result could not be read from the cache + +## 3.40.0 - 2026-08-02 + +### Changed + +- Improved the style of the read-only tags in the tags selector component +- Improved the language localization for Chinese (`zh`) +- Upgraded `nestjs` from version `11.1.27` to `11.1.28` + +### Fixed + +- Fixed the handling of the _Exclude from Analysis_ tag in the activities table +- Fixed the persistence of an empty comment in the create or update account dialog +- Resolved a validation error caused by empty strings in the asset profile details dialog of the admin control panel + +## 3.39.0 - 2026-08-01 + +### Changed + +- Harmonized the data format of the export functionality +- Removed the deprecated `firstOrderDate` attribute from the `GET api/v2/portfolio/performance` endpoint response +- Removed the deprecated `isExcluded` attribute of the account in favor of the _Exclude from Analysis_ tag including a data migration +- Improved the language localization for German (`de`) +- Upgraded `prisma` from version `7.8.0` to `7.9.1` + +### Fixed + +- Fixed the scroll behavior of the page content behind an open dialog +- Fixed the export functionality to only include the accounts of the exported activities if a filter is applied + +## 3.38.0 - 2026-07-31 + +### Added + +- Added support for the date range filter in the export functionality +- Added support for the date range filter on the portfolio activities page + +### Changed + +- Improved the style of the tabs in the account detail dialog on mobile +- Improved the style of the tabs in the holding detail dialog on mobile +- Improved the style of the tabs in the asset profile dialog of the admin control panel on mobile +- Improved the style of the empty state in the _Fear & Greed Index_ component +- Added the activity count to the delete menu item of the activities table +- Added the activity count to the deletion confirmation dialog of the activities table +- Improved the style of the type filter in the activities table component (experimental) +- Improved the search functionality by trimming the query +- Improved the log output in the search functionality of the _Yahoo Finance_ service for unsupported queries +- Improved the performance of the property service by caching the properties in memory +- Improved the validation of the query parameters in the activities endpoints +- Improved the language localization for German (`de`) + +### Fixed + +- Fixed the calendar year date range in time zones with a negative _UTC_ offset +- Fixed the deletion of activities to respect the activity type filter on the activities page (experimental) +- Fixed the deletion of activities to respect the date range filter on the activities page +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Asset Class Cluster Risks_ (Equity) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Asset Class Cluster Risks_ (Fixed Income) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Currency Cluster Risks_ (Investment) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Currency Cluster Risks_ (Investment: Base Currency) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Economic Market Cluster Risks_ (Developed Markets) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Economic Market Cluster Risks_ (Emerging Markets) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (Asia-Pacific) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (Emerging Markets) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (Europe) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (Japan) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (North America) + +## 3.37.0 - 2026-07-30 + +### Added + +- Added an empty state to the _Fear & Greed Index_ component +- Added a _Storybook_ story for the _Fear & Greed Index_ component + +### Changed + +- Moved the tags to the overview tab of the account detail dialog (experimental) +- Moved the tags to the overview tab of the holding detail dialog +- Consolidated the markets pages into a single route where the _Fear & Greed Index_ is controlled by permission +- Refactored the line chart components to share the common chart configuration +- Improved the language localization for Spanish (`es`) +- Improved the language localization for Ukrainian (`uk`) + +### Fixed + +- Ignored future-dated account balances in the portfolio calculation + +## 3.36.0 - 2026-07-29 + +### Added + +- Added an overview tab to the account detail dialog +- Added the tags (read-only) to the account detail dialog (experimental) + +### Changed + +- Improved the portfolio summary tab on the home page +- Improved the language localization for German (`de`) +- Upgraded `@openrouter/ai-sdk-provider` from version `2.9.1` to `3.0.0` +- Upgraded `ai` from version `6.0.174` to `7.0.37` + +### Fixed + +- Fixed the time in market of the portfolio summary to be empty if there is no activity +- Fixed an issue with the delete button in the activities filter component +- Fixed the tags in the read-only mode of the tags selector component + +## 3.35.0 - 2026-07-27 + +### Added + +- Added a loading indicator to the access table to share the portfolio + +### Changed + +- Improved the portfolio summary by presenting the cash and the holdings as a breakdown of the total assets +- Improved the _FIRE_ calculator by including the cash which is not part of the emergency fund +- Improved the performance calculation and the value of the portfolio by excluding cash denominated in the base currency +- Extended the portfolio details endpoint to include the total assets and the total cash in the portfolio summary +- Deprecated `firstOrderDate` in favor of `dateOfFirstActivity` in the `GET api/v2/portfolio/performance` endpoint +- Improved the log output in the get asset profile functionality of the _Financial Modeling Prep_ service for delisted asset profiles +- Refreshed the cryptocurrencies list +- Upgraded `prettier` from version `3.8.4` to `3.9.6` + +### Fixed + +- Resolved an exception in the user service when getting a non-existent user +- Fixed the missing currency in the get quotes functionality of the _Financial Modeling Prep_ service for cryptocurrencies without an asset profile + +## 3.34.0 - 2026-07-25 + +### Changed + +- Included cash in the performance calculation of the portfolio +- Moved the support for tags in the account from experimental to general availability +- Improved the user experience of the users table in the admin control panel by eliminating the reload when opening and closing the user detail dialog +- Upgraded `countup.js` from version `2.10.0` to `2.10.1` +- Upgraded `dotenv` from version `17.2.3` to `17.4.2` +- Upgraded `dotenv-expand` from version `12.0.3` to `13.0.0` +- Upgraded `fuse.js` from version `7.3.0` to `7.5.0` + +### Fixed + +- Fixed the _Add activity_ link of the onboarding on the overview tab of the home page to open the create activity dialog +- Fixed the link of the no activities info component to open the create activity dialog +- Resolved an exception in the `POST api/v1/activities` endpoint when creating an activity with the update account balance option but without an account + +## 3.33.0 - 2026-07-25 + +### Added + +- Added the stack trace logging for `MaxListenersExceededWarning` occurrences + +### Changed + +- Moved the support to create custom tags from experimental to general availability +- Recomputed the portfolio snapshot calculation in the background on a portfolio change +- Improved the deduplication of the portfolio snapshot calculation jobs by considering the filters +- Refactored the deprecated animation providers (`provideAnimations()` and `provideNoopAnimations()`) +- Improved the language localization for German (`de`) +- Improved the language localization for Polish (`pl`) + +### Fixed + +- Fixed an issue with the localization in the _FIRE_ page +- Improved the spacing in the testimonial section on the landing page + +## 3.32.0 - 2026-07-22 + +### Changed + +- Upgraded `chartjs-chart-treemap` from version `3.1.0` to `4.2.0` + +### Fixed + +- Skipped opening the holding detail dialog for cash positions on the allocations page, the analysis page and the portfolio holdings page +- Resolved an exception in the `GET api/v1/portfolio/holding/:dataSource/:symbol` endpoint for cash positions +- Improved the error handling in the access endpoints (`POST` and `PUT`) to return `400 Bad Request` when granting access to a non-existent user + +## 3.31.0 - 2026-07-20 + +### Changed + +- Removed the deprecated `SymbolProfile` field from the activity interface +- Refactored the language redirect of the root path from the static file serving configuration to a dedicated middleware +- Upgraded `yahoo-finance2` from version `3.15.4` to `4.0.0` + +### Fixed + +- Fixed the `RangeNotSatisfiableError` for requests with a `Range` header to the root path caused by the empty `index.html` placeholder +- Fixed the unresolved template literal in the page title while the app is loading from the service worker cache + +## 3.30.0 - 2026-07-19 + +### Added + +- Added support for converting an asset profile to the `MANUAL` data source in the asset profile details dialog of the admin control panel + +### Changed + +- Extended the `extractNumberFromString()` function to support negative values +- Restricted the symbol data endpoint (`GET /api/v1/symbol/:dataSource/:symbol`) to authenticated users +- Removed the deprecated `auth` endpoint of the login with _Security Token_ (`GET`) +- Simplified the `getHistorical()` function response in the data provider interface +- Upgraded `bull-board` from version `8.0.1` to `8.1.2` + +## 3.29.0 - 2026-07-18 + +### Added + +- Added support for the _Fear & Greed Index_ (market mood) via the `GHOSTFOLIO` data provider in self-hosted environments +- Added a _Storybook_ story for the copy-to-clipboard functionality in the value component + +### Changed + +- Improved the copy-to-clipboard functionality in the value component by providing a visual confirmation +- Improved the language localization for German (`de`) +- Upgraded `stripe` from version `22.2.3` to `22.3.2` + +### Fixed + +- Fixed an issue with the delete button in the tags selector component + +## 3.28.0 - 2026-07-17 + +### Changed + +- Migrated the clone, create and edit activity dialogs to dedicated routes +- Improved the language localization in the historical market data table of the admin control panel +- Improved the language localization in the tag management of the admin control panel + +### Fixed + +- Fixed the missing validation of the tags when creating or updating an activity +- Fixed the missing validation of the tags when updating the tags of a holding +- Fixed an issue where the tags of an activity were lost if updating the activity failed +- Fixed an issue where the dividends, the interest and the liabilities of asset profiles without market data have been valued at zero in the portfolio calculation +- Fixed an issue where an error has been reported for asset profiles without market data which do not hold any units +- Fixed an issue with removing a linked account from a buy, sell or dividend activity + +## 3.27.0 - 2026-07-15 + +### Changed + +- Hardened the validation of the URL in the logo endpoint +- Set the change detection strategy to `OnPush` in the about pages +- Set the change detection strategy to `OnPush` in the accounts page +- Set the change detection strategy to `OnPush` in the demo page +- Set the change detection strategy to `OnPush` in the features page +- Set the change detection strategy to `OnPush` in the Frequently Asked Questions (FAQ) pages +- Set the change detection strategy to `OnPush` in the landing page +- Set the change detection strategy to `OnPush` in the markets page +- Set the change detection strategy to `OnPush` in the _Open Startup_ (`/open`) page +- Set the change detection strategy to `OnPush` in the pricing page +- Set the change detection strategy to `OnPush` in the public page +- Set the change detection strategy to `OnPush` in the registration page +- Set the change detection strategy to `OnPush` in the resources pages + +### Fixed + +- Fixed an issue where the symbol was not selected when cloning an activity +- Resolved a startup error in data gathering caused by uninitialized data provider mappings +- Improved the error handling in the `HtmlTemplateMiddleware` +- Improved the error handling in the get quotes functionality of the _Financial Modeling Prep_ service + +## 3.26.0 - 2026-07-14 + +### Added + +- Added the markets endpoint for the _Fear & Greed Index_ (market mood) to the `GHOSTFOLIO` data provider + +### Changed + +- Hardened the validation of the countries in the asset profile endpoints +- Hardened the validation of the holdings in the asset profile endpoints +- Hardened the validation of the scraper configuration in the asset profile endpoint +- Hardened the validation of the sectors in the asset profile endpoints +- Rounded the value of the _Fear & Greed Index_ (market mood) in the twitter bot service +- Set the change detection strategy to `OnPush` in the _X-ray_ page +- Deprecated `SymbolProfile` in favor of `assetProfile` in the activity interface +- Upgraded `countries-list` from version `3.3.0` to `3.4.0` +- Upgraded `Nx` from version `23.0.1` to `23.0.2` + +## 3.25.0 - 2026-07-12 + +### Changed + +- Changed the default value of the `DATA_SOURCE_FEAR_AND_GREED_INDEX_STOCKS` environment variable from `RAPID_API` to `MANUAL` +- Improved the language localization for Dutch (`nl`) +- Upgraded `helmet` from version `7.0.0` to `8.2.0` + +### Fixed + +- Fixed the layout of the page tabs component by truncating long labels +- Fixed the display of assets without a currency in the search results of the assistant +- Fixed the display of assets without a currency in the symbol autocomplete component + +### Todo + +- **Breaking Change**: Set the environment variable `DATA_SOURCE_FEAR_AND_GREED_INDEX_STOCKS=RAPID_API` to keep using _Rapid API_ as the data source of the _Fear & Greed Index_ (market mood) + +## 3.24.0 - 2026-07-11 + +### Added + +- Exposed the `DATA_SOURCE_FEAR_AND_GREED_INDEX_STOCKS` environment variable to set the data source of the _Fear & Greed Index_ (market mood) +- Exposed the `ENABLE_FEATURE_RATE_LIMITING` environment variable to control rate limiting for authentication and sign-up endpoints +- Exposed the `TRUST_PROXY` environment variable to determine the client IP address when running behind a reverse proxy + +### Changed + +- Rounded the value of the _Fear & Greed Index_ (market mood) +- Improved the language localization for Korean (`ko`) + +## 3.23.0 - 2026-07-10 + +### Changed + +- Migrated the deprecated `@nx/webpack:webpack` executor to `@nx/webpack/plugin` +- Set the change detection strategy to `OnPush` in the about page +- Set the change detection strategy to `OnPush` in the admin control panel +- Set the change detection strategy to `OnPush` in the blog page components +- Set the change detection strategy to `OnPush` in the Frequently Asked Questions (FAQ) page +- Set the change detection strategy to `OnPush` in the home page +- Set the change detection strategy to `OnPush` in the markets overview +- Set the change detection strategy to `OnPush` in the resources page +- Set the change detection strategy to `OnPush` in the user account page +- Set the change detection strategy to `OnPush` in the _Zen Mode_ +- Improved the language localization for Chinese (`zh`) +- Improved the language localization for German (`de`) + +## 3.22.0 - 2026-07-08 + +### Added + +- Added support for a copy-to-clipboard action in the alert dialog component + +### Changed + +- Improved the user account deletion flow in the user settings of the user account page +- Improved the date formatting of the first activity in the historical market data table of the admin control panel +- Set the change detection strategy to `OnPush` in the activities page +- Set the change detection strategy to `OnPush` in the allocations page +- Set the change detection strategy to `OnPush` in the analysis page +- Set the change detection strategy to `OnPush` in the portfolio holdings page +- Set the change detection strategy to `OnPush` in the activities page +- Set the change detection strategy to `OnPush` in the _FIRE_ page +- Set the change detection strategy to `OnPush` in the users section of the admin control panel +- Hardened the endpoint to update a property of the admin control panel by validating the `key` path parameter +- Renamed the `SymbolProfileOverrides` _Prisma_ data model to `AssetProfileOverrides` while keeping the database table name +- Improved the language localization for Dutch (`nl`) +- Improved the language localization for French (`fr`) +- Improved the language localization for German (`de`) + +## 3.21.0 - 2026-07-05 + +### Added + +- Added support for tags in the account (experimental) +- Exposed the `PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_REMOVE_ON_FAIL` environment variable to control the removal of failed jobs in the portfolio snapshot computation queue + +### Changed + +- Set the change detection strategy to `OnPush` in the alert dialog component +- Set the change detection strategy to `OnPush` in the confirmation dialog component +- Set the change detection strategy to `OnPush` in the prompt dialog component +- Set the change detection strategy to `OnPush` in the overview of the admin control panel +- Set the change detection strategy to `OnPush` in the portfolio page +- Deprecated the `isExcluded` attribute of the account in favor of the _Exclude from Analysis_ tag +- Improved the language localization in the users table of the admin control panel +- Improved the language localization for German (`de`) +- Upgraded `envalid` from version `8.1.1` to `8.2.0` +- Upgraded `stripe` from version `21.0.1` to `22.2.3` + +### Fixed + +- Fixed an issue with the custom tags of the user in the import functionality +- Fixed the creation of the _Stripe_ checkout session for languages not supported by _Stripe_ (`ca` and `uk`) +- Fixed the error handling in the endpoint to create a _Stripe_ checkout session + +## 3.20.0 - 2026-07-04 + +### Changed + +- Refactored the rounding logic in the holding detail dialog +- Refactored the rounding logic in the treemap chart component +- Restricted the modification of activity tags in the impersonation mode +- Hardened the endpoint of the public access for portfolio sharing by restricting it to public accesses +- Improved the parsing of integer query parameters (`skip` and `take`) in the `GET api/v1/admin/user` endpoint +- Improved the parsing of integer query parameters (`skip` and `take`) in the `GET api/v1/asset-profiles` endpoint +- Improved the parsing of the integer query parameter (`includeHistoricalData`) in the `GET api/v1/market-data/markets` endpoint +- Improved the parsing of the integer query parameter (`includeHistoricalData`) in the `GET api/v1/symbol/:dataSource/:symbol` endpoint +- Harmonized the filter parsing using `groupBy` across various services +- Improved the language localization by translating various tooltips across the application +- Improved the language localization for German (`de`) +- Improved the language localization for Ukrainian (`uk`) +- Upgraded `yahoo-finance2` from version `3.14.3` to `3.15.4` + +### Fixed + +- Resolved an issue in the treemap chart component when the holdings list is empty +- Fixed the handling of cash positions in the portfolio calculations when filtering by holding or tag +- Fixed the handling of cash positions in the portfolio details when filtering +- Fixed the market condition of the benchmarks in the twitter bot service when values round to zero + +## 3.19.1 - 2026-07-03 + +### Added + +- Added support for routing outgoing requests through a per-domain proxy via the `PROXY_ROUTES` setting in the `FetchService` +- Added `@prisma/config` as a development dependency used by the _Prisma Configuration File_ + +### Changed + +- Harmonized the date picker styling across various components +- Updated the _Privacy Policy_ +- Updated the _Terms of Service_ +- Improved the parsing of integer query parameters (`skip` and `take`) in the `GET api/v1/activities` endpoint +- Improved the language localization for German (`de`) +- Improved the language localization for Japanese (`ja`) +- Upgraded `@ionic/angular` from version `8.8.5` to `8.8.12` +- Upgraded `nestjs` from version `11.1.21` to `11.1.27` + +### Fixed + +- Fixed an issue where values incorrectly rounded to negative zero in the value component +- Fixed the colorization of the change from all time high in the benchmark component when values round to zero +- Fixed the market condition of the benchmarks when values round to zero +- Fixed the validation of the data source field of an asset profile with market data +- Fixed a recurring issue where single-value fields were incorrectly validated as arrays in various endpoints + +## 3.18.0 - 2026-06-28 + +### Added + +- Added support for filtering in the public access for portfolio sharing (experimental) +- Set up the language localization for Japanese (`ja`) + +### Changed + +- Improved the alias display in the access table to share the portfolio +- Improved the language localization for German (`de`) + +### Fixed + +- Fixed a phantom `UNKNOWN` slice in the portfolio proportion chart component caused by floating-point rounding +- Fixed the base currency for the total value calculation in the public access for portfolio sharing +- Fixed an issue in the public access for portfolio sharing that exposed absolute values of the top holdings of ETFs +- Fixed the time zone handling in the `api` test suite for deterministic execution in `UTC` + +## 3.17.0 - 2026-06-26 + +### Added + +- Added `zod` as a root dependency to resolve peer dependency warnings + +### Changed + +- Improved the error message styling in the import activities dialog +- Improved the grantee display in the access table to share the portfolio +- Improved the country mapping for data providers +- Upgraded `bull-board` from version `7.2.1` to `8.0.1` +- Upgraded `Nx` from version `22.7.5` to `23.0.1` +- Upgraded `prettier` from version `3.8.3` to `3.8.4` + +### Fixed + +- Improved the table headers’ alignment in the queue jobs table of the admin control panel + +## 3.16.0 - 2026-06-24 + +### Added + +- Extended the user account settings with a copy-to-clipboard button for the user id +- Added pagination to the platform management of the admin control panel +- Added pagination to the tag management of the admin control panel +- Extended the asset profile details dialog of the admin control panel with a copy-to-clipboard button for the ISIN number +- Extended the asset profile details dialog of the admin control panel with a copy-to-clipboard button for the symbol + +### Changed + +- Improved the throughput of the market data gathering queue by applying the rate limit per data source +- Decreased the rate limiter duration of the market data gathering queue jobs from 4 to 3 seconds +- Removed the deprecated `SymbolProfile` field from the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol` +- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `13.2.2` to `13.3` + +### Fixed + +- Fixed an issue with hourly market data updates not refreshing prices for asset profiles with `MANUAL` data source +- Fixed an issue with the log context formatting in the performance logging service + +## 3.15.1 - 2026-06-23 + +### Changed + +- Improved the dynamic numerical precision for various values in the account detail dialog on mobile +- Improved the dynamic numerical precision for various values in the holding detail dialog on mobile +- Upgraded `@internationalized/number` from version `3.6.6` to `3.6.7` + +### Fixed + +- Fixed an issue where symbols with special characters caused API request failures by URL encoding the symbol +- Fixed the disabled state of the delete action in the asset profiles actions menu of the historical market data table in the admin control panel +- Fixed the persistence of an empty `locale` string in the scraper configuration +- Fixed a transaction timeout that prevented gathering historical market data for symbols with a long history +- Fixed an exception in various portfolio endpoints when historical exchange rate data is missing + +## 3.14.0 - 2026-06-22 + +### Added + +- Exposed the `ENABLE_FEATURE_CRON` environment variable to control scheduled cron job execution +- Exposed the `PROCESSOR_GATHER_STATISTICS_CONCURRENCY` environment variable to control the concurrency of the statistics gathering queue processor + +### Changed + +- Consolidated the exchange rates to be gathered with hourly market data +- Improved the language localization for German (`de`) +- Upgraded `@openrouter/ai-sdk-provider` from version `2.9.0` to `2.9.1` +- Upgraded `undici` from version `7.24.4` to `8.5.0` + +### Fixed + +- Fixed an issue in the data provider service where asset profiles and historical data could be missing for symbols that exist in multiple data sources by keying the responses by the asset profile identifier +- Resolved an exception in the benchmarks service when the current market price is unavailable + +## 3.13.0 - 2026-06-20 + +### Added + +- Added an icon to indicate external links in the page tabs component +- Added the Korean (`ko`) language to the footer +- Added a data gathering frequency (`DAILY` or `HOURLY`) to the asset profile to control the market data gathering interval + +### Changed + +- Changed the _Fear & Greed Index_ (market mood) in the markets overview to use the stored market data instead of a live quote +- Moved the endpoint to get the asset profiles from `GET api/v1/admin/market-data` to `GET api/v1/asset-profiles` +- Moved the endpoint to get the asset profile details from `GET api/v1/market-data/:dataSource/:symbol` to `GET api/v1/asset-profiles/:dataSource/:symbol` +- Added the selected asset profile count to the delete menu item of the historical market data table in the admin control panel +- Added the selected asset profile count to the deletion confirmation dialog of the historical market data table in the admin control panel +- Improved the sorting to be case-insensitive in the platform management of the admin control panel +- Improved the sorting to be case-insensitive in the tag management of the admin control panel +- Improved the language localization for German (`de`) +- Upgraded `yahoo-finance2` from version `3.14.2` to `3.15.3` + +### Fixed + +- Fixed an issue with the localization of the country names +- Fixed an issue in the data provider service where quotes could be missing for symbols that exist in multiple data sources by keying the quotes response by the asset profile identifier + +## 3.12.0 - 2026-06-17 + +### Changed + +- Improved the styling of the checkboxes to consistently use the primary color in their states +- Improved the account name display in the accounts table +- Improved the name display in the activities table +- Improved the last activity display in the users table of the admin control panel +- Improved the registration display in the users table of the admin control panel +- Improved the user id display in the users table of the admin control panel +- Deprecated `SymbolProfile` in favor of `assetProfile` in the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol` +- Improved the language localization for German (`de`) +- Upgraded `svgmap` from version `2.19.3` to `2.21.0` + +### Fixed + +- Fixed a chart error on interaction by registering the annotation plugin early +- Fixed an issue on the allocations page where clicking an account in the _By Account_ chart did not open the detail dialog +- Restricted the maximum height of the import activities dialog +- Fixed the dark mode styling of the safe withdrawal rate selector in the _FIRE_ section (experimental) + +## 3.11.0 - 2026-06-14 + +### Added + +- Added support for a click handler in the page tabs component + +### Changed + +- Improved the styling of the tabs across various dialogs +- Improved the styling of the page tabs component on desktop +- Enabled the _Bull Dashboard_ tab in the admin control panel (experimental) +- Migrated the settings dialog to customize the rule thresholds of the _X-ray_ page from `ngModel` to form control +- Improved the language localization for Spanish (`es`) +- Upgraded `bull-board` from version `7.1.5` to `7.2.1` +- Upgraded `date-fns` from version `4.1.0` to `4.4.0` + +### Fixed + +- Improved the loading state when customizing the rule thresholds on the _X-ray_ page + +## 3.10.0 - 2026-06-13 + +### Changed + +- Improved the dynamic numerical precision for various values in the account detail dialog on mobile +- Improved the dynamic numerical precision for various values in the holding detail dialog on mobile +- Improved the account name display in the activities table +- Optimized the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol` by improving the processing of the historical market data + +### Fixed + +- Fixed an issue in the import dividends dialog +- Fixed an issue where certain symbols were incorrectly identified as currencies in various data providers +- Fixed the last request date in the users table of the admin control panel + +## 3.9.0 - 2026-06-12 + +### Added + +- Extended the _Public API_ with the endpoint to update the asset profile data (`PATCH api/v1/asset-profiles/:dataSource/:symbol`) (experimental) +- Added support for a dedicated _OpenRouter_ model for the `web_fetch` tool in the `FetchService` + +### Changed + +- Prefilled the form in the account balance management with the current cash balance +- Disabled the selection of future dates in the account balance management +- Grouped commodities and cryptocurrencies into the unknown bucket of the allocations by continent, country, currency, market and sector charts on the allocations page +- Moved the support for specific calendar year date ranges (`2025`, `2024`, `2023`, etc.) in the assistant from experimental to general availability +- Migrated various components from `NgStyle` to style bindings +- Improved the language localization for Korean (`ko`) + +### Fixed + +- Grouped activities without an account into the unknown bucket of the allocations by account and platform charts on the allocations page + +## 3.8.0 - 2026-06-07 + +### Added + +- Added an automatic refresh every 30 seconds to the users table in the admin control panel + +### Changed + +- Harmonized the sector names across the data providers +- Localized the country names +- Localized the sector names +- Centralized the asset profile override logic for manual adjustments +- Improved the styling in the user detail dialog of the admin control panel’s users section +- Prevented the deletion of asset profiles that are currently in use +- Ensured market data is correctly removed when an asset profile with no remaining activities is deleted +- Refactored the backend logging to use the instance-based `Logger` +- Improved the language localization for German (`de`) +- Improved the language localization for Ukrainian (`uk`) + +### Fixed + +- Prevented the floating action button from overlapping the paginator on mobile +- Fixed an issue where the asset profile override (asset class and asset sub class) was not applied to the data enhancers when gathering asset profiles +- Fixed a layout issue in the asset profile dialog of the admin control panel by truncating long titles + +## 3.7.0 - 2026-06-02 + +### Added + +- Added support for routing selected requests through the _OpenRouter_ `web_fetch` tool in the `FetchService` + +### Changed + +- Extended the countries mapping in the data enhancer for asset profile data via _Trackinsight_ +- Removed the deprecated attributes (`assetClass`, `assetClassLabel`, `assetSubClass`, `assetSubClassLabel`, `countries`, `currency`, `dataSource`, `holdings`, `name`, `sectors`, `symbol` and `url`) from the holdings of the portfolio details endpoint response +- Upgraded `Nx` from version `22.7.2` to `22.7.5` + +### Fixed + +- Resolved an issue in the impersonation mode where the values did not match the owner’s currency +- Fixed the environment variable expansion in the `.env` file when debugging via _Visual Studio Code_ + +## 3.6.0 - 2026-05-28 + +### Added + +- Added `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` environment variable support to outbound HTTP requests +- Added the `FetchService` to centralize outbound HTTP requests + +### Changed + +- Extracted the floating action buttons (FAB) to a reusable component +- Upgraded `nestjs` from version `11.1.19` to `11.1.21` +- Upgraded `yahoo-finance2` from version `3.14.0` to `3.14.2` + +## 3.5.0 - 2026-05-24 + +### Added + +- Configured the `min-release-age` in `.npmrc` + +### Changed + +- Removed the deprecated attributes (`assetClass`, `countries`, `currency`, `dataSource`, `name`, `sectors`, `symbol` and `url`) from the holdings of the public portfolio endpoint response +- Removed the deprecated `api/v1/order` endpoints +- Upgraded `@keyv/redis` from version `4.4.0` to `5.1.6` + +### Fixed + +- Fixed a layout regression that caused a double scrollbar on pages without tabs +- Resolved an issue with missing cash positions caused by an incorrect data source + +## 3.4.0 - 2026-05-21 + +### Added + +- Added the icon column to the benchmark component +>>>>>>> upstream/main - Added support for the `DIRECT_URL` environment variable to enable direct database connections ### Changed @@ -18,7 +796,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved the pagination in the activities table of the holding detail dialog - Randomized the placeholder in the assistant +- Filtered out sectors with zero weight for ETF and mutual fund assets in the _Yahoo Finance_ data enhancer - Enabled the _Bull Dashboard_ in the admin control panel without requiring an environment variable (experimental) +- Improved the verification of the _Stripe_ checkout session when creating a subscription +- Relaxed the URL validation in the asset profile DTOs to accept both `HTTP` and `HTTPS` protocols +- Relaxed the URL validation in the platform DTOs to accept both `HTTP` and `HTTPS` protocols - Extracted the page tabs to a reusable component - Improved the language localization for German (`de`) - Improved the language localization for Spanish (`es`) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 6ea0b5e40..5b1b36afb 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -84,6 +84,12 @@ https://ghostfol.io/development/storybook 1. Run `npx npm-check-updates --upgrade --target "minor" --filter "/@angular.*/"` +### NestJS + +#### Upgrade (minor versions) + +1. Run `npx npm-check-updates --upgrade --target "minor" --filter "/@nestjs.*/"` + ### Nx #### Upgrade diff --git a/README.md b/README.md index 8557d4330..69192124f 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,8 @@ The frontend is built with [Angular](https://angular.dev) and uses [Angular Mate We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64`, `linux/arm/v7` and `linux/arm64`. +Find answers to commonly asked questions about self-hosting Ghostfolio in our [Frequently Asked Questions (FAQ)](https://ghostfol.io/en/faq/self-hosting) section. +
[Buy me a coffee button](https://www.buymeacoffee.com/ghostfolio) @@ -85,29 +87,30 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c ### Supported Environment Variables -| Name | Type | Default Value | Description | -| --------------------------- | --------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens | -| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key | -| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key | -| `DATABASE_URL` | `string` | | The database connection URL. If using a connection pooler, use the pooled connection URL here. e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}` | -| `DIRECT_URL` | `string` (optional) | | The direct database connection URL used by the _Prisma CLI_ (e.g. for schema migrations) and seeding, bypassing any connection poolers (falls back to `DATABASE_URL`) | -| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token | -| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on | -| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) | -| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` | -| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on | -| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database | -| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database | -| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database | -| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ | -| `REDIS_HOST` | `string` | | The host where _Redis_ is running | -| `REDIS_PASSWORD` | `string` | | The password of _Redis_ | -| `REDIS_PORT` | `number` | | The port where _Redis_ is running | -| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | -| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. | - -#### OpenID Connect OIDC (Experimental) +| 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. If using a connection pooler, use the pooled connection URL here. e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}` | +| `DIRECT_URL` | `string` (optional) | | The direct database connection URL used by the _Prisma CLI_ (e.g. for schema migrations) and seeding, bypassing any connection poolers (falls back to `DATABASE_URL`) | +| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token | +| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on | +| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) | +| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` | +| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on | +| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database | +| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database | +| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database | +| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ | +| `REDIS_HOST` | `string` | | The host where _Redis_ is running | +| `REDIS_PASSWORD` | `string` | | The password of _Redis_ | +| `REDIS_PORT` | `number` | | The port where _Redis_ is running | +| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | +| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. | +| `TRUST_PROXY` | `string` (optional) | | The [trust proxy](https://expressjs.com/en/guide/behind-proxies.html) setting of _Express.js_ to determine the client IP address for rate limiting, e.g. `1` if the Ghostfolio application runs behind a single reverse proxy | + +#### OpenID Connect OIDC (experimental) | Name | Type | Default Value | Description | | -------------------------- | --------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------- | @@ -187,8 +190,6 @@ Set the header for each request as follows: 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/`. - ### Health Check (experimental) #### Request @@ -302,6 +303,58 @@ Grant access of type _Public_ in the _Access_ tab of _My Ghostfolio_. } ``` +### Update Asset Profile Data (experimental) + +#### Prerequisites + +[Bearer Token](#authorization-bearer-token) for authorization with admin role + +#### Request + +`PATCH http://localhost:3333/api/v1/asset-profiles//` + +#### Body + +``` +{ + "countries": [ + { + "code": "US", + "weight": 1 + } + ], + "sectors": [ + { + "name": "Technology", + "weight": 1 + } + ] +} +``` + +| Field | Type | Description | +| ----------- | ------------------ | ---------------------------------------------------------------------- | +| `countries` | `array` (optional) | Countries with `code` (`ISO 3166-1 alpha-2`) and `weight` (`0` to `1`) | +| `holdings` | `array` (optional) | Holdings with `name` and `weight` (`0` to `1`) | +| `sectors` | `array` (optional) | Sectors with `name` and `weight` (`0` to `1`) | + +#### Response + +##### Success + +`200 OK` + +##### Error + +`404 Not Found` + +``` +{ + "error": "Not Found", + "message": "Could not find the asset profile for MSFT (YAHOO)" +} +``` + ## Community Projects Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio @@ -310,7 +363,7 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r ## Contributing -Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public, including you. +Ghostfolio is **100% free** and **open source**. We support an active and healthy community and welcome contributions from everyone, including you. Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22%20no%3Aassignee), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%20no%3Aassignee). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. We would love to hear from you. @@ -326,10 +379,6 @@ If you like to support this project, get [**Ghostfolio Premium**](https://ghostf
-## Analytics - -![Alt](https://repobeats.axiom.co/api/embed/281a80b2d0c4af1162866c24c803f1f18e5ed60e.svg 'Repobeats analytics image') - ## License © 2021 - 2026 [Ghostfolio](https://ghostfol.io) diff --git a/apps/api/jest.config.ts b/apps/api/jest.config.ts index b87f91a79..805710396 100644 --- a/apps/api/jest.config.ts +++ b/apps/api/jest.config.ts @@ -1,4 +1,8 @@ /* eslint-disable */ + +// Run tests in UTC for deterministic date-based calculations +process.env.TZ = 'UTC'; + export default { displayName: 'api', diff --git a/apps/api/project.json b/apps/api/project.json index 4e1affb13..c4c1be7ac 100644 --- a/apps/api/project.json +++ b/apps/api/project.json @@ -7,32 +7,10 @@ "generators": {}, "targets": { "build": { - "executor": "@nx/webpack:webpack", - "options": { - "compiler": "tsc", - "deleteOutputPath": false, - "main": "apps/api/src/main.ts", - "outputPath": "dist/apps/api", - "sourceMap": true, - "target": "node", - "tsConfig": "apps/api/tsconfig.app.json", - "webpackConfig": "apps/api/webpack.config.js" - }, "configurations": { - "production": { - "generatePackageJson": true, - "optimization": true, - "extractLicenses": true, - "inspect": false, - "fileReplacements": [ - { - "replace": "apps/api/src/environments/environment.ts", - "with": "apps/api/src/environments/environment.prod.ts" - } - ] - } + "production": {} }, - "outputs": ["{options.outputPath}"] + "outputs": ["{workspaceRoot}/dist/apps/api"] }, "copy-assets": { "executor": "nx:run-commands", diff --git a/apps/api/src/app/access/access.controller.ts b/apps/api/src/app/access/access.controller.ts index 28b459203..3bad0e171 100644 --- a/apps/api/src/app/access/access.controller.ts +++ b/apps/api/src/app/access/access.controller.ts @@ -3,7 +3,7 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard' import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos'; import { SubscriptionType } from '@ghostfolio/common/enums'; -import { Access } from '@ghostfolio/common/interfaces'; +import { Access, AccessSettings } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; import type { RequestWithUser } from '@ghostfolio/common/types'; @@ -46,13 +46,14 @@ export class AccessController { }); return accessesWithGranteeUser.map( - ({ alias, granteeUser, id, permissions }) => { + ({ alias, granteeUser, id, permissions, settings }) => { if (granteeUser) { return { alias, id, permissions, grantee: granteeUser?.id, + settings: settings as AccessSettings, type: 'PRIVATE' }; } @@ -62,6 +63,7 @@ export class AccessController { id, permissions, grantee: 'Public', + settings: settings as AccessSettings, type: 'PUBLIC' }; } @@ -76,7 +78,7 @@ export class AccessController { ): Promise { if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - this.request.user.subscription.type === SubscriptionType.Basic + this.request.user.subscription?.type === SubscriptionType.Basic ) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), @@ -85,12 +87,13 @@ export class AccessController { } try { - return this.accessService.createAccess({ + return await this.accessService.createAccess({ alias: data.alias || undefined, granteeUser: data.granteeUserId ? { connect: { id: data.granteeUserId } } : undefined, permissions: data.permissions, + settings: this.accessService.buildSettings(data.filters), user: { connect: { id: this.request.user.id } } }); } catch { @@ -131,7 +134,7 @@ export class AccessController { ): Promise { if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - this.request.user.subscription.type === SubscriptionType.Basic + this.request.user.subscription?.type === SubscriptionType.Basic ) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), @@ -152,13 +155,14 @@ export class AccessController { } try { - return this.accessService.updateAccess({ + return await this.accessService.updateAccess({ data: { alias: data.alias, granteeUser: data.granteeUserId ? { connect: { id: data.granteeUserId } } : { disconnect: true }, - permissions: data.permissions + permissions: data.permissions, + settings: this.accessService.buildSettings(data.filters) }, where: { id } }); diff --git a/apps/api/src/app/access/access.service.ts b/apps/api/src/app/access/access.service.ts index 70e46dc36..e50a6c7d0 100644 --- a/apps/api/src/app/access/access.service.ts +++ b/apps/api/src/app/access/access.service.ts @@ -1,4 +1,5 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { AccessSettings, Filter } from '@ghostfolio/common/interfaces'; import { AccessWithGranteeUser } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; @@ -39,6 +40,12 @@ export class AccessService { }); } + public buildSettings(filters?: Filter[]) { + const settings: AccessSettings = filters?.length ? { filters } : {}; + + return settings as Prisma.InputJsonValue; + } + public async createAccess(data: Prisma.AccessCreateInput): Promise { return this.prismaService.access.create({ data 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 02323acc9..f7b1efc51 100644 --- a/apps/api/src/app/account-balance/account-balance.module.ts +++ b/apps/api/src/app/account-balance/account-balance.module.ts @@ -1,6 +1,7 @@ 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'; +import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { Module } from '@nestjs/common'; @@ -10,7 +11,7 @@ import { AccountBalanceService } from './account-balance.service'; @Module({ controllers: [AccountBalanceController], exports: [AccountBalanceService], - imports: [ExchangeRateDataModule, PrismaModule], + imports: [ExchangeRateDataModule, PrismaModule, TagModule], 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 321624003..29c7f2887 100644 --- a/apps/api/src/app/account-balance/account-balance.service.ts +++ b/apps/api/src/app/account-balance/account-balance.service.ts @@ -1,4 +1,8 @@ import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; +import { + isAccountBalanceInFuture, + WHERE_ACCOUNT_NOT_EXCLUDED +} from '@ghostfolio/api/helper/account.helper'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; @@ -14,7 +18,8 @@ import { Injectable } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { AccountBalance, Prisma } from '@prisma/client'; import { Big } from 'big.js'; -import { format, parseISO } from 'date-fns'; +import { endOfToday, format, parseISO } from 'date-fns'; +import { groupBy } from 'lodash'; @Injectable() export class AccountBalanceService { @@ -112,8 +117,13 @@ export class AccountBalanceService { const accumulatedBalancesByDate: { [date: string]: HistoricalDataItem } = {}; const lastBalancesByAccount: { [accountId: string]: Big } = {}; + const endOfTodayDate = endOfToday(); for (const { accountId, date, valueInBaseCurrency } of balances) { + if (isAccountBalanceInFuture({ date, endOfTodayDate })) { + continue; + } + const formattedDate = format(date, DATE_FORMAT); lastBalancesByAccount[accountId] = new Big(valueInBaseCurrency); @@ -144,16 +154,16 @@ export class AccountBalanceService { }): Promise { const where: Prisma.AccountBalanceWhereInput = { userId }; - const accountFilter = filters?.find(({ type }) => { - return type === 'ACCOUNT'; + const { ACCOUNT: [filterByAccount] = [] } = groupBy(filters, ({ type }) => { + return type; }); - if (accountFilter) { - where.accountId = accountFilter.id; + if (filterByAccount) { + where.accountId = filterByAccount.id; } if (withExcludedAccounts === false) { - where.account = { isExcluded: false }; + where.account = WHERE_ACCOUNT_NOT_EXCLUDED; } const balances = await this.prismaService.accountBalance.findMany({ @@ -176,7 +186,7 @@ export class AccountBalanceService { accountId: balance.account.id, valueInBaseCurrency: this.exchangeRateDataService.toCurrency( balance.value, - balance.account.currency, + balance.account.currency ?? userCurrency, userCurrency ) }; diff --git a/apps/api/src/app/account/account.controller.ts b/apps/api/src/app/account/account.controller.ts index 052720176..f43aeedd5 100644 --- a/apps/api/src/app/account/account.controller.ts +++ b/apps/api/src/app/account/account.controller.ts @@ -1,5 +1,6 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.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 { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; @@ -50,7 +51,8 @@ export class AccountController { private readonly apiService: ApiService, private readonly impersonationService: ImpersonationService, private readonly portfolioService: PortfolioService, - @Inject(REQUEST) private readonly request: RequestWithUser + @Inject(REQUEST) private readonly request: RequestWithUser, + private readonly userService: UserService ) {} @Delete(':id') @@ -137,11 +139,14 @@ export class AccountController { ): Promise { const impersonationUserId = await this.impersonationService.validateImpersonationId(impersonationId); + const userId = impersonationUserId || this.request.user.id; + + const { settings } = await this.userService.user({ id: userId }); return this.accountBalanceService.getAccountBalances({ + userId, filters: [{ id, type: 'ACCOUNT' }], - userCurrency: this.request.user.settings.settings.baseCurrency, - userId: impersonationUserId || this.request.user.id + userCurrency: settings.settings.baseCurrency }); } @@ -151,28 +156,34 @@ export class AccountController { public async createAccount( @Body() data: CreateAccountDto ): Promise { - if (data.platformId) { - const platformId = data.platformId; - delete data.platformId; + const { balance, tags: tagIds, ...accountData } = data; + + if (accountData.platformId) { + const platformId = accountData.platformId; + delete accountData.platformId; - return this.accountService.createAccount( - { - ...data, + return this.accountService.createAccount({ + balance, + tagIds, + data: { + ...accountData, platform: { connect: { id: platformId } }, user: { connect: { id: this.request.user.id } } }, - this.request.user.id - ); + userId: this.request.user.id + }); } else { - delete data.platformId; + delete accountData.platformId; - return this.accountService.createAccount( - { - ...data, + return this.accountService.createAccount({ + balance, + tagIds, + data: { + ...accountData, user: { connect: { id: this.request.user.id } } }, - this.request.user.id - ); + userId: this.request.user.id + }); } } @@ -248,48 +259,50 @@ export class AccountController { ); } - if (data.platformId) { - const platformId = data.platformId; - delete data.platformId; - - return this.accountService.updateAccount( - { - data: { - ...data, - platform: { connect: { id: platformId } }, - user: { connect: { id: this.request.user.id } } - }, - where: { - id_userId: { - id, - userId: this.request.user.id - } - } + const { balance, tags: tagIds, ...accountData } = data; + + if (accountData.platformId) { + const platformId = accountData.platformId; + delete accountData.platformId; + + return this.accountService.updateAccount({ + balance, + tagIds, + data: { + ...accountData, + platform: { connect: { id: platformId } }, + user: { connect: { id: this.request.user.id } } }, - this.request.user.id - ); + userId: this.request.user.id, + where: { + id_userId: { + id, + userId: this.request.user.id + } + } + }); } else { // platformId is null, remove it - delete data.platformId; - - return this.accountService.updateAccount( - { - data: { - ...data, - platform: originalAccount.platformId - ? { disconnect: true } - : undefined, - user: { connect: { id: this.request.user.id } } - }, - where: { - id_userId: { - id, - userId: this.request.user.id - } - } + delete accountData.platformId; + + return this.accountService.updateAccount({ + balance, + tagIds, + data: { + ...accountData, + platform: originalAccount.platformId + ? { disconnect: true } + : undefined, + user: { connect: { id: this.request.user.id } } }, - this.request.user.id - ); + userId: this.request.user.id, + where: { + id_userId: { + id, + userId: this.request.user.id + } + } + }); } } } diff --git a/apps/api/src/app/account/account.module.ts b/apps/api/src/app/account/account.module.ts index fb89bb2b6..47b859ba3 100644 --- a/apps/api/src/app/account/account.module.ts +++ b/apps/api/src/app/account/account.module.ts @@ -1,11 +1,13 @@ import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module'; import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.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 { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { Module } from '@nestjs/common'; @@ -23,7 +25,9 @@ import { AccountService } from './account.service'; ImpersonationModule, PortfolioModule, PrismaModule, - RedactValuesInResponseModule + RedactValuesInResponseModule, + TagModule, + UserModule ], providers: [AccountService] }) diff --git a/apps/api/src/app/account/account.service.ts b/apps/api/src/app/account/account.service.ts index e1b01a6ed..3d0bb91bd 100644 --- a/apps/api/src/app/account/account.service.ts +++ b/apps/api/src/app/account/account.service.ts @@ -1,9 +1,16 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; +import { + getWhereAccountBalanceNotInFuture, + isAccountBalanceInFuture, + WHERE_ACCOUNT_NOT_EXCLUDED +} from '@ghostfolio/api/helper/account.helper'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { Filter } from '@ghostfolio/common/interfaces'; +import { AccountWithBalance } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; @@ -13,11 +20,12 @@ import { Order, Platform, Prisma, - SymbolProfile + SymbolProfile, + Tag } from '@prisma/client'; import { Big } from 'big.js'; -import { format } from 'date-fns'; -import { groupBy } from 'lodash'; +import { endOfToday, format } from 'date-fns'; +import { groupBy, isNil } from 'lodash'; import { CashDetails } from './interfaces/cash-details.interface'; @@ -27,17 +35,35 @@ export class AccountService { private readonly accountBalanceService: AccountBalanceService, private readonly eventEmitter: EventEmitter2, private readonly exchangeRateDataService: ExchangeRateDataService, - private readonly prismaService: PrismaService + private readonly prismaService: PrismaService, + private readonly tagService: TagService ) {} public async account({ id_userId - }: Prisma.AccountWhereUniqueInput): Promise { - const [account] = await this.accounts({ - where: id_userId + }: Prisma.AccountWhereUniqueInput): Promise { + const account = await this.prismaService.account.findUnique({ + include: { + balances: { + orderBy: { date: 'desc' }, + take: 1, + // Ignore account balances in the future + where: getWhereAccountBalanceNotInFuture() + } + }, + where: { id_userId } }); - return account; + if (!account) { + return null; + } + + const { balances, ...accountData } = account; + + return { + ...accountData, + balance: balances[0]?.value ?? 0 + }; } public async accountWithActivities( @@ -62,21 +88,40 @@ export class AccountService { where?: Prisma.AccountWhereInput; orderBy?: Prisma.AccountOrderByWithRelationInput; }): Promise< - (Account & { + (AccountWithBalance & { activities?: (Order & { SymbolProfile?: SymbolProfile })[]; balances?: AccountBalance[]; platform?: Platform; + tags?: Tag[]; })[] > { const { include = {}, skip, take, cursor, where, orderBy } = params; const isBalancesIncluded = !!include.balances; + const isTagsIncluded = !!include.tags; include.balances = { orderBy: { date: 'desc' }, - ...(isBalancesIncluded ? {} : { take: 1 }) + // If the balances are included, they are returned as-is (including the + // ones in the future) because the client renders the full history. The + // balance is derived below and skips the account balances in the future. + ...(isBalancesIncluded + ? {} + : { + take: 1, + // Ignore account balances in the future + where: getWhereAccountBalanceNotInFuture() + }) }; + if (isTagsIncluded) { + include.tags = { + include: { + tag: true + } + }; + } + const accounts = await this.prismaService.account.findMany({ cursor, include, @@ -86,31 +131,72 @@ export class AccountService { where }); + const endOfTodayDate = endOfToday(); + return accounts.map((account) => { - account = { ...account, balance: account.balances[0]?.value ?? 0 }; + const result = { + ...account, + balance: + // The balances are ordered by date descending, hence the first account + // balance which is not in the future reflects the current balance + account.balances.find(({ date }) => { + return !isAccountBalanceInFuture({ date, endOfTodayDate }); + })?.value ?? 0, + tags: isTagsIncluded + ? (account.tags as unknown as { tag: Tag }[]).map(({ tag }) => { + return tag; + }) + : undefined + }; if (!isBalancesIncluded) { - delete account.balances; + delete result.balances; } - return account; + if (!isTagsIncluded) { + delete result.tags; + } + + return result; }); } - public async createAccount( - data: Prisma.AccountCreateInput, - aUserId: string - ): Promise { + public async createAccount({ + balance, + data, + tagIds, + userId + }: { + balance?: number; + data: Prisma.AccountCreateInput; + tagIds?: string[]; + userId: string; + }): Promise { + await this.tagService.validateTagIds({ tagIds, userId }); + const account = await this.prismaService.account.create({ - data + data: { + ...data, + tags: tagIds + ? { + create: tagIds.map((tagId) => { + return { + tag: { connect: { id: tagId } } + }; + }) + } + : undefined + } }); - await this.accountBalanceService.createOrUpdateAccountBalance({ - accountId: account.id, - balance: data.balance, - date: format(new Date(), DATE_FORMAT), - userId: aUserId - }); + if (!isNil(balance)) { + await this.accountBalanceService.createOrUpdateAccountBalance({ + balance, + userId, + accountId: account.id, + date: format(new Date(), DATE_FORMAT) + }); + } this.eventEmitter.emit( PortfolioChangedEvent.getName(), @@ -139,11 +225,12 @@ export class AccountService { return account; } - public async getAccounts(aUserId: string): Promise { + public async getAccounts(aUserId: string): Promise { const accounts = await this.accounts({ include: { activities: true, - platform: true + platform: true, + tags: true }, orderBy: { name: 'asc' }, where: { userId: aUserId } @@ -184,14 +271,14 @@ export class AccountService { }; if (withExcludedAccounts === false) { - where.isExcluded = false; + where.AND = [WHERE_ACCOUNT_NOT_EXCLUDED]; } - const { ACCOUNT: filtersByAccount } = groupBy(filters, ({ type }) => { + const { ACCOUNT: filtersByAccount = [] } = groupBy(filters, ({ type }) => { return type; }); - if (filtersByAccount?.length > 0) { + if (filtersByAccount.length > 0) { where.id = { in: filtersByAccount.map(({ id }) => { return id; @@ -217,27 +304,47 @@ export class AccountService { }; } - public async updateAccount( - params: { - where: Prisma.AccountWhereUniqueInput; - data: Prisma.AccountUpdateInput; - }, - aUserId: string - ): Promise { - const { data, where } = params; - - await this.accountBalanceService.createOrUpdateAccountBalance({ - accountId: data.id as string, - balance: data.balance as number, - date: format(new Date(), DATE_FORMAT), - userId: aUserId - }); + public async updateAccount({ + balance, + data, + tagIds, + userId, + where + }: { + balance?: number; + data: Prisma.AccountUpdateInput; + tagIds?: string[]; + userId: string; + where: Prisma.AccountWhereUniqueInput; + }): Promise { + await this.tagService.validateTagIds({ tagIds, userId }); const account = await this.prismaService.account.update({ - data, + data: { + ...data, + tags: tagIds + ? { + create: tagIds.map((tagId) => { + return { + tag: { connect: { id: tagId } } + }; + }), + deleteMany: {} + } + : undefined + }, where }); + if (!isNil(balance)) { + await this.accountBalanceService.createOrUpdateAccountBalance({ + balance, + userId, + accountId: account.id, + date: format(new Date(), DATE_FORMAT) + }); + } + this.eventEmitter.emit( PortfolioChangedEvent.getName(), new PortfolioChangedEvent({ diff --git a/apps/api/src/app/account/interfaces/cash-details.interface.ts b/apps/api/src/app/account/interfaces/cash-details.interface.ts index 715343766..b396328a5 100644 --- a/apps/api/src/app/account/interfaces/cash-details.interface.ts +++ b/apps/api/src/app/account/interfaces/cash-details.interface.ts @@ -1,6 +1,6 @@ -import { Account } from '@prisma/client'; +import { AccountWithBalance } from '@ghostfolio/common/types'; export interface CashDetails { - accounts: Account[]; + accounts: AccountWithBalance[]; balanceInBaseCurrency: number; } diff --git a/apps/api/src/app/activities/activities-filter.dto.ts b/apps/api/src/app/activities/activities-filter.dto.ts new file mode 100644 index 000000000..0c56aacc8 --- /dev/null +++ b/apps/api/src/app/activities/activities-filter.dto.ts @@ -0,0 +1,21 @@ +import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto'; +import { FilterDto } from '@ghostfolio/api/dtos/filter.dto'; +import { DateRange } from '@ghostfolio/common/types'; + +import { Type as ActivityType } from '@prisma/client'; +import { Transform, TransformFnParams } from 'class-transformer'; +import { IsEnum, IsOptional, Matches } from 'class-validator'; +import { isString } from 'lodash'; + +export class ActivitiesFilterDto extends FilterDto { + @IsEnum(ActivityType, { each: true }) + @IsOptional() + @Transform(({ value }: TransformFnParams) => { + return isString(value) ? value.split(',') : value; + }) + activityTypes?: ActivityType[]; + + @IsOptional() + @Matches(DATE_RANGE_PATTERN) + range?: DateRange; +} diff --git a/apps/api/src/app/activities/activities.controller.ts b/apps/api/src/app/activities/activities.controller.ts index 6b0440dc4..a1b559c84 100644 --- a/apps/api/src/app/activities/activities.controller.ts +++ b/apps/api/src/app/activities/activities.controller.ts @@ -18,7 +18,7 @@ import { ActivityResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; -import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; +import type { RequestWithUser } from '@ghostfolio/common/types'; import { Body, @@ -37,17 +37,15 @@ import { } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; -import { Order, Prisma, Type as ActivityType } from '@prisma/client'; +import { Order } from '@prisma/client'; import { parseISO } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; +import { ActivitiesFilterDto } from './activities-filter.dto'; import { ActivitiesService } from './activities.service'; +import { GetActivitiesDto } from './get-activities.dto'; -@Controller([ - 'activities', - /** @deprecated */ - 'order' -]) +@Controller('activities') export class ActivitiesController { public constructor( private readonly activitiesService: ActivitiesService, @@ -63,22 +61,47 @@ export class ActivitiesController { @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async deleteActivities( - @Query('accounts') filterByAccounts?: string, - @Query('assetClasses') filterByAssetClasses?: string, - @Query('dataSource') filterByDataSource?: string, - @Query('symbol') filterBySymbol?: string, - @Query('tags') filterByTags?: string + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Query() + { + accounts, + activityTypes, + assetClasses, + dataSource, + range, + symbol, + tags + }: ActivitiesFilterDto ): Promise { + if (impersonationId) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + let endDate: Date; + let startDate: Date; + + if (range) { + ({ endDate, startDate } = getIntervalFromDateRange({ + dateRange: range + })); + } + const filters = this.apiService.buildFiltersFromQueryParams({ - filterByAccounts, - filterByAssetClasses, - filterByDataSource, - filterBySymbol, - filterByTags + filterByAccounts: accounts, + filterByAssetClasses: assetClasses, + filterByDataSource: dataSource, + filterBySymbol: symbol, + filterByTags: tags }); return this.activitiesService.deleteActivities({ + endDate, filters, + startDate, + types: activityTypes, userId: this.request.user.id }); } @@ -111,51 +134,54 @@ export class ActivitiesController { @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getAllActivities( @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, - @Query('accounts') filterByAccounts?: string, - @Query('activityTypes') filterByTypes?: string, - @Query('assetClasses') filterByAssetClasses?: string, - @Query('dataSource') filterByDataSource?: string, - @Query('range') dateRange?: DateRange, - @Query('skip') skip?: number, - @Query('sortColumn') sortColumn?: string, - @Query('sortDirection') sortDirection?: Prisma.SortOrder, - @Query('symbol') filterBySymbol?: string, - @Query('tags') filterByTags?: string, - @Query('take') take?: number + @Query() + { + accounts, + activityTypes, + assetClasses, + dataSource, + range, + skip, + sortColumn, + sortDirection, + symbol, + tags, + take + }: GetActivitiesDto ): Promise { let endDate: Date; let startDate: Date; - if (dateRange) { - ({ endDate, startDate } = getIntervalFromDateRange({ dateRange })); + if (range) { + ({ endDate, startDate } = getIntervalFromDateRange({ + dateRange: range + })); } const filters = this.apiService.buildFiltersFromQueryParams({ - filterByAccounts, - filterByAssetClasses, - filterByDataSource, - filterBySymbol, - filterByTags + filterByAccounts: accounts, + filterByAssetClasses: assetClasses, + filterByDataSource: dataSource, + filterBySymbol: symbol, + filterByTags: tags }); const impersonationUserId = await this.impersonationService.validateImpersonationId(impersonationId); - const types = (filterByTypes?.split(',') as ActivityType[]) ?? []; - const userCurrency = this.request.user.settings.settings.baseCurrency; const { activities, count } = await this.activitiesService.getActivities({ endDate, filters, + skip, sortColumn, sortDirection, startDate, - types, + take, userCurrency, includeDrafts: true, - skip: isNaN(skip) ? undefined : skip, - take: isNaN(take) ? undefined : take, + types: activityTypes, userId: impersonationUserId || this.request.user.id, withExcludedAccountsAndActivities: true }); @@ -318,11 +344,13 @@ export class ActivitiesController { data: { ...data, date, - account: { - connect: { - id_userId: { id: accountId, userId: this.request.user.id } - } - }, + account: accountId + ? { + connect: { + id_userId: { id: accountId, userId: this.request.user.id } + } + } + : { disconnect: true }, SymbolProfile: { connect: { dataSource_symbol: { @@ -341,6 +369,7 @@ export class ActivitiesController { }), user: { connect: { id: this.request.user.id } } }, + userId: this.request.user.id, where: { id } diff --git a/apps/api/src/app/activities/activities.module.ts b/apps/api/src/app/activities/activities.module.ts index f4e592c3f..34091ba5e 100644 --- a/apps/api/src/app/activities/activities.module.ts +++ b/apps/api/src/app/activities/activities.module.ts @@ -6,12 +6,15 @@ import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redac import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; +import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; +import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { Module } from '@nestjs/common'; @@ -23,15 +26,18 @@ import { ActivitiesService } from './activities.service'; exports: [ActivitiesService], imports: [ ApiModule, + BenchmarkModule, CacheModule, DataGatheringQueueModule, DataProviderModule, ExchangeRateDataModule, ImpersonationModule, + MarketDataModule, PrismaModule, RedactValuesInResponseModule, RedisCacheModule, SymbolProfileModule, + TagModule, TransformDataSourceInRequestModule, TransformDataSourceInResponseModule ], diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 821185e11..140726aeb 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -3,25 +3,36 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; +import { + isAccountBalanceInFuture, + WHERE_ACCOUNT_NOT_EXCLUDED +} from '@ghostfolio/api/helper/account.helper'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; +import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.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 { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { DATA_GATHERING_QUEUE_PRIORITY_HIGH, GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS, - ghostfolioPrefix, + NON_INVESTMENT_ACTIVITY_TYPES, TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; -import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; +import { + canDeleteAssetProfile, + getAssetProfileIdentifier, + isValidCustomAssetProfileSymbol +} from '@ghostfolio/common/helper'; import { ActivitiesResponse, Activity, AssetProfileIdentifier, - EnhancedSymbolProfile, + EnhancedAssetProfile, Filter } from '@ghostfolio/common/interfaces'; import { OrderWithAccount } from '@ghostfolio/common/types'; @@ -38,7 +49,6 @@ import { Type as ActivityType } from '@prisma/client'; import { Big } from 'big.js'; -import { isUUID } from 'class-validator'; import { endOfToday, isAfter } from 'date-fns'; import { groupBy, uniqBy } from 'lodash'; import { randomUUID } from 'node:crypto'; @@ -48,20 +58,67 @@ export class ActivitiesService { public constructor( private readonly accountBalanceService: AccountBalanceService, private readonly accountService: AccountService, + private readonly benchmarkService: BenchmarkService, private readonly dataGatheringService: DataGatheringService, private readonly dataProviderService: DataProviderService, private readonly eventEmitter: EventEmitter2, private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly marketDataService: MarketDataService, private readonly prismaService: PrismaService, - private readonly symbolProfileService: SymbolProfileService + private readonly symbolProfileService: SymbolProfileService, + private readonly tagService: TagService ) {} + public areCashActivitiesExcludedByFilters(filters: Filter[] = []) { + const { + ASSET_CLASS: filtersByAssetClass = [], + DATA_SOURCE: [filterByDataSource] = [], + SYMBOL: [filterBySymbol] = [], + TAG: filtersByTag = [] + } = groupBy(filters, ({ type }) => { + return type; + }); + + const isFilteredByAssetClassOtherThanLiquidity = + filtersByAssetClass.length > 0 && + !filtersByAssetClass.some(({ id }) => { + return id === AssetClass.LIQUIDITY; + }); + + const isFilteredByAssetProfile = !!(filterByDataSource || filterBySymbol); + const isFilteredByTag = filtersByTag.length > 0; + + const isFilteredByUnsupportedType = filters.some(({ type }) => { + return ![ + 'ACCOUNT', + 'ASSET_CLASS', + 'DATA_SOURCE', + 'SYMBOL', + 'TAG' + ].includes(type); + }); + + return ( + isFilteredByAssetClassOtherThanLiquidity || + isFilteredByAssetProfile || + isFilteredByTag || + isFilteredByUnsupportedType + ); + } + public async assignTags({ dataSource, symbol, tags, userId }: { tags: Tag[]; userId: string } & AssetProfileIdentifier) { + await this.tagService.validateTagIds({ + userId, + tagIds: tags.map(({ id }) => { + return id; + }) + }); + const activities = await this.prismaService.order.findMany({ where: { userId, @@ -108,6 +165,15 @@ export class ActivitiesService { userId: string; } ): Promise { + const tags = data.tags ?? []; + + await this.tagService.validateTagIds({ + tagIds: tags.map(({ id }) => { + return id; + }), + userId: data.userId + }); + let account: Prisma.AccountCreateNestedOneWithoutActivitiesInput; if (data.accountId) { @@ -122,12 +188,11 @@ export class ActivitiesService { } const accountId = data.accountId; - const tags = data.tags ?? []; const updateAccountBalance = data.updateAccountBalance ?? false; const userId = data.userId; if ( - ['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) || + NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type) || (data.SymbolProfile.connectOrCreate.create.dataSource === 'MANUAL' && data.type === 'BUY') ) { @@ -139,10 +204,9 @@ export class ActivitiesService { let symbol: string; if ( - data.SymbolProfile.connectOrCreate.create.symbol.startsWith( - `${ghostfolioPrefix}_` - ) || - isUUID(data.SymbolProfile.connectOrCreate.create.symbol) + isValidCustomAssetProfileSymbol( + data.SymbolProfile.connectOrCreate.create.symbol + ) ) { // Connect custom asset profile (clone) symbol = data.SymbolProfile.connectOrCreate.create.symbol; @@ -197,7 +261,7 @@ export class ActivitiesService { const orderData: Prisma.OrderCreateInput = data; - const isDraft = ['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) + const isDraft = NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type) ? false : isAfter(data.date as Date, endOfToday()); @@ -213,7 +277,7 @@ export class ActivitiesService { include: { SymbolProfile: true } }); - if (updateAccountBalance === true) { + if (accountId && updateAccountBalance === true) { let amount = new Big(data.unitPrice).mul(data.quantity); if (['BUY', 'FEE'].includes(data.type)) { @@ -262,7 +326,26 @@ export class ActivitiesService { activity.symbolProfileId ]); - if (symbolProfile.activitiesCount === 0) { + const benchmarkAssetProfiles = + await this.benchmarkService.getBenchmarkAssetProfiles(); + + const isBenchmark = benchmarkAssetProfiles.some(({ id }) => { + return id === symbolProfile.id; + }); + + if ( + canDeleteAssetProfile({ + isBenchmark, + activitiesCount: symbolProfile.activitiesCount, + symbol: symbolProfile.symbol, + watchedByCount: symbolProfile.watchedByCount + }) + ) { + await this.marketDataService.deleteMany({ + dataSource: symbolProfile.dataSource, + symbol: symbolProfile.symbol + }); + await this.symbolProfileService.deleteById(activity.symbolProfileId); } @@ -277,14 +360,23 @@ export class ActivitiesService { } public async deleteActivities({ + endDate, filters, + startDate, + types, userId }: { + endDate?: Date; filters?: Filter[]; + startDate?: Date; + types?: ActivityType[]; userId: string; }): Promise { const { activities } = await this.getActivities({ + endDate, filters, + startDate, + types, userId, includeDrafts: true, userCurrency: undefined, @@ -308,8 +400,31 @@ export class ActivitiesService { }) ); - for (const { activitiesCount, id } of symbolProfiles) { - if (activitiesCount === 0) { + const benchmarkAssetProfiles = + await this.benchmarkService.getBenchmarkAssetProfiles(); + + for (const { + activitiesCount, + dataSource, + id, + symbol, + watchedByCount + } of symbolProfiles) { + const isBenchmark = benchmarkAssetProfiles.some( + (benchmarkAssetProfile) => { + return benchmarkAssetProfile.id === id; + } + ); + + if ( + canDeleteAssetProfile({ + activitiesCount, + isBenchmark, + symbol, + watchedByCount + }) + ) { + await this.marketDataService.deleteMany({ dataSource, symbol }); await this.symbolProfileService.deleteById(id); } } @@ -344,17 +459,7 @@ export class ActivitiesService { userCurrency: string; userId: string; }): Promise { - const filtersByAssetClass = filters.filter(({ type }) => { - return type === 'ASSET_CLASS'; - }); - - if ( - filtersByAssetClass.length > 0 && - !filtersByAssetClass.find(({ id }) => { - return id === AssetClass.LIQUIDITY; - }) - ) { - // If asset class filters are present and none of them is liquidity, return an empty response + if (this.areCashActivitiesExcludedByFilters(filters)) { return { activities: [], count: 0 @@ -362,6 +467,7 @@ export class ActivitiesService { } const activities: Activity[] = []; + const endOfTodayDate = endOfToday(); for (const account of cashDetails.accounts) { const { balances } = await this.accountBalanceService.getAccountBalances({ @@ -374,21 +480,20 @@ export class ActivitiesService { let currentBalanceInBaseCurrency = 0; for (const balanceItem of balances) { + if ( + isAccountBalanceInFuture({ + endOfTodayDate, + date: balanceItem.date + }) + ) { + continue; + } + const syntheticActivityTemplate: Activity = { userId, accountId: account.id, accountUserId: account.userId, - comment: account.name, - createdAt: new Date(balanceItem.date), - currency: account.currency, - date: new Date(balanceItem.date), - fee: 0, - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - id: balanceItem.id, - isDraft: false, - quantity: 1, - SymbolProfile: { + assetProfile: { activitiesCount: 0, assetClass: AssetClass.LIQUIDITY, assetSubClass: AssetSubClass.CASH, @@ -405,6 +510,16 @@ export class ActivitiesService { symbol: account.currency, updatedAt: new Date(balanceItem.date) }, + comment: account.name, + createdAt: new Date(balanceItem.date), + currency: account.currency, + date: new Date(balanceItem.date), + fee: 0, + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + id: balanceItem.id, + isDraft: false, + quantity: 1, symbolProfileId: account.currency, type: ActivityType.BUY, unitPrice: 1, @@ -492,41 +607,29 @@ export class ActivitiesService { { date: 'asc' } ]; - const where: Prisma.OrderWhereInput = { userId }; + const andConditions: Prisma.OrderWhereInput[] = []; + const where: Prisma.OrderWhereInput = { userId, AND: andConditions }; - if (endDate || startDate) { - where.AND = []; - - if (endDate) { - where.AND.push({ date: { lte: endDate } }); - } + if (endDate) { + andConditions.push({ date: { lte: endDate } }); + } - if (startDate) { - where.AND.push({ date: { gt: startDate } }); - } + if (startDate) { + andConditions.push({ date: { gt: startDate } }); } const { - ACCOUNT: filtersByAccount, - ASSET_CLASS: filtersByAssetClass, - TAG: filtersByTag + ACCOUNT: filtersByAccount = [], + ASSET_CLASS: filtersByAssetClass = [], + DATA_SOURCE: [filterByDataSource] = [], + SEARCH_QUERY: [filterBySearchQuery] = [], + SYMBOL: [filterBySymbol] = [], + TAG: filtersByTag = [] } = groupBy(filters, ({ type }) => { return type; }); - const filterByDataSource = filters?.find(({ type }) => { - return type === 'DATA_SOURCE'; - })?.id; - - const filterBySymbol = filters?.find(({ type }) => { - return type === 'SYMBOL'; - })?.id; - - const searchQuery = filters?.find(({ type }) => { - return type === 'SEARCH_QUERY'; - })?.id; - - if (filtersByAccount?.length > 0) { + if (filtersByAccount.length > 0) { where.accountId = { in: filtersByAccount.map(({ id }) => { return id; @@ -538,7 +641,7 @@ export class ActivitiesService { where.isDraft = false; } - if (filtersByAssetClass?.length > 0) { + if (filtersByAssetClass.length > 0) { where.SymbolProfile = { OR: [ { @@ -550,14 +653,14 @@ export class ActivitiesService { }, { OR: [ - { SymbolProfileOverrides: { is: null } }, - { SymbolProfileOverrides: { assetClass: null } } + { assetProfileOverrides: { is: null } }, + { assetProfileOverrides: { assetClass: null } } ] } ] }, { - SymbolProfileOverrides: { + assetProfileOverrides: { OR: filtersByAssetClass.map(({ id }) => { return { assetClass: AssetClass[id] }; }) @@ -574,8 +677,8 @@ export class ActivitiesService { where.SymbolProfile, { AND: [ - { dataSource: filterByDataSource as DataSource }, - { symbol: filterBySymbol } + { dataSource: filterByDataSource.id as DataSource }, + { symbol: filterBySymbol.id } ] } ] @@ -583,19 +686,19 @@ export class ActivitiesService { } else { where.SymbolProfile = { AND: [ - { dataSource: filterByDataSource as DataSource }, - { symbol: filterBySymbol } + { dataSource: filterByDataSource.id as DataSource }, + { symbol: filterBySymbol.id } ] }; } } - if (searchQuery) { + if (filterBySearchQuery) { const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [ - { id: { mode: 'insensitive', startsWith: searchQuery } }, - { isin: { mode: 'insensitive', startsWith: searchQuery } }, - { name: { mode: 'insensitive', startsWith: searchQuery } }, - { symbol: { mode: 'insensitive', startsWith: searchQuery } } + { id: { mode: 'insensitive', startsWith: filterBySearchQuery.id } }, + { isin: { mode: 'insensitive', startsWith: filterBySearchQuery.id } }, + { name: { mode: 'insensitive', startsWith: filterBySearchQuery.id } }, + { symbol: { mode: 'insensitive', startsWith: filterBySearchQuery.id } } ]; if (where.SymbolProfile) { @@ -614,14 +717,31 @@ export class ActivitiesService { } } - if (filtersByTag?.length > 0) { - where.tags = { - some: { - OR: filtersByTag.map(({ id }) => { - return { id }; - }) - } - }; + if (filtersByTag.length > 0) { + andConditions.push({ + OR: [ + { + tags: { + some: { + OR: filtersByTag.map(({ id }) => { + return { id }; + }) + } + } + }, + { + account: { + tags: { + some: { + OR: filtersByTag.map(({ id }) => { + return { tagId: id }; + }) + } + } + } + } + ] + }); } if (sortColumn) { @@ -633,13 +753,9 @@ export class ActivitiesService { } if (withExcludedAccountsAndActivities === false) { - where.OR = [ - { account: null }, - { account: { NOT: { isExcluded: true } } } - ]; + where.OR = [{ account: null }, { account: WHERE_ACCOUNT_NOT_EXCLUDED }]; where.tags = { - ...where.tags, none: { id: TAG_ID_EXCLUDE_FROM_ANALYSIS } @@ -654,7 +770,12 @@ export class ActivitiesService { include: { account: { include: { - platform: true + platform: true, + tags: { + include: { + tag: true + } + } } }, // eslint-disable-next-line @typescript-eslint/naming-convention @@ -666,6 +787,16 @@ export class ActivitiesService { this.prismaService.order.count({ where }) ]); + for (const order of orders) { + if (order.account) { + order.account.tags = ( + order.account.tags as unknown as { tag: Tag }[] + ).map(({ tag }) => { + return tag; + }); + } + } + const assetProfileIdentifiers = uniqBy( orders.map(({ SymbolProfile }) => { return { @@ -697,10 +828,10 @@ export class ActivitiesService { const value = new Big(order.quantity).mul(order.unitPrice).toNumber(); const [ - feeInAssetProfileCurrency, - feeInBaseCurrency, - unitPriceInAssetProfileCurrency, - valueInBaseCurrency + feeInAssetProfileCurrency = 0, + feeInBaseCurrency = 0, + unitPriceInAssetProfileCurrency = 0, + valueInBaseCurrency = 0 ] = await Promise.all([ this.exchangeRateDataService.toCurrencyAtDate( order.fee, @@ -730,12 +861,12 @@ export class ActivitiesService { return { ...order, + assetProfile, feeInAssetProfileCurrency, feeInBaseCurrency, unitPriceInAssetProfileCurrency, value, - valueInBaseCurrency, - SymbolProfile: assetProfile + valueInBaseCurrency }; }) ); @@ -770,7 +901,7 @@ export class ActivitiesService { withExcludedAccountsAndActivities: false // TODO }); - if (withCash) { + if (withCash && !this.areCashActivitiesExcludedByFilters(filters)) { const cashDetails = await this.accountService.getCashDetails({ filters, userId, @@ -792,10 +923,10 @@ export class ActivitiesService { } public async getStatisticsByCurrency( - currency: EnhancedSymbolProfile['currency'] + currency: EnhancedAssetProfile['currency'] ): Promise<{ - activitiesCount: EnhancedSymbolProfile['activitiesCount']; - dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity']; + activitiesCount: EnhancedAssetProfile['activitiesCount']; + dateOfFirstActivity: EnhancedAssetProfile['dateOfFirstActivity']; }> { const { _count, _min } = await this.prismaService.order.aggregate({ _count: true, @@ -821,6 +952,7 @@ export class ActivitiesService { public async updateActivity({ data, + userId, where }: { data: Prisma.OrderUpdateInput & { @@ -831,25 +963,29 @@ export class ActivitiesService { tags?: { id: string }[]; type?: ActivityType; }; + userId: string; where: Prisma.OrderWhereUniqueInput; }): Promise { + const tags = data.tags ?? []; + + await this.tagService.validateTagIds({ + userId, + tagIds: tags.map(({ id }) => { + return id; + }) + }); + if (!data.comment) { data.comment = null; } - const tags = data.tags ?? []; - let isDraft = false; if ( - ['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) || + NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type) || (data.SymbolProfile.connect.dataSource_symbol.dataSource === 'MANUAL' && data.type === 'BUY') ) { - if (data.account?.connect?.id_userId?.id === null) { - data.account = { disconnect: true }; - } - delete data.SymbolProfile.connect; delete data.SymbolProfile.update.name; } else { @@ -878,19 +1014,13 @@ export class ActivitiesService { delete data.symbol; delete data.tags; - // Remove existing tags - await this.prismaService.order.update({ - where, - data: { tags: { set: [] } } - }); - const activity = await this.prismaService.order.update({ where, data: { ...data, isDraft, tags: { - connect: tags + set: tags } } }); diff --git a/apps/api/src/app/activities/get-activities.dto.ts b/apps/api/src/app/activities/get-activities.dto.ts new file mode 100644 index 000000000..0430a89c1 --- /dev/null +++ b/apps/api/src/app/activities/get-activities.dto.ts @@ -0,0 +1,27 @@ +import { Prisma } from '@prisma/client'; +import { Type } from 'class-transformer'; +import { IsIn, IsInt, IsOptional, Min } from 'class-validator'; + +import { ActivitiesFilterDto } from './activities-filter.dto'; + +export class GetActivitiesDto extends ActivitiesFilterDto { + @IsInt() + @IsOptional() + @Min(0) + @Type(() => Number) + skip?: number; + + @IsIn(Object.values(Prisma.OrderScalarFieldEnum)) + @IsOptional() + sortColumn?: keyof typeof Prisma.OrderScalarFieldEnum; + + @IsIn(['asc', 'desc'] as Prisma.SortOrder[]) + @IsOptional() + sortDirection?: Prisma.SortOrder; + + @IsInt() + @IsOptional() + @Min(0) + @Type(() => Number) + take?: number; +} diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 69b619625..9009ded54 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -1,10 +1,11 @@ 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/transform-data-source-in-request.interceptor'; -import { ApiService } from '@ghostfolio/api/services/api/api.service'; +import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { DemoService } from '@ghostfolio/api/services/demo/demo.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { DATA_GATHERING_QUEUE_PRIORITY_HIGH, @@ -16,19 +17,21 @@ import { UpdateAssetProfileDto, UpdatePropertyDto } from '@ghostfolio/common/dtos'; -import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; +import { + canDeleteAssetProfile, + getAssetProfileIdentifier +} from '@ghostfolio/common/helper'; import { AdminData, - AdminMarketData, AdminUserResponse, AdminUsersResponse, - EnhancedSymbolProfile, + EnhancedAssetProfile, ScraperConfiguration } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; import type { DateRange, - MarketDataPreset, + PropertyKey, RequestWithUser } from '@ghostfolio/common/types'; @@ -41,6 +44,7 @@ import { Inject, Logger, Param, + ParseIntPipe, Patch, Post, Put, @@ -55,16 +59,20 @@ import { isDate, parseISO } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { AdminService } from './admin.service'; +import { PropertyKeyPipe } from './pipes/property-key.pipe'; @Controller('admin') export class AdminController { + private readonly logger = new Logger(AdminController.name); + public constructor( private readonly adminService: AdminService, - private readonly apiService: ApiService, + private readonly benchmarkService: BenchmarkService, private readonly dataGatheringService: DataGatheringService, private readonly demoService: DemoService, private readonly manualService: ManualService, - @Inject(REQUEST) private readonly request: RequestWithUser + @Inject(REQUEST) private readonly request: RequestWithUser, + private readonly symbolProfileService: SymbolProfileService ) {} @Get() @@ -84,8 +92,8 @@ export class AdminController { @HasPermission(permissions.accessAdminControl) @Post('gather') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) - public async gather7Days(): Promise { - this.dataGatheringService.gather7Days(); + public async gatherRecentMarketData(): Promise { + this.dataGatheringService.gatherRecentMarketData(); } @HasPermission(permissions.accessAdminControl) @@ -209,35 +217,6 @@ export class AdminController { }); } - @Get('market-data') - @HasPermission(permissions.accessAdminControl) - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) - public async getMarketData( - @Query('assetSubClasses') filterByAssetSubClasses?: string, - @Query('dataSource') filterByDataSource?: string, - @Query('presetId') presetId?: MarketDataPreset, - @Query('query') filterBySearchQuery?: string, - @Query('skip') skip?: number, - @Query('sortColumn') sortColumn?: string, - @Query('sortDirection') sortDirection?: Prisma.SortOrder, - @Query('take') take?: number - ): Promise { - const filters = this.apiService.buildFiltersFromQueryParams({ - filterByAssetSubClasses, - filterByDataSource, - filterBySearchQuery - }); - - return this.adminService.getMarketData({ - filters, - presetId, - sortColumn, - sortDirection, - skip: isNaN(skip) ? undefined : skip, - take: isNaN(take) ? undefined : take - }); - } - @HasPermission(permissions.accessAdminControl) @Post('market-data/:dataSource/:symbol/test') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @@ -260,7 +239,7 @@ export class AdminController { `Could not parse the market price for ${symbol} (${dataSource})` ); } catch (error) { - Logger.error(error, 'AdminController'); + this.logger.error(error); throw new HttpException(error.message, StatusCodes.BAD_REQUEST); } @@ -288,6 +267,33 @@ export class AdminController { @Param('dataSource') dataSource: DataSource, @Param('symbol') symbol: string ): Promise { + const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ + { dataSource, symbol } + ]); + + if (assetProfile) { + const benchmarkAssetProfiles = + await this.benchmarkService.getBenchmarkAssetProfiles(); + + const isBenchmark = benchmarkAssetProfiles.some(({ id }) => { + return id === assetProfile.id; + }); + + if ( + !canDeleteAssetProfile({ + isBenchmark, + activitiesCount: assetProfile.activitiesCount, + symbol: assetProfile.symbol, + watchedByCount: assetProfile.watchedByCount + }) + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + } + return this.adminService.deleteProfileData({ dataSource, symbol }); } @@ -298,7 +304,7 @@ export class AdminController { @Body() assetProfile: UpdateAssetProfileDto, @Param('dataSource') dataSource: DataSource, @Param('symbol') symbol: string - ): Promise { + ): Promise { return this.adminService.patchAssetProfileData( { dataSource, symbol }, assetProfile @@ -309,7 +315,7 @@ export class AdminController { @Put('settings/:key') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async updateProperty( - @Param('key') key: string, + @Param('key', PropertyKeyPipe) key: PropertyKey, @Body() data: UpdatePropertyDto ) { return this.adminService.putSetting(key, data.value); @@ -319,12 +325,12 @@ export class AdminController { @HasPermission(permissions.accessAdminControl) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async getUsers( - @Query('skip') skip?: number, - @Query('take') take?: number + @Query('skip', new ParseIntPipe({ optional: true })) skip?: number, + @Query('take', new ParseIntPipe({ optional: true })) take?: number ): Promise { return this.adminService.getUsers({ - skip: isNaN(skip) ? undefined : skip, - take: isNaN(take) ? undefined : take + skip, + take }); } diff --git a/apps/api/src/app/admin/admin.module.ts b/apps/api/src/app/admin/admin.module.ts index e87df9e74..2d5c734dc 100644 --- a/apps/api/src/app/admin/admin.module.ts +++ b/apps/api/src/app/admin/admin.module.ts @@ -1,6 +1,4 @@ -import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.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 { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; @@ -20,8 +18,6 @@ import { QueueModule } from './queue/queue.module'; @Module({ imports: [ - ActivitiesModule, - ApiModule, BenchmarkModule, ConfigurationModule, DataGatheringQueueModule, diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index ba338b5b9..d384e0d55 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -1,6 +1,4 @@ -import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { environment } from '@ghostfolio/api/environments/environment'; -import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.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'; @@ -9,28 +7,24 @@ 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 { + ghostfolioPrefix, PROPERTY_CURRENCIES, PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_USER_SIGNUP_ENABLED } from '@ghostfolio/common/config'; import { + applyAssetProfileOverrides, getAssetProfileIdentifier, getCurrencyFromSymbol, - isCurrency + hasGhostfolioPrefix } from '@ghostfolio/common/helper'; import { AdminData, - AdminMarketData, - AdminMarketDataDetails, - AdminMarketDataItem, AdminUserResponse, AdminUsersResponse, - AssetProfileIdentifier, - EnhancedSymbolProfile, - Filter + AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; -import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; -import { MarketDataPreset } from '@ghostfolio/common/types'; +import { PropertyKey } from '@ghostfolio/common/types'; import { BadRequestException, @@ -48,13 +42,11 @@ import { } from '@prisma/client'; import { differenceInDays } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; -import { groupBy } from 'lodash'; +import { randomUUID } from 'node:crypto'; @Injectable() export class AdminService { public constructor( - private readonly activitiesService: ActivitiesService, - private readonly benchmarkService: BenchmarkService, private readonly configurationService: ConfigurationService, private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, @@ -73,6 +65,12 @@ export class AdminService { > { try { if (dataSource === 'MANUAL') { + if (!hasGhostfolioPrefix(symbol)) { + throw new BadRequestException( + `symbol ("${symbol}") must start with the prefix "${ghostfolioPrefix}_" for the data source ("${dataSource}")` + ); + } + return this.symbolProfileService.add({ currency, dataSource, @@ -84,14 +82,17 @@ export class AdminService { { dataSource, symbol } ]); - if (!assetProfiles[symbol]?.currency) { + const assetProfile = + assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]; + + if (!assetProfile?.currency) { throw new BadRequestException( `Asset profile not found for ${symbol} (${dataSource})` ); } return this.symbolProfileService.add( - assetProfiles[symbol] as Prisma.SymbolProfileCreateInput + assetProfile as Prisma.SymbolProfileCreateInput ); } catch (error) { if ( @@ -114,8 +115,11 @@ export class AdminService { await this.marketDataService.deleteMany({ dataSource, symbol }); const currency = getCurrencyFromSymbol(symbol); - const customCurrencies = - await this.propertyService.getByKey(PROPERTY_CURRENCIES); + + const customCurrencies = await this.propertyService.getByKey( + PROPERTY_CURRENCIES, + { skipCache: true } + ); if (customCurrencies.includes(currency)) { const updatedCustomCurrencies = customCurrencies.filter( @@ -188,332 +192,24 @@ export class AdminService { }; } - public async getMarketData({ - filters, - presetId, - sortColumn, - sortDirection = 'asc', - skip, - take = Number.MAX_SAFE_INTEGER - }: { - filters?: Filter[]; - presetId?: MarketDataPreset; - skip?: number; - sortColumn?: string; - sortDirection?: Prisma.SortOrder; - take?: number; - }): Promise { - let orderBy: Prisma.Enumerable = - [{ symbol: 'asc' }]; - const where: Prisma.SymbolProfileWhereInput = {}; - - 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' || - presetId === 'ETF_WITHOUT_SECTORS' - ) { - filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }]; - } else if (presetId === 'NO_ACTIVITIES') { - where.activities = { - none: {} - }; - } - - const searchQuery = filters.find(({ type }) => { - return type === 'SEARCH_QUERY'; - })?.id; - - const { - ASSET_SUB_CLASS: filtersByAssetSubClass, - DATA_SOURCE: filtersByDataSource - } = groupBy(filters, ({ type }) => { - return type; - }); - - const marketDataItems = await this.prismaService.marketData.groupBy({ - _count: true, - by: ['dataSource', 'symbol'] - }); - - if (filtersByAssetSubClass) { - where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id]; - } - - if (filtersByDataSource) { - where.dataSource = DataSource[filtersByDataSource[0].id]; - } - - if (searchQuery) { - where.OR = [ - { id: { mode: 'insensitive', startsWith: searchQuery } }, - { isin: { mode: 'insensitive', startsWith: searchQuery } }, - { name: { mode: 'insensitive', startsWith: searchQuery } }, - { symbol: { mode: 'insensitive', startsWith: searchQuery } } - ]; - } - - if (sortColumn) { - orderBy = [{ [sortColumn]: sortDirection }]; - - if (sortColumn === 'activitiesCount') { - orderBy = [ - { - activities: { - _count: sortDirection - } - } - ]; - } - } - - const extendedPrismaClient = this.getExtendedPrismaClient(); - - const symbolProfileResult = await Promise.all([ - extendedPrismaClient.symbolProfile.findMany({ - skip, - take, - where, - orderBy: [...orderBy, { id: sortDirection }], - select: { - _count: { - select: { - activities: true, - watchedBy: true - } - }, - activities: { - orderBy: [{ date: 'asc' }], - select: { date: true }, - take: 1 - }, - assetClass: true, - assetSubClass: true, - comment: true, - countries: true, - currency: true, - dataSource: true, - id: true, - isActive: true, - isUsedByUsersWithSubscription: true, - name: true, - scraperConfiguration: true, - sectors: true, - symbol: true, - SymbolProfileOverrides: true - } - }), - this.prismaService.symbolProfile.count({ where }) - ]); - const assetProfiles = symbolProfileResult[0]; - let count = symbolProfileResult[1]; - - const lastMarketPrices = await this.prismaService.marketData.findMany({ - distinct: ['dataSource', 'symbol'], - orderBy: { date: 'desc' }, - select: { - dataSource: true, - marketPrice: true, - symbol: true - }, - where: { - dataSource: { - in: assetProfiles.map(({ dataSource }) => { - return dataSource; - }) - }, - symbol: { - in: assetProfiles.map(({ symbol }) => { - return symbol; - }) - } - } + public async getUser(id: string): Promise { + const [user] = await this.getUsersWithAnalytics({ + where: { id } }); - const lastMarketPriceMap = new Map(); - - for (const { dataSource, marketPrice, symbol } of lastMarketPrices) { - lastMarketPriceMap.set( - getAssetProfileIdentifier({ dataSource, symbol }), - marketPrice - ); - } - - let marketData: AdminMarketDataItem[] = await Promise.all( - assetProfiles.map( - async ({ - _count, - activities, - assetClass, - assetSubClass, - comment, - countries, - currency, - dataSource, - id, - isActive, - isUsedByUsersWithSubscription, - name, - sectors, - symbol, - SymbolProfileOverrides - }) => { - let countriesCount = countries ? Object.keys(countries).length : 0; - - const lastMarketPrice = lastMarketPriceMap.get( - getAssetProfileIdentifier({ dataSource, symbol }) - ); - - const marketDataItemCount = - marketDataItems.find((marketDataItem) => { - return ( - marketDataItem.dataSource === dataSource && - marketDataItem.symbol === symbol - ); - })?._count ?? 0; - - let sectorsCount = sectors ? Object.keys(sectors).length : 0; - - if (SymbolProfileOverrides) { - assetClass = SymbolProfileOverrides.assetClass ?? assetClass; - assetSubClass = - SymbolProfileOverrides.assetSubClass ?? assetSubClass; - - if ( - (SymbolProfileOverrides.countries as unknown as Prisma.JsonArray) - ?.length > 0 - ) { - countriesCount = ( - SymbolProfileOverrides.countries as unknown as Prisma.JsonArray - ).length; - } - - name = SymbolProfileOverrides.name ?? name; - - if ( - (SymbolProfileOverrides.sectors as unknown as Sector[])?.length > - 0 - ) { - sectorsCount = ( - SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray - ).length; - } - } - - return { - assetClass, - assetSubClass, - comment, - countriesCount, - currency, - dataSource, - id, - isActive, - lastMarketPrice, - marketDataItemCount, - name, - sectorsCount, - symbol, - activitiesCount: _count.activities, - date: activities?.[0]?.date, - isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription, - watchedByCount: _count.watchedBy - }; - } - ) - ); - - if (presetId) { - if (presetId === 'ETF_WITHOUT_COUNTRIES') { - marketData = marketData.filter(({ countriesCount }) => { - return countriesCount === 0; - }); - } else if (presetId === 'ETF_WITHOUT_SECTORS') { - marketData = marketData.filter(({ sectorsCount }) => { - return sectorsCount === 0; - }); - } - - count = marketData.length; - } - - return { - count, - marketData - }; - } - - public async getMarketDataBySymbol({ - dataSource, - symbol - }: AssetProfileIdentifier): Promise { - let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0; - let currency: EnhancedSymbolProfile['currency'] = '-'; - let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity']; - - const isCurrencyAssetProfile = isCurrency(getCurrencyFromSymbol(symbol)); - - if (isCurrencyAssetProfile) { - currency = getCurrencyFromSymbol(symbol); - ({ activitiesCount, dateOfFirstActivity } = - await this.activitiesService.getStatisticsByCurrency(currency)); + if (!user) { + throw new NotFoundException(`User with ID ${id} not found`); } - const [[assetProfile], marketData] = await Promise.all([ - this.symbolProfileService.getSymbolProfiles([ - { - dataSource, - symbol - } - ]), - this.marketDataService.marketDataItems({ + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + user.subscriptions = await this.prismaService.subscription.findMany({ orderBy: { - date: 'asc' + expiresAt: 'desc' }, where: { - dataSource, - symbol + userId: id } - }) - ]); - - if (assetProfile) { - assetProfile.dataProviderInfo = this.dataProviderService - .getDataProvider(assetProfile.dataSource) - .getDataProviderInfo(); - } - - return { - marketData, - assetProfile: assetProfile ?? { - activitiesCount, - currency, - dataSource, - dateOfFirstActivity, - symbol, - assetClass: isCurrencyAssetProfile ? AssetClass.LIQUIDITY : undefined, - assetSubClass: isCurrencyAssetProfile ? AssetSubClass.CASH : undefined, - isActive: true - } - }; - } - - public async getUser(id: string): Promise { - const [user] = await this.getUsersWithAnalytics({ - where: { id } - }); - - if (!user) { - throw new NotFoundException(`User with ID ${id} not found`); + }); } return user; @@ -545,6 +241,7 @@ export class AdminService { comment, countries, currency, + dataGatheringFrequency, dataSource: newDataSource, holdings, isActive, @@ -556,16 +253,25 @@ export class AdminService { url }: Prisma.SymbolProfileUpdateInput ) { + const isConversionToManualDataSource = + newDataSource === DataSource.MANUAL && dataSource !== DataSource.MANUAL; + + if (isConversionToManualDataSource && !newSymbol) { + newSymbol = randomUUID(); + } + if ( - newSymbol && newDataSource && - (newSymbol !== symbol || newDataSource !== dataSource) + newSymbol && + (newDataSource !== dataSource || newSymbol !== symbol) ) { + const newAssetProfileIdentifier: AssetProfileIdentifier = { + dataSource: newDataSource as DataSource, + symbol: newSymbol as string + }; + const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ - { - dataSource: DataSource[newDataSource.toString()], - symbol: newSymbol as string - } + newAssetProfileIdentifier ]); if (assetProfile) { @@ -575,47 +281,85 @@ export class AdminService { ); } - try { - Promise.all([ - await this.symbolProfileService.updateAssetProfileIdentifier( - { - dataSource, - symbol - }, - { - dataSource: DataSource[newDataSource.toString()], - symbol: newSymbol as string - } + const operations: Prisma.PrismaPromise[] = [ + this.symbolProfileService.updateAssetProfileIdentifier( + { + dataSource, + symbol + }, + newAssetProfileIdentifier + ), + this.marketDataService.updateAssetProfileIdentifier( + { + dataSource, + symbol + }, + newAssetProfileIdentifier + ) + ]; + + if (isConversionToManualDataSource) { + const currentAssetProfile = + await this.prismaService.symbolProfile.findUnique({ + include: { assetProfileOverrides: true }, + where: { dataSource_symbol: { dataSource, symbol } } + }); + + if (!currentAssetProfile) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + const currentAssetProfileWithOverrides = applyAssetProfileOverrides( + currentAssetProfile, + currentAssetProfile.assetProfileOverrides + ); + + operations.push( + // The overrides are applied on every read, so delete them and + // persist the merged values in the asset profile instead + this.symbolProfileService.deleteAssetProfileOverrides( + newAssetProfileIdentifier ), - await this.marketDataService.updateAssetProfileIdentifier( + this.symbolProfileService.updateSymbolProfile( + newAssetProfileIdentifier, { - dataSource, - symbol - }, - { - dataSource: DataSource[newDataSource.toString()], - symbol: newSymbol as string + assetClass: currentAssetProfileWithOverrides.assetClass, + assetSubClass: currentAssetProfileWithOverrides.assetSubClass, + countries: + currentAssetProfileWithOverrides.countries ?? undefined, + holdings: currentAssetProfileWithOverrides.holdings ?? undefined, + name: currentAssetProfileWithOverrides.name, + sectors: currentAssetProfileWithOverrides.sectors ?? undefined, + url: currentAssetProfileWithOverrides.url } ) - ]); + ); + } - return this.symbolProfileService.getSymbolProfiles([ - { - dataSource: DataSource[newDataSource.toString()], - symbol: newSymbol as string - } - ])?.[0]; + try { + await this.prismaService.$transaction(operations); } catch { throw new HttpException( getReasonPhrase(StatusCodes.BAD_REQUEST), StatusCodes.BAD_REQUEST ); } + + const [updatedAssetProfile] = + await this.symbolProfileService.getSymbolProfiles([ + newAssetProfileIdentifier + ]); + + return updatedAssetProfile; } else { - const symbolProfileOverrides = { + const assetProfileOverrides = { assetClass: assetClass as AssetClass, assetSubClass: assetSubClass as AssetSubClass, countries: countries as Prisma.JsonArray, + holdings: holdings as Prisma.JsonArray, name: name as string, sectors: sectors as Prisma.JsonArray, url: url as string @@ -624,22 +368,16 @@ export class AdminService { const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput = { comment, currency, + dataGatheringFrequency, dataSource, - holdings, isActive, scraperConfiguration, symbol, symbolMapping, - ...(dataSource === 'MANUAL' - ? { assetClass, assetSubClass, countries, name, sectors, url } - : { - SymbolProfileOverrides: { - upsert: { - create: symbolProfileOverrides, - update: symbolProfileOverrides - } - } - }) + ...this.symbolProfileService.getAssetProfileUpdateInput( + { dataSource, symbol }, + assetProfileOverrides + ) }; await this.symbolProfileService.updateSymbolProfile( @@ -650,22 +388,30 @@ export class AdminService { updatedSymbolProfile ); - return this.symbolProfileService.getSymbolProfiles([ - { - dataSource: dataSource as DataSource, - symbol: symbol as string - } - ])?.[0]; + const [updatedAssetProfile] = + await this.symbolProfileService.getSymbolProfiles([ + { + dataSource: dataSource as DataSource, + symbol: symbol as string + } + ]); + + return updatedAssetProfile; } } - public async putSetting(key: string, value: string) { + public async putSetting(key: PropertyKey, value: string) { let response: Property; if (value) { - response = await this.propertyService.put({ key, value }); + response = await this.propertyService.put({ + key, + value + }); } else { - response = await this.propertyService.delete({ key }); + response = await this.propertyService.delete({ + key + }); } if (key === PROPERTY_IS_READ_ONLY_MODE && value === 'true') { @@ -693,138 +439,6 @@ export class AdminService { }); } - private getExtendedPrismaClient() { - const symbolProfileExtension = Prisma.defineExtension((client) => { - return client.$extends({ - result: { - symbolProfile: { - isUsedByUsersWithSubscription: { - compute: async ({ id }) => { - const { _count } = - await this.prismaService.symbolProfile.findUnique({ - select: { - _count: { - select: { - activities: { - where: { - user: { - subscriptions: { - some: { - expiresAt: { - gt: new Date() - } - } - } - } - } - } - } - } - }, - where: { - id - } - }); - - return _count.activities > 0; - } - } - } - } - }); - }); - - return this.prismaService.$extends(symbolProfileExtension); - } - - private async getMarketDataForCurrencies(): Promise { - const currencyPairs = this.exchangeRateDataService.getCurrencyPairs(); - - const [lastMarketPrices, marketDataItems] = await Promise.all([ - this.prismaService.marketData.findMany({ - distinct: ['dataSource', 'symbol'], - orderBy: { date: 'desc' }, - select: { - dataSource: true, - marketPrice: true, - symbol: true - }, - where: { - dataSource: { - in: currencyPairs.map(({ dataSource }) => { - return dataSource; - }) - }, - symbol: { - in: currencyPairs.map(({ symbol }) => { - return symbol; - }) - } - } - }), - this.prismaService.marketData.groupBy({ - _count: true, - by: ['dataSource', 'symbol'] - }) - ]); - - const lastMarketPriceMap = new Map(); - - for (const { dataSource, marketPrice, symbol } of lastMarketPrices) { - lastMarketPriceMap.set( - getAssetProfileIdentifier({ dataSource, symbol }), - marketPrice - ); - } - - const marketDataPromise: Promise[] = currencyPairs.map( - async ({ dataSource, symbol }) => { - let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0; - let currency: EnhancedSymbolProfile['currency'] = '-'; - let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity']; - - if (isCurrency(getCurrencyFromSymbol(symbol))) { - currency = getCurrencyFromSymbol(symbol); - ({ activitiesCount, dateOfFirstActivity } = - await this.activitiesService.getStatisticsByCurrency(currency)); - } - - const lastMarketPrice = lastMarketPriceMap.get( - getAssetProfileIdentifier({ dataSource, symbol }) - ); - - const marketDataItemCount = - marketDataItems.find((marketDataItem) => { - return ( - marketDataItem.dataSource === dataSource && - marketDataItem.symbol === symbol - ); - })?._count ?? 0; - - return { - activitiesCount, - currency, - dataSource, - lastMarketPrice, - marketDataItemCount, - symbol, - assetClass: AssetClass.LIQUIDITY, - assetSubClass: AssetSubClass.CASH, - countriesCount: 0, - date: dateOfFirstActivity, - id: undefined, - isActive: true, - name: symbol, - sectorsCount: 0, - watchedByCount: 0 - }; - } - ); - - const marketData = await Promise.all(marketDataPromise); - return { marketData, count: marketData.length }; - } - private async getUsersWithAnalytics({ skip, take, @@ -876,7 +490,7 @@ export class AdminService { activityCount: true, country: true, dataProviderGhostfolioDailyRequests: true, - updatedAt: true + lastRequestAt: true } }, createdAt: true, @@ -922,7 +536,7 @@ export class AdminService { activityCount: _count.activities || 0, country: analytics?.country, dailyApiRequests: analytics?.dataProviderGhostfolioDailyRequests || 0, - lastActivity: analytics?.updatedAt + lastActivity: analytics?.lastRequestAt }; } ); diff --git a/apps/api/src/app/admin/pipes/property-key.pipe.ts b/apps/api/src/app/admin/pipes/property-key.pipe.ts new file mode 100644 index 000000000..a980b552b --- /dev/null +++ b/apps/api/src/app/admin/pipes/property-key.pipe.ts @@ -0,0 +1,29 @@ +import * as config from '@ghostfolio/common/config'; +import type { PropertyKey } from '@ghostfolio/common/types'; + +import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common'; + +@Injectable() +export class PropertyKeyPipe implements PipeTransform { + private readonly allowedKeys: Set; + + public constructor() { + this.allowedKeys = new Set( + Object.entries(config) + .filter(([key]) => { + return key.startsWith('PROPERTY_'); + }) + .map(([, value]) => { + return value as string; + }) + ); + } + + public transform(value: string): PropertyKey { + if (!this.allowedKeys.has(value)) { + throw new BadRequestException(`Invalid property key: ${value}`); + } + + return value as PropertyKey; + } +} diff --git a/apps/api/src/app/admin/queue/queue.service.ts b/apps/api/src/app/admin/queue/queue.service.ts index f47b3d3a1..d31834eea 100644 --- a/apps/api/src/app/admin/queue/queue.service.ts +++ b/apps/api/src/app/admin/queue/queue.service.ts @@ -56,7 +56,7 @@ export class QueueService { } public async getJobs({ - limit = 1000, + limit = 5000, status = QUEUE_JOB_STATUS_LIST }: { limit?: number; diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 4857c7e14..ddda044a7 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -1,7 +1,10 @@ import { EventsModule } from '@ghostfolio/api/events/events.module'; +import { PortfolioSnapshotComputationExceptionFilter } from '@ghostfolio/api/filters/portfolio-snapshot-computation-exception.filter'; +import { getRedisConnectionOptions } from '@ghostfolio/api/helper/redis.helper'; import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.middleware'; import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { CronModule } from '@ghostfolio/api/services/cron/cron.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'; @@ -12,19 +15,22 @@ import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-g import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { BULL_BOARD_ROUTE, - DEFAULT_LANGUAGE_CODE, - SUPPORTED_LANGUAGE_CODES + THROTTLE_DEFAULT_LIMIT, + THROTTLE_DEFAULT_TTL } from '@ghostfolio/common/config'; import { ExpressAdapter } from '@bull-board/express'; import { BullBoardModule } from '@bull-board/nestjs'; +import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis'; import { BullModule } from '@nestjs/bull'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { APP_FILTER } from '@nestjs/core'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { ScheduleModule } from '@nestjs/schedule'; import { ServeStaticModule } from '@nestjs/serve-static'; -import { StatusCodes } from 'http-status-codes'; +import { ThrottlerModule } from '@nestjs/throttler'; +import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { join } from 'node:path'; import { AccessModule } from './access/access.module'; @@ -38,6 +44,7 @@ import { AuthModule } from './auth/auth.module'; import { CacheModule } from './cache/cache.module'; import { AiModule } from './endpoints/ai/ai.module'; import { ApiKeysModule } from './endpoints/api-keys/api-keys.module'; +import { AssetProfilesModule } from './endpoints/asset-profiles/asset-profiles.module'; import { AssetsModule } from './endpoints/assets/assets.module'; import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module'; import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module'; @@ -69,6 +76,7 @@ import { UserModule } from './user/user.module'; ActivitiesModule, AiModule, ApiKeysModule, + AssetProfilesModule, AssetModule, AssetsModule, AuthDeviceModule, @@ -93,12 +101,13 @@ import { UserModule } from './user/user.module'; middleware: BullBoardAuthMiddleware, route: BULL_BOARD_ROUTE }), - BullModule.forRoot({ - redis: { - db: parseInt(process.env.REDIS_DB ?? '0', 10), - host: process.env.REDIS_HOST, - password: process.env.REDIS_PASSWORD, - port: parseInt(process.env.REDIS_PORT ?? '6379', 10) + BullModule.forRootAsync({ + imports: [ConfigurationModule], + inject: [ConfigurationService], + useFactory: (configurationService: ConfigurationService) => { + return { + redis: getRedisConnectionOptions(configurationService) + }; } }), CacheModule, @@ -134,27 +143,7 @@ import { UserModule } from './user/user.module'; '/api/*wildcard', '/sitemap.xml' ], - rootPath: join(__dirname, '..', 'client'), - serveStaticOptions: { - setHeaders: (res) => { - if (res.req?.path === '/') { - let languageCode = DEFAULT_LANGUAGE_CODE; - - try { - const code = res.req.headers['accept-language'] - .split(',')[0] - .split('-')[0]; - - if (SUPPORTED_LANGUAGE_CODES.includes(code)) { - languageCode = code; - } - } catch {} - - res.set('Location', `/${languageCode}`); - res.statusCode = StatusCodes.MOVED_PERMANENTLY; - } - } - } + rootPath: join(__dirname, '..', 'client') }), ServeStaticModule.forRoot({ rootPath: join(__dirname, '..', 'client', '.well-known'), @@ -164,10 +153,46 @@ import { UserModule } from './user/user.module'; SubscriptionModule, SymbolModule, TagsModule, + ThrottlerModule.forRootAsync({ + imports: [ConfigurationModule], + inject: [ConfigurationService], + useFactory: (configurationService: ConfigurationService) => { + const isRateLimitingEnabled = configurationService.get( + 'ENABLE_FEATURE_RATE_LIMITING' + ); + + return { + errorMessage: getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + skipIf: () => { + return !isRateLimitingEnabled; + }, + storage: isRateLimitingEnabled + ? new ThrottlerStorageRedisService({ + ...getRedisConnectionOptions(configurationService), + // Reject commands immediately while Redis is unavailable + enableOfflineQueue: false, + maxRetriesPerRequest: 1 + }) + : undefined, + throttlers: [ + { + limit: THROTTLE_DEFAULT_LIMIT, + ttl: THROTTLE_DEFAULT_TTL + } + ] + }; + } + }), UserModule, WatchlistModule ], - providers: [I18nService] + providers: [ + I18nService, + { + provide: APP_FILTER, + useClass: PortfolioSnapshotComputationExceptionFilter + } + ] }) export class AppModule implements NestModule { public configure(consumer: MiddlewareConsumer) { diff --git a/apps/api/src/app/asset/asset.controller.ts b/apps/api/src/app/asset/asset.controller.ts index 3b2031084..49afb43d7 100644 --- a/apps/api/src/app/asset/asset.controller.ts +++ b/apps/api/src/app/asset/asset.controller.ts @@ -1,4 +1,4 @@ -import { AdminService } from '@ghostfolio/api/app/admin/admin.service'; +import { AssetProfilesService } from '@ghostfolio/api/app/endpoints/asset-profiles/asset-profiles.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 { AssetResponse } from '@ghostfolio/common/interfaces'; @@ -9,7 +9,9 @@ import { pick } from 'lodash'; @Controller('asset') export class AssetController { - public constructor(private readonly adminService: AdminService) {} + public constructor( + private readonly assetProfilesService: AssetProfilesService + ) {} @Get(':dataSource/:symbol') @UseInterceptors(TransformDataSourceInRequestInterceptor) @@ -18,11 +20,15 @@ export class AssetController { @Param('dataSource') dataSource: DataSource, @Param('symbol') symbol: string ): Promise { - const { assetProfile, marketData } = - await this.adminService.getMarketDataBySymbol({ dataSource, symbol }); + const { assetProfile, marketData, splits } = + await this.assetProfilesService.getAssetProfile({ + dataSource, + symbol + }); return { marketData, + splits, 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 index 168585ed8..311c8694f 100644 --- a/apps/api/src/app/asset/asset.module.ts +++ b/apps/api/src/app/asset/asset.module.ts @@ -1,4 +1,4 @@ -import { AdminModule } from '@ghostfolio/api/app/admin/admin.module'; +import { AssetProfilesModule } from '@ghostfolio/api/app/endpoints/asset-profiles/asset-profiles.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'; @@ -9,7 +9,7 @@ import { AssetController } from './asset.controller'; @Module({ controllers: [AssetController], imports: [ - AdminModule, + AssetProfilesModule, TransformDataSourceInRequestModule, TransformDataSourceInResponseModule ] diff --git a/apps/api/src/app/auth/api-key.strategy.ts b/apps/api/src/app/auth/api-key.strategy.ts index f9937aaa7..232a272bc 100644 --- a/apps/api/src/app/auth/api-key.strategy.ts +++ b/apps/api/src/app/auth/api-key.strategy.ts @@ -35,6 +35,13 @@ export class ApiKeyStrategy extends PassportStrategy( ); } + if (await this.userService.isDailyRequestLimitExceeded({ user })) { + throw new HttpException( + getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + StatusCodes.TOO_MANY_REQUESTS + ); + } + await this.prismaService.analytics.upsert({ create: { user: { connect: { id: user.id } } }, update: { diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index 388f1dbd3..e3886e39c 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -1,4 +1,5 @@ import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; +import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; @@ -13,7 +14,6 @@ import { Controller, Get, HttpException, - Param, Post, Req, Res, @@ -35,26 +35,8 @@ export class AuthController { private readonly webAuthService: WebAuthService ) {} - /** - * @deprecated - */ - @Get('anonymous/:accessToken') - public async accessTokenLoginGet( - @Param('accessToken') accessToken: string - ): Promise { - try { - const authToken = - await this.authService.validateAnonymousLogin(accessToken); - return { authToken }; - } catch { - throw new HttpException( - getReasonPhrase(StatusCodes.FORBIDDEN), - StatusCodes.FORBIDDEN - ); - } - } - @Post('anonymous') + @UseGuards(CustomThrottlerGuard) public async accessTokenLogin( @Body() body: { accessToken: string } ): Promise { @@ -135,6 +117,7 @@ export class AuthController { } @Post('webauthn/generate-authentication-options') + @UseGuards(CustomThrottlerGuard) public async generateAuthenticationOptions( @Body() body: { deviceId: string } ) { @@ -156,6 +139,7 @@ export class AuthController { } @Post('webauthn/verify-authentication') + @UseGuards(CustomThrottlerGuard) public async verifyAuthentication( @Body() body: { deviceId: string; credential: AssertionCredentialJSON } ) { diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index 9fc5d0925..ddc41abad 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -1,12 +1,17 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; +import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module'; +import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; +import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { Logger, Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; @@ -22,13 +27,17 @@ import { OidcStrategy } from './oidc.strategy'; @Module({ controllers: [AuthController], imports: [ + ApiModule, ConfigurationModule, + FetchModule, JwtModule.register({ secret: process.env.JWT_SECRET_KEY, signOptions: { expiresIn: '180 days' } }), + PortfolioSnapshotQueueModule, PrismaModule, PropertyModule, + RedisCacheModule, SubscriptionModule, UserModule ], @@ -40,12 +49,15 @@ import { OidcStrategy } from './oidc.strategy'; GoogleStrategy, JwtStrategy, { - inject: [AuthService, ConfigurationService], + inject: [AuthService, ConfigurationService, FetchService], provide: OidcStrategy, useFactory: async ( authService: AuthService, - configurationService: ConfigurationService + configurationService: ConfigurationService, + fetchService: FetchService ) => { + const logger = new Logger('OidcStrategy'); + const isOidcEnabled = configurationService.get( 'ENABLE_FEATURE_AUTH_OIDC' ); @@ -81,7 +93,7 @@ import { OidcStrategy } from './oidc.strategy'; } else { // Fetch OIDC configuration from discovery endpoint try { - const response = await fetch( + const response = await fetchService.fetch( `${issuer}/.well-known/openid-configuration` ); @@ -97,7 +109,7 @@ import { OidcStrategy } from './oidc.strategy'; tokenURL = manualTokenUrl || config.token_endpoint; userInfoURL = manualUserInfoUrl || config.userinfo_endpoint; } catch (error) { - Logger.error(error, 'OidcStrategy'); + logger.error(error); throw new Error('Failed to fetch OIDC configuration from issuer'); } } diff --git a/apps/api/src/app/auth/google.strategy.ts b/apps/api/src/app/auth/google.strategy.ts index 3e4b4ca0d..53720c383 100644 --- a/apps/api/src/app/auth/google.strategy.ts +++ b/apps/api/src/app/auth/google.strategy.ts @@ -10,6 +10,8 @@ import { AuthService } from './auth.service'; @Injectable() export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { + private readonly logger = new Logger(GoogleStrategy.name); + public constructor( private readonly authService: AuthService, configurationService: ConfigurationService @@ -40,7 +42,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { done(null, { jwt }); } catch (error) { - Logger.error(error, 'GoogleStrategy'); + this.logger.error(error); done(error, false); } } diff --git a/apps/api/src/app/auth/jwt.strategy.ts b/apps/api/src/app/auth/jwt.strategy.ts index c70e8fb60..189389a86 100644 --- a/apps/api/src/app/auth/jwt.strategy.ts +++ b/apps/api/src/app/auth/jwt.strategy.ts @@ -42,6 +42,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { ); } + if (await this.userService.isDailyRequestLimitExceeded({ user })) { + throw new HttpException( + getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + StatusCodes.TOO_MANY_REQUESTS + ); + } + const country = countriesAndTimezones.getCountryForTimezone(timezone)?.id; diff --git a/apps/api/src/app/auth/oidc.strategy.ts b/apps/api/src/app/auth/oidc.strategy.ts index 96b284121..661f2a821 100644 --- a/apps/api/src/app/auth/oidc.strategy.ts +++ b/apps/api/src/app/auth/oidc.strategy.ts @@ -15,6 +15,8 @@ import { OidcStateStore } from './oidc-state.store'; @Injectable() export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { + private readonly logger = new Logger(OidcStrategy.name); + private static readonly stateStore = new OidcStateStore(); public constructor( @@ -52,9 +54,8 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { }); if (!thirdPartyId) { - Logger.error( - `Missing subject identifier in OIDC response from ${issuer}`, - 'OidcStrategy' + this.logger.error( + `Missing subject identifier in OIDC response from ${issuer}` ); throw new Error('Missing subject identifier in OIDC response'); @@ -62,7 +63,7 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { return { jwt }; } catch (error) { - Logger.error(error, 'OidcStrategy'); + this.logger.error(error); throw error; } } diff --git a/apps/api/src/app/auth/web-auth.service.ts b/apps/api/src/app/auth/web-auth.service.ts index 6cffcd244..cb9dd8cb7 100644 --- a/apps/api/src/app/auth/web-auth.service.ts +++ b/apps/api/src/app/auth/web-auth.service.ts @@ -1,6 +1,15 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; +import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { + PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW, + PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, + PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS +} from '@ghostfolio/common/config'; import { AuthDeviceDto } from '@ghostfolio/common/dtos'; import { AssertionCredentialJSON, @@ -29,14 +38,20 @@ import { VerifyRegistrationResponseOpts } from '@simplewebauthn/server'; import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers'; +import { isPast } from 'date-fns'; import ms from 'ms'; @Injectable() export class WebAuthService { + private readonly logger = new Logger(WebAuthService.name); + public constructor( + private readonly apiService: ApiService, private readonly configurationService: ConfigurationService, private readonly deviceService: AuthDeviceService, private readonly jwtService: JwtService, + private readonly portfolioSnapshotService: PortfolioSnapshotService, + private readonly redisCacheService: RedisCacheService, private readonly userService: UserService, @Inject(REQUEST) private readonly request: RequestWithUser ) {} @@ -103,7 +118,7 @@ export class WebAuthService { verification = await verifyRegistrationResponse(opts); } catch (error) { - Logger.error(error, 'WebAuthService'); + this.logger.error(error); throw new InternalServerErrorException(error.message); } @@ -153,6 +168,9 @@ export class WebAuthService { throw new Error('Device not found'); } + // Compute in the background during the biometric authentication + void this.warmUpPortfolioSnapshot({ userId: device.userId }); + const opts: GenerateAuthenticationOptionsOpts = { allowCredentials: [], rpID: this.rpID, @@ -210,7 +228,7 @@ export class WebAuthService { verification = await verifyAuthenticationResponse(opts); } catch (error) { - Logger.error(error, 'WebAuthService'); + this.logger.error(error); throw new InternalServerErrorException({ error: error.message }); } @@ -231,4 +249,57 @@ export class WebAuthService { throw new Error(); } + + private async isPortfolioSnapshotExpired(portfolioSnapshotKey: string) { + try { + const { expiration }: PortfolioSnapshotValue = JSON.parse( + await this.redisCacheService.get(portfolioSnapshotKey) + ); + + return isPast(new Date(expiration)); + } catch { + return true; + } + } + + private async warmUpPortfolioSnapshot({ userId }: { userId: string }) { + try { + const user = await this.userService.user({ id: userId }); + + if (!user) { + return; + } + + const userSettings = user.settings.settings; + + const filters = this.apiService.buildFiltersFromUserSettings({ + userSettings + }); + + const portfolioSnapshotKey = + this.redisCacheService.getPortfolioSnapshotKey({ filters, userId }); + + if (await this.isPortfolioSnapshotExpired(portfolioSnapshotKey)) { + await this.portfolioSnapshotService.addJobToQueue({ + data: { + filters, + userId, + calculationType: userSettings.performanceCalculationType, + userCurrency: userSettings.baseCurrency + }, + name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, + opts: { + ...PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS, + jobId: portfolioSnapshotKey, + priority: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW + } + }); + } + } catch (error) { + this.logger.error( + `Portfolio snapshot of user '${userId}' could not be warmed up`, + error + ); + } + } } diff --git a/apps/api/src/app/endpoints/ai/ai.controller.ts b/apps/api/src/app/endpoints/ai/ai.controller.ts index b1607b53b..6c8102db1 100644 --- a/apps/api/src/app/endpoints/ai/ai.controller.ts +++ b/apps/api/src/app/endpoints/ai/ai.controller.ts @@ -1,4 +1,5 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { FilterDto } from '@ghostfolio/api/dtos/filter.dto'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { AiPromptResponse } from '@ghostfolio/common/interfaces'; @@ -31,18 +32,15 @@ export class AiController { @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async getPrompt( @Param('mode') mode: AiPromptMode, - @Query('accounts') filterByAccounts?: string, - @Query('assetClasses') filterByAssetClasses?: string, - @Query('dataSource') filterByDataSource?: string, - @Query('symbol') filterBySymbol?: string, - @Query('tags') filterByTags?: string + @Query() + { accounts, assetClasses, dataSource, symbol, tags }: FilterDto ): Promise { const filters = this.apiService.buildFiltersFromQueryParams({ - filterByAccounts, - filterByAssetClasses, - filterByDataSource, - filterBySymbol, - filterByTags + filterByAccounts: accounts, + filterByAssetClasses: assetClasses, + filterByDataSource: dataSource, + filterBySymbol: symbol, + filterByTags: tags }); const prompt = await this.aiService.getPrompt({ diff --git a/apps/api/src/app/endpoints/ai/ai.module.ts b/apps/api/src/app/endpoints/ai/ai.module.ts index 5267f40c8..d5cf0e3e9 100644 --- a/apps/api/src/app/endpoints/ai/ai.module.ts +++ b/apps/api/src/app/endpoints/ai/ai.module.ts @@ -20,6 +20,7 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; +import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { Module } from '@nestjs/common'; @@ -44,6 +45,7 @@ import { AiService } from './ai.service'; PropertyModule, RedisCacheModule, SymbolProfileModule, + TagModule, UserModule ], providers: [ diff --git a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts new file mode 100644 index 000000000..5ffb756a0 --- /dev/null +++ b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts @@ -0,0 +1,236 @@ +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/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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { + CreateAssetProfileSplitDto, + UpdateAssetProfileDataDto +} from '@ghostfolio/common/dtos'; +import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; +import { AssetProfileResponse } from '@ghostfolio/common/interfaces'; +import { + AssetProfilesResponse, + EnhancedAssetProfile +} from '@ghostfolio/common/interfaces'; +import { hasPermission } from '@ghostfolio/common/permissions'; +import { permissions } from '@ghostfolio/common/permissions'; +import { MarketDataPreset, RequestWithUser } from '@ghostfolio/common/types'; + +import { + Body, + Controller, + Delete, + Get, + HttpException, + Inject, + Param, + ParseIntPipe, + Patch, + Post, + Query, + UseGuards, + UseInterceptors +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { AssetProfileSplit, DataSource, Prisma } from '@prisma/client'; +import { parseISO } from 'date-fns'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +import { AssetProfilesService } from './asset-profiles.service'; + +@Controller('asset-profiles') +export class AssetProfilesController { + public constructor( + private readonly apiService: ApiService, + private readonly assetProfilesService: AssetProfilesService, + @Inject(REQUEST) private readonly request: RequestWithUser, + private readonly symbolProfileService: SymbolProfileService + ) {} + + @Get() + @HasPermission(permissions.accessAdminControl) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getAssetProfiles( + @Query('assetSubClasses') filterByAssetSubClasses?: string, + @Query('dataSource') filterByDataSource?: string, + @Query('presetId') presetId?: MarketDataPreset, + @Query('query') filterBySearchQuery?: string, + @Query('skip', new ParseIntPipe({ optional: true })) skip?: number, + @Query('sortColumn') sortColumn?: string, + @Query('sortDirection') sortDirection?: Prisma.SortOrder, + @Query('take', new ParseIntPipe({ optional: true })) take?: number + ): Promise { + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAssetSubClasses, + filterByDataSource, + filterBySearchQuery + }); + + return this.assetProfilesService.getAssetProfiles({ + filters, + presetId, + skip, + sortColumn, + sortDirection, + take + }); + } + + @Get(':dataSource/:symbol') + @UseGuards(AuthGuard('jwt')) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async getAssetProfile( + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ + { dataSource, symbol } + ]); + + if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + const canReadAllAssetProfiles = hasPermission( + this.request.user.permissions, + permissions.readMarketData + ); + + const canReadOwnAssetProfile = + assetProfile?.userId === this.request.user.id && + hasPermission( + this.request.user.permissions, + permissions.readMarketDataOfOwnAssetProfile + ); + + if (!canReadAllAssetProfiles && !canReadOwnAssetProfile) { + throw new HttpException( + assetProfile?.userId + ? getReasonPhrase(StatusCodes.NOT_FOUND) + : getReasonPhrase(StatusCodes.FORBIDDEN), + assetProfile?.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN + ); + } + + return this.assetProfilesService.getAssetProfile({ + dataSource, + symbol + }); + } + + @Post(':dataSource/:symbol/splits') + @UseGuards(AuthGuard('jwt')) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + public async createSplit( + @Body() data: CreateAssetProfileSplitDto, + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + const { id: symbolProfileId } = await this.validateAccessToSplits({ + dataSource, + symbol, + permission: permissions.createAssetProfileSplit, + permissionOfOwnAssetProfile: + permissions.createAssetProfileSplitOfOwnAssetProfile + }); + + return this.assetProfilesService.createSplit({ + dataSource, + symbol, + symbolProfileId, + date: parseISO(data.date), + denominator: data.denominator, + numerator: data.numerator + }); + } + + @Delete(':dataSource/:symbol/splits/:id') + @UseGuards(AuthGuard('jwt')) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + public async deleteSplit( + @Param('dataSource') dataSource: DataSource, + @Param('id') id: string, + @Param('symbol') symbol: string + ): Promise { + const { id: symbolProfileId } = await this.validateAccessToSplits({ + dataSource, + symbol, + permission: permissions.deleteAssetProfileSplit, + permissionOfOwnAssetProfile: + permissions.deleteAssetProfileSplitOfOwnAssetProfile + }); + + return this.assetProfilesService.deleteSplit({ id, symbolProfileId }); + } + + @HasPermission(permissions.accessAdminControl) + @Patch(':dataSource/:symbol') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async updateAssetProfileData( + @Body() assetProfileData: UpdateAssetProfileDataDto, + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + if (!this.request.user.settings.settings.isExperimentalFeatures) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + return this.assetProfilesService.updateAssetProfileData( + { dataSource, symbol }, + assetProfileData + ); + } + + private async validateAccessToSplits({ + dataSource, + permission, + permissionOfOwnAssetProfile, + symbol + }: { + dataSource: DataSource; + permission: string; + permissionOfOwnAssetProfile: string; + symbol: string; + }) { + const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ + { dataSource, symbol } + ]); + + if (!assetProfile) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + const canAccessAllAssetProfiles = hasPermission( + this.request.user.permissions, + permission + ); + + const canAccessOwnAssetProfile = + assetProfile.userId === this.request.user.id && + hasPermission(this.request.user.permissions, permissionOfOwnAssetProfile); + + if (!canAccessAllAssetProfiles && !canAccessOwnAssetProfile) { + throw new HttpException( + assetProfile.userId + ? getReasonPhrase(StatusCodes.NOT_FOUND) + : getReasonPhrase(StatusCodes.FORBIDDEN), + assetProfile.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN + ); + } + + return assetProfile; + } +} diff --git a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts new file mode 100644 index 000000000..9df342f5f --- /dev/null +++ b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts @@ -0,0 +1,38 @@ +import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.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 { AssetProfileSplitModule } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.module'; +import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; +import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; + +import { Module } from '@nestjs/common'; + +import { AssetProfilesController } from './asset-profiles.controller'; +import { AssetProfilesService } from './asset-profiles.service'; + +@Module({ + controllers: [AssetProfilesController], + exports: [AssetProfilesService], + imports: [ + ActivitiesModule, + ApiModule, + AssetProfileSplitModule, + BenchmarkModule, + DataGatheringQueueModule, + DataProviderModule, + ExchangeRateDataModule, + MarketDataModule, + PrismaModule, + SymbolProfileModule, + TransformDataSourceInRequestModule, + TransformDataSourceInResponseModule + ], + providers: [AssetProfilesService] +}) +export class AssetProfilesModule {} diff --git a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts new file mode 100644 index 000000000..0d50e2223 --- /dev/null +++ b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts @@ -0,0 +1,587 @@ +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; +import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; +import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.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 { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos'; +import { + applyAssetProfileOverrides, + getAssetProfileIdentifier, + getCurrencyFromSymbol, + isCurrency +} from '@ghostfolio/common/helper'; +import { + AdminMarketDataDetails, + AssetProfileIdentifier, + AssetProfileItem, + AssetProfilesResponse, + EnhancedAssetProfile, + Filter +} from '@ghostfolio/common/interfaces'; +import { MarketDataPreset } from '@ghostfolio/common/types'; + +import { Injectable, NotFoundException } from '@nestjs/common'; +import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client'; +import { groupBy } from 'lodash'; + +@Injectable() +export class AssetProfilesService { + public constructor( + private readonly activitiesService: ActivitiesService, + private readonly assetProfileSplitService: AssetProfileSplitService, + private readonly benchmarkService: BenchmarkService, + private readonly dataGatheringService: DataGatheringService, + private readonly dataProviderService: DataProviderService, + private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly marketDataService: MarketDataService, + private readonly prismaService: PrismaService, + private readonly symbolProfileService: SymbolProfileService + ) {} + + public async createSplit({ + dataSource, + date, + denominator, + numerator, + symbol, + symbolProfileId + }: { + date: Date; + denominator: number; + numerator: number; + symbolProfileId: string; + } & AssetProfileIdentifier) { + const assetProfileSplit = await this.assetProfileSplitService.upsert({ + date, + denominator, + numerator, + symbolProfileId + }); + + await this.dataGatheringService.gatherSymbol({ dataSource, symbol }); + + return assetProfileSplit; + } + + public async deleteSplit({ + id, + symbolProfileId + }: { + id: string; + symbolProfileId: string; + }) { + const isDeleted = await this.assetProfileSplitService.deleteById({ + id, + symbolProfileId + }); + + if (!isDeleted) { + throw new NotFoundException(); + } + } + + public async getAssetProfile({ + dataSource, + symbol + }: AssetProfileIdentifier): Promise { + let activitiesCount: EnhancedAssetProfile['activitiesCount'] = 0; + let currency: EnhancedAssetProfile['currency'] = '-'; + let dateOfFirstActivity: EnhancedAssetProfile['dateOfFirstActivity']; + + const isCurrencyAssetProfile = isCurrency(getCurrencyFromSymbol(symbol)); + + if (isCurrencyAssetProfile) { + currency = getCurrencyFromSymbol(symbol); + ({ activitiesCount, dateOfFirstActivity } = + await this.activitiesService.getStatisticsByCurrency(currency)); + } + + const [[assetProfile], marketData, splits] = await Promise.all([ + this.symbolProfileService.getSymbolProfiles([ + { + dataSource, + symbol + } + ]), + this.marketDataService.marketDataItems({ + orderBy: { + date: 'asc' + }, + where: { + dataSource, + symbol + } + }), + this.assetProfileSplitService.getSplits({ dataSource, symbol }) + ]); + + if (assetProfile) { + assetProfile.dataProviderInfo = this.dataProviderService + .getDataProvider(assetProfile.dataSource) + .getDataProviderInfo(); + } + + return { + marketData, + splits, + assetProfile: assetProfile ?? { + activitiesCount, + currency, + dataSource, + dateOfFirstActivity, + symbol, + assetClass: isCurrencyAssetProfile ? AssetClass.LIQUIDITY : undefined, + assetSubClass: isCurrencyAssetProfile ? AssetSubClass.CASH : undefined, + isActive: true + } + }; + } + + public async getAssetProfiles({ + filters = [], + presetId, + sortColumn, + sortDirection = 'asc', + skip, + take = Number.MAX_SAFE_INTEGER + }: { + filters?: Filter[]; + presetId?: MarketDataPreset; + skip?: number; + sortColumn?: string; + sortDirection?: Prisma.SortOrder; + take?: number; + }): Promise { + let orderBy: Prisma.Enumerable = + [{ symbol: 'asc' }]; + const where: Prisma.SymbolProfileWhereInput = {}; + + if (presetId === 'BENCHMARKS') { + const benchmarkAssetProfiles = + await this.benchmarkService.getBenchmarkAssetProfiles(); + + where.id = { + in: benchmarkAssetProfiles.map(({ id }) => { + return id; + }) + }; + } else if (presetId === 'CURRENCIES') { + return this.getAssetProfilesForCurrencies(); + } else if ( + presetId === 'ETF_WITHOUT_COUNTRIES' || + presetId === 'ETF_WITHOUT_SECTORS' + ) { + filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }]; + } else if (presetId === 'NO_ACTIVITIES') { + where.activities = { + none: {} + }; + } + + const { + ASSET_SUB_CLASS: [filterByAssetSubClass] = [], + DATA_SOURCE: [filterByDataSource] = [], + SEARCH_QUERY: [filterBySearchQuery] = [] + } = groupBy(filters, ({ type }) => { + return type; + }); + + const marketDataItems = await this.prismaService.marketData.groupBy({ + _count: true, + by: ['dataSource', 'symbol'] + }); + + if (filterByAssetSubClass) { + where.assetSubClass = AssetSubClass[filterByAssetSubClass.id]; + } + + if (filterByDataSource) { + where.dataSource = DataSource[filterByDataSource.id]; + } + + if (filterBySearchQuery) { + where.OR = [ + { id: { mode: 'insensitive', startsWith: filterBySearchQuery.id } }, + { isin: { mode: 'insensitive', startsWith: filterBySearchQuery.id } }, + { name: { mode: 'insensitive', startsWith: filterBySearchQuery.id } }, + { symbol: { mode: 'insensitive', startsWith: filterBySearchQuery.id } } + ]; + } + + if (sortColumn) { + orderBy = [{ [sortColumn]: sortDirection }]; + + if (sortColumn === 'activitiesCount') { + orderBy = [ + { + activities: { + _count: sortDirection + } + } + ]; + } + } + + const extendedPrismaClient = this.getExtendedPrismaClient(); + + const symbolProfileResult = await Promise.all([ + extendedPrismaClient.symbolProfile.findMany({ + skip, + take, + where, + orderBy: [...orderBy, { id: sortDirection }], + select: { + _count: { + select: { + activities: true, + watchedBy: true + } + }, + activities: { + orderBy: [{ date: 'asc' }], + select: { date: true }, + take: 1 + }, + assetClass: true, + assetProfileOverrides: true, + assetSubClass: true, + comment: true, + countries: true, + currency: true, + dataSource: true, + id: true, + isin: true, + isActive: true, + isUsedByUsersWithSubscription: true, + name: true, + scraperConfiguration: true, + sectors: true, + symbol: true + } + }), + this.prismaService.symbolProfile.count({ where }) + ]); + const symbolProfiles = symbolProfileResult[0]; + let count = symbolProfileResult[1]; + + const lastMarketPrices = await this.prismaService.marketData.findMany({ + distinct: ['dataSource', 'symbol'], + orderBy: { date: 'desc' }, + select: { + dataSource: true, + marketPrice: true, + symbol: true + }, + where: { + dataSource: { + in: symbolProfiles.map(({ dataSource }) => { + return dataSource; + }) + }, + symbol: { + in: symbolProfiles.map(({ symbol }) => { + return symbol; + }) + } + } + }); + + const lastMarketPriceMap = new Map(); + + for (const { dataSource, marketPrice, symbol } of lastMarketPrices) { + lastMarketPriceMap.set( + getAssetProfileIdentifier({ dataSource, symbol }), + marketPrice + ); + } + + let assetProfiles: AssetProfileItem[] = await Promise.all( + symbolProfiles.map(async (assetProfile) => { + const { + _count, + activities, + comment, + currency, + dataSource, + id, + isin, + isActive, + isUsedByUsersWithSubscription, + symbol + } = assetProfile; + + const { assetClass, assetSubClass, countries, name, sectors } = + applyAssetProfileOverrides( + assetProfile, + assetProfile.assetProfileOverrides + ); + + const countriesCount = countries ? Object.keys(countries).length : 0; + + const lastMarketPrice = lastMarketPriceMap.get( + getAssetProfileIdentifier({ dataSource, symbol }) + ); + + 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, + countriesCount, + currency, + dataSource, + id, + isActive, + isin, + lastMarketPrice, + marketDataItemCount, + name, + sectorsCount, + symbol, + activitiesCount: _count.activities, + date: activities?.[0]?.date, + isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription, + watchedByCount: _count.watchedBy + }; + }) + ); + + if (presetId) { + if (presetId === 'ETF_WITHOUT_COUNTRIES') { + assetProfiles = assetProfiles.filter(({ countriesCount }) => { + return countriesCount === 0; + }); + } else if (presetId === 'ETF_WITHOUT_SECTORS') { + assetProfiles = assetProfiles.filter(({ sectorsCount }) => { + return sectorsCount === 0; + }); + } + + count = assetProfiles.length; + } + + return { + assetProfiles, + count + }; + } + + public async updateAssetProfileData( + { dataSource, symbol }: AssetProfileIdentifier, + assetProfileData: UpdateAssetProfileDataDto + ): Promise { + const notFoundMessage = `Could not find the asset profile for ${symbol} (${dataSource})`; + + const data = this.getAssetProfileDataUpdate(assetProfileData); + + if (Object.keys(data).length > 0) { + try { + await this.symbolProfileService.updateSymbolProfile( + { + dataSource, + symbol + }, + this.symbolProfileService.getAssetProfileUpdateInput( + { dataSource, symbol }, + data + ) + ); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2025' + ) { + throw new NotFoundException(notFoundMessage); + } + + throw error; + } + } + + const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ + { + dataSource, + symbol + } + ]); + + if (!assetProfile) { + throw new NotFoundException(notFoundMessage); + } + + return assetProfile; + } + + private getAssetProfileDataUpdate({ + countries, + holdings, + sectors + }: UpdateAssetProfileDataDto): Pick< + Prisma.SymbolProfileUpdateInput, + 'countries' | 'holdings' | 'sectors' + > { + const data: Pick< + Prisma.SymbolProfileUpdateInput, + 'countries' | 'holdings' | 'sectors' + > = {}; + + if (countries !== undefined) { + data.countries = countries as Prisma.JsonArray; + } + + if (holdings !== undefined) { + data.holdings = holdings as Prisma.JsonArray; + } + + if (sectors !== undefined) { + data.sectors = sectors as Prisma.JsonArray; + } + + return data; + } + + private async getAssetProfilesForCurrencies(): Promise { + const currencyPairs = this.exchangeRateDataService.getCurrencyPairs(); + + const [lastMarketPrices, marketDataItems] = await Promise.all([ + this.prismaService.marketData.findMany({ + distinct: ['dataSource', 'symbol'], + orderBy: { date: 'desc' }, + select: { + dataSource: true, + marketPrice: true, + symbol: true + }, + where: { + dataSource: { + in: currencyPairs.map(({ dataSource }) => { + return dataSource; + }) + }, + symbol: { + in: currencyPairs.map(({ symbol }) => { + return symbol; + }) + } + } + }), + this.prismaService.marketData.groupBy({ + _count: true, + by: ['dataSource', 'symbol'] + }) + ]); + + const lastMarketPriceMap = new Map(); + + for (const { dataSource, marketPrice, symbol } of lastMarketPrices) { + lastMarketPriceMap.set( + getAssetProfileIdentifier({ dataSource, symbol }), + marketPrice + ); + } + + const assetProfilePromises: Promise[] = currencyPairs.map( + async ({ dataSource, symbol }) => { + let activitiesCount: EnhancedAssetProfile['activitiesCount'] = 0; + let currency: EnhancedAssetProfile['currency'] = '-'; + let dateOfFirstActivity: EnhancedAssetProfile['dateOfFirstActivity']; + + if (isCurrency(getCurrencyFromSymbol(symbol))) { + currency = getCurrencyFromSymbol(symbol); + ({ activitiesCount, dateOfFirstActivity } = + await this.activitiesService.getStatisticsByCurrency(currency)); + } + + const lastMarketPrice = lastMarketPriceMap.get( + getAssetProfileIdentifier({ dataSource, symbol }) + ); + + const marketDataItemCount = + marketDataItems.find((marketDataItem) => { + return ( + marketDataItem.dataSource === dataSource && + marketDataItem.symbol === symbol + ); + })?._count ?? 0; + + return { + activitiesCount, + currency, + dataSource, + lastMarketPrice, + marketDataItemCount, + symbol, + assetClass: AssetClass.LIQUIDITY, + assetSubClass: AssetSubClass.CASH, + countriesCount: 0, + date: dateOfFirstActivity, + id: undefined, + isActive: true, + name: symbol, + sectorsCount: 0, + watchedByCount: 0 + }; + } + ); + + const assetProfiles = await Promise.all(assetProfilePromises); + return { assetProfiles, count: assetProfiles.length }; + } + + private getExtendedPrismaClient() { + const symbolProfileExtension = Prisma.defineExtension((client) => { + return client.$extends({ + result: { + symbolProfile: { + isUsedByUsersWithSubscription: { + compute: async ({ id }) => { + const { _count } = + await this.prismaService.symbolProfile.findUnique({ + select: { + _count: { + select: { + activities: { + where: { + user: { + subscriptions: { + some: { + expiresAt: { + gt: new Date() + } + } + } + } + } + } + } + } + }, + where: { + id + } + }); + + return _count.activities > 0; + } + } + } + } + }); + }); + + return this.prismaService.$extends(symbolProfileExtension); + } +} diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts index 970925777..53df9bd92 100644 --- a/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts @@ -12,7 +12,7 @@ import type { BenchmarkResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; -import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; +import type { RequestWithUser } from '@ghostfolio/common/types'; import { Body, @@ -34,6 +34,7 @@ import { DataSource } from '@prisma/client'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { BenchmarksService } from './benchmarks.service'; +import { GetBenchmarkMarketDataDto } from './get-benchmark-market-data.dto'; @Controller('benchmarks') export class BenchmarksController { @@ -118,38 +119,39 @@ export class BenchmarksController { @Param('dataSource') dataSource: DataSource, @Param('startDateString') startDateString: string, @Param('symbol') symbol: string, - @Query('range') dateRange: DateRange = 'max', - @Query('accounts') filterByAccounts?: string, - @Query('assetClasses') filterByAssetClasses?: string, - @Query('dataSource') filterByDataSource?: string, - @Query('symbol') filterBySymbol?: string, - @Query('tags') filterByTags?: string, - @Query('withExcludedAccounts') withExcludedAccountsParam = 'false' + @Query() + { + accounts, + assetClasses, + dataSource: filterByDataSource, + range, + symbol: filterBySymbol, + tags, + withExcludedAccounts + }: GetBenchmarkMarketDataDto ): Promise { const { endDate, startDate } = getIntervalFromDateRange({ - dateRange, + dateRange: range, startDate: new Date(startDateString) }); const filters = this.apiService.buildFiltersFromQueryParams({ - filterByAccounts, - filterByAssetClasses, filterByDataSource, filterBySymbol, - filterByTags + filterByAccounts: accounts, + filterByAssetClasses: assetClasses, + filterByTags: tags }); - const withExcludedAccounts = withExcludedAccountsParam === 'true'; - return this.benchmarksService.getMarketDataForUser({ dataSource, - dateRange, endDate, filters, impersonationId, startDate, symbol, withExcludedAccounts, + dateRange: range, user: this.request.user }); } diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts index 2bcd6177d..3c540c337 100644 --- a/apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts @@ -23,6 +23,7 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; +import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { Module } from '@nestjs/common'; @@ -46,6 +47,7 @@ import { BenchmarksService } from './benchmarks.service'; RedisCacheModule, SymbolModule, SymbolProfileModule, + TagModule, TransformDataSourceInRequestModule, TransformDataSourceInResponseModule, UserModule diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts index 03ff32c21..1fe42ab0d 100644 --- a/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts @@ -17,6 +17,8 @@ import { isNumber } from 'lodash'; @Injectable() export class BenchmarksService { + private readonly logger = new Logger(BenchmarksService.name); + public constructor( private readonly benchmarkService: BenchmarkService, private readonly exchangeRateDataService: ExchangeRateDataService, @@ -79,6 +81,14 @@ export class BenchmarksService { }) ]); + if (!currentSymbolItem) { + this.logger.error( + `No current market price is available for ${symbol} (${dataSource})` + ); + + return { marketData }; + } + const exchangeRates = await this.exchangeRateDataService.getExchangeRatesByCurrency({ startDate, @@ -96,12 +106,11 @@ export class BenchmarksService { })?.marketPrice; if (!marketPriceAtStartDate) { - Logger.error( + this.logger.error( `No historical market data has been found for ${symbol} (${dataSource}) at ${format( startDate, DATE_FORMAT - )}`, - 'BenchmarkService' + )}` ); return { marketData }; diff --git a/apps/api/src/app/endpoints/benchmarks/get-benchmark-market-data.dto.ts b/apps/api/src/app/endpoints/benchmarks/get-benchmark-market-data.dto.ts new file mode 100644 index 000000000..9599ade59 --- /dev/null +++ b/apps/api/src/app/endpoints/benchmarks/get-benchmark-market-data.dto.ts @@ -0,0 +1,12 @@ +import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto'; + +import { Transform, TransformFnParams } from 'class-transformer'; +import { IsBoolean } from 'class-validator'; + +export class GetBenchmarkMarketDataDto extends DateRangeFilterDto { + @IsBoolean() + @Transform(({ value }: TransformFnParams) => { + return value === 'true'; + }) + withExcludedAccounts? = false; +} diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts index 04165e9a1..0cdca8110 100644 --- a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts +++ b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts @@ -8,6 +8,7 @@ import { DividendsResponse, HistoricalResponse, LookupResponse, + MarketDataOfMarketsResponse, QuotesResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; @@ -19,6 +20,7 @@ import { HttpException, Inject, Param, + ParseIntPipe, Query, UseGuards, Version @@ -49,7 +51,7 @@ export class GhostfolioController { const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); if ( - this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests + this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests ) { throw new HttpException( getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), @@ -88,12 +90,12 @@ export class GhostfolioController { @Version('2') public async getDividends( @Param('symbol') symbol: string, - @Query() query: GetDividendsDto + @Query() { from, granularity, to }: GetDividendsDto ): Promise { const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); if ( - this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests + this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests ) { throw new HttpException( getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), @@ -103,10 +105,10 @@ export class GhostfolioController { try { const dividends = await this.ghostfolioService.getDividends({ + granularity, symbol, - from: parseDate(query.from), - granularity: query.granularity, - to: parseDate(query.to) + from: parseDate(from), + to: parseDate(to) }); await this.ghostfolioService.incrementDailyRequests({ @@ -128,12 +130,12 @@ export class GhostfolioController { @Version('2') public async getHistorical( @Param('symbol') symbol: string, - @Query() query: GetHistoricalDto + @Query() { from, granularity, to }: GetHistoricalDto ): Promise { const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); if ( - this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests + this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests ) { throw new HttpException( getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), @@ -143,10 +145,10 @@ export class GhostfolioController { try { const historicalData = await this.ghostfolioService.getHistorical({ + granularity, symbol, - from: parseDate(query.from), - granularity: query.granularity, - to: parseDate(query.to) + from: parseDate(from), + to: parseDate(to) }); await this.ghostfolioService.incrementDailyRequests({ @@ -174,7 +176,7 @@ export class GhostfolioController { const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); if ( - this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests + this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests ) { throw new HttpException( getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), @@ -203,17 +205,54 @@ export class GhostfolioController { } } + @Get('markets') + @HasPermission(permissions.enableDataProviderGhostfolio) + @UseGuards(AuthGuard('api-key'), HasPermissionGuard) + public async getMarketDataOfMarkets( + @Query('includeHistoricalData', new ParseIntPipe({ optional: true })) + includeHistoricalData = 0 + ): Promise { + const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); + + if ( + this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + StatusCodes.TOO_MANY_REQUESTS + ); + } + + try { + const marketDataOfMarkets = + await this.ghostfolioService.getMarketDataOfMarkets({ + includeHistoricalData + }); + + await this.ghostfolioService.incrementDailyRequests({ + userId: this.request.user.id + }); + + return marketDataOfMarkets; + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), + StatusCodes.INTERNAL_SERVER_ERROR + ); + } + } + @Get('quotes') @HasPermission(permissions.enableDataProviderGhostfolio) @UseGuards(AuthGuard('api-key'), HasPermissionGuard) @Version('2') public async getQuotes( - @Query() query: GetQuotesDto + @Query() { symbols }: GetQuotesDto ): Promise { const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); if ( - this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests + this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests ) { throw new HttpException( getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), @@ -223,7 +262,7 @@ export class GhostfolioController { try { const quotes = await this.ghostfolioService.getQuotes({ - symbols: query.symbols + symbols }); await this.ghostfolioService.incrementDailyRequests({ diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts index 01691bcf4..1b8788ecb 100644 --- a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts +++ b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts @@ -1,4 +1,5 @@ import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; +import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module'; import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; @@ -12,6 +13,7 @@ import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/goog import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service'; import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; +import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; @@ -27,10 +29,12 @@ import { GhostfolioService } from './ghostfolio.service'; imports: [ CryptocurrencyModule, DataProviderModule, + FetchModule, MarketDataModule, PrismaModule, PropertyModule, RedisCacheModule, + SymbolModule, SymbolProfileModule ], providers: [ diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts index d088bf3ac..b858688c2 100644 --- a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts +++ b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts @@ -1,3 +1,4 @@ +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 { GhostfolioService as GhostfolioDataProviderService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service'; @@ -8,6 +9,7 @@ import { GetQuotesParams, GetSearchParams } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { @@ -15,6 +17,10 @@ import { DERIVED_CURRENCIES } from '@ghostfolio/common/config'; import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config'; +import { + getAssetProfileIdentifier, + isValidSearchQuery +} from '@ghostfolio/common/helper'; import { DataProviderGhostfolioAssetProfileResponse, DataProviderHistoricalResponse, @@ -23,6 +29,7 @@ import { HistoricalResponse, LookupItem, LookupResponse, + MarketDataOfMarketsResponse, QuotesResponse } from '@ghostfolio/common/interfaces'; import { UserWithSettings } from '@ghostfolio/common/types'; @@ -30,14 +37,19 @@ import { UserWithSettings } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; import { DataSource, SymbolProfile } from '@prisma/client'; import { Big } from 'big.js'; +import { isEmpty } from 'lodash'; @Injectable() export class GhostfolioService { + private readonly logger = new Logger(GhostfolioService.name); + public constructor( private readonly configurationService: ConfigurationService, private readonly dataProviderService: DataProviderService, + private readonly fetchService: FetchService, private readonly prismaService: PrismaService, - private readonly propertyService: PropertyService + private readonly propertyService: PropertyService, + private readonly symbolService: SymbolService ) {} public async getAssetProfile({ symbol }: GetAssetProfileParams) { @@ -56,7 +68,13 @@ export class GhostfolioService { } ]) .then(async (assetProfiles) => { - const assetProfile = assetProfiles[symbol]; + const assetProfile = + assetProfiles[ + getAssetProfileIdentifier({ + symbol, + dataSource: dataProviderService.getName() + }) + ]; const dataSourceOrigin = DataSource.GHOSTFOLIO; if (assetProfile) { @@ -97,7 +115,7 @@ export class GhostfolioService { return result; } catch (error) { - Logger.error(error, 'GhostfolioService'); + this.logger.error(error); throw error; } @@ -139,7 +157,7 @@ export class GhostfolioService { return result; } catch (error) { - Logger.error(error, 'GhostfolioService'); + this.logger.error(error); throw error; } @@ -156,7 +174,7 @@ export class GhostfolioService { try { const promises: Promise<{ - [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + [date: string]: DataProviderHistoricalResponse; }>[] = []; for (const dataProviderService of this.getDataProviderServices()) { @@ -170,7 +188,7 @@ export class GhostfolioService { to }) .then((historicalData) => { - result.historicalData = historicalData[symbol]; + result.historicalData = historicalData; return historicalData; }) @@ -181,7 +199,34 @@ export class GhostfolioService { return result; } catch (error) { - Logger.error(error, 'GhostfolioService'); + this.logger.error(error); + + throw error; + } + } + + public async getMarketDataOfMarkets({ + includeHistoricalData + }: { + includeHistoricalData: number; + }): Promise { + try { + const marketDataOfMarkets = + await this.symbolService.getMarketDataOfMarkets({ + includeHistoricalData + }); + + for (const symbolItem of Object.values( + marketDataOfMarkets.fearAndGreedIndex + )) { + if (!isEmpty(symbolItem)) { + symbolItem.dataSource = DataSource.GHOSTFOLIO; + } + } + + return marketDataOfMarkets; + } catch (error) { + this.logger.error(error); throw error; } @@ -269,7 +314,7 @@ export class GhostfolioService { return results; } catch (error) { - Logger.error(error, 'GhostfolioService'); + this.logger.error(error); throw error; } @@ -284,8 +329,12 @@ export class GhostfolioService { } public async incrementDailyRequests({ userId }: { userId: string }) { - await this.prismaService.analytics.update({ - data: { + await this.prismaService.analytics.upsert({ + create: { + dataProviderGhostfolioDailyRequests: 1, + user: { connect: { id: userId } } + }, + update: { dataProviderGhostfolioDailyRequests: { increment: 1 } }, where: { userId } @@ -298,7 +347,9 @@ export class GhostfolioService { }: GetSearchParams): Promise { const results: LookupResponse = { items: [] }; - if (!query) { + query = query?.trim(); + + if (!isValidSearchQuery(query)) { return results; } @@ -306,10 +357,6 @@ export class GhostfolioService { let lookupItems: LookupItem[] = []; const promises: Promise<{ items: LookupItem[] }>[] = []; - if (query?.length < 2) { - return { items: lookupItems }; - } - for (const dataProviderService of this.getDataProviderServices()) { promises.push( dataProviderService.search({ @@ -346,7 +393,7 @@ export class GhostfolioService { return results; } catch (error) { - Logger.error(error, 'GhostfolioService'); + this.logger.error(error); throw error; } @@ -355,6 +402,7 @@ export class GhostfolioService { private getDataProviderInfo(): DataProviderInfo { const ghostfolioDataProviderService = new GhostfolioDataProviderService( this.configurationService, + this.fetchService, this.propertyService ); diff --git a/apps/api/src/app/endpoints/market-data/market-data.controller.ts b/apps/api/src/app/endpoints/market-data/market-data.controller.ts index 0dae82d2c..03d50c284 100644 --- a/apps/api/src/app/endpoints/market-data/market-data.controller.ts +++ b/apps/api/src/app/endpoints/market-data/market-data.controller.ts @@ -1,23 +1,11 @@ -import { AdminService } from '@ghostfolio/api/app/admin/admin.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; 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/transform-data-source-in-request.interceptor'; -import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; -import { - ghostfolioFearAndGreedIndexDataSourceCryptocurrencies, - ghostfolioFearAndGreedIndexDataSourceStocks, - ghostfolioFearAndGreedIndexSymbolCryptocurrencies, - ghostfolioFearAndGreedIndexSymbolStocks -} from '@ghostfolio/common/config'; import { UpdateBulkMarketDataDto } from '@ghostfolio/common/dtos'; import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; -import { - MarketDataDetailsResponse, - MarketDataOfMarketsResponse -} from '@ghostfolio/common/interfaces'; +import { MarketDataOfMarketsResponse } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { RequestWithUser } from '@ghostfolio/common/types'; @@ -28,10 +16,10 @@ import { HttpException, Inject, Param, + ParseIntPipe, Post, Query, - UseGuards, - UseInterceptors + UseGuards } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; @@ -42,7 +30,6 @@ import { getReasonPhrase, StatusCodes } from 'http-status-codes'; @Controller('market-data') export class MarketDataController { public constructor( - private readonly adminService: AdminService, private readonly marketDataService: MarketDataService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly symbolProfileService: SymbolProfileService, @@ -53,81 +40,12 @@ export class MarketDataController { @HasPermission(permissions.readMarketDataOfMarkets) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async getMarketDataOfMarkets( - @Query('includeHistoricalData') includeHistoricalData = 0 + @Query('includeHistoricalData', new ParseIntPipe({ optional: true })) + includeHistoricalData = 0 ): Promise { - const [ - marketDataFearAndGreedIndexCryptocurrencies, - marketDataFearAndGreedIndexStocks - ] = await Promise.all([ - this.symbolService.get({ - includeHistoricalData, - dataGatheringItem: { - dataSource: ghostfolioFearAndGreedIndexDataSourceCryptocurrencies, - symbol: ghostfolioFearAndGreedIndexSymbolCryptocurrencies - } - }), - this.symbolService.get({ - includeHistoricalData, - dataGatheringItem: { - dataSource: ghostfolioFearAndGreedIndexDataSourceStocks, - symbol: ghostfolioFearAndGreedIndexSymbolStocks - } - }) - ]); - - return { - fearAndGreedIndex: { - CRYPTOCURRENCIES: { - ...marketDataFearAndGreedIndexCryptocurrencies - }, - STOCKS: { - ...marketDataFearAndGreedIndexStocks - } - } - }; - } - - @Get(':dataSource/:symbol') - @UseGuards(AuthGuard('jwt')) - @UseInterceptors(TransformDataSourceInRequestInterceptor) - @UseInterceptors(TransformDataSourceInResponseInterceptor) - public async getMarketDataBySymbol( - @Param('dataSource') dataSource: DataSource, - @Param('symbol') symbol: string - ): Promise { - const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ - { dataSource, symbol } - ]); - - if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) { - throw new HttpException( - getReasonPhrase(StatusCodes.NOT_FOUND), - StatusCodes.NOT_FOUND - ); - } - - const canReadAllAssetProfiles = hasPermission( - this.request.user.permissions, - permissions.readMarketData - ); - - const canReadOwnAssetProfile = - assetProfile?.userId === this.request.user.id && - hasPermission( - this.request.user.permissions, - permissions.readMarketDataOfOwnAssetProfile - ); - - if (!canReadAllAssetProfiles && !canReadOwnAssetProfile) { - throw new HttpException( - assetProfile.userId - ? getReasonPhrase(StatusCodes.NOT_FOUND) - : getReasonPhrase(StatusCodes.FORBIDDEN), - assetProfile.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN - ); - } - - return this.adminService.getMarketDataBySymbol({ dataSource, symbol }); + return this.symbolService.getMarketDataOfMarkets({ + includeHistoricalData + }); } @Post(':dataSource/:symbol') diff --git a/apps/api/src/app/endpoints/market-data/market-data.module.ts b/apps/api/src/app/endpoints/market-data/market-data.module.ts index d5d64673d..1de10907b 100644 --- a/apps/api/src/app/endpoints/market-data/market-data.module.ts +++ b/apps/api/src/app/endpoints/market-data/market-data.module.ts @@ -1,7 +1,4 @@ -import { AdminModule } from '@ghostfolio/api/app/admin/admin.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 { MarketDataModule as MarketDataServiceModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; @@ -11,13 +8,6 @@ import { MarketDataController } from './market-data.controller'; @Module({ controllers: [MarketDataController], - imports: [ - AdminModule, - MarketDataServiceModule, - SymbolModule, - SymbolProfileModule, - TransformDataSourceInRequestModule, - TransformDataSourceInResponseModule - ] + imports: [MarketDataServiceModule, SymbolModule, SymbolProfileModule] }) export class MarketDataModule {} diff --git a/apps/api/src/app/endpoints/public/public.controller.ts b/apps/api/src/app/endpoints/public/public.controller.ts index 1d6eb6b0b..67bed71ef 100644 --- a/apps/api/src/app/endpoints/public/public.controller.ts +++ b/apps/api/src/app/endpoints/public/public.controller.ts @@ -9,19 +9,23 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { SubscriptionType } from '@ghostfolio/common/enums'; import { getSum } from '@ghostfolio/common/helper'; -import { PublicPortfolioResponse } from '@ghostfolio/common/interfaces'; -import type { RequestWithUser } from '@ghostfolio/common/types'; +import { + AccessSettings, + PublicPortfolioResponse +} from '@ghostfolio/common/interfaces'; import { Controller, Get, HttpException, - Inject, Param, UseInterceptors } from '@nestjs/common'; -import { REQUEST } from '@nestjs/core'; -import { Type as ActivityType } from '@prisma/client'; +import { + AssetClass, + AssetSubClass, + Type as ActivityType +} from '@prisma/client'; import { Big } from 'big.js'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; @@ -33,7 +37,6 @@ export class PublicController { private readonly configurationService: ConfigurationService, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly portfolioService: PortfolioService, - @Inject(REQUEST) private readonly request: RequestWithUser, private readonly userService: UserService ) {} @@ -43,7 +46,10 @@ export class PublicController { public async getPublicPortfolio( @Param('accessId') accessId: string ): Promise { - const access = await this.accessService.access({ id: accessId }); + const access = await this.accessService.access({ + granteeUserId: null, + id: accessId + }); if (!access) { throw new HttpException( @@ -59,9 +65,11 @@ export class PublicController { }); if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { - hasDetails = user.subscription.type === SubscriptionType.Premium; + hasDetails = user?.subscription?.type === SubscriptionType.Premium; } + const { filters } = (access.settings ?? {}) as AccessSettings; + const [ { createdAt, holdings, markets }, { performance: performance1d }, @@ -69,6 +77,7 @@ export class PublicController { { performance: performanceYtd } ] = await Promise.all([ this.portfolioService.getDetails({ + filters, impersonationId: access.userId, userId: user.id, withMarkets: true @@ -76,6 +85,7 @@ export class PublicController { ...['1d', 'max', 'ytd'].map((dateRange) => { return this.portfolioService.getPerformance({ dateRange, + filters, impersonationId: undefined, userId: user.id }); @@ -83,11 +93,12 @@ export class PublicController { ]); const { activities } = await this.activitiesService.getActivities({ + filters, sortColumn: 'date', sortDirection: 'desc', take: 10, types: [ActivityType.BUY, ActivityType.SELL], - userCurrency: user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY, + userCurrency: user?.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY, userId: user.id, withExcludedAccountsAndActivities: false }); @@ -99,22 +110,22 @@ export class PublicController { ? [] : activities.map( ({ + assetProfile, currency, date, fee, quantity, - SymbolProfile, type, unitPrice, value, valueInBaseCurrency }) => { return { + assetProfile, currency, date, fee, quantity, - SymbolProfile, type, unitPrice, value, @@ -156,8 +167,7 @@ export class PublicController { this.exchangeRateDataService.toCurrency( quantity * marketPrice, assetProfile.currency, - this.request.user?.settings?.settings.baseCurrency ?? - DEFAULT_CURRENCY + user?.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY ) ); }) @@ -167,19 +177,46 @@ export class PublicController { publicPortfolioResponse.holdings[symbol] = { allocationInPercentage: portfolioPosition.valueInBaseCurrency / totalValue, - assetClass: hasDetails ? portfolioPosition.assetClass : undefined, - assetProfile: hasDetails ? portfolioPosition.assetProfile : undefined, - countries: hasDetails ? portfolioPosition.countries : [], - currency: hasDetails ? portfolioPosition.currency : undefined, - dataSource: portfolioPosition.dataSource, + assetProfile: { + ...portfolioPosition.assetProfile, + assetClass: + hasDetails || + portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY + ? portfolioPosition.assetProfile.assetClass + : undefined, + assetClassLabel: + hasDetails || + portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY + ? portfolioPosition.assetProfile.assetClassLabel + : undefined, + assetSubClass: + hasDetails || + portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH + ? portfolioPosition.assetProfile.assetSubClass + : undefined, + assetSubClassLabel: + hasDetails || + portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH + ? portfolioPosition.assetProfile.assetSubClassLabel + : undefined, + holdings: portfolioPosition.assetProfile.holdings?.map( + ({ allocationInPercentage, name }) => { + return { allocationInPercentage, name }; + } + ), + ...(hasDetails + ? {} + : { + countries: [], + currency: undefined, + holdings: [], + sectors: [] + }) + }, dateOfFirstActivity: portfolioPosition.dateOfFirstActivity, markets: hasDetails ? portfolioPosition.markets : undefined, - name: portfolioPosition.name, netPerformancePercentWithCurrencyEffect: portfolioPosition.netPerformancePercentWithCurrencyEffect, - sectors: hasDetails ? portfolioPosition.sectors : [], - symbol: portfolioPosition.symbol, - url: portfolioPosition.url, valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue }; } diff --git a/apps/api/src/app/endpoints/public/public.module.ts b/apps/api/src/app/endpoints/public/public.module.ts index e8395228f..b992694c5 100644 --- a/apps/api/src/app/endpoints/public/public.module.ts +++ b/apps/api/src/app/endpoints/public/public.module.ts @@ -18,6 +18,7 @@ import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-da import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; +import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { Module } from '@nestjs/common'; @@ -38,6 +39,7 @@ import { PublicController } from './public.controller'; PrismaModule, RedisCacheModule, SymbolProfileModule, + TagModule, TransformDataSourceInRequestModule, UserModule ], diff --git a/apps/api/src/app/endpoints/tags/tags.controller.ts b/apps/api/src/app/endpoints/tags/tags.controller.ts index 925e1e0ed..cd043b593 100644 --- a/apps/api/src/app/endpoints/tags/tags.controller.ts +++ b/apps/api/src/app/endpoints/tags/tags.controller.ts @@ -2,6 +2,7 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos'; +import { isSystemTag } from '@ghostfolio/common/helper'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { RequestWithUser } from '@ghostfolio/common/types'; @@ -69,7 +70,7 @@ export class TagsController { id }); - if (!originalTag) { + if (!originalTag || isSystemTag(originalTag)) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), StatusCodes.FORBIDDEN @@ -83,7 +84,7 @@ export class TagsController { @HasPermission(permissions.readTags) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async getTags() { - return this.tagService.getTagsWithActivityCount(); + return this.tagService.getTagsWithAccountAndActivityCount(); } @HasPermission(permissions.updateTag) @@ -94,7 +95,7 @@ export class TagsController { id }); - if (!originalTag) { + if (!originalTag || isSystemTag(originalTag)) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), StatusCodes.FORBIDDEN diff --git a/apps/api/src/app/endpoints/watchlist/watchlist.service.ts b/apps/api/src/app/endpoints/watchlist/watchlist.service.ts index 666023dbf..88702da00 100644 --- a/apps/api/src/app/endpoints/watchlist/watchlist.service.ts +++ b/apps/api/src/app/endpoints/watchlist/watchlist.service.ts @@ -4,10 +4,14 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data/market-d import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; -import { WatchlistResponse } from '@ghostfolio/common/interfaces'; +import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; +import { + AssetProfileIdentifier, + WatchlistResponse +} from '@ghostfolio/common/interfaces'; import { BadRequestException, Injectable } from '@nestjs/common'; -import { DataSource, Prisma } from '@prisma/client'; +import { Prisma } from '@prisma/client'; @Injectable() export class WatchlistService { @@ -24,11 +28,7 @@ export class WatchlistService { dataSource, symbol, userId - }: { - dataSource: DataSource; - symbol: string; - userId: string; - }): Promise { + }: { userId: string } & AssetProfileIdentifier): Promise { const symbolProfile = await this.prismaService.symbolProfile.findUnique({ where: { dataSource_symbol: { dataSource, symbol } @@ -40,14 +40,17 @@ export class WatchlistService { { dataSource, symbol } ]); - if (!assetProfiles[symbol]?.currency) { + const assetProfile = + assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]; + + if (!assetProfile?.currency) { throw new BadRequestException( `Asset profile not found for ${symbol} (${dataSource})` ); } await this.symbolProfileService.add( - assetProfiles[symbol] as Prisma.SymbolProfileCreateInput + assetProfile as Prisma.SymbolProfileCreateInput ); } @@ -72,11 +75,7 @@ export class WatchlistService { dataSource, symbol, userId - }: { - dataSource: DataSource; - symbol: string; - userId: string; - }) { + }: { userId: string } & AssetProfileIdentifier) { await this.prismaService.user.update({ data: { watchlist: { @@ -127,7 +126,8 @@ export class WatchlistService { const performancePercent = this.benchmarkService.calculateChangeInPercentage( allTimeHigh?.marketPrice, - quotes[symbol]?.marketPrice + quotes[getAssetProfileIdentifier({ dataSource, symbol })] + ?.marketPrice ); return { diff --git a/apps/api/src/app/export/export.controller.ts b/apps/api/src/app/export/export.controller.ts index 4f4f4e6dd..27218d03d 100644 --- a/apps/api/src/app/export/export.controller.ts +++ b/apps/api/src/app/export/export.controller.ts @@ -2,6 +2,7 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard' import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { 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 { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { ExportResponse } from '@ghostfolio/common/interfaces'; import type { RequestWithUser } from '@ghostfolio/common/types'; @@ -15,9 +16,9 @@ import { } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; -import { Type as ActivityType } from '@prisma/client'; import { ExportService } from './export.service'; +import { GetExportDto } from './get-export.dto'; @Controller('export') export class ExportController { @@ -32,29 +33,41 @@ export class ExportController { @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async export( - @Query('accounts') filterByAccounts?: string, - @Query('activityIds') filterByActivityIds?: string, - @Query('activityTypes') filterByTypes?: string, - @Query('assetClasses') filterByAssetClasses?: string, - @Query('dataSource') filterByDataSource?: string, - @Query('symbol') filterBySymbol?: string, - @Query('tags') filterByTags?: string + @Query() + { + accounts, + activityIds, + activityTypes, + assetClasses, + dataSource, + range, + symbol, + tags + }: GetExportDto ): Promise { - const activityIds = filterByActivityIds?.split(',') ?? []; - const activityTypes = (filterByTypes?.split(',') as ActivityType[]) ?? []; + let endDate: Date; + let startDate: Date; + + if (range) { + ({ endDate, startDate } = getIntervalFromDateRange({ + dateRange: range + })); + } const filters = this.apiService.buildFiltersFromQueryParams({ - filterByAccounts, - filterByAssetClasses, - filterByDataSource, - filterBySymbol, - filterByTags + filterByAccounts: accounts, + filterByAssetClasses: assetClasses, + filterByDataSource: dataSource, + filterBySymbol: symbol, + filterByTags: tags }); return this.exportService.export({ activityIds, activityTypes, + endDate, filters, + startDate, userId: this.request.user.id, userSettings: this.request.user.settings.settings }); diff --git a/apps/api/src/app/export/export.service.ts b/apps/api/src/app/export/export.service.ts index 4da942cd7..35db20993 100644 --- a/apps/api/src/app/export/export.service.ts +++ b/apps/api/src/app/export/export.service.ts @@ -25,23 +25,29 @@ export class ExportService { public async export({ activityIds, activityTypes, + endDate, filters, + startDate, userId, userSettings }: { activityIds?: string[]; activityTypes?: ActivityType[]; + endDate?: Date; filters?: Filter[]; + startDate?: Date; userId: string; userSettings: UserSettings; }): Promise { - const { ACCOUNT: filtersByAccount } = groupBy(filters, ({ type }) => { + const { ACCOUNT: filtersByAccount = [] } = groupBy(filters, ({ type }) => { return type; }); const platformsMap: { [platformId: string]: Platform } = {}; let { activities } = await this.activitiesService.getActivities({ + endDate, filters, + startDate, userId, includeDrafts: true, sortColumn: 'date', @@ -59,7 +65,7 @@ export class ExportService { const where: Prisma.AccountWhereInput = { userId }; - if (filtersByAccount?.length > 0) { + if (filtersByAccount.length > 0) { where.id = { in: filtersByAccount.map(({ id }) => { return id; @@ -67,12 +73,20 @@ export class ExportService { }; } + const isFilteredExport = + activityIds?.length > 0 || + activityTypes?.length > 0 || + filters?.length > 0 || + !!endDate || + !!startDate; + const accounts = ( await this.accountService.accounts({ where, include: { balances: true, - platform: true + platform: true, + tags: true }, orderBy: { name: 'asc' @@ -80,7 +94,7 @@ export class ExportService { }) ) .filter(({ id }) => { - return activityIds?.length > 0 + return isFilteredExport ? activities.some(({ accountId }) => { return accountId === id; }) @@ -88,39 +102,39 @@ export class ExportService { }) .map( ({ - balance, balances, comment, currency, id, - isExcluded, name, platform, - platformId - }) => { + platformId, + tags + }): ExportResponse['accounts'][number] => { if (platformId) { platformsMap[platformId] = platform; } return { - balance, balances: balances.map(({ date, value }) => { return { date: date.toISOString(), value }; }), comment, currency, id, - isExcluded, name, - platformId + platformId, + tags: tags.map(({ id: tagId }) => { + return tagId; + }) }; } ); const customAssetProfiles = uniqBy( activities - .map(({ SymbolProfile }) => { - return SymbolProfile; + .map(({ assetProfile }) => { + return assetProfile; }) .filter(({ userId: assetProfileUserId }) => { return assetProfileUserId === userId; @@ -151,11 +165,14 @@ export class ExportService { .filter(({ id, isUsed }) => { return ( isUsed && - activities.some((activity) => { - return activity.tags.some(({ id: tagId }) => { - return tagId === id; - }); - }) + (accounts.some(({ tags: tagIds }) => { + return tagIds.includes(id); + }) || + activities.some((activity) => { + return activity.tags.some(({ id: tagId }) => { + return tagId === id; + }); + })) ); }) .map(({ id, name }) => { @@ -216,13 +233,13 @@ export class ExportService { activities: activities.map( ({ accountId, + assetProfile, comment, currency, date, fee, id, quantity, - SymbolProfile, tags: currentTags, type, unitPrice @@ -235,10 +252,10 @@ export class ExportService { quantity, type, unitPrice, - currency: currency ?? SymbolProfile.currency, - dataSource: SymbolProfile.dataSource, + currency: currency ?? assetProfile.currency, + dataSource: assetProfile.dataSource, date: date.toISOString(), - symbol: SymbolProfile.symbol, + symbol: assetProfile.symbol, tags: currentTags.map(({ id: tagId }) => { return tagId; }) diff --git a/apps/api/src/app/export/get-export.dto.ts b/apps/api/src/app/export/get-export.dto.ts new file mode 100644 index 000000000..5fc3c81ba --- /dev/null +++ b/apps/api/src/app/export/get-export.dto.ts @@ -0,0 +1,14 @@ +import { ActivitiesFilterDto } from '@ghostfolio/api/app/activities/activities-filter.dto'; + +import { Transform, TransformFnParams } from 'class-transformer'; +import { IsOptional, IsUUID } from 'class-validator'; +import { isString } from 'lodash'; + +export class GetExportDto extends ActivitiesFilterDto { + @IsOptional() + @IsUUID(undefined, { each: true }) + @Transform(({ value }: TransformFnParams) => { + return isString(value) ? value.split(',') : value; + }) + activityIds?: string[]; +} diff --git a/apps/api/src/app/health/health.controller.ts b/apps/api/src/app/health/health.controller.ts index 35f3fa348..4f88a03f0 100644 --- a/apps/api/src/app/health/health.controller.ts +++ b/apps/api/src/app/health/health.controller.ts @@ -24,6 +24,8 @@ import { HealthService } from './health.service'; @Controller('health') export class HealthController { + private readonly logger = new Logger(HealthController.name); + public constructor( private readonly aiService: AiService, private readonly healthService: HealthService @@ -61,7 +63,7 @@ export class HealthController { .json({ status: getReasonPhrase(StatusCodes.OK) }); } } catch (error) { - Logger.error(error, 'HealthController'); + this.logger.error(error); } return response diff --git a/apps/api/src/app/health/health.service.ts b/apps/api/src/app/health/health.service.ts index f08f33a1e..42a0be61b 100644 --- a/apps/api/src/app/health/health.service.ts +++ b/apps/api/src/app/health/health.service.ts @@ -26,7 +26,9 @@ export class HealthService { public async isDatabaseHealthy() { try { - await this.propertyService.getByKey(PROPERTY_CURRENCIES); + await this.propertyService.getByKey(PROPERTY_CURRENCIES, { + skipCache: true + }); return true; } catch { diff --git a/apps/api/src/app/import/import-data.dto.ts b/apps/api/src/app/import/import-data.dto.ts index bf45c7cda..1ab6fe3e5 100644 --- a/apps/api/src/app/import/import-data.dto.ts +++ b/apps/api/src/app/import/import-data.dto.ts @@ -2,6 +2,7 @@ import { CreateAccountWithBalancesDto, CreateAssetProfileWithMarketDataDto, CreateOrderDto, + CreatePlatformDto, CreateTagDto } from '@ghostfolio/common/dtos'; @@ -26,6 +27,12 @@ export class ImportDataDto { @ValidateNested({ each: true }) assetProfiles?: CreateAssetProfileWithMarketDataDto[]; + @IsArray() + @IsOptional() + @Type(() => CreatePlatformDto) + @ValidateNested({ each: true }) + platforms?: CreatePlatformDto[]; + @IsArray() @IsOptional() @Type(() => CreateTagDto) diff --git a/apps/api/src/app/import/import.controller.ts b/apps/api/src/app/import/import.controller.ts index 521be56f7..cd378d07d 100644 --- a/apps/api/src/app/import/import.controller.ts +++ b/apps/api/src/app/import/import.controller.ts @@ -31,6 +31,8 @@ import { ImportService } from './import.service'; @Controller('import') export class ImportController { + private readonly logger = new Logger(ImportController.name); + public constructor( private readonly configurationService: ConfigurationService, private readonly importService: ImportService, @@ -63,7 +65,7 @@ export class ImportController { if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - this.request.user.subscription.type === SubscriptionType.Premium + this.request.user.subscription?.type === SubscriptionType.Premium ) { maxActivitiesToImport = Number.MAX_SAFE_INTEGER; } @@ -75,13 +77,14 @@ export class ImportController { accountsWithBalancesDto: importData.accounts ?? [], activitiesDto: importData.activities, assetProfilesWithMarketDataDto: importData.assetProfiles ?? [], + platformsDto: importData.platforms ?? [], tagsDto: importData.tags ?? [], user: this.request.user }); return { activities }; } catch (error) { - Logger.error(error, ImportController); + this.logger.error(error); throw new HttpException( { @@ -107,7 +110,7 @@ export class ImportController { if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - this.request.user.subscription.type === SubscriptionType.Premium + this.request.user.subscription?.type === SubscriptionType.Premium ) { maxActivitiesToImport = Number.MAX_SAFE_INTEGER; } diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index b82f763a0..9d1e899c3 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -9,14 +9,16 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data/market-d import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service'; -import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config'; import { - CreateAssetProfileDto, - CreateAccountDto, - CreateOrderDto -} from '@ghostfolio/common/dtos'; + DATA_GATHERING_QUEUE_PRIORITY_HIGH, + ghostfolioPrefix, + NON_INVESTMENT_ACTIVITY_TYPES, + TAG_ID_EXCLUDE_FROM_ANALYSIS +} from '@ghostfolio/common/config'; +import { CreateAssetProfileDto, CreateOrderDto } from '@ghostfolio/common/dtos'; import { getAssetProfileIdentifier, + isValidCustomAssetProfileSymbol, parseDate } from '@ghostfolio/common/helper'; import { @@ -69,8 +71,7 @@ export class ImportService { const holding = await this.portfolioService.getHolding({ dataSource, symbol, - userId, - impersonationId: undefined + userId }); if (!holding) { @@ -127,11 +128,11 @@ export class ImportService { const isDuplicate = activities.some((activity) => { return ( activity.accountId === account?.id && - activity.SymbolProfile.currency === assetProfile.currency && - activity.SymbolProfile.dataSource === assetProfile.dataSource && + activity.assetProfile.currency === assetProfile.currency && + activity.assetProfile.dataSource === assetProfile.dataSource && isSameSecond(activity.date, date) && activity.quantity === quantity && - activity.SymbolProfile.symbol === assetProfile.symbol && + activity.assetProfile.symbol === assetProfile.symbol && activity.type === 'DIVIDEND' && activity.unitPrice === marketPrice ); @@ -143,6 +144,7 @@ export class ImportService { return { account, + assetProfile, date, error, quantity, @@ -157,7 +159,6 @@ export class ImportService { feeInBaseCurrency: 0, id: assetProfile.id, isDraft: false, - SymbolProfile: assetProfile, symbolProfileId: assetProfile.id, type: 'DIVIDEND', unitPrice: marketPrice, @@ -179,6 +180,7 @@ export class ImportService { assetProfilesWithMarketDataDto, isDryRun = false, maxActivitiesToImport, + platformsDto, tagsDto, user }: { @@ -187,14 +189,143 @@ export class ImportService { assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles']; isDryRun?: boolean; maxActivitiesToImport: number; + platformsDto: ImportDataDto['platforms']; tagsDto: ImportDataDto['tags']; user: UserWithSettings; }): Promise { const accountIdMapping: { [oldAccountId: string]: string } = {}; const assetProfileSymbolMapping: { [oldSymbol: string]: string } = {}; + const platformIdMapping: { [oldPlatformId: string]: string } = {}; const tagIdMapping: { [oldTagId: string]: string } = {}; const userCurrency = user.settings.settings.baseCurrency; + // Validate the symbols before any data is persisted + for (const [index, assetProfileWithMarketData] of ( + assetProfilesWithMarketDataDto ?? [] + ).entries()) { + if ( + assetProfileWithMarketData.dataSource === DataSource.MANUAL && + !isValidCustomAssetProfileSymbol(assetProfileWithMarketData.symbol) + ) { + throw new Error( + `assetProfiles.${index}.symbol ("${assetProfileWithMarketData.symbol}") must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source ("${DataSource.MANUAL}")` + ); + } + } + + // Validate the symbols before any data is persisted. Activities without a + // data source are excluded, since a symbol is generated in + // createActivity() if needed. + for (const [index, activity] of activitiesDto.entries()) { + if (!activity.dataSource) { + if (NON_INVESTMENT_ACTIVITY_TYPES.includes(activity.type)) { + activity.dataSource = DataSource.MANUAL; + } else { + activity.dataSource = + this.dataProviderService.getDataSourceForImport(); + } + } else if ( + activity.dataSource === DataSource.MANUAL && + !isValidCustomAssetProfileSymbol(activity.symbol) + ) { + throw new Error( + `activities.${index}.symbol ("${activity.symbol}") must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source ("${DataSource.MANUAL}")` + ); + } + } + + if (platformsDto?.length) { + const canCreatePlatform = hasPermission( + user.permissions, + permissions.createPlatform + ); + + const existingPlatforms = await this.platformService.getPlatforms(); + + for (const platform of platformsDto) { + // Check if there is any existing platform with the same ID, otherwise + // fall back to a platform with the same URL + const existingPlatform = + existingPlatforms.find(({ id }) => { + return id === platform.id; + }) ?? + existingPlatforms.find(({ url }) => { + return url === platform.url; + }); + + if (existingPlatform) { + // Store the new to old platform ID mappings for creating accounts + if (platform.id && existingPlatform.id !== platform.id) { + platformIdMapping[platform.id] = existingPlatform.id; + } + } else { + if (!canCreatePlatform) { + throw new Error( + `Insufficient permissions to create platform ("${platform.name}")` + ); + } + + if (!isDryRun) { + await this.platformService.createPlatform(platform); + } + } + } + } + + const existingTagsOfUser = + tagsDto?.length || (!isDryRun && accountsWithBalancesDto?.length) + ? await this.tagService.getTagsForUser(user.id) + : []; + + if (tagsDto?.length) { + const canCreateOwnTag = hasPermission( + user.permissions, + permissions.createOwnTag + ); + + for (const tag of tagsDto) { + const existingTagOfUser = existingTagsOfUser.find(({ id }) => { + return id === tag.id; + }); + + if (!existingTagOfUser) { + if (!canCreateOwnTag) { + throw new Error( + `Insufficient permissions to create custom tag ("${tag.name}")` + ); + } + + if (!isDryRun) { + const existingTag = await this.tagService.getTag({ id: tag.id }); + let oldTagId: string; + + if (existingTag) { + oldTagId = tag.id; + delete tag.id; + } + + const tagObject: Prisma.TagCreateInput = { + ...tag, + user: { connect: { id: user.id } } + }; + + const newTag = await this.tagService.createTag(tagObject); + + if (existingTag && oldTagId) { + tagIdMapping[oldTagId] = newTag.id; + } + + existingTagsOfUser.push({ + id: newTag.id, + isUsed: false, + name: newTag.name, + userId: newTag.userId + }); + } + } + } + } + if (!isDryRun && accountsWithBalancesDto?.length) { const [existingAccounts, existingPlatforms] = await Promise.all([ this.accountService.accounts({ @@ -209,6 +340,12 @@ export class ImportService { this.platformService.getPlatforms() ]); + const existingTagIds = new Set( + existingTagsOfUser.map(({ id }) => { + return id; + }) + ); + for (const accountWithBalances of accountsWithBalancesDto) { // Check if there is any existing account with the same ID const accountWithSameId = existingAccounts.find((existingAccount) => { @@ -217,13 +354,16 @@ export class ImportService { // If there is no account or if the account belongs to a different user then create a new account if (!accountWithSameId || accountWithSameId.userId !== user.id) { - const account: CreateAccountDto = omit( - accountWithBalances, - 'balances' - ); + const account = omit(accountWithBalances, [ + 'balance', + 'balances', + 'isExcluded', + 'tags' + ]); let oldAccountId: string; - const platformId = account.platformId; + const platformId = + platformIdMapping[account.platformId] ?? account.platformId; delete account.platformId; @@ -232,6 +372,24 @@ export class ImportService { delete account.id; } + const tagIds = (accountWithBalances.tags ?? []) + .map((tagId) => { + return tagIdMapping[tagId] ?? tagId; + }) + .filter((tagId) => { + return existingTagIds.has(tagId); + }); + + // Map the legacy isExcluded attribute of old export files to + // the "Exclude from Analysis" tag + if ( + accountWithBalances.isExcluded && + existingTagIds.has(TAG_ID_EXCLUDE_FROM_ANALYSIS) && + !tagIds.includes(TAG_ID_EXCLUDE_FROM_ANALYSIS) + ) { + tagIds.push(TAG_ID_EXCLUDE_FROM_ANALYSIS); + } + let accountObject: Prisma.AccountCreateInput = { ...account, balances: { @@ -251,10 +409,12 @@ export class ImportService { }; } - const newAccount = await this.accountService.createAccount( - accountObject, - user.id - ); + const newAccount = await this.accountService.createAccount({ + tagIds, + balance: accountWithBalances.balance, + data: accountObject, + userId: user.id + }); // Store the new to old account ID mappings for updating activities if (accountWithSameId && oldAccountId) { @@ -264,115 +424,117 @@ export class ImportService { } } - if (!isDryRun && assetProfilesWithMarketDataDto?.length) { - const existingAssetProfiles = - await this.symbolProfileService.getSymbolProfiles( - assetProfilesWithMarketDataDto.map(({ dataSource, symbol }) => { - return { dataSource, symbol }; + if (assetProfilesWithMarketDataDto?.length) { + const customAssetProfileNames = assetProfilesWithMarketDataDto + .filter(({ dataSource, name }) => { + return dataSource === DataSource.MANUAL && Boolean(name); + }) + .map(({ name }) => { + return name; + }); + + const [existingAssetProfiles, existingCustomAssetProfilesOfUser] = + await Promise.all([ + this.symbolProfileService.getSymbolProfiles( + assetProfilesWithMarketDataDto.map(({ dataSource, symbol }) => { + return { dataSource, symbol }; + }) + ), + this.symbolProfileService.getCustomSymbolProfilesByNames({ + names: customAssetProfileNames, + userId: user.id }) - ); + ]); for (const assetProfileWithMarketData of assetProfilesWithMarketDataDto) { + let symbol = assetProfileWithMarketData.symbol; + // Check if there is any existing asset profile const existingAssetProfile = existingAssetProfiles.find( - ({ dataSource, symbol }) => { + (assetProfile) => { return ( - dataSource === assetProfileWithMarketData.dataSource && - symbol === assetProfileWithMarketData.symbol + assetProfile.dataSource === + assetProfileWithMarketData.dataSource && + assetProfile.symbol === assetProfileWithMarketData.symbol ); } ); - // If there is no asset profile or if the asset profile belongs to a different user, then create a new asset profile + // If there is no asset profile or if the asset profile belongs to a + // different user, then reuse the custom asset profile of the user or + // create a new asset profile if (!existingAssetProfile || existingAssetProfile.userId !== user.id) { - const assetProfile: CreateAssetProfileDto = omit( - assetProfileWithMarketData, - 'marketData' - ); + // Check if the user has a custom asset profile with the same name. + // Skip asset profiles with a legacy free-text symbol as they would + // fail the symbol validation on a future import. + const existingCustomAssetProfileOfUser = + assetProfileWithMarketData.dataSource === DataSource.MANUAL + ? existingCustomAssetProfilesOfUser.find((customAssetProfile) => { + return ( + customAssetProfile.name === + assetProfileWithMarketData.name && + isValidCustomAssetProfileSymbol(customAssetProfile.symbol) + ); + }) + : undefined; + + if (existingCustomAssetProfileOfUser) { + // Reuse the custom asset profile of the user instead of creating a duplicate + symbol = existingCustomAssetProfileOfUser.symbol; + } else { + const assetProfile: CreateAssetProfileDto = omit( + assetProfileWithMarketData, + 'marketData' + ); + + // Asset profile belongs to a different user, generate a new symbol + if (existingAssetProfile && !isDryRun) { + symbol = randomUUID(); + } - // Asset profile belongs to a different user - if (existingAssetProfile) { - const symbol = randomUUID(); - assetProfileSymbolMapping[assetProfile.symbol] = symbol; assetProfile.symbol = symbol; + + if (!isDryRun) { + // Create a new asset profile + const assetProfileObject: Prisma.SymbolProfileCreateInput = { + ...assetProfile, + user: { connect: { id: user.id } } + }; + + await this.symbolProfileService.add(assetProfileObject); + } } - // Create a new asset profile - const assetProfileObject: Prisma.SymbolProfileCreateInput = { - ...assetProfile, - user: { connect: { id: user.id } } - }; + if (symbol !== assetProfileWithMarketData.symbol) { + assetProfileSymbolMapping[assetProfileWithMarketData.symbol] = + symbol; - await this.symbolProfileService.add(assetProfileObject); + // Keep the asset profile in sync with the activities to validate + assetProfileWithMarketData.symbol = symbol; + } } - // Insert or update market data - const marketDataObjects = assetProfileWithMarketData.marketData.map( - (marketData) => { + if (!isDryRun) { + // Insert or update market data + const marketDataObjects = ( + assetProfileWithMarketData.marketData ?? [] + ).map((marketData) => { return { ...marketData, - dataSource: assetProfileWithMarketData.dataSource, - symbol: assetProfileWithMarketData.symbol + symbol, + dataSource: assetProfileWithMarketData.dataSource } as Prisma.MarketDataUpdateInput; - } - ); - - await this.marketDataService.updateMany({ data: marketDataObjects }); - } - } - - if (tagsDto?.length) { - const existingTagsOfUser = await this.tagService.getTagsForUser(user.id); - - const canCreateOwnTag = hasPermission( - user.permissions, - permissions.createOwnTag - ); - - for (const tag of tagsDto) { - const existingTagOfUser = existingTagsOfUser.find(({ id }) => { - return id === tag.id; - }); - - if (!existingTagOfUser || existingTagOfUser.userId !== null) { - if (!canCreateOwnTag) { - throw new Error( - `Insufficient permissions to create custom tag ("${tag.name}")` - ); - } - - if (!isDryRun) { - const existingTag = await this.tagService.getTag({ id: tag.id }); - let oldTagId: string; - - if (existingTag) { - oldTagId = tag.id; - delete tag.id; - } - - const tagObject: Prisma.TagCreateInput = { - ...tag, - user: { connect: { id: user.id } } - }; - - const newTag = await this.tagService.createTag(tagObject); + }); - if (existingTag && oldTagId) { - tagIdMapping[oldTagId] = newTag.id; - } - } + await this.marketDataService.updateMany({ data: marketDataObjects }); } } } for (const activity of activitiesDto) { - if (!activity.dataSource) { - if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) { - activity.dataSource = DataSource.MANUAL; - } else { - activity.dataSource = - this.dataProviderService.getDataSourceForImport(); - } + // If an asset profile is created or reused, then update the symbol in all activities + if (assetProfileSymbolMapping[activity.symbol]) { + activity.symbol = assetProfileSymbolMapping[activity.symbol]; } if (!isDryRun) { @@ -381,11 +543,6 @@ export class ImportService { activity.accountId = accountIdMapping[activity.accountId]; } - // If a new asset profile is created, then update the symbol in all activities - if (assetProfileSymbolMapping[activity.symbol]) { - activity.symbol = assetProfileSymbolMapping[activity.symbol]; - } - // If a new tag is created, then update the tag ID in all activities activity.tags = (activity.tags ?? []).map((tagId) => { return tagIdMapping[tagId] ?? tagId; @@ -446,19 +603,18 @@ export class ImportService { const error = activity.error; const fee = activity.fee; const quantity = activity.quantity; - const SymbolProfile = activity.SymbolProfile; const tagIds = activity.tagIds ?? []; const type = activity.type; const unitPrice = activity.unitPrice; const assetProfile = assetProfiles[ getAssetProfileIdentifier({ - dataSource: SymbolProfile.dataSource, - symbol: SymbolProfile.symbol + dataSource: activity.assetProfile.dataSource, + symbol: activity.assetProfile.symbol }) ] ?? { - dataSource: SymbolProfile.dataSource, - symbol: SymbolProfile.symbol + dataSource: activity.assetProfile.dataSource, + symbol: activity.assetProfile.symbol }; const { assetClass, @@ -536,6 +692,8 @@ export class ImportService { url, comment: assetProfile.comment, currency: assetProfile.currency, + dataGatheringFrequency: + assetProfile.dataGatheringFrequency ?? 'DAILY', userId: dataSource === 'MANUAL' ? user.id : undefined }, symbolProfileId: undefined, @@ -590,20 +748,21 @@ export class ImportService { const value = new Big(quantity).mul(unitPrice).toNumber(); - const valueInBaseCurrency = this.exchangeRateDataService.toCurrencyAtDate( - value, - currency ?? assetProfile.currency, - userCurrency, - date - ); + const valueInBaseCurrency = + (await this.exchangeRateDataService.toCurrencyAtDate( + value, + currency ?? assetProfile.currency, + userCurrency, + date + )) ?? 0; activities.push({ ...order, + // @ts-ignore + assetProfile, error, value, - valueInBaseCurrency: await valueInBaseCurrency, - // @ts-ignore - SymbolProfile: assetProfile + valueInBaseCurrency }); } @@ -613,19 +772,19 @@ export class ImportService { if (!isDryRun) { // Gather symbol data in the background, if not dry run - const uniqueActivities = uniqBy(activities, ({ SymbolProfile }) => { + const uniqueActivities = uniqBy(activities, ({ assetProfile }) => { return getAssetProfileIdentifier({ - dataSource: SymbolProfile.dataSource, - symbol: SymbolProfile.symbol + dataSource: assetProfile.dataSource, + symbol: assetProfile.symbol }); }); this.dataGatheringService.gatherSymbols({ - dataGatheringItems: uniqueActivities.map(({ date, SymbolProfile }) => { + dataGatheringItems: uniqueActivities.map(({ assetProfile, date }) => { return { date, - dataSource: SymbolProfile.dataSource, - symbol: SymbolProfile.symbol + dataSource: assetProfile.dataSource, + symbol: assetProfile.symbol }; }), priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH @@ -672,12 +831,12 @@ export class ImportService { activity.accountId === accountId && activity.comment === comment && (activity.currency === currency || - activity.SymbolProfile.currency === currency) && - activity.SymbolProfile.dataSource === dataSource && + activity.assetProfile.currency === currency) && + activity.assetProfile.dataSource === dataSource && isSameSecond(activity.date, date) && activity.fee === fee && activity.quantity === quantity && - activity.SymbolProfile.symbol === symbol && + activity.assetProfile.symbol === symbol && activity.type === type && activity.unitPrice === unitPrice ); @@ -697,7 +856,7 @@ export class ImportService { quantity, type, unitPrice, - SymbolProfile: { + assetProfile: { dataSource, symbol, activitiesCount: undefined, diff --git a/apps/api/src/app/info/info.module.ts b/apps/api/src/app/info/info.module.ts index e33c5e0c2..06b724909 100644 --- a/apps/api/src/app/info/info.module.ts +++ b/apps/api/src/app/info/info.module.ts @@ -7,6 +7,7 @@ import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.mo import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; @@ -29,6 +30,7 @@ import { InfoService } from './info.service'; secret: process.env.JWT_SECRET_KEY, signOptions: { expiresIn: '30 days' } }), + MarketDataModule, PlatformModule, PropertyModule, RedisCacheModule, diff --git a/apps/api/src/app/info/info.service.ts b/apps/api/src/app/info/info.service.ts index 86630db53..cb7d24bcb 100644 --- a/apps/api/src/app/info/info.service.ts +++ b/apps/api/src/app/info/info.service.ts @@ -3,10 +3,13 @@ import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscripti import { UserService } from '@ghostfolio/api/app/user/user.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.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 { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { DEFAULT_CURRENCY, + ghostfolioFearAndGreedIndexSymbolStocks, PROPERTY_COUNTRIES_OF_SUBSCRIBERS, PROPERTY_DEMO_USER_ID, PROPERTY_DOCKER_HUB_PULLS, @@ -14,15 +17,14 @@ import { PROPERTY_GITHUB_STARGAZERS, PROPERTY_IS_READ_ONLY_MODE, PROPERTY_SLACK_COMMUNITY_USERS, - PROPERTY_UPTIME, - ghostfolioFearAndGreedIndexDataSourceStocks + PROPERTY_UPTIME } from '@ghostfolio/common/config'; -import { encodeDataSource } from '@ghostfolio/common/helper'; import { InfoItem, Statistics } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; import { Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; +import { MarketData } from '@prisma/client'; import { subDays } from 'date-fns'; import { isNil } from 'lodash'; @@ -33,8 +35,10 @@ export class InfoService { public constructor( private readonly benchmarkService: BenchmarkService, private readonly configurationService: ConfigurationService, + private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly jwtService: JwtService, + private readonly marketDataService: MarketDataService, private readonly propertyService: PropertyService, private readonly redisCacheService: RedisCacheService, private readonly subscriptionService: SubscriptionService, @@ -44,6 +48,7 @@ export class InfoService { public async get(): Promise { const info: Partial = {}; let isReadOnlyMode: boolean; + let latestFearAndGreedStocksMarketDataPromise: Promise; const globalPermissions: string[] = []; @@ -60,14 +65,12 @@ export class InfoService { } if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { - if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { - info.fearAndGreedDataSource = encodeDataSource( - ghostfolioFearAndGreedIndexDataSourceStocks - ); - } else { - info.fearAndGreedDataSource = - ghostfolioFearAndGreedIndexDataSourceStocks; - } + latestFearAndGreedStocksMarketDataPromise = + this.marketDataService.getLatest({ + dataSource: + this.dataProviderService.getDataSourceForFearAndGreedIndexStocks(), + symbol: ghostfolioFearAndGreedIndexSymbolStocks + }); globalPermissions.push(permissions.enableFearAndGreedIndex); } @@ -99,12 +102,14 @@ export class InfoService { benchmarks, demoAuthToken, isUserSignupEnabled, + latestFearAndGreedStocksMarketData, statistics, subscriptionOffer ] = await Promise.all([ this.benchmarkService.getBenchmarkAssetProfiles(), this.getDemoAuthToken(), this.propertyService.isUserSignupEnabled(), + latestFearAndGreedStocksMarketDataPromise, this.getStatistics(), this.subscriptionService.getSubscriptionOffer({ key: 'default' }) ]); @@ -122,7 +127,9 @@ export class InfoService { statistics, subscriptionOffer, baseCurrency: DEFAULT_CURRENCY, - currencies: this.exchangeRateDataService.getCurrencies() + currencies: this.exchangeRateDataService.getCurrencies(), + fearAndGreedStocksMarketPrice: + latestFearAndGreedStocksMarketData?.marketPrice }; } diff --git a/apps/api/src/app/logo/get-logo.dto.ts b/apps/api/src/app/logo/get-logo.dto.ts new file mode 100644 index 000000000..e19753157 --- /dev/null +++ b/apps/api/src/app/logo/get-logo.dto.ts @@ -0,0 +1,9 @@ +import { IsUrl } from 'class-validator'; + +export class GetLogoDto { + @IsUrl({ + protocols: ['http', 'https'], + require_protocol: true + }) + url: string; +} diff --git a/apps/api/src/app/logo/logo.controller.ts b/apps/api/src/app/logo/logo.controller.ts index fdbe430c9..47bd1fc75 100644 --- a/apps/api/src/app/logo/logo.controller.ts +++ b/apps/api/src/app/logo/logo.controller.ts @@ -12,6 +12,7 @@ import { import { DataSource } from '@prisma/client'; import { Response } from 'express'; +import { GetLogoDto } from './get-logo.dto'; import { LogoService } from './logo.service'; @Controller('logo') @@ -41,7 +42,7 @@ export class LogoController { @Get() public async getLogoByUrl( - @Query('url') url: string, + @Query() { url }: GetLogoDto, @Res() response: Response ) { try { diff --git a/apps/api/src/app/logo/logo.module.ts b/apps/api/src/app/logo/logo.module.ts index 1f59df1c8..8eede126a 100644 --- a/apps/api/src/app/logo/logo.module.ts +++ b/apps/api/src/app/logo/logo.module.ts @@ -1,5 +1,6 @@ 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 { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { Module } from '@nestjs/common'; @@ -11,6 +12,7 @@ import { LogoService } from './logo.service'; controllers: [LogoController], imports: [ ConfigurationModule, + FetchModule, SymbolProfileModule, TransformDataSourceInRequestModule ], diff --git a/apps/api/src/app/logo/logo.service.ts b/apps/api/src/app/logo/logo.service.ts index ba1acdd29..e01e6cace 100644 --- a/apps/api/src/app/logo/logo.service.ts +++ b/apps/api/src/app/logo/logo.service.ts @@ -1,4 +1,5 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; @@ -10,6 +11,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes'; export class LogoService { public constructor( private readonly configurationService: ConfigurationService, + private readonly fetchService: FetchService, private readonly symbolProfileService: SymbolProfileService ) {} @@ -43,15 +45,17 @@ export class LogoService { } private async getBuffer(aUrl: string) { - const blob = await fetch( - `https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`, - { - headers: { 'User-Agent': 'request' }, - signal: AbortSignal.timeout( - this.configurationService.get('REQUEST_TIMEOUT') - ) - } - ).then((res) => res.blob()); + const blob = await this.fetchService + .fetch( + `https://t0.gstatic.com/faviconV2?client=SOCIAL&fallback_opts=TYPE,SIZE,URL&size=64&type=FAVICON&url=${encodeURIComponent(aUrl)}`, + { + headers: { 'User-Agent': 'request' }, + signal: AbortSignal.timeout( + this.configurationService.get('REQUEST_TIMEOUT') + ) + } + ) + .then((res) => res.blob()); return { buffer: await blob.arrayBuffer().then((arrayBuffer) => { 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 index f4c99916f..5e6bfba99 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts @@ -22,7 +22,7 @@ export const activityDummyData = { valueInBaseCurrency: undefined }; -export const symbolProfileDummyData = { +export const assetProfileDummyData = { activitiesCount: undefined, assetClass: undefined, assetSubClass: undefined, diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index d57b85d8c..cdab3fdf0 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -1,4 +1,6 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { PortfolioSnapshotComputationError } from '@ghostfolio/api/app/portfolio/errors/portfolio-snapshot-computation.error'; +import { PortfolioCalculatorPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-position.interface'; import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface'; import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface'; import { TransactionPointSymbol } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point-symbol.interface'; @@ -34,7 +36,7 @@ import { ResponseError, SymbolMetrics } from '@ghostfolio/common/interfaces'; -import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; +import { PortfolioSnapshot } from '@ghostfolio/common/models'; import { GroupBy } from '@ghostfolio/common/types'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; @@ -51,6 +53,8 @@ import { format, isAfter, isBefore, + isFuture, + isPast, isWithinInterval, min, startOfDay, @@ -62,6 +66,10 @@ import { isNumber, sortBy, sum, uniqBy } from 'lodash'; export abstract class PortfolioCalculator { protected static readonly ENABLE_LOGGING = false; + private static readonly MAX_INITIALIZATION_ATTEMPTS = 3; + + protected readonly logger = new Logger(PortfolioCalculator.name); + protected accountBalanceItems: HistoricalDataItem[]; protected activities: PortfolioOrder[]; @@ -119,11 +127,11 @@ export abstract class PortfolioCalculator { this.activities = activities .map( ({ + assetProfile, date, feeInAssetProfileCurrency, feeInBaseCurrency, quantity, - SymbolProfile, tags = [], type, unitPriceInAssetProfileCurrency @@ -132,14 +140,14 @@ export abstract class PortfolioCalculator { dateOfFirstActivity = date; } - if (isAfter(date, new Date())) { + if (isFuture(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, + assetProfile, tags, type, date: format(date, DATE_FORMAT), @@ -169,10 +177,15 @@ export abstract class PortfolioCalculator { this.computeTransactionPoints(); this.snapshotPromise = this.initialize(); + + // Mark the rejection as handled to prevent an unhandled promise rejection + // in case the snapshot promise is never awaited. Consumers awaiting it + // still receive the error. + this.snapshotPromise.catch(() => undefined); } protected abstract calculateOverallPerformance( - positions: TimelinePosition[] + positions: PortfolioCalculatorPosition[] ): PortfolioSnapshot; @LogPerformance @@ -192,6 +205,7 @@ export abstract class PortfolioCalculator { hasErrors: false, historicalData: [], positions: [], + totalCashInBaseCurrency: new Big(0), totalFeesWithCurrencyEffect: new Big(0), totalInterestWithCurrencyEffect: new Big(0), totalInvestment: new Big(0), @@ -200,10 +214,12 @@ export abstract class PortfolioCalculator { }; } + const cashSymbols = new Set(); const currencies: { [symbol: string]: string } = {}; const dataGatheringItems: DataGatheringItem[] = []; let firstIndex = transactionPoints.length; let firstTransactionPoint: TransactionPoint = null; + let totalCashInBaseCurrency = new Big(0); let totalInterestWithCurrencyEffect = new Big(0); let totalLiabilitiesWithCurrencyEffect = new Big(0); @@ -305,20 +321,19 @@ export abstract class PortfolioCalculator { const errors: ResponseError['errors'] = []; let hasAnySymbolMetricsErrors = false; - const positions: (TimelinePosition & { - includeInHoldings: boolean; - })[] = []; + const positions: PortfolioCalculatorPosition[] = []; const accumulatedValuesByDate: { [date: string]: { investmentValueWithCurrencyEffect: Big; - totalAccountBalanceWithCurrencyEffect: Big; + totalCashValueWithCurrencyEffect: Big; totalCurrentValue: Big; totalCurrentValueWithCurrencyEffect: Big; totalInvestmentValue: Big; totalInvestmentValueWithCurrencyEffect: Big; totalNetPerformanceValue: Big; totalNetPerformanceValueWithCurrencyEffect: Big; + totalNetWorthValueWithCurrencyEffect: Big; totalTimeWeightedInvestmentValue: Big; totalTimeWeightedInvestmentValueWithCurrencyEffect: Big; }; @@ -333,6 +348,7 @@ export abstract class PortfolioCalculator { investmentValuesWithCurrencyEffect: { [date: string]: Big }; netPerformanceValues: { [date: string]: Big }; netPerformanceValuesWithCurrencyEffect: { [date: string]: Big }; + netWorthValuesWithCurrencyEffect: { [date: string]: Big }; timeWeightedInvestmentValues: { [date: string]: Big }; timeWeightedInvestmentValuesWithCurrencyEffect: { [date: string]: Big }; }; @@ -347,6 +363,13 @@ export abstract class PortfolioCalculator { ] ?? 1 ); + const valueInBaseCurrency = marketPriceInBaseCurrency.mul(item.quantity); + + const isCashInBaseCurrency = + item.assetSubClass === AssetSubClass.CASH && + item.currency === this.currency && + item.symbol === this.currency; + const { currentValues, currentValuesWithCurrencyEffect, @@ -387,25 +410,37 @@ export abstract class PortfolioCalculator { hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors; - const includeInTotalAssetValue = - item.assetSubClass !== AssetSubClass.CASH; - - if (includeInTotalAssetValue) { - valuesBySymbol[item.symbol] = { - currentValues, - currentValuesWithCurrencyEffect, - investmentValuesAccumulated, - investmentValuesAccumulatedWithCurrencyEffect, - investmentValuesWithCurrencyEffect, - netPerformanceValues, - netPerformanceValuesWithCurrencyEffect, - timeWeightedInvestmentValues, - timeWeightedInvestmentValuesWithCurrencyEffect - }; - } + // Cash in the base currency cannot generate a currency effect and thus + // contributes nothing but its balance to the performance calculation. It + // is therefore excluded from the value and the investment, while still + // contributing to the net worth. + valuesBySymbol[item.symbol] = isCashInBaseCurrency + ? { + currentValues: {}, + currentValuesWithCurrencyEffect: {}, + investmentValuesAccumulated: {}, + investmentValuesAccumulatedWithCurrencyEffect: {}, + investmentValuesWithCurrencyEffect: {}, + netPerformanceValues: {}, + netPerformanceValuesWithCurrencyEffect: {}, + netWorthValuesWithCurrencyEffect: currentValuesWithCurrencyEffect, + timeWeightedInvestmentValues: {}, + timeWeightedInvestmentValuesWithCurrencyEffect: {} + } + : { + currentValues, + currentValuesWithCurrencyEffect, + investmentValuesAccumulated, + investmentValuesAccumulatedWithCurrencyEffect, + investmentValuesWithCurrencyEffect, + netPerformanceValues, + netPerformanceValuesWithCurrencyEffect, + timeWeightedInvestmentValues, + timeWeightedInvestmentValuesWithCurrencyEffect, + netWorthValuesWithCurrencyEffect: currentValuesWithCurrencyEffect + }; positions.push({ - includeInTotalAssetValue, timeWeightedInvestment, timeWeightedInvestmentWithCurrencyEffect, activitiesCount: item.activitiesCount, @@ -428,6 +463,7 @@ export abstract class PortfolioCalculator { ? (grossPerformanceWithCurrencyEffect ?? null) : null, includeInHoldings: item.includeInHoldings, + includeInPerformance: !isCashInBaseCurrency, investment: totalInvestment, investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect, marketPrice: @@ -446,11 +482,16 @@ export abstract class PortfolioCalculator { quantity: item.quantity, symbol: item.symbol, tags: item.tags, - valueInBaseCurrency: new Big(marketPriceInBaseCurrency).mul( - item.quantity - ) + valueInBaseCurrency }); + if (item.assetSubClass === AssetSubClass.CASH) { + cashSymbols.add(item.symbol); + + totalCashInBaseCurrency = + totalCashInBaseCurrency.plus(valueInBaseCurrency); + } + totalInterestWithCurrencyEffect = totalInterestWithCurrencyEffect.plus( totalInterestInBaseCurrency ); @@ -470,28 +511,7 @@ export abstract class PortfolioCalculator { } } - const accountBalanceItemsMap = this.accountBalanceItems.reduce( - (map, { date, value }) => { - map[date] = new Big(value); - - return map; - }, - {} as { [date: string]: Big } - ); - - const accountBalanceMap: { [date: string]: Big } = {}; - - let lastKnownBalance = new Big(0); - for (const dateString of chartDates) { - if (accountBalanceItemsMap[dateString] !== undefined) { - // If there's an exact balance for this date, update lastKnownBalance - lastKnownBalance = accountBalanceItemsMap[dateString]; - } - - // Add the most recent balance to the accountBalanceMap - accountBalanceMap[dateString] = lastKnownBalance; - for (const symbol of Object.keys(valuesBySymbol)) { const symbolValues = valuesBySymbol[symbol]; @@ -521,6 +541,10 @@ export abstract class PortfolioCalculator { symbolValues.netPerformanceValuesWithCurrencyEffect?.[dateString] ?? new Big(0); + const netWorthValueWithCurrencyEffect = + symbolValues.netWorthValuesWithCurrencyEffect?.[dateString] ?? + new Big(0); + const timeWeightedInvestmentValue = symbolValues.timeWeightedInvestmentValues?.[dateString] ?? new Big(0); @@ -534,7 +558,14 @@ export abstract class PortfolioCalculator { accumulatedValuesByDate[dateString] ?.investmentValueWithCurrencyEffect ?? new Big(0) ).add(investmentValueWithCurrencyEffect), - totalAccountBalanceWithCurrencyEffect: accountBalanceMap[dateString], + totalCashValueWithCurrencyEffect: ( + accumulatedValuesByDate[dateString] + ?.totalCashValueWithCurrencyEffect ?? new Big(0) + ).add( + cashSymbols.has(symbol) + ? netWorthValueWithCurrencyEffect + : new Big(0) + ), totalCurrentValue: ( accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0) ).add(currentValue), @@ -558,6 +589,10 @@ export abstract class PortfolioCalculator { accumulatedValuesByDate[dateString] ?.totalNetPerformanceValueWithCurrencyEffect ?? new Big(0) ).add(netPerformanceValueWithCurrencyEffect), + totalNetWorthValueWithCurrencyEffect: ( + accumulatedValuesByDate[dateString] + ?.totalNetWorthValueWithCurrencyEffect ?? new Big(0) + ).add(netWorthValueWithCurrencyEffect), totalTimeWeightedInvestmentValue: ( accumulatedValuesByDate[dateString] ?.totalTimeWeightedInvestmentValue ?? new Big(0) @@ -575,13 +610,14 @@ export abstract class PortfolioCalculator { ).map(([date, values]) => { const { investmentValueWithCurrencyEffect, - totalAccountBalanceWithCurrencyEffect, + totalCashValueWithCurrencyEffect, totalCurrentValue, totalCurrentValueWithCurrencyEffect, totalInvestmentValue, totalInvestmentValueWithCurrencyEffect, totalNetPerformanceValue, totalNetPerformanceValueWithCurrencyEffect, + totalNetWorthValueWithCurrencyEffect, totalTimeWeightedInvestmentValue, totalTimeWeightedInvestmentValueWithCurrencyEffect } = values; @@ -608,10 +644,8 @@ export abstract class PortfolioCalculator { netPerformance: totalNetPerformanceValue.toNumber(), netPerformanceWithCurrencyEffect: totalNetPerformanceValueWithCurrencyEffect.toNumber(), - netWorth: totalCurrentValueWithCurrencyEffect - .plus(totalAccountBalanceWithCurrencyEffect) - .toNumber(), - totalAccountBalance: totalAccountBalanceWithCurrencyEffect.toNumber(), + netWorth: totalNetWorthValueWithCurrencyEffect.toNumber(), + totalCashInBaseCurrency: totalCashValueWithCurrencyEffect.toNumber(), totalInvestment: totalInvestmentValue.toNumber(), totalInvestmentValueWithCurrencyEffect: totalInvestmentValueWithCurrencyEffect.toNumber(), @@ -627,7 +661,7 @@ export abstract class PortfolioCalculator { return includeInHoldings; }) // eslint-disable-next-line @typescript-eslint/no-unused-vars - .map(({ includeInHoldings, ...rest }) => { + .map(({ includeInHoldings, includeInPerformance, ...rest }) => { return rest; }); @@ -635,6 +669,7 @@ export abstract class PortfolioCalculator { ...overall, errors, historicalData, + totalCashInBaseCurrency, totalInterestWithCurrencyEffect, totalLiabilitiesWithCurrencyEffect, hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors, @@ -772,11 +807,6 @@ export abstract class PortfolioCalculator { ? 0 : netPerformanceWithCurrencyEffectSinceStartDate / timeWeightedInvestmentValue - // TODO: Add net worth - // netWorth: totalCurrentValueWithCurrencyEffect - // .plus(totalAccountBalanceWithCurrencyEffect) - // .toNumber() - // netWorth: 0 }); } } @@ -794,25 +824,39 @@ export abstract class PortfolioCalculator { let firstAccountBalanceDate: Date; let firstActivityDate: Date; - try { - const firstAccountBalanceDateString = this.accountBalanceItems[0]?.date; - firstAccountBalanceDate = firstAccountBalanceDateString - ? parseDate(firstAccountBalanceDateString) - : new Date(); - } catch (error) { - firstAccountBalanceDate = new Date(); + if (this.accountBalanceItems?.length > 0) { + try { + const firstAccountBalanceDateString = this.accountBalanceItems[0].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(); + if (this.transactionPoints?.length > 0) { + try { + const firstActivityDateString = this.transactionPoints[0].date; + firstActivityDate = firstActivityDateString + ? parseDate(firstActivityDateString) + : new Date(); + } catch (error) { + firstActivityDate = new Date(); + } } - return min([firstAccountBalanceDate, firstActivityDate]); + const dates = [firstAccountBalanceDate, firstActivityDate].filter( + (date) => { + return !!date; + } + ); + + if (dates.length === 0) { + return undefined; + } + + return min(dates); } protected abstract getSymbolMetrics({ @@ -932,23 +976,23 @@ export abstract class PortfolioCalculator { let lastTransactionPoint: TransactionPoint = null; for (const { + assetProfile, date, fee, feeInBaseCurrency, quantity, - SymbolProfile, tags, type, unitPrice } of this.activities) { let currentTransactionPointItem: TransactionPointSymbol; - const assetSubClass = SymbolProfile.assetSubClass; - const currency = SymbolProfile.currency; - const dataSource = SymbolProfile.dataSource; + const assetSubClass = assetProfile.assetSubClass; + const currency = assetProfile.currency; + const dataSource = assetProfile.dataSource; const factor = getFactor(type); - const skipErrors = !!SymbolProfile.userId; // Skip errors for custom asset profiles - const symbol = SymbolProfile.symbol; + const skipErrors = !!assetProfile.userId; // Skip errors for custom asset profiles + const symbol = assetProfile.symbol; const oldAccumulatedSymbol = symbols[symbol]; @@ -1032,12 +1076,12 @@ export abstract class PortfolioCalculator { 'id' ); - symbols[SymbolProfile.symbol] = currentTransactionPointItem; + symbols[symbol] = currentTransactionPointItem; const items = lastTransactionPoint?.items ?? []; const newItems = items.filter(({ symbol }) => { - return symbol !== SymbolProfile.symbol; + return symbol !== assetProfile.symbol; }); newItems.push(currentTransactionPointItem); @@ -1088,20 +1132,23 @@ export abstract class PortfolioCalculator { } @LogPerformance - private async initialize() { + private async initialize(attempt = 1) { const startTimeTotal = performance.now(); let cachedPortfolioSnapshot: PortfolioSnapshot; let isCachedPortfolioSnapshotExpired = false; - const jobId = this.userId; + const portfolioSnapshotKey = this.redisCacheService.getPortfolioSnapshotKey( + { + filters: this.filters, + userId: this.userId + } + ); + + const jobId = portfolioSnapshotKey; try { - const cachedPortfolioSnapshotValue = await this.redisCacheService.get( - this.redisCacheService.getPortfolioSnapshotKey({ - filters: this.filters, - userId: this.userId - }) - ); + const cachedPortfolioSnapshotValue = + await this.redisCacheService.get(portfolioSnapshotKey); const { expiration, portfolioSnapshot }: PortfolioSnapshotValue = JSON.parse(cachedPortfolioSnapshotValue); @@ -1111,7 +1158,7 @@ export abstract class PortfolioCalculator { portfolioSnapshot ); - if (isAfter(new Date(), new Date(expiration))) { + if (isPast(new Date(expiration))) { isCachedPortfolioSnapshotExpired = true; } } catch {} @@ -1119,12 +1166,11 @@ export abstract class PortfolioCalculator { if (cachedPortfolioSnapshot) { this.snapshot = cachedPortfolioSnapshot; - Logger.debug( + this.logger.debug( `Fetched portfolio snapshot from cache in ${( (performance.now() - startTimeTotal) / 1000 - ).toFixed(3)} seconds`, - 'PortfolioCalculator' + ).toFixed(3)} seconds` ); if (isCachedPortfolioSnapshotExpired) { @@ -1145,6 +1191,12 @@ export abstract class PortfolioCalculator { }); } } else { + if (attempt > PortfolioCalculator.MAX_INITIALIZATION_ATTEMPTS) { + throw new PortfolioSnapshotComputationError( + `Portfolio snapshot of user '${this.userId}' could not be computed after ${PortfolioCalculator.MAX_INITIALIZATION_ATTEMPTS} attempts` + ); + } + // Wait for computation await this.portfolioSnapshotService.addJobToQueue({ data: { @@ -1167,7 +1219,7 @@ export abstract class PortfolioCalculator { await job.finished(); } - await this.initialize(); + await this.initialize(attempt + 1); } } } diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts index 9a93d0419..a6bedc55d 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts @@ -1,6 +1,6 @@ import { activityDummyData, - symbolProfileDummyData, + assetProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); @@ -65,7 +68,7 @@ describe('PortfolioCalculator', () => { null ); - portfolioSnapshotService = new PortfolioSnapshotService(null); + portfolioSnapshotService = new PortfolioSnapshotService(null, null); redisCacheService = new RedisCacheService(null, null); @@ -85,33 +88,33 @@ describe('PortfolioCalculator', () => { const activities: Activity[] = [ { ...activityDummyData, - date: new Date('2021-11-22'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'CHF', dataSource: 'YAHOO', name: 'Bâloise Holding AG', symbol: 'BALN.SW' }, + date: new Date('2021-11-22'), + feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, + quantity: 2, type: 'BUY', unitPriceInAssetProfileCurrency: 142.9 }, { ...activityDummyData, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.65, - feeInBaseCurrency: 1.65, - quantity: 2, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'CHF', dataSource: 'YAHOO', name: 'Bâloise Holding AG', symbol: 'BALN.SW' }, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 1.65, + feeInBaseCurrency: 1.65, + quantity: 2, type: 'BUY', unitPriceInAssetProfileCurrency: 136.6 } diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts index c876d0db1..dc22cdbab 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts @@ -1,231 +1,234 @@ -import { - activityDummyData, - symbolProfileDummyData, - userDummyData -} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; -import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; -import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; -import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; -import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; -import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; -import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; -import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; -import { parseDate } from '@ghostfolio/common/helper'; -import { Activity } from '@ghostfolio/common/interfaces'; -import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; - -import { Big } from 'big.js'; - -jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { - return { - CurrentRateService: jest.fn().mockImplementation(() => { - return CurrentRateServiceMock; - }) - }; -}); - -jest.mock( - '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', - () => { - return { - PortfolioSnapshotService: jest.fn().mockImplementation(() => { - return PortfolioSnapshotServiceMock; - }) - }; - } -); - -jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { - return { - RedisCacheService: jest.fn().mockImplementation(() => { - return RedisCacheServiceMock; - }) - }; -}); - -describe('PortfolioCalculator', () => { - let configurationService: ConfigurationService; - let currentRateService: CurrentRateService; - let exchangeRateDataService: ExchangeRateDataService; - let portfolioCalculatorFactory: PortfolioCalculatorFactory; - let portfolioSnapshotService: PortfolioSnapshotService; - let redisCacheService: RedisCacheService; - - beforeEach(() => { - configurationService = new ConfigurationService(); - - currentRateService = new CurrentRateService(null, null, null, null); - - exchangeRateDataService = new ExchangeRateDataService( - null, - null, - null, - null - ); - - portfolioSnapshotService = new PortfolioSnapshotService(null); - - redisCacheService = new RedisCacheService(null, null); - - portfolioCalculatorFactory = new PortfolioCalculatorFactory( - configurationService, - currentRateService, - exchangeRateDataService, - portfolioSnapshotService, - 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'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - SymbolProfile: { - ...symbolProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - type: 'BUY', - unitPriceInAssetProfileCurrency: 142.9 - }, - { - ...activityDummyData, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.65, - feeInBaseCurrency: 1.65, - quantity: 1, - SymbolProfile: { - ...symbolProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - type: 'SELL', - unitPriceInAssetProfileCurrency: 136.6 - }, - { - ...activityDummyData, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - quantity: 1, - SymbolProfile: { - ...symbolProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - type: 'SELL', - unitPriceInAssetProfileCurrency: 136.6 - } - ]; - - const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ - activities, - calculationType: PerformanceCalculationType.ROAI, - currency: 'CHF', - userId: userDummyData.id - }); - - const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); - - const investments = portfolioCalculator.getInvestments(); - - const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ - data: portfolioSnapshot.historicalData, - groupBy: 'month' - }); - - const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ - data: portfolioSnapshot.historicalData, - groupBy: 'year' - }); - - expect(portfolioSnapshot).toMatchObject({ - currentValueInBaseCurrency: new Big('0'), - errors: [], - hasErrors: false, - positions: [ - { - activitiesCount: 3, - averagePrice: new Big('0'), - currency: 'CHF', - dataSource: 'YAHOO', - dateOfFirstActivity: '2021-11-22', - dividend: new Big('0'), - dividendInBaseCurrency: new Big('0'), - fee: new Big('3.2'), - feeInBaseCurrency: new Big('3.2'), - 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' - ), - 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') - }); - - expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( - expect.objectContaining({ - netPerformance: -15.8, - netPerformanceInPercentage: -0.05528341497550734703, - netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703, - netPerformanceWithCurrencyEffect: -15.8, - totalInvestment: 0, - 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 } - ]); - - expect(investmentsByYear).toEqual([ - { date: '2021-01-01', investment: 0 } - ]); - }); - }); -}); +import { + activityDummyData, + assetProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null, null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + 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, + assetProfile: { + ...assetProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + date: new Date('2021-11-22'), + feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, + quantity: 2, + type: 'BUY', + unitPriceInAssetProfileCurrency: 142.9 + }, + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 1.65, + feeInBaseCurrency: 1.65, + quantity: 1, + type: 'SELL', + unitPriceInAssetProfileCurrency: 136.6 + }, + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + type: 'SELL', + unitPriceInAssetProfileCurrency: 136.6 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('0'), + errors: [], + hasErrors: false, + positions: [ + { + activitiesCount: 3, + averagePrice: new Big('0'), + currency: 'CHF', + dataSource: 'YAHOO', + dateOfFirstActivity: '2021-11-22', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('3.2'), + feeInBaseCurrency: new Big('3.2'), + 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' + ), + 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') + }); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + netPerformance: -15.8, + netPerformanceInPercentage: -0.05528341497550734703, + netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703, + netPerformanceWithCurrencyEffect: -15.8, + totalInvestment: 0, + 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 } + ]); + + expect(investmentsByYear).toEqual([ + { date: '2021-01-01', investment: 0 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts index ae921d6d9..9d55a79dd 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts @@ -1,6 +1,6 @@ import { activityDummyData, - symbolProfileDummyData, + assetProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); @@ -65,7 +68,7 @@ describe('PortfolioCalculator', () => { null ); - portfolioSnapshotService = new PortfolioSnapshotService(null); + portfolioSnapshotService = new PortfolioSnapshotService(null, null); redisCacheService = new RedisCacheService(null, null); @@ -85,33 +88,33 @@ describe('PortfolioCalculator', () => { const activities: Activity[] = [ { ...activityDummyData, - date: new Date('2021-11-22'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'CHF', dataSource: 'YAHOO', name: 'Bâloise Holding AG', symbol: 'BALN.SW' }, + date: new Date('2021-11-22'), + feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, + quantity: 2, type: 'BUY', unitPriceInAssetProfileCurrency: 142.9 }, { ...activityDummyData, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.65, - feeInBaseCurrency: 1.65, - quantity: 2, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'CHF', dataSource: 'YAHOO', name: 'Bâloise Holding AG', symbol: 'BALN.SW' }, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 1.65, + feeInBaseCurrency: 1.65, + quantity: 2, type: 'SELL', unitPriceInAssetProfileCurrency: 136.6 } diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts index 6207f1417..a2d576361 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts @@ -1,6 +1,6 @@ import { activityDummyData, - symbolProfileDummyData, + assetProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); @@ -65,7 +68,7 @@ describe('PortfolioCalculator', () => { null ); - portfolioSnapshotService = new PortfolioSnapshotService(null); + portfolioSnapshotService = new PortfolioSnapshotService(null, null); redisCacheService = new RedisCacheService(null, null); @@ -85,17 +88,17 @@ describe('PortfolioCalculator', () => { const activities: Activity[] = [ { ...activityDummyData, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'CHF', dataSource: 'YAHOO', name: 'Bâloise Holding AG', symbol: 'BALN.SW' }, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, + quantity: 2, type: 'BUY', unitPriceInAssetProfileCurrency: 136.6 } @@ -217,17 +220,17 @@ describe('PortfolioCalculator', () => { const activities: Activity[] = [ { ...activityDummyData, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'CHF', dataSource: 'YAHOO', name: 'Bâloise Holding AG', symbol: 'BALN.SW' }, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, + quantity: 2, type: 'BUY', unitPriceInAssetProfileCurrency: 135.0 } @@ -257,17 +260,17 @@ describe('PortfolioCalculator', () => { const activities: Activity[] = [ { ...activityDummyData, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'CHF', dataSource: 'YAHOO', name: 'Bâloise Holding AG', symbol: 'BALN.SW' }, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, + quantity: 2, type: 'BUY', unitPriceInAssetProfileCurrency: 135.0 } diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts index 774c1d2f6..1143e3bd2 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts @@ -1,7 +1,7 @@ import { activityDummyData, + assetProfileDummyData, loadExportFile, - symbolProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -76,6 +76,9 @@ describe('PortfolioCalculator', () => { }); beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); @@ -87,7 +90,7 @@ describe('PortfolioCalculator', () => { null ); - portfolioSnapshotService = new PortfolioSnapshotService(null); + portfolioSnapshotService = new PortfolioSnapshotService(null, null); redisCacheService = new RedisCacheService(null, null); @@ -107,17 +110,17 @@ describe('PortfolioCalculator', () => { const activities: Activity[] = exportResponse.activities.map( (activity) => ({ ...activityDummyData, - ...activity, - date: parseDate(activity.date), - feeInAssetProfileCurrency: 4.46, - feeInBaseCurrency: 3.94, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: activity.dataSource, name: 'Bitcoin', symbol: activity.symbol }, + ...activity, + date: parseDate(activity.date), + feeInAssetProfileCurrency: 4.46, + feeInBaseCurrency: 3.94, unitPriceInAssetProfileCurrency: 44558.42 }) ); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts index 055356325..e5b0d69d6 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts @@ -1,7 +1,7 @@ import { activityDummyData, + assetProfileDummyData, loadExportFile, - symbolProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -64,6 +64,9 @@ describe('PortfolioCalculator', () => { }); beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); @@ -75,7 +78,7 @@ describe('PortfolioCalculator', () => { null ); - portfolioSnapshotService = new PortfolioSnapshotService(null); + portfolioSnapshotService = new PortfolioSnapshotService(null, null); redisCacheService = new RedisCacheService(null, null); @@ -95,17 +98,17 @@ describe('PortfolioCalculator', () => { const activities: Activity[] = exportResponse.activities.map( (activity) => ({ ...activityDummyData, - ...activity, - date: parseDate(activity.date), - feeInAssetProfileCurrency: 4.46, - feeInBaseCurrency: 4.46, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: activity.dataSource, name: 'Bitcoin', symbol: activity.symbol }, + ...activity, + date: parseDate(activity.date), + feeInAssetProfileCurrency: 4.46, + feeInBaseCurrency: 4.46, unitPriceInAssetProfileCurrency: 44558.42 }) ); @@ -145,7 +148,7 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentageWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0, netWorth: 0, - totalAccountBalance: 0, + totalCashInBaseCurrency: 0, totalInvestment: 0, totalInvestmentValueWithCurrencyEffect: 0, value: 0, @@ -163,7 +166,7 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentageWithCurrencyEffect: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412 netPerformanceWithCurrencyEffect: 5535.42, netWorth: 50098.3, // 1 * 50098.3 = 50098.3 - totalAccountBalance: 0, + totalCashInBaseCurrency: 0, totalInvestment: 44558.42, totalInvestmentValueWithCurrencyEffect: 44558.42, value: 50098.3, // 1 * 50098.3 = 50098.3 @@ -182,7 +185,7 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentageWithCurrencyEffect: -0.032837340282712, netPerformanceWithCurrencyEffect: -1463.18, netWorth: 43099.7, - totalAccountBalance: 0, + totalCashInBaseCurrency: 0, totalInvestment: 44558.42, totalInvestmentValueWithCurrencyEffect: 44558.42, value: 43099.7, diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts index 11765fc49..ea1df4203 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts @@ -1,6 +1,6 @@ import { activityDummyData, - symbolProfileDummyData, + assetProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -66,6 +66,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); @@ -77,7 +80,7 @@ describe('PortfolioCalculator', () => { null ); - portfolioSnapshotService = new PortfolioSnapshotService(null); + portfolioSnapshotService = new PortfolioSnapshotService(null, null); redisCacheService = new RedisCacheService(null, null); @@ -98,33 +101,33 @@ describe('PortfolioCalculator', () => { const activities: Activity[] = [ { ...activityDummyData, - date: new Date('2015-01-01'), - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - quantity: 2, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: 'YAHOO', name: 'Bitcoin USD', symbol: 'BTCUSD' }, + date: new Date('2015-01-01'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 2, type: 'BUY', unitPriceInAssetProfileCurrency: 320.43 }, { ...activityDummyData, - date: new Date('2017-12-31'), - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - quantity: 1, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: 'YAHOO', name: 'Bitcoin USD', symbol: 'BTCUSD' }, + date: new Date('2017-12-31'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, type: 'SELL', unitPriceInAssetProfileCurrency: 14156.4 } diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts index 6a45f79c6..93d91c500 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts @@ -1,7 +1,7 @@ import { activityDummyData, + assetProfileDummyData, loadExportFile, - symbolProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -64,6 +64,9 @@ describe('PortfolioCalculator', () => { }); beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); @@ -75,7 +78,7 @@ describe('PortfolioCalculator', () => { null ); - portfolioSnapshotService = new PortfolioSnapshotService(null); + portfolioSnapshotService = new PortfolioSnapshotService(null, null); redisCacheService = new RedisCacheService(null, null); @@ -96,16 +99,16 @@ describe('PortfolioCalculator', () => { (activity) => ({ ...activityDummyData, ...activity, - date: parseDate(activity.date), - feeInAssetProfileCurrency: activity.fee, - feeInBaseCurrency: activity.fee, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: activity.dataSource, name: 'Bitcoin', symbol: activity.symbol }, + date: parseDate(activity.date), + feeInAssetProfileCurrency: activity.fee, + feeInBaseCurrency: activity.fee, unitPriceInAssetProfileCurrency: activity.unitPrice }) ); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts index 64882061f..1fa2d1264 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts @@ -1,7 +1,7 @@ import { activityDummyData, + assetProfileDummyData, loadExportFile, - symbolProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -64,6 +64,9 @@ describe('PortfolioCalculator', () => { }); beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); @@ -75,7 +78,7 @@ describe('PortfolioCalculator', () => { null ); - portfolioSnapshotService = new PortfolioSnapshotService(null); + portfolioSnapshotService = new PortfolioSnapshotService(null, null); redisCacheService = new RedisCacheService(null, null); @@ -96,16 +99,16 @@ describe('PortfolioCalculator', () => { (activity) => ({ ...activityDummyData, ...activity, - date: parseDate(activity.date), - feeInAssetProfileCurrency: 4.46, - feeInBaseCurrency: 4.46, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: activity.dataSource, name: 'Bitcoin', symbol: activity.symbol }, + date: parseDate(activity.date), + feeInAssetProfileCurrency: 4.46, + feeInBaseCurrency: 4.46, unitPriceInAssetProfileCurrency: 44558.42 }) ); @@ -145,7 +148,7 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentageWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0, netWorth: 0, - totalAccountBalance: 0, + totalCashInBaseCurrency: 0, totalInvestment: 0, totalInvestmentValueWithCurrencyEffect: 0, value: 0, @@ -163,7 +166,7 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentageWithCurrencyEffect: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412 netPerformanceWithCurrencyEffect: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42 netWorth: 50098.3, // 1 * 50098.3 = 50098.3 - totalAccountBalance: 0, + totalCashInBaseCurrency: 0, totalInvestment: 44558.42, totalInvestmentValueWithCurrencyEffect: 44558.42, value: 50098.3, // 1 * 50098.3 = 50098.3 @@ -182,7 +185,7 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentageWithCurrencyEffect: -0.032837340282712, netPerformanceWithCurrencyEffect: -1463.18, netWorth: 43099.7, - totalAccountBalance: 0, + totalCashInBaseCurrency: 0, totalInvestment: 44558.42, totalInvestmentValueWithCurrencyEffect: 44558.42, value: 43099.7, diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts index 217a67c49..3b09bfd26 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts @@ -1,7 +1,11 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; -import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { + activityDummyData, + assetProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; @@ -19,6 +23,7 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance import { DataSource } from '@prisma/client'; import { Big } from 'big.js'; +import { eachDayOfInterval } from 'date-fns'; import { randomUUID } from 'node:crypto'; jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { @@ -72,6 +77,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); exchangeRateDataService = new ExchangeRateDataService( @@ -91,6 +99,7 @@ describe('PortfolioCalculator', () => { accountBalanceService, null, exchangeRateDataService, + null, null ); @@ -116,14 +125,17 @@ describe('PortfolioCalculator', () => { accountBalanceService, accountService, null, + null, dataProviderService, null, exchangeRateDataService, null, + null, + null, null ); - portfolioSnapshotService = new PortfolioSnapshotService(null); + portfolioSnapshotService = new PortfolioSnapshotService(null, null); portfolioCalculatorFactory = new PortfolioCalculatorFactory( configurationService, @@ -146,17 +158,25 @@ describe('PortfolioCalculator', () => { balances: [ { accountId, - id: randomUUID(), date: parseDate('2023-12-31'), + id: randomUUID(), value: 1000, valueInBaseCurrency: 850 }, { accountId, - id: randomUUID(), date: parseDate('2024-12-31'), + id: randomUUID(), value: 2000, valueInBaseCurrency: 1800 + }, + { + // Ignored future account balance + accountId, + date: parseDate('2050-12-31'), + id: randomUUID(), + value: 0, + valueInBaseCurrency: 0 } ] }); @@ -169,7 +189,6 @@ describe('PortfolioCalculator', () => { createdAt: parseDate('2023-12-31'), currency: 'USD', id: accountId, - isExcluded: false, name: 'USD', platformId: null, updatedAt: parseDate('2023-12-31'), @@ -244,7 +263,6 @@ describe('PortfolioCalculator', () => { '0.08211603004634809014' ), grossPerformanceWithCurrencyEffect: new Big(70), - includeInTotalAssetValue: false, investment: new Big(1820), investmentWithCurrencyEffect: new Big(1750), marketPrice: 1, @@ -279,11 +297,317 @@ describe('PortfolioCalculator', () => { }); expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big(1820), + hasErrors: false, + totalCashInBaseCurrency: new Big(1820), + totalFeesWithCurrencyEffect: new Big(0), + totalInterestWithCurrencyEffect: new Big(0), + totalInvestment: new Big(1820), + totalLiabilitiesWithCurrencyEffect: new Big(0) + }); + + /** + * Value with currency effect: 2000 USD * 0.91 = 1820 CHF + * Net worth: 1820 CHF (the cash is included in the value and therefore + * not added on top of it again) + * Cash in base currency: 2000 USD * 0.91 = 1820 CHF (the whole portfolio + * consists of cash, hence it matches the value) + * Net performance with currency effect: 70 CHF / 852.45 CHF ≈ 8.21 % + */ + expect(portfolioSnapshot.historicalData.at(-1)).toEqual({ + date: '2025-01-01', + investmentValueWithCurrencyEffect: 0, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0.08211603004634808, + netPerformanceWithCurrencyEffect: 70, + netWorth: 1820, + totalCashInBaseCurrency: 1820, + totalInvestment: 1820, + totalInvestmentValueWithCurrencyEffect: 1750, + value: 1820, + valueWithCurrencyEffect: 1820 + }); + }); + + it('should exclude cash in the base currency from the performance calculation', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2025-01-01').getTime()); + + const accountId = randomUUID(); + + jest + .spyOn(accountBalanceService, 'getAccountBalances') + .mockResolvedValue({ + balances: [ + { + accountId, + date: parseDate('2023-12-31'), + id: randomUUID(), + value: 1000, + valueInBaseCurrency: 1000 + }, + { + accountId, + date: parseDate('2024-12-31'), + id: randomUUID(), + value: 2000, + valueInBaseCurrency: 2000 + } + ] + }); + + jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({ + accounts: [ + { + balance: 2000, + comment: null, + createdAt: parseDate('2023-12-31'), + currency: 'CHF', + id: accountId, + name: 'CHF', + platformId: null, + updatedAt: parseDate('2023-12-31'), + userId: userDummyData.id + } + ], + balanceInBaseCurrency: 2000 + }); + + jest + .spyOn(dataProviderService, 'getDataSourceForExchangeRates') + .mockReturnValue(DataSource.YAHOO); + + jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({ + activities: [], + count: 0 + }); + + const { activities } = + await activitiesService.getActivitiesForPortfolioCalculator({ + userCurrency: 'CHF', + userId: userDummyData.id, + withCash: true + }); + + jest.spyOn(currentRateService, 'getValues').mockResolvedValue({ + dataProviderInfos: [], + errors: [], + values: [] + }); + + const accountBalanceItems = + await accountBalanceService.getAccountBalanceItems({ + userCurrency: 'CHF', + userId: userDummyData.id + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + accountBalanceItems, + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const position = portfolioSnapshot.positions.find(({ symbol }) => { + return symbol === 'CHF'; + }); + + /** + * The holding itself keeps its investment and value so that it remains + * visible in the holdings table + */ + expect(position).toMatchObject>({ + currency: 'CHF', + grossPerformance: new Big(0), + grossPerformanceWithCurrencyEffect: new Big(0), + investment: new Big(2000), + investmentWithCurrencyEffect: new Big(2000), + netPerformance: new Big(0), + quantity: new Big(2000), + symbol: 'CHF', + valueInBaseCurrency: new Big(2000) + }); + + /** + * Total investment: 0 CHF (cash in the base currency cannot generate a + * currency effect and would only dilute the performance) + * Current value in base currency: 2000 CHF (the cash still counts + * towards the net worth) + */ + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big(2000), hasErrors: false, + totalCashInBaseCurrency: new Big(2000), totalFeesWithCurrencyEffect: new Big(0), totalInterestWithCurrencyEffect: new Big(0), + totalInvestment: new Big(0), totalLiabilitiesWithCurrencyEffect: new Big(0) }); + + /** + * Value: 0 CHF (the cash is excluded from the performance calculation + * and therefore from the value it is measured against) + * Net worth: 2000 CHF (the cash still counts towards the net worth) + */ + expect(portfolioSnapshot.historicalData.at(-1)).toEqual({ + date: '2025-01-01', + investmentValueWithCurrencyEffect: 0, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + netWorth: 2000, + totalCashInBaseCurrency: 2000, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0, + value: 0, + valueWithCurrencyEffect: 0 + }); + }); + + it('should add cash in the base currency to the net worth of a portfolio with holdings', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2025-01-01').getTime()); + + const accountId = randomUUID(); + + jest + .spyOn(accountBalanceService, 'getAccountBalances') + .mockResolvedValue({ + balances: [ + { + accountId, + date: parseDate('2023-12-31'), + id: randomUUID(), + value: 2000, + valueInBaseCurrency: 2000 + } + ] + }); + + jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({ + accounts: [ + { + balance: 2000, + comment: null, + createdAt: parseDate('2023-12-31'), + currency: 'CHF', + id: accountId, + name: 'CHF', + platformId: null, + updatedAt: parseDate('2023-12-31'), + userId: userDummyData.id + } + ], + balanceInBaseCurrency: 2000 + }); + + jest + .spyOn(dataProviderService, 'getDataSourceForExchangeRates') + .mockReturnValue(DataSource.YAHOO); + + jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({ + activities: [ + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Novartis AG', + symbol: 'NOVN.SW' + }, + date: parseDate('2023-12-31'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 2, + type: 'BUY', + unitPriceInAssetProfileCurrency: 100 + } + ], + count: 1 + }); + + const { activities } = + await activitiesService.getActivitiesForPortfolioCalculator({ + userCurrency: 'CHF', + userId: userDummyData.id, + withCash: true + }); + + // The cash symbol has no market data, the holding is quoted at a + // constant price so that it does not generate any performance on its own + jest + .spyOn(currentRateService, 'getValues') + .mockImplementation(({ dataGatheringItems, dateQuery }) => { + const values = []; + + for (const date of eachDayOfInterval({ + end: dateQuery.lt, + start: dateQuery.gte + })) { + for (const { dataSource, symbol } of dataGatheringItems) { + if (symbol === 'NOVN.SW') { + values.push({ date, dataSource, marketPrice: 100, symbol }); + } + } + } + + return Promise.resolve({ + values, + dataProviderInfos: [], + errors: [] + }); + }); + + const accountBalanceItems = + await accountBalanceService.getAccountBalanceItems({ + userCurrency: 'CHF', + userId: userDummyData.id + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + accountBalanceItems, + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + /** + * Total assets: 2000 CHF cash + 2 * 100 CHF holding = 2200 CHF + * Total investment: 200 CHF (only the holding, the cash is excluded) + */ + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big(2200), + hasErrors: false, + totalCashInBaseCurrency: new Big(2000), + totalInvestment: new Big(200) + }); + + /** + * Value: 200 CHF (the holding only, the cash is excluded from the + * performance calculation) + * Net worth: 2200 CHF (the value plus the cash, counted exactly once) + */ + expect(portfolioSnapshot.historicalData.at(-1)).toEqual({ + date: '2025-01-01', + investmentValueWithCurrencyEffect: 0, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + netWorth: 2200, + totalCashInBaseCurrency: 2000, + totalInvestment: 200, + totalInvestmentValueWithCurrencyEffect: 200, + value: 200, + valueWithCurrencyEffect: 200 + }); }); }); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts index a3fbc0758..000cc5935 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts @@ -1,6 +1,6 @@ import { activityDummyData, - symbolProfileDummyData, + assetProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); @@ -65,7 +68,7 @@ describe('PortfolioCalculator', () => { null ); - portfolioSnapshotService = new PortfolioSnapshotService(null); + portfolioSnapshotService = new PortfolioSnapshotService(null, null); redisCacheService = new RedisCacheService(null, null); @@ -85,17 +88,17 @@ describe('PortfolioCalculator', () => { const activities: Activity[] = [ { ...activityDummyData, - date: new Date('2021-09-01'), - feeInAssetProfileCurrency: 49, - feeInBaseCurrency: 49, - quantity: 0, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: 'MANUAL', name: 'Account Opening Fee', symbol: '2c463fb3-af07-486e-adb0-8301b3d72141' }, + date: new Date('2021-09-01'), + feeInAssetProfileCurrency: 49, + feeInBaseCurrency: 49, + quantity: 0, type: 'FEE', unitPriceInAssetProfileCurrency: 0 } @@ -113,7 +116,7 @@ describe('PortfolioCalculator', () => { expect(portfolioSnapshot).toMatchObject({ currentValueInBaseCurrency: new Big('0'), errors: [], - hasErrors: true, + hasErrors: false, positions: [], totalFeesWithCurrencyEffect: new Big('49'), totalInterestWithCurrencyEffect: new Big('0'), diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts index 122a9aaed..451973a9f 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts @@ -1,6 +1,6 @@ import { activityDummyData, - symbolProfileDummyData, + assetProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -66,6 +66,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); @@ -77,7 +80,7 @@ describe('PortfolioCalculator', () => { null ); - portfolioSnapshotService = new PortfolioSnapshotService(null); + portfolioSnapshotService = new PortfolioSnapshotService(null, null); redisCacheService = new RedisCacheService(null, null); @@ -97,17 +100,17 @@ describe('PortfolioCalculator', () => { const activities: Activity[] = [ { ...activityDummyData, - date: new Date('2023-01-03'), - feeInAssetProfileCurrency: 1, - feeInBaseCurrency: 0.9238, - quantity: 1, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: 'YAHOO', name: 'Alphabet Inc.', symbol: 'GOOGL' }, + date: new Date('2023-01-03'), + feeInAssetProfileCurrency: 1, + feeInBaseCurrency: 0.9238, + quantity: 1, type: 'BUY', unitPriceInAssetProfileCurrency: 89.12 } diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts index d5b22e864..2cc87934a 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts @@ -1,190 +1,193 @@ -import { - activityDummyData, - loadExportFile, - symbolProfileDummyData, - userDummyData -} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; -import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; -import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; -import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; -import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; -import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; -import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; -import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; -import { parseDate } from '@ghostfolio/common/helper'; -import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; -import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; - -import { Big } from 'big.js'; -import { join } from 'node:path'; - -jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { - return { - CurrentRateService: jest.fn().mockImplementation(() => { - return CurrentRateServiceMock; - }) - }; -}); - -jest.mock( - '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', - () => { - return { - PortfolioSnapshotService: jest.fn().mockImplementation(() => { - return PortfolioSnapshotServiceMock; - }) - }; - } -); - -jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { - return { - RedisCacheService: jest.fn().mockImplementation(() => { - return RedisCacheServiceMock; - }) - }; -}); - -describe('PortfolioCalculator', () => { - let exportResponse: ExportResponse; - - let configurationService: ConfigurationService; - let currentRateService: CurrentRateService; - let exchangeRateDataService: ExchangeRateDataService; - let portfolioCalculatorFactory: PortfolioCalculatorFactory; - let portfolioSnapshotService: PortfolioSnapshotService; - let redisCacheService: RedisCacheService; - - beforeAll(() => { - exportResponse = loadExportFile( - join( - __dirname, - '../../../../../../../test/import/ok/jnug-buy-and-sell-and-buy-and-sell.json' - ) - ); - }); - - beforeEach(() => { - configurationService = new ConfigurationService(); - - currentRateService = new CurrentRateService(null, null, null, null); - - exchangeRateDataService = new ExchangeRateDataService( - null, - null, - null, - null - ); - - portfolioSnapshotService = new PortfolioSnapshotService(null); - - redisCacheService = new RedisCacheService(null, null); - - portfolioCalculatorFactory = new PortfolioCalculatorFactory( - configurationService, - currentRateService, - exchangeRateDataService, - portfolioSnapshotService, - redisCacheService - ); - }); - - describe('get current positions', () => { - it.only('with JNUG buy and sell', async () => { - jest.useFakeTimers().setSystemTime(parseDate('2025-12-28').getTime()); - - const activities: Activity[] = exportResponse.activities.map( - (activity) => ({ - ...activityDummyData, - ...activity, - date: parseDate(activity.date), - feeInAssetProfileCurrency: activity.fee, - feeInBaseCurrency: activity.fee, - SymbolProfile: { - ...symbolProfileDummyData, - currency: activity.currency, - dataSource: activity.dataSource, - name: 'Direxion Daily Junior Gold Miners Index Bull 2X Shares', - symbol: activity.symbol - }, - unitPriceInAssetProfileCurrency: activity.unitPrice - }) - ); - - const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ - activities, - calculationType: PerformanceCalculationType.ROAI, - currency: exportResponse.user.settings.currency, - userId: userDummyData.id - }); - - const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); - - const investments = portfolioCalculator.getInvestments(); - - const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ - data: portfolioSnapshot.historicalData, - groupBy: 'month' - }); - - const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ - data: portfolioSnapshot.historicalData, - groupBy: 'year' - }); - - expect(portfolioSnapshot).toMatchObject({ - currentValueInBaseCurrency: new Big('0'), - errors: [], - hasErrors: false, - positions: [ - { - activitiesCount: 4, - averagePrice: new Big('0'), - currency: 'USD', - dataSource: 'YAHOO', - dateOfFirstActivity: '2025-12-11', - dividend: new Big('0'), - dividendInBaseCurrency: new Big('0'), - fee: new Big('4'), - feeInBaseCurrency: new Big('4'), - grossPerformance: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) - grossPerformanceWithCurrencyEffect: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) - investment: new Big('0'), - investmentWithCurrencyEffect: new Big('0'), - netPerformance: new Big('39.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4 - netPerformanceWithCurrencyEffectMap: { - max: new Big('39.95') // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4 - }, - marketPrice: 237.8000030517578, - marketPriceInBaseCurrency: 237.8000030517578, - quantity: new Big('0'), - symbol: 'JNUG', - tags: [], - valueInBaseCurrency: new Big('0') - } - ], - totalFeesWithCurrencyEffect: new Big('4'), - totalInterestWithCurrencyEffect: new Big('0'), - totalInvestment: new Big('0'), - totalInvestmentWithCurrencyEffect: new Big('0'), - totalLiabilitiesWithCurrencyEffect: new Big('0') - }); - - expect(investments).toEqual([ - { date: '2025-12-11', investment: new Big('1885.05') }, - { date: '2025-12-18', investment: new Big('2041.1') }, - { date: '2025-12-28', investment: new Big('0') } - ]); - - expect(investmentsByMonth).toEqual([ - { date: '2025-12-01', investment: 0 } - ]); - - expect(investmentsByYear).toEqual([ - { date: '2025-01-01', investment: 0 } - ]); - }); - }); -}); +import { + activityDummyData, + assetProfileDummyData, + loadExportFile, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; +import { join } from 'node:path'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let exportResponse: ExportResponse; + + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeAll(() => { + exportResponse = loadExportFile( + join( + __dirname, + '../../../../../../../test/import/ok/jnug-buy-and-sell-and-buy-and-sell.json' + ) + ); + }); + + beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null, null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with JNUG buy and sell', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2025-12-28').getTime()); + + const activities: Activity[] = exportResponse.activities.map( + (activity) => ({ + ...activityDummyData, + ...activity, + assetProfile: { + ...assetProfileDummyData, + currency: activity.currency, + dataSource: activity.dataSource, + name: 'Direxion Daily Junior Gold Miners Index Bull 2X Shares', + symbol: activity.symbol + }, + date: parseDate(activity.date), + feeInAssetProfileCurrency: activity.fee, + feeInBaseCurrency: activity.fee, + unitPriceInAssetProfileCurrency: activity.unitPrice + }) + ); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: exportResponse.user.settings.currency, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('0'), + errors: [], + hasErrors: false, + positions: [ + { + activitiesCount: 4, + averagePrice: new Big('0'), + currency: 'USD', + dataSource: 'YAHOO', + dateOfFirstActivity: '2025-12-11', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('4'), + feeInBaseCurrency: new Big('4'), + grossPerformance: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) + grossPerformanceWithCurrencyEffect: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) + investment: new Big('0'), + investmentWithCurrencyEffect: new Big('0'), + netPerformance: new Big('39.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4 + netPerformanceWithCurrencyEffectMap: { + max: new Big('39.95') // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4 + }, + marketPrice: 237.8000030517578, + marketPriceInBaseCurrency: 237.8000030517578, + quantity: new Big('0'), + symbol: 'JNUG', + tags: [], + valueInBaseCurrency: new Big('0') + } + ], + totalFeesWithCurrencyEffect: new Big('4'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('0'), + totalInvestmentWithCurrencyEffect: new Big('0'), + totalLiabilitiesWithCurrencyEffect: new Big('0') + }); + + expect(investments).toEqual([ + { date: '2025-12-11', investment: new Big('1885.05') }, + { date: '2025-12-18', investment: new Big('2041.1') }, + { date: '2025-12-28', investment: new Big('0') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2025-12-01', investment: 0 } + ]); + + expect(investmentsByYear).toEqual([ + { date: '2025-01-01', investment: 0 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts index acbf6a66b..68572c63e 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts @@ -1,6 +1,6 @@ import { activityDummyData, - symbolProfileDummyData, + assetProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); @@ -65,7 +68,7 @@ describe('PortfolioCalculator', () => { null ); - portfolioSnapshotService = new PortfolioSnapshotService(null); + portfolioSnapshotService = new PortfolioSnapshotService(null, null); redisCacheService = new RedisCacheService(null, null); @@ -85,17 +88,17 @@ describe('PortfolioCalculator', () => { const activities: Activity[] = [ { ...activityDummyData, - date: new Date('2023-01-01'), // Date in future - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - quantity: 1, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: 'MANUAL', name: 'Loan', symbol: '55196015-1365-4560-aa60-8751ae6d18f8' }, + date: new Date('2023-01-01'), // Date in future + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, type: 'LIABILITY', unitPriceInAssetProfileCurrency: 3000 } diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts index baa6ae1ed..53236f007 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts @@ -1,6 +1,6 @@ import { activityDummyData, - symbolProfileDummyData, + assetProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -52,6 +52,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); exchangeRateDataService = new ExchangeRateDataService( @@ -60,7 +63,7 @@ describe('PortfolioCalculator', () => { null, null ); - portfolioSnapshotService = new PortfolioSnapshotService(null); + portfolioSnapshotService = new PortfolioSnapshotService(null, null); redisCacheService = new RedisCacheService(null, null); portfolioCalculatorFactory = new PortfolioCalculatorFactory( configurationService, @@ -78,49 +81,49 @@ describe('PortfolioCalculator', () => { const activities: Activity[] = [ { ...activityDummyData, - date: new Date('2024-03-08'), - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - quantity: 0.3333333333333333, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: 'YAHOO', name: 'Microsoft Inc.', symbol: 'MSFT' }, + date: new Date('2024-03-08'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 0.3333333333333333, type: 'BUY', unitPriceInAssetProfileCurrency: 408 }, { ...activityDummyData, - date: new Date('2024-03-13'), - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - quantity: 0.6666666666666666, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: 'YAHOO', name: 'Microsoft Inc.', symbol: 'MSFT' }, + date: new Date('2024-03-13'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 0.6666666666666666, type: 'BUY', unitPriceInAssetProfileCurrency: 400 }, { ...activityDummyData, - date: new Date('2024-03-14'), - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - quantity: 1, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: 'YAHOO', name: 'Microsoft Inc.', symbol: 'MSFT' }, + date: new Date('2024-03-14'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, type: 'SELL', unitPriceInAssetProfileCurrency: 411 } diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts index e7eff6682..f6598f22b 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts @@ -1,6 +1,6 @@ import { activityDummyData, - symbolProfileDummyData, + assetProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); @@ -65,7 +68,7 @@ describe('PortfolioCalculator', () => { null ); - portfolioSnapshotService = new PortfolioSnapshotService(null); + portfolioSnapshotService = new PortfolioSnapshotService(null, null); redisCacheService = new RedisCacheService(null, null); @@ -85,33 +88,33 @@ describe('PortfolioCalculator', () => { const activities: Activity[] = [ { ...activityDummyData, - date: new Date('2021-09-16'), - feeInAssetProfileCurrency: 19, - feeInBaseCurrency: 19, - quantity: 1, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: 'YAHOO', name: 'Microsoft Inc.', symbol: 'MSFT' }, + date: new Date('2021-09-16'), + feeInAssetProfileCurrency: 19, + feeInBaseCurrency: 19, + quantity: 1, type: 'BUY', unitPriceInAssetProfileCurrency: 298.58 }, { ...activityDummyData, - date: new Date('2021-11-16'), - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - quantity: 1, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: 'YAHOO', name: 'Microsoft Inc.', symbol: 'MSFT' }, + date: new Date('2021-11-16'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, type: 'DIVIDEND', unitPriceInAssetProfileCurrency: 0.62 } diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-activities.spec.ts similarity index 97% rename from apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts rename to apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-activities.spec.ts index 6c47af7ca..fb7a43477 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-activities.spec.ts @@ -49,6 +49,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); @@ -60,7 +63,7 @@ describe('PortfolioCalculator', () => { null ); - portfolioSnapshotService = new PortfolioSnapshotService(null); + portfolioSnapshotService = new PortfolioSnapshotService(null, null); redisCacheService = new RedisCacheService(null, null); @@ -74,7 +77,7 @@ describe('PortfolioCalculator', () => { }); describe('get current positions', () => { - it('with no orders', async () => { + it('with no activities', async () => { jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts index 3034e3a1f..8c3858dcd 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts @@ -1,7 +1,7 @@ import { activityDummyData, + assetProfileDummyData, loadExportFile, - symbolProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -67,6 +67,9 @@ describe('PortfolioCalculator', () => { }); beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); @@ -78,7 +81,7 @@ describe('PortfolioCalculator', () => { null ); - portfolioSnapshotService = new PortfolioSnapshotService(null); + portfolioSnapshotService = new PortfolioSnapshotService(null, null); redisCacheService = new RedisCacheService(null, null); @@ -99,16 +102,16 @@ describe('PortfolioCalculator', () => { (activity) => ({ ...activityDummyData, ...activity, - date: parseDate(activity.date), - feeInAssetProfileCurrency: activity.fee, - feeInBaseCurrency: activity.fee, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: activity.currency, dataSource: activity.dataSource, name: 'Novartis AG', symbol: activity.symbol }, + date: parseDate(activity.date), + feeInAssetProfileCurrency: activity.fee, + feeInBaseCurrency: activity.fee, unitPriceInAssetProfileCurrency: activity.unitPrice }) ); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts index c79fdef58..364d173e1 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts @@ -1,264 +1,267 @@ -import { - activityDummyData, - loadExportFile, - symbolProfileDummyData, - userDummyData -} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; -import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; -import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; -import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; -import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; -import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; -import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; -import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; -import { parseDate } from '@ghostfolio/common/helper'; -import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; -import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; - -import { Big } from 'big.js'; -import { join } from 'node:path'; - -jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { - return { - CurrentRateService: jest.fn().mockImplementation(() => { - return CurrentRateServiceMock; - }) - }; -}); - -jest.mock( - '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', - () => { - return { - PortfolioSnapshotService: jest.fn().mockImplementation(() => { - return PortfolioSnapshotServiceMock; - }) - }; - } -); - -jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { - return { - RedisCacheService: jest.fn().mockImplementation(() => { - return RedisCacheServiceMock; - }) - }; -}); - -describe('PortfolioCalculator', () => { - let exportResponse: ExportResponse; - - let configurationService: ConfigurationService; - let currentRateService: CurrentRateService; - let exchangeRateDataService: ExchangeRateDataService; - let portfolioCalculatorFactory: PortfolioCalculatorFactory; - let portfolioSnapshotService: PortfolioSnapshotService; - let redisCacheService: RedisCacheService; - - beforeAll(() => { - exportResponse = loadExportFile( - join( - __dirname, - '../../../../../../../test/import/ok/novn-buy-and-sell.json' - ) - ); - }); - - beforeEach(() => { - configurationService = new ConfigurationService(); - - currentRateService = new CurrentRateService(null, null, null, null); - - exchangeRateDataService = new ExchangeRateDataService( - null, - null, - null, - null - ); - - portfolioSnapshotService = new PortfolioSnapshotService(null); - - redisCacheService = new RedisCacheService(null, null); - - portfolioCalculatorFactory = new PortfolioCalculatorFactory( - configurationService, - currentRateService, - exchangeRateDataService, - portfolioSnapshotService, - 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[] = exportResponse.activities.map( - (activity) => ({ - ...activityDummyData, - ...activity, - date: parseDate(activity.date), - feeInAssetProfileCurrency: activity.fee, - feeInBaseCurrency: activity.fee, - SymbolProfile: { - ...symbolProfileDummyData, - currency: activity.currency, - dataSource: activity.dataSource, - name: 'Novartis AG', - symbol: activity.symbol - }, - unitPriceInAssetProfileCurrency: activity.unitPrice - }) - ); - - const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ - activities, - calculationType: PerformanceCalculationType.ROAI, - currency: exportResponse.user.settings.currency, - userId: userDummyData.id - }); - - const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); - - const investments = portfolioCalculator.getInvestments(); - - const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ - data: portfolioSnapshot.historicalData, - groupBy: 'month' - }); - - const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ - data: portfolioSnapshot.historicalData, - groupBy: 'year' - }); - - 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 - }); - - /** - * Closing price on 2022-03-07 is unknown, - * hence it uses the last unit price (2022-04-11): 87.8 - */ - expect(portfolioSnapshot.historicalData[1]).toEqual({ - date: '2022-03-07', - investmentValueWithCurrencyEffect: 151.6, - netPerformance: 24, // 2 * (87.8 - 75.8) = 24 - netPerformanceInPercentage: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438 - netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438 - netPerformanceWithCurrencyEffect: 24, - netWorth: 175.6, // 2 * 87.8 = 175.6 - totalAccountBalance: 0, - totalInvestment: 151.6, - totalInvestmentValueWithCurrencyEffect: 151.6, - value: 175.6, // 2 * 87.8 = 175.6 - valueWithCurrencyEffect: 175.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: [ - { - activitiesCount: 2, - averagePrice: new Big('0'), - currency: 'CHF', - dataSource: 'YAHOO', - dateOfFirstActivity: '2022-03-07', - dividend: new Big('0'), - dividendInBaseCurrency: new Big('0'), - fee: new Big('0'), - feeInBaseCurrency: new Big('0'), - 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'), - valueInBaseCurrency: new Big('0') - } - ], - totalFeesWithCurrencyEffect: new Big('0'), - totalInterestWithCurrencyEffect: new Big('0'), - totalInvestment: new Big('0'), - totalInvestmentWithCurrencyEffect: new Big('0'), - totalLiabilitiesWithCurrencyEffect: new Big('0') - }); - - expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( - expect.objectContaining({ - netPerformance: 19.86, - netPerformanceInPercentage: 0.13100263852242744063, - netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063, - netPerformanceWithCurrencyEffect: 19.86, - totalInvestment: 0, - 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 } - ]); - - expect(investmentsByYear).toEqual([ - { date: '2022-01-01', investment: 0 } - ]); - }); - }); -}); +import { + activityDummyData, + assetProfileDummyData, + loadExportFile, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; +import { join } from 'node:path'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let exportResponse: ExportResponse; + + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeAll(() => { + exportResponse = loadExportFile( + join( + __dirname, + '../../../../../../../test/import/ok/novn-buy-and-sell.json' + ) + ); + }); + + beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null, null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + 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[] = exportResponse.activities.map( + (activity) => ({ + ...activityDummyData, + ...activity, + assetProfile: { + ...assetProfileDummyData, + currency: activity.currency, + dataSource: activity.dataSource, + name: 'Novartis AG', + symbol: activity.symbol + }, + date: parseDate(activity.date), + feeInAssetProfileCurrency: activity.fee, + feeInBaseCurrency: activity.fee, + unitPriceInAssetProfileCurrency: activity.unitPrice + }) + ); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: exportResponse.user.settings.currency, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + + expect(portfolioSnapshot.historicalData[0]).toEqual({ + date: '2022-03-06', + investmentValueWithCurrencyEffect: 0, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + netWorth: 0, + totalCashInBaseCurrency: 0, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0, + value: 0, + valueWithCurrencyEffect: 0 + }); + + /** + * Closing price on 2022-03-07 is unknown, + * hence it uses the last unit price (2022-04-11): 87.8 + */ + expect(portfolioSnapshot.historicalData[1]).toEqual({ + date: '2022-03-07', + investmentValueWithCurrencyEffect: 151.6, + netPerformance: 24, // 2 * (87.8 - 75.8) = 24 + netPerformanceInPercentage: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438 + netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438 + netPerformanceWithCurrencyEffect: 24, + netWorth: 175.6, // 2 * 87.8 = 175.6 + totalCashInBaseCurrency: 0, + totalInvestment: 151.6, + totalInvestmentValueWithCurrencyEffect: 151.6, + value: 175.6, // 2 * 87.8 = 175.6 + valueWithCurrencyEffect: 175.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, + totalCashInBaseCurrency: 0, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0, + value: 0, + valueWithCurrencyEffect: 0 + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('0'), + errors: [], + hasErrors: false, + positions: [ + { + activitiesCount: 2, + averagePrice: new Big('0'), + currency: 'CHF', + dataSource: 'YAHOO', + dateOfFirstActivity: '2022-03-07', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('0'), + feeInBaseCurrency: new Big('0'), + 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'), + valueInBaseCurrency: new Big('0') + } + ], + totalFeesWithCurrencyEffect: new Big('0'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('0'), + totalInvestmentWithCurrencyEffect: new Big('0'), + totalLiabilitiesWithCurrencyEffect: new Big('0') + }); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + netPerformance: 19.86, + netPerformanceInPercentage: 0.13100263852242744063, + netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063, + netPerformanceWithCurrencyEffect: 19.86, + totalInvestment: 0, + 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 } + ]); + + expect(investmentsByYear).toEqual([ + { date: '2022-01-01', investment: 0 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts index e518a5994..ce5f90f5c 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts @@ -1,6 +1,6 @@ import { activityDummyData, - symbolProfileDummyData, + assetProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); @@ -65,7 +68,7 @@ describe('PortfolioCalculator', () => { null ); - portfolioSnapshotService = new PortfolioSnapshotService(null); + portfolioSnapshotService = new PortfolioSnapshotService(null, null); redisCacheService = new RedisCacheService(null, null); @@ -85,17 +88,17 @@ describe('PortfolioCalculator', () => { const activities: Activity[] = [ { ...activityDummyData, - date: new Date('2022-01-01'), - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - quantity: 1, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: 'MANUAL', name: 'Penthouse Apartment', symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde' }, + date: new Date('2022-01-01'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, type: 'BUY', unitPriceInAssetProfileCurrency: 500000 } diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts index 2841e9975..9a87af153 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -1,4 +1,5 @@ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; +import { PortfolioCalculatorPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-position.interface'; 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'; @@ -7,11 +8,10 @@ import { AssetProfileIdentifier, SymbolMetrics } from '@ghostfolio/common/interfaces'; -import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; +import { PortfolioSnapshot } from '@ghostfolio/common/models'; import { DateRange } from '@ghostfolio/common/types'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; -import { Logger } from '@nestjs/common'; import { Big } from 'big.js'; import { addMilliseconds, @@ -27,7 +27,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { private chartDates: string[]; protected calculateOverallPerformance( - positions: TimelinePosition[] + positions: PortfolioCalculatorPosition[] ): PortfolioSnapshot { let currentValueInBaseCurrency = new Big(0); let grossPerformance = new Big(0); @@ -41,17 +41,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { let totalTimeWeightedInvestment = new Big(0); let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0); - for (const currentPosition of positions.filter( - ({ includeInTotalAssetValue }) => { - return includeInTotalAssetValue; - } - )) { - if (currentPosition.feeInBaseCurrency) { - totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus( - currentPosition.feeInBaseCurrency - ); - } - + for (const currentPosition of positions) { if (currentPosition.valueInBaseCurrency) { currentValueInBaseCurrency = currentValueInBaseCurrency.plus( currentPosition.valueInBaseCurrency @@ -60,6 +50,16 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { hasErrors = true; } + if (!currentPosition.includeInPerformance) { + continue; + } + + if (currentPosition.feeInBaseCurrency) { + totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus( + currentPosition.feeInBaseCurrency + ); + } + if (currentPosition.investment) { totalInvestment = totalInvestment.plus(currentPosition.investment); @@ -96,9 +96,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { currentPosition.timeWeightedInvestmentWithCurrencyEffect ); } else if (!currentPosition.quantity.eq(0)) { - Logger.warn( - `Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`, - 'PortfolioCalculator' + this.logger.warn( + `Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})` ); hasErrors = true; @@ -119,6 +118,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { createdAt: new Date(), errors: [], historicalData: [], + totalCashInBaseCurrency: new Big(0), totalLiabilitiesWithCurrencyEffect: new Big(0) }; } @@ -194,12 +194,12 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { // Clone orders to keep the original values in this.orders let orders: PortfolioOrderItem[] = cloneDeep( - this.activities.filter(({ SymbolProfile }) => { - return SymbolProfile.symbol === symbol; + this.activities.filter(({ assetProfile }) => { + return assetProfile.symbol === symbol; }) ); - const isCash = orders[0]?.SymbolProfile?.assetSubClass === 'CASH'; + const isCash = orders[0]?.assetProfile?.assetSubClass === 'CASH'; if (orders.length <= 0) { return { @@ -238,6 +238,36 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { }; } + // The dividends, the interest and the liabilities are derived from the + // activities only. Accumulate them upfront so that they survive the bail + // out for symbols without a market price below. + for (const order of orders) { + 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 === 'LIABILITY') { + const liabilities = order.quantity.mul(order.unitPrice); + + totalLiabilities = totalLiabilities.plus(liabilities); + totalLiabilitiesInBaseCurrency = totalLiabilitiesInBaseCurrency.plus( + liabilities.mul(exchangeRateAtOrderDate ?? 1) + ); + } + } + const dateOfFirstTransaction = new Date(orders[0].date); const endDateString = format(end, DATE_FORMAT); @@ -265,7 +295,20 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { !unitPriceAtEndDate || (!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start)) ) { + // A missing market price can only affect the units which are held. The + // dividends, the interest and the liabilities do not hold any units and + // are therefore not in error. + const hasActivitiesWithUnits = orders.some(({ type }) => { + return ['BUY', 'SELL'].includes(type); + }); + return { + totalDividend, + totalDividendInBaseCurrency, + totalInterest, + totalInterestInBaseCurrency, + totalLiabilities, + totalLiabilitiesInBaseCurrency, currentValues: {}, currentValuesWithCurrencyEffect: {}, feesWithCurrencyEffect: new Big(0), @@ -273,7 +316,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { grossPerformancePercentage: new Big(0), grossPerformancePercentageWithCurrencyEffect: new Big(0), grossPerformanceWithCurrencyEffect: new Big(0), - hasErrors: true, + hasErrors: hasActivitiesWithUnits, initialValue: new Big(0), initialValueWithCurrencyEffect: new Big(0), investmentValuesAccumulated: {}, @@ -290,43 +333,35 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { 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) + totalInvestmentWithCurrencyEffect: new Big(0) }; } + const assetProfile: PortfolioOrderItem['assetProfile'] = { + dataSource, + symbol, + assetSubClass: isCash ? 'CASH' : undefined + }; + // Add a synthetic order at the start and the end date orders.push({ + assetProfile, date: startDateString, fee: new Big(0), feeInBaseCurrency: new Big(0), itemType: 'start', quantity: new Big(0), - SymbolProfile: { - dataSource, - symbol, - assetSubClass: isCash ? 'CASH' : undefined - }, type: 'BUY', unitPrice: unitPriceAtStartDate }); orders.push({ + assetProfile, date: endDateString, fee: new Big(0), feeInBaseCurrency: new Big(0), itemType: 'end', - SymbolProfile: { - dataSource, - symbol, - assetSubClass: isCash ? 'CASH' : undefined - }, quantity: new Big(0), type: 'BUY', unitPrice: unitPriceAtEndDate @@ -359,15 +394,11 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { } } else { orders.push({ + assetProfile, date: dateString, fee: new Big(0), feeInBaseCurrency: new Big(0), quantity: new Big(0), - SymbolProfile: { - dataSource, - symbol, - assetSubClass: isCash ? 'CASH' : undefined - }, type: 'BUY', unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice, unitPriceFromMarketData: @@ -423,29 +454,6 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { 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 === '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 diff --git a/apps/api/src/app/portfolio/current-rate.service.ts b/apps/api/src/app/portfolio/current-rate.service.ts index f0a451975..9cfeda3bd 100644 --- a/apps/api/src/app/portfolio/current-rate.service.ts +++ b/apps/api/src/app/portfolio/current-rate.service.ts @@ -51,13 +51,13 @@ export class CurrentRateService { const values: GetValueObject[] = []; if (includesToday) { - const quotesBySymbol = await this.dataProviderService.getQuotes({ + const quotes = await this.dataProviderService.getQuotes({ items: dataGatheringItems, user: this.request?.user }); for (const { dataSource, symbol } of dataGatheringItems) { - const quote = quotesBySymbol[symbol]; + const quote = quotes[getAssetProfileIdentifier({ dataSource, symbol })]; if (quote?.dataProviderInfo) { dataProviderInfos.push(quote.dataProviderInfo); diff --git a/apps/api/src/app/portfolio/errors/portfolio-snapshot-computation.error.ts b/apps/api/src/app/portfolio/errors/portfolio-snapshot-computation.error.ts new file mode 100644 index 000000000..074ac0bae --- /dev/null +++ b/apps/api/src/app/portfolio/errors/portfolio-snapshot-computation.error.ts @@ -0,0 +1,7 @@ +export class PortfolioSnapshotComputationError extends Error { + public constructor(message: string) { + super(message); + + this.name = 'PortfolioSnapshotComputationError'; + } +} diff --git a/apps/api/src/app/portfolio/get-details.dto.ts b/apps/api/src/app/portfolio/get-details.dto.ts new file mode 100644 index 000000000..e2a13e3b6 --- /dev/null +++ b/apps/api/src/app/portfolio/get-details.dto.ts @@ -0,0 +1,12 @@ +import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto'; + +import { Transform, TransformFnParams } from 'class-transformer'; +import { IsBoolean } from 'class-validator'; + +export class GetDetailsDto extends DateRangeFilterDto { + @IsBoolean() + @Transform(({ value }: TransformFnParams) => { + return value === 'true'; + }) + withMarkets? = false; +} diff --git a/apps/api/src/app/portfolio/get-dividends.dto.ts b/apps/api/src/app/portfolio/get-dividends.dto.ts new file mode 100644 index 000000000..f1dff4a97 --- /dev/null +++ b/apps/api/src/app/portfolio/get-dividends.dto.ts @@ -0,0 +1,10 @@ +import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto'; +import { GroupBy } from '@ghostfolio/common/types'; + +import { IsIn, IsOptional } from 'class-validator'; + +export class GetDividendsDto extends DateRangeFilterDto { + @IsIn(['month', 'year'] as GroupBy[]) + @IsOptional() + groupBy?: GroupBy; +} diff --git a/apps/api/src/app/portfolio/get-holdings.dto.ts b/apps/api/src/app/portfolio/get-holdings.dto.ts new file mode 100644 index 000000000..b776bfe0d --- /dev/null +++ b/apps/api/src/app/portfolio/get-holdings.dto.ts @@ -0,0 +1,14 @@ +import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto'; +import { HoldingType } from '@ghostfolio/common/types'; + +import { IsIn, IsOptional, IsString } from 'class-validator'; + +export class GetHoldingsDto extends DateRangeFilterDto { + @IsIn(['ACTIVE', 'CLOSED'] as HoldingType[]) + @IsOptional() + holdingType?: HoldingType; + + @IsOptional() + @IsString() + query?: string; +} diff --git a/apps/api/src/app/portfolio/get-investments.dto.ts b/apps/api/src/app/portfolio/get-investments.dto.ts new file mode 100644 index 000000000..bf312a543 --- /dev/null +++ b/apps/api/src/app/portfolio/get-investments.dto.ts @@ -0,0 +1,10 @@ +import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto'; +import { GroupBy } from '@ghostfolio/common/types'; + +import { IsIn, IsOptional } from 'class-validator'; + +export class GetInvestmentsDto extends DateRangeFilterDto { + @IsIn(['month', 'year'] as GroupBy[]) + @IsOptional() + groupBy?: GroupBy; +} diff --git a/apps/api/src/app/portfolio/get-performance.dto.ts b/apps/api/src/app/portfolio/get-performance.dto.ts new file mode 100644 index 000000000..5992c2a09 --- /dev/null +++ b/apps/api/src/app/portfolio/get-performance.dto.ts @@ -0,0 +1,12 @@ +import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto'; + +import { Transform, TransformFnParams } from 'class-transformer'; +import { IsBoolean } from 'class-validator'; + +export class GetPerformanceDto extends DateRangeFilterDto { + @IsBoolean() + @Transform(({ value }: TransformFnParams) => { + return value === 'true'; + }) + withExcludedAccounts? = false; +} diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-calculator-position.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-calculator-position.interface.ts new file mode 100644 index 000000000..f0cf8a774 --- /dev/null +++ b/apps/api/src/app/portfolio/interfaces/portfolio-calculator-position.interface.ts @@ -0,0 +1,6 @@ +import { TimelinePosition } from '@ghostfolio/common/models'; + +export interface PortfolioCalculatorPosition extends TimelinePosition { + includeInHoldings: boolean; + includeInPerformance: boolean; +} 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 2dbd68f12..becab8c04 100644 --- a/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts @@ -1,13 +1,13 @@ import { Activity } from '@ghostfolio/common/interfaces'; export interface PortfolioOrder extends Pick { + assetProfile: Pick< + Activity['assetProfile'], + 'assetSubClass' | 'currency' | 'dataSource' | 'name' | 'symbol' | 'userId' + >; date: string; fee: Big; feeInBaseCurrency: Big; quantity: Big; - SymbolProfile: Pick< - Activity['SymbolProfile'], - 'assetSubClass' | 'currency' | 'dataSource' | 'name' | 'symbol' | 'userId' - >; unitPrice: Big; } diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index cb6f21e8a..953976a4a 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -1,4 +1,5 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.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 { @@ -32,11 +33,7 @@ import { isRestrictedView, permissions } from '@ghostfolio/common/permissions'; -import type { - DateRange, - GroupBy, - RequestWithUser -} from '@ghostfolio/common/types'; +import type { RequestWithUser } from '@ghostfolio/common/types'; import { Body, @@ -58,6 +55,11 @@ import { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; import { Big } from 'big.js'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; +import { GetDetailsDto } from './get-details.dto'; +import { GetDividendsDto } from './get-dividends.dto'; +import { GetHoldingsDto } from './get-holdings.dto'; +import { GetInvestmentsDto } from './get-investments.dto'; +import { GetPerformanceDto } from './get-performance.dto'; import { PortfolioService } from './portfolio.service'; import { UpdateHoldingTagsDto } from './update-holding-tags.dto'; @@ -69,7 +71,8 @@ export class PortfolioController { private readonly configurationService: ConfigurationService, private readonly impersonationService: ImpersonationService, private readonly portfolioService: PortfolioService, - @Inject(REQUEST) private readonly request: RequestWithUser + @Inject(REQUEST) private readonly request: RequestWithUser, + private readonly userService: UserService ) {} @Get('details') @@ -79,22 +82,23 @@ export class PortfolioController { @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getDetails( @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, - @Query('accounts') filterByAccounts?: string, - @Query('assetClasses') filterByAssetClasses?: string, - @Query('dataSource') filterByDataSource?: string, - @Query('range') dateRange: DateRange = 'max', - @Query('symbol') filterBySymbol?: string, - @Query('tags') filterByTags?: string, - @Query('withMarkets') withMarketsParam = 'false' + @Query() + { + accounts: filterByAccounts, + assetClasses: filterByAssetClasses, + dataSource: filterByDataSource, + range, + symbol: filterBySymbol, + tags: filterByTags, + withMarkets + }: GetDetailsDto ): Promise { - const withMarkets = withMarketsParam === 'true'; - let hasDetails = true; let hasError = false; if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { hasDetails = - this.request.user.subscription.type === SubscriptionType.Premium; + this.request.user.subscription?.type === SubscriptionType.Premium; } const filters = this.apiService.buildFiltersFromQueryParams({ @@ -115,10 +119,10 @@ export class PortfolioController { platforms, summary } = await this.portfolioService.getDetails({ - dateRange, filters, impersonationId, withMarkets, + dateRange: range, userId: this.request.user.id, withSummary: true }); @@ -132,7 +136,7 @@ export class PortfolioController { if ( hasReadRestrictedAccessPermission({ impersonationId, - user: this.request.user + accesses: this.request.user?.accessesGet }) || isRestrictedView(this.request.user) ) { @@ -143,10 +147,10 @@ export class PortfolioController { .reduce((a, b) => a + b, 0); const totalValue = Object.values(holdings) - .filter(({ assetClass, assetSubClass }) => { + .filter(({ assetProfile }) => { return ( - assetClass !== AssetClass.LIQUIDITY && - assetSubClass !== AssetSubClass.CASH + assetProfile.assetClass !== AssetClass.LIQUIDITY && + assetProfile.assetSubClass !== AssetSubClass.CASH ); }) .map(({ valueInBaseCurrency }) => { @@ -176,7 +180,7 @@ export class PortfolioController { hasDetails === false || hasReadRestrictedAccessPermission({ impersonationId, - user: this.request.user + accesses: this.request.user?.accessesGet }) || isRestrictedView(this.request.user) ) { @@ -205,7 +209,9 @@ export class PortfolioController { 'liabilitiesInBaseCurrency', 'netPerformance', 'netPerformanceWithCurrencyEffect', + 'totalAssetsInBaseCurrency', 'totalBuy', + 'totalCashInBaseCurrency', 'totalInvestment', 'totalInvestmentValueWithCurrencyEffect', 'totalSell', @@ -216,22 +222,41 @@ export class PortfolioController { for (const [symbol, portfolioPosition] of Object.entries(holdings)) { holdings[symbol] = { ...portfolioPosition, - assetClass: - hasDetails || portfolioPosition.assetClass === AssetClass.LIQUIDITY - ? portfolioPosition.assetClass - : undefined, - assetSubClass: - hasDetails || portfolioPosition.assetSubClass === AssetSubClass.CASH - ? portfolioPosition.assetSubClass - : undefined, - countries: hasDetails ? portfolioPosition.countries : [], - currency: hasDetails ? portfolioPosition.currency : undefined, - holdings: hasDetails ? portfolioPosition.holdings : [], + assetProfile: { + ...portfolioPosition.assetProfile, + assetClass: + hasDetails || + portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY + ? portfolioPosition.assetProfile.assetClass + : undefined, + assetClassLabel: + hasDetails || + portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY + ? portfolioPosition.assetProfile.assetClassLabel + : undefined, + assetSubClass: + hasDetails || + portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH + ? portfolioPosition.assetProfile.assetSubClass + : undefined, + assetSubClassLabel: + hasDetails || + portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH + ? portfolioPosition.assetProfile.assetSubClassLabel + : undefined, + ...(hasDetails + ? {} + : { + countries: [], + currency: undefined, + holdings: [], + sectors: [] + }) + }, markets: hasDetails ? portfolioPosition.markets : undefined, marketsAdvanced: hasDetails ? portfolioPosition.marketsAdvanced - : undefined, - sectors: hasDetails ? portfolioPosition.sectors : [] + : undefined }; } @@ -302,34 +327,42 @@ export class PortfolioController { @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getDividends( @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, - @Query('accounts') filterByAccounts?: string, - @Query('assetClasses') filterByAssetClasses?: string, - @Query('dataSource') filterByDataSource?: string, - @Query('groupBy') groupBy?: GroupBy, - @Query('range') dateRange: DateRange = 'max', - @Query('symbol') filterBySymbol?: string, - @Query('tags') filterByTags?: string + @Query() + { + accounts, + assetClasses, + dataSource, + groupBy, + range, + symbol, + tags + }: GetDividendsDto ): Promise { const filters = this.apiService.buildFiltersFromQueryParams({ - filterByAccounts, - filterByAssetClasses, - filterByDataSource, - filterBySymbol, - filterByTags + filterByAccounts: accounts, + filterByAssetClasses: assetClasses, + filterByDataSource: dataSource, + filterBySymbol: symbol, + filterByTags: tags }); const impersonationUserId = await this.impersonationService.validateImpersonationId(impersonationId); - const userCurrency = this.request.user.settings.settings.baseCurrency; + const userId = impersonationUserId || this.request.user.id; - const { endDate, startDate } = getIntervalFromDateRange({ dateRange }); + const { settings } = await this.userService.user({ id: userId }); + const userCurrency = settings.settings.baseCurrency; + + const { endDate, startDate } = getIntervalFromDateRange({ + dateRange: range + }); const { activities } = await this.activitiesService.getActivities({ endDate, filters, startDate, userCurrency, - userId: impersonationUserId || this.request.user.id, + userId, types: ['DIVIDEND'] }); @@ -341,7 +374,7 @@ export class PortfolioController { if ( hasReadRestrictedAccessPermission({ impersonationId, - user: this.request.user + accesses: this.request.user?.accessesGet }) || isRestrictedView(this.request.user) ) { @@ -358,7 +391,7 @@ export class PortfolioController { if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - this.request.user.subscription.type === SubscriptionType.Basic + this.request.user.subscription?.type === SubscriptionType.Basic ) { dividends = dividends.map((item) => { return nullifyValuesInObject(item, ['investment']); @@ -402,29 +435,32 @@ export class PortfolioController { @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getHoldings( @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, - @Query('accounts') filterByAccounts?: string, - @Query('assetClasses') filterByAssetClasses?: string, - @Query('dataSource') filterByDataSource?: string, - @Query('holdingType') filterByHoldingType?: string, - @Query('query') filterBySearchQuery?: string, - @Query('range') dateRange: DateRange = 'max', - @Query('symbol') filterBySymbol?: string, - @Query('tags') filterByTags?: string + @Query() + { + accounts, + assetClasses, + dataSource, + holdingType, + query, + range, + symbol, + tags + }: GetHoldingsDto ): Promise { const filters = this.apiService.buildFiltersFromQueryParams({ - filterByAccounts, - filterByAssetClasses, - filterByDataSource, - filterByHoldingType, - filterBySearchQuery, - filterBySymbol, - filterByTags + filterByAccounts: accounts, + filterByAssetClasses: assetClasses, + filterByDataSource: dataSource, + filterByHoldingType: holdingType, + filterBySearchQuery: query, + filterBySymbol: symbol, + filterByTags: tags }); const holdings = await this.portfolioService.getHoldings({ - dateRange, filters, impersonationId, + dateRange: range, userId: this.request.user.id }); @@ -436,35 +472,38 @@ export class PortfolioController { @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getInvestments( @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, - @Query('accounts') filterByAccounts?: string, - @Query('assetClasses') filterByAssetClasses?: string, - @Query('dataSource') filterByDataSource?: string, - @Query('groupBy') groupBy?: GroupBy, - @Query('range') dateRange: DateRange = 'max', - @Query('symbol') filterBySymbol?: string, - @Query('tags') filterByTags?: string + @Query() + { + accounts, + assetClasses, + dataSource, + groupBy, + range, + symbol, + tags + }: GetInvestmentsDto ): Promise { const filters = this.apiService.buildFiltersFromQueryParams({ - filterByAccounts, - filterByAssetClasses, - filterByDataSource, - filterBySymbol, - filterByTags + filterByAccounts: accounts, + filterByAssetClasses: assetClasses, + filterByDataSource: dataSource, + filterBySymbol: symbol, + filterByTags: tags }); - let { investments, streaks } = await this.portfolioService.getInvestments({ - dateRange, - filters, - groupBy, - impersonationId, - savingsRate: this.request.user?.settings?.settings.savingsRate, - userId: this.request.user.id - }); + let { investments, savingsRate, streaks } = + await this.portfolioService.getInvestments({ + filters, + groupBy, + impersonationId, + dateRange: range, + userId: this.request.user.id + }); if ( hasReadRestrictedAccessPermission({ impersonationId, - user: this.request.user + accesses: this.request.user?.accessesGet }) || isRestrictedView(this.request.user) ) { @@ -482,11 +521,13 @@ export class PortfolioController { 'currentStreak', 'longestStreak' ]); + + savingsRate = null; } if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - this.request.user.subscription.type === SubscriptionType.Basic + this.request.user.subscription?.type === SubscriptionType.Basic ) { investments = investments.map((item) => { return nullifyValuesInObject(item, ['investment']); @@ -498,7 +539,7 @@ export class PortfolioController { ]); } - return { investments, streaks }; + return { investments, savingsRate, streaks }; } @Get('performance') @@ -509,36 +550,37 @@ export class PortfolioController { @Version('2') public async getPerformanceV2( @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, - @Query('accounts') filterByAccounts?: string, - @Query('assetClasses') filterByAssetClasses?: string, - @Query('dataSource') filterByDataSource?: string, - @Query('range') dateRange: DateRange = 'max', - @Query('symbol') filterBySymbol?: string, - @Query('tags') filterByTags?: string, - @Query('withExcludedAccounts') withExcludedAccountsParam = 'false' + @Query() + { + accounts, + assetClasses, + dataSource, + range, + symbol, + tags, + withExcludedAccounts + }: GetPerformanceDto ): Promise { - const withExcludedAccounts = withExcludedAccountsParam === 'true'; - const filters = this.apiService.buildFiltersFromQueryParams({ - filterByAccounts, - filterByAssetClasses, - filterByDataSource, - filterBySymbol, - filterByTags + filterByAccounts: accounts, + filterByAssetClasses: assetClasses, + filterByDataSource: dataSource, + filterBySymbol: symbol, + filterByTags: tags }); const performanceInformation = await this.portfolioService.getPerformance({ - dateRange, filters, impersonationId, withExcludedAccounts, + dateRange: range, userId: this.request.user.id }); if ( hasReadRestrictedAccessPermission({ impersonationId, - user: this.request.user + accesses: this.request.user?.accessesGet }) || isRestrictedView(this.request.user) || this.request.user.settings.settings.viewMode === 'ZEN' @@ -598,7 +640,7 @@ export class PortfolioController { if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - this.request.user.subscription.type === SubscriptionType.Basic + this.request.user.subscription?.type === SubscriptionType.Basic ) { performanceInformation.chart = performanceInformation.chart.map( (item) => { @@ -626,7 +668,7 @@ export class PortfolioController { if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - this.request.user.subscription.type === SubscriptionType.Basic + this.request.user.subscription?.type === SubscriptionType.Basic ) { for (const category of report.xRay.categories) { category.rules = null; @@ -647,13 +689,11 @@ export class PortfolioController { @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.getHolding({ dataSource, - impersonationId, symbol, userId: this.request.user.id }); @@ -667,7 +707,6 @@ export class PortfolioController { 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 d818195ca..7f7a894df 100644 --- a/apps/api/src/app/portfolio/portfolio.module.ts +++ b/apps/api/src/app/portfolio/portfolio.module.ts @@ -20,6 +20,7 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; +import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { Module } from '@nestjs/common'; @@ -50,6 +51,7 @@ import { RulesService } from './rules.service'; RedactValuesInResponseModule, RedisCacheModule, SymbolProfileModule, + TagModule, TransformDataSourceInRequestModule, TransformDataSourceInResponseModule, UserModule diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts new file mode 100644 index 000000000..eed3a27cb --- /dev/null +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -0,0 +1,514 @@ +import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; +import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; +import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { UserService } from '@ghostfolio/api/app/user/user.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 { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { UNKNOWN_KEY } from '@ghostfolio/common/config'; +import { parseDate } from '@ghostfolio/common/helper'; +import { + AssetProfileIdentifier, + PortfolioSummary +} from '@ghostfolio/common/interfaces'; +import { AccountWithBalance } from '@ghostfolio/common/types'; + +import { DataSource } from '@prisma/client'; +import { Big } from 'big.js'; +import { randomUUID } from 'node:crypto'; + +import { PortfolioService } from './portfolio.service'; + +describe('PortfolioService', () => { + let accountService: AccountService; + let activitiesService: ActivitiesService; + let configurationService: ConfigurationService; + let dataProviderService: DataProviderService; + let exchangeRateDataService: ExchangeRateDataService; + let impersonationService: ImpersonationService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioService: PortfolioService; + let symbolProfileService: SymbolProfileService; + let userService: UserService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + dataProviderService = new DataProviderService( + configurationService, + null, + null, + null, + null, + null + ); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + accountService = new AccountService( + null, + null, + exchangeRateDataService, + null, + null + ); + + activitiesService = new ActivitiesService( + null, + accountService, + null, + null, + dataProviderService, + null, + exchangeRateDataService, + null, + null, + null, + null + ); + + impersonationService = new ImpersonationService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + null, + exchangeRateDataService, + null, + null + ); + + symbolProfileService = new SymbolProfileService(null); + + userService = new UserService( + null, + null, + null, + null, + null, + null, + null, + null, + null + ); + + portfolioService = new PortfolioService( + null, + accountService, + activitiesService, + null, + portfolioCalculatorFactory, + dataProviderService, + exchangeRateDataService, + null, + impersonationService, + null, + null, + symbolProfileService, + userService + ); + }); + + describe('getAggregatedMarkets', () => { + const getAggregatedMarkets = (holdings: object) => { + return ( + portfolioService as unknown as { + getAggregatedMarkets: (aHoldings: object) => { + markets: Record< + string, + { valueInBaseCurrency: number; valueInPercentage: number } + >; + marketsAdvanced: Record; + }; + } + ).getAggregatedMarkets(holdings); + }; + + it('should distribute holdings with countries to their market and route holdings without countries (e.g. commodities, cryptocurrencies) to the unknown bucket', () => { + const holdings = { + 'GC=F': { + // Gold + assetProfile: { countries: [] }, + markets: { developedMarkets: 0, emergingMarkets: 0, otherMarkets: 0 }, + marketsAdvanced: { + asiaPacific: 0, + emergingMarkets: 0, + europe: 0, + japan: 0, + northAmerica: 0, + otherMarkets: 0 + }, + valueInBaseCurrency: 500 + }, + MSFT: { + assetProfile: { countries: [{ code: 'US', weight: 1 }] }, + markets: { developedMarkets: 1, emergingMarkets: 0, otherMarkets: 0 }, + marketsAdvanced: { + asiaPacific: 0, + emergingMarkets: 0, + europe: 0, + japan: 0, + northAmerica: 1, + otherMarkets: 0 + }, + valueInBaseCurrency: 1000 + } + }; + + const { markets, marketsAdvanced } = getAggregatedMarkets(holdings); + + expect(markets.developedMarkets.valueInBaseCurrency).toBe(1000); + expect(markets[UNKNOWN_KEY].valueInBaseCurrency).toBe(500); + + expect(markets.developedMarkets.valueInPercentage).toBeCloseTo( + 1000 / 1500 + ); + expect(markets[UNKNOWN_KEY].valueInPercentage).toBeCloseTo(500 / 1500); + + expect(marketsAdvanced.northAmerica.valueInBaseCurrency).toBe(1000); + expect(marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency).toBe(500); + }); + }); + + describe('getCashSymbolProfiles', () => { + it('should use the exchange-rate data source so the symbol-profile join in getDetails matches the calculator positions', () => { + jest + .spyOn(dataProviderService, 'getDataSourceForExchangeRates') + .mockReturnValue(DataSource.YAHOO); + + const cashDetails: CashDetails = { + accounts: [ + { + balance: 2000, + comment: null, + createdAt: parseDate('2024-01-01'), + currency: 'USD', + id: randomUUID(), + name: 'USD', + platformId: null, + updatedAt: parseDate('2024-01-01'), + userId: userDummyData.id + } + ], + balanceInBaseCurrency: 1820 + }; + + const assetProfiles = ( + portfolioService as unknown as { + getCashSymbolProfiles: ( + aCashDetails: CashDetails + ) => AssetProfileIdentifier[]; + } + ).getCashSymbolProfiles(cashDetails); + + expect(assetProfiles).toHaveLength(1); + expect(assetProfiles[0].dataSource).toBe(DataSource.YAHOO); + expect(assetProfiles[0].symbol).toBe('USD'); + }); + }); + + describe('getDetails', () => { + it('should return cash holdings when the calculator emits cash positions with the exchange-rate data source', async () => { + const accountId = randomUUID(); + + const cashAccount: AccountWithBalance = { + balance: 2000, + comment: null, + createdAt: parseDate('2024-01-01'), + currency: 'USD', + id: accountId, + name: 'USD', + platformId: null, + updatedAt: parseDate('2024-01-01'), + userId: userDummyData.id + }; + + jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({ + accounts: [cashAccount], + balanceInBaseCurrency: 1820 + }); + + jest + .spyOn(activitiesService, 'getActivitiesForPortfolioCalculator') + .mockResolvedValue({ activities: [], count: 0 }); + + jest + .spyOn(dataProviderService, 'getDataSourceForExchangeRates') + .mockReturnValue(DataSource.YAHOO); + + jest + .spyOn(impersonationService, 'validateImpersonationId') + .mockResolvedValue(null); + + jest + .spyOn(symbolProfileService, 'getSymbolProfiles') + .mockResolvedValue([]); + + jest.spyOn(userService, 'user').mockResolvedValue({ + accessesGet: [], + accounts: [], + activityCount: 0, + dataProviderGhostfolioDailyRequests: 0, + id: userDummyData.id, + settings: { + settings: { + baseCurrency: 'CHF' + } + } + } as unknown as Awaited>); + + const usdPosition = { + activitiesCount: 1, + averagePrice: new Big(1), + currency: 'USD', + dataSource: DataSource.YAHOO, + dateOfFirstActivity: '2024-01-01', + dividend: new Big(0), + dividendInBaseCurrency: new Big(0), + fee: new Big(0), + feeInBaseCurrency: new Big(0), + grossPerformance: new Big(0), + grossPerformancePercentage: new Big(0), + grossPerformancePercentageWithCurrencyEffect: new Big(0), + grossPerformanceWithCurrencyEffect: new Big(0), + investment: new Big(1820), + investmentWithCurrencyEffect: new Big(1820), + marketPrice: 1, + marketPriceInBaseCurrency: 0.91, + netPerformance: new Big(0), + netPerformancePercentage: new Big(0), + netPerformancePercentageWithCurrencyEffectMap: {}, + netPerformanceWithCurrencyEffectMap: {}, + quantity: new Big(2000), + symbol: 'USD', + tags: [], + timeWeightedInvestment: new Big(0), + timeWeightedInvestmentWithCurrencyEffect: new Big(0), + valueInBaseCurrency: new Big(1820) + }; + + jest + .spyOn(portfolioCalculatorFactory, 'createCalculator') + .mockReturnValue({ + getSnapshot: jest.fn().mockResolvedValue({ + activitiesCount: 1, + createdAt: parseDate('2024-01-01'), + currentValueInBaseCurrency: new Big(1820), + errors: [], + hasErrors: false, + historicalData: [], + positions: [usdPosition], + totalFeesWithCurrencyEffect: new Big(0), + totalInterestWithCurrencyEffect: new Big(0), + totalInvestment: new Big(1820), + totalInvestmentWithCurrencyEffect: new Big(1820), + totalLiabilitiesWithCurrencyEffect: new Big(0) + }) + } as unknown as ReturnType< + typeof portfolioCalculatorFactory.createCalculator + >); + + jest + .spyOn( + portfolioService as unknown as { + getValueOfAccountsAndPlatforms: () => Promise<{ + accounts: object; + platforms: object; + }>; + }, + 'getValueOfAccountsAndPlatforms' + ) + .mockResolvedValue({ accounts: {}, platforms: {} }); + + const { holdings } = await portfolioService.getDetails({ + filters: [], + impersonationId: userDummyData.id, + userId: userDummyData.id + }); + + expect(holdings['USD']).toBeDefined(); + expect(holdings['USD'].assetProfile.dataSource).toBe(DataSource.YAHOO); + expect(holdings['USD'].assetProfile.symbol).toBe('USD'); + }); + }); + + describe('getSummary', () => { + const getSummary = (args: object) => { + return ( + portfolioService as unknown as { + getSummary: (aArgs: object) => Promise; + } + ).getSummary(args); + }; + + function createPortfolioCalculator() { + return { + getDividendInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)), + getFeesInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)), + getInterestInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)), + getLiabilitiesInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)), + getSnapshot: jest.fn().mockResolvedValue({ + currentValueInBaseCurrency: new Big(3000), + totalCashInBaseCurrency: new Big(1000), + totalInvestment: new Big(2000), + totalInvestmentWithCurrencyEffect: new Big(2000) + }), + getStartDate: jest.fn().mockReturnValue(parseDate('2024-01-01')) + } as unknown as PortfolioCalculator; + } + + beforeEach(() => { + jest + .spyOn(activitiesService, 'getActivities') + .mockResolvedValue({ activities: [], count: 0 }); + + jest + .spyOn(impersonationService, 'validateImpersonationId') + .mockResolvedValue(null); + + jest.spyOn(portfolioService, 'getPerformance').mockResolvedValue({ + performance: { + currentValueInBaseCurrency: 3000, + netPerformance: 500, + netPerformancePercentage: 0.2, + netPerformancePercentageWithCurrencyEffect: 0.2, + netPerformanceWithCurrencyEffect: 500 + } + } as Awaited>); + + jest.spyOn(userService, 'user').mockResolvedValue({ + id: userDummyData.id, + settings: { + settings: { + baseCurrency: 'CHF' + } + } + } as unknown as Awaited>); + }); + + it('should derive the cash and net worth from the account balance when there are no excluded accounts, no emergency fund and no liabilities', async () => { + jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({ + accounts: [], + balanceInBaseCurrency: 1000 + }); + + const portfolioCalculator = createPortfolioCalculator(); + + const summary = await getSummary({ + portfolioCalculator, + balanceInBaseCurrency: 1000, + emergencyFundHoldingsValueInBaseCurrency: 0, + filteredValueInBaseCurrency: new Big(3000), + impersonationId: undefined, + userCurrency: 'CHF', + userId: userDummyData.id + }); + + expect(summary.cash).toBe(1000); + expect(summary.emergencyFund.total).toBe(0); + expect(summary.excludedAccountsAndActivities).toBe(0); + expect(summary.totalAssetsInBaseCurrency).toBe(3000); + expect(summary.totalValueInBaseCurrency).toBe(3000); + }); + }); + + describe('getValueOfAccountsAndPlatforms', () => { + const getValueOfAccountsAndPlatforms = (args: object) => { + return ( + portfolioService as unknown as { + getValueOfAccountsAndPlatforms: (aArgs: object) => Promise<{ + accounts: Record; + platforms: Record; + }>; + } + ).getValueOfAccountsAndPlatforms(args); + }; + + const account = { + balance: 100, + currency: 'USD', + id: randomUUID(), + name: 'Account 1', + platform: { name: 'Platform 1' }, + platformId: randomUUID() + }; + + beforeEach(() => { + jest + .spyOn(accountService, 'getAccounts') + .mockResolvedValue([account] as unknown as AccountWithBalance[]); + + jest + .spyOn(exchangeRateDataService, 'toCurrency') + .mockImplementation((aValue) => aValue); + }); + + it('should group activities without an account into the unknown bucket of accounts and platforms', async () => { + const { accounts, platforms } = await getValueOfAccountsAndPlatforms({ + activities: [ + { + account, + accountId: account.id, + assetProfile: { symbol: 'AAPL' }, + quantity: 1, + type: 'BUY' + }, + { + account: null, + accountId: null, + assetProfile: { symbol: 'BABA' }, + quantity: 2, + type: 'BUY' + } + ], + filters: [], + portfolioItemsNow: { + AAPL: { marketPriceInBaseCurrency: 10 }, + BABA: { marketPriceInBaseCurrency: 20 } + }, + userCurrency: 'USD', + userId: userDummyData.id + }); + + // 100 (balance) + 1 * 10 (activity) + expect(accounts[account.id].valueInBaseCurrency).toBe(110); + expect(platforms[account.platformId].valueInBaseCurrency).toBe(110); + + // 2 * 20 (activity without an account) + expect(accounts[UNKNOWN_KEY].valueInBaseCurrency).toBe(40); + expect(platforms[UNKNOWN_KEY].valueInBaseCurrency).toBe(40); + }); + + it('should not create an unknown bucket when every activity has an account', async () => { + const { accounts, platforms } = await getValueOfAccountsAndPlatforms({ + activities: [ + { + account, + accountId: account.id, + assetProfile: { symbol: 'AAPL' }, + quantity: 1, + type: 'BUY' + } + ], + filters: [], + portfolioItemsNow: { + AAPL: { marketPriceInBaseCurrency: 10 } + }, + userCurrency: 'USD', + userId: userDummyData.id + }); + + expect(accounts[UNKNOWN_KEY]).toBeUndefined(); + expect(platforms[UNKNOWN_KEY]).toBeUndefined(); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index ade683d41..48ea66dac 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -32,6 +32,7 @@ import { } from '@ghostfolio/common/calculation-helper'; import { DEFAULT_CURRENCY, + DEFAULT_DATE_RANGE, TAG_ID_EMERGENCY_FUND, TAG_ID_EXCLUDE_FROM_ANALYSIS, UNKNOWN_KEY @@ -40,12 +41,14 @@ import { DATE_FORMAT, getAssetProfileIdentifier, getSum, + isAccountExcluded, parseDate } from '@ghostfolio/common/helper'; import { AccountsResponse, Activity, - EnhancedSymbolProfile, + AssetProfileIdentifier, + EnhancedAssetProfile, Filter, HistoricalDataItem, InvestmentItem, @@ -61,6 +64,7 @@ import { } from '@ghostfolio/common/interfaces'; import { TimelinePosition } from '@ghostfolio/common/models'; import { + AccountWithBalance, AccountWithValue, DateRange, GroupBy, @@ -72,7 +76,6 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance import { Inject, Injectable, Logger } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { - Account, Type as ActivityType, AssetClass, AssetSubClass, @@ -93,6 +96,7 @@ import { parseISO, set } from 'date-fns'; +import { groupBy } from 'lodash'; import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory'; @@ -107,6 +111,8 @@ const europeMarkets = require('../../assets/countries/europe-markets.json'); @Injectable() export class PortfolioService { + private readonly logger = new Logger(PortfolioService.name); + public constructor( private readonly accountBalanceService: AccountBalanceService, private readonly accountService: AccountService, @@ -134,20 +140,16 @@ export class PortfolioService { }): Promise { const where: Prisma.AccountWhereInput = { userId }; - const filterByAccount = filters?.find(({ type }) => { - return type === 'ACCOUNT'; - })?.id; - - const filterByDataSource = filters?.find(({ type }) => { - return type === 'DATA_SOURCE'; - })?.id; - - const filterBySymbol = filters?.find(({ type }) => { - return type === 'SYMBOL'; - })?.id; + const { + ACCOUNT: [filterByAccount] = [], + DATA_SOURCE: [filterByDataSource] = [], + SYMBOL: [filterBySymbol] = [] + } = groupBy(filters, ({ type }) => { + return type; + }); if (filterByAccount) { - where.id = filterByAccount; + where.id = filterByAccount.id; } if (filterByDataSource && filterBySymbol) { @@ -155,32 +157,38 @@ export class PortfolioService { some: { SymbolProfile: { AND: [ - { dataSource: filterByDataSource as DataSource }, - { symbol: filterBySymbol } + { dataSource: filterByDataSource.id as DataSource }, + { symbol: filterBySymbol.id } ] } } }; } - const [accounts, details] = await Promise.all([ + const filtersWithoutSearchQueryFilter = filters?.filter(({ type }) => { + return type !== 'SEARCH_QUERY'; + }); + + const [accounts, details, user] = await Promise.all([ this.accountService.accounts({ where, include: { activities: { include: { SymbolProfile: true } }, - platform: true + platform: true, + tags: true }, orderBy: { name: 'asc' } }), this.getDetails({ - filters, withExcludedAccounts, + filters: filtersWithoutSearchQueryFilter, impersonationId: userId, userId: this.request.user.id - }) + }), + this.userService.user({ id: userId }) ]); - const userCurrency = this.request.user.settings.settings.baseCurrency; + const userCurrency = this.getUserCurrency(user); return Promise.all( accounts.map(async (account) => { @@ -200,21 +208,21 @@ export class PortfolioService { switch (type) { case ActivityType.DIVIDEND: dividendInBaseCurrency += - await this.exchangeRateDataService.toCurrencyAtDate( + (await this.exchangeRateDataService.toCurrencyAtDate( new Big(quantity).mul(unitPrice).toNumber(), currency ?? SymbolProfile.currency, userCurrency, date - ); + )) ?? 0; break; case ActivityType.INTEREST: interestInBaseCurrency += - await this.exchangeRateDataService.toCurrencyAtDate( + (await this.exchangeRateDataService.toCurrencyAtDate( unitPrice, currency ?? SymbolProfile.currency, userCurrency, date - ); + )) ?? 0; break; } @@ -269,17 +277,20 @@ export class PortfolioService { let activitiesCount = 0; - const searchQuery = filters.find(({ type }) => { - return type === 'SEARCH_QUERY'; - })?.id; + const { SEARCH_QUERY: [filterBySearchQuery] = [] } = groupBy( + filters, + ({ type }) => { + return type; + } + ); - if (searchQuery) { + if (filterBySearchQuery) { const fuse = new Fuse(accounts, { keys: ['name', 'platform.name'], threshold: 0.3 }); - accounts = fuse.search(searchQuery).map(({ item }) => { + accounts = fuse.search(filterBySearchQuery.id).map(({ item }) => { return item; }); } @@ -362,26 +373,34 @@ export class PortfolioService { userId: string; }) { userId = await this.getUserId(impersonationId, userId); + + const { SEARCH_QUERY: [filterBySearchQuery] = [] } = groupBy( + filters, + ({ type }) => { + return type; + } + ); + + const filtersWithoutSearchQueryFilter = filters?.filter(({ type }) => { + return type !== 'SEARCH_QUERY'; + }); + const { holdings: holdingsMap } = await this.getDetails({ dateRange, - filters, impersonationId, - userId + userId, + filters: filtersWithoutSearchQueryFilter }); let holdings = Object.values(holdingsMap); - const searchQuery = filters.find(({ type }) => { - return type === 'SEARCH_QUERY'; - })?.id; - - if (searchQuery) { + if (filterBySearchQuery) { const fuse = new Fuse(holdings, { - keys: ['isin', 'name', 'symbol'], + keys: ['assetProfile.isin', 'assetProfile.name', 'assetProfile.symbol'], threshold: 0.3 }); - holdings = fuse.search(searchQuery).map(({ item }) => { + holdings = fuse.search(filterBySearchQuery.id).map(({ item }) => { return item; }); } @@ -394,19 +413,18 @@ export class PortfolioService { filters, groupBy, impersonationId, - savingsRate, userId }: { dateRange: DateRange; filters?: Filter[]; groupBy?: GroupBy; impersonationId: string; - savingsRate: number; userId: string; }): Promise { userId = await this.getUserId(impersonationId, userId); const user = await this.userService.user({ id: userId }); const userCurrency = this.getUserCurrency(user); + const savingsRate = (user.settings?.settings as UserSettings)?.savingsRate; const { endDate, startDate } = getIntervalFromDateRange({ dateRange }); @@ -419,6 +437,7 @@ export class PortfolioService { if (activities.length === 0) { return { + savingsRate, investments: [], streaks: { currentStreak: 0, longestStreak: 0 } }; @@ -465,12 +484,13 @@ export class PortfolioService { return { investments, + savingsRate, streaks }; } public async getDetails({ - dateRange = 'max', + dateRange = DEFAULT_DATE_RANGE, filters, impersonationId, userId, @@ -520,34 +540,16 @@ export class PortfolioService { const holdings: PortfolioDetails['holdings'] = {}; - const totalValueInBaseCurrency = currentValueInBaseCurrency.plus( - cashDetails.balanceInBaseCurrency - ); + const { + HOLDING_TYPE: [filterByHoldingType] = [], + TAG: [filterByTag] = [] + } = groupBy(filters, ({ type }) => { + return type; + }); - const isFilteredByAccount = - filters?.some(({ type }) => { - return type === 'ACCOUNT'; - }) ?? false; - - const isFilteredByClosedHoldings = - filters?.some(({ id, type }) => { - return id === 'CLOSED' && type === 'HOLDING_TYPE'; - }) ?? false; - - let filteredValueInBaseCurrency = isFilteredByAccount - ? totalValueInBaseCurrency - : currentValueInBaseCurrency; - - if ( - filters?.length === 0 || - (filters?.length === 1 && - filters[0].id === AssetClass.LIQUIDITY && - filters[0].type === 'ASSET_CLASS') - ) { - filteredValueInBaseCurrency = filteredValueInBaseCurrency.plus( - cashDetails.balanceInBaseCurrency - ); - } + const isFilteredByClosedHoldings = filterByHoldingType?.id === 'CLOSED'; + + let filteredValueInBaseCurrency = currentValueInBaseCurrency; const assetProfileIdentifiers = positions.map(({ dataSource, symbol }) => { return { @@ -564,7 +566,7 @@ export class PortfolioService { symbolProfiles.push(...cashSymbolProfiles); const symbolProfileMap: { - [assetProfileIdentifier: string]: EnhancedSymbolProfile; + [assetProfileIdentifier: string]: EnhancedAssetProfile; } = {}; for (const symbolProfile of symbolProfiles) { @@ -583,7 +585,6 @@ export class PortfolioService { for (const { activitiesCount, - currency, dataSource, dateOfFirstActivity, dividend, @@ -618,9 +619,8 @@ export class PortfolioService { symbolProfileMap[getAssetProfileIdentifier({ dataSource, symbol })]; if (!assetProfile) { - Logger.warn( - `Asset profile not found for ${symbol} (${dataSource})`, - 'PortfolioService' + this.logger.warn( + `Asset profile not found for ${symbol} (${dataSource})` ); continue; @@ -637,16 +637,13 @@ export class PortfolioService { holdings[symbol] = { activitiesCount, - currency, markets, marketsAdvanced, marketPrice, - symbol, tags, allocationInPercentage: filteredValueInBaseCurrency.eq(0) ? 0 : valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(), - assetClass: assetProfile.assetClass, assetProfile: { assetClass: assetProfile.assetClass, assetSubClass: assetProfile.assetSubClass, @@ -664,14 +661,12 @@ export class PortfolioService { }; } ), + isin: assetProfile.isin, name: assetProfile.name, sectors: assetProfile.sectors, symbol: assetProfile.symbol, url: assetProfile.url }, - assetSubClass: assetProfile.assetSubClass, - countries: assetProfile.countries, - dataSource: assetProfile.dataSource, dateOfFirstActivity: parseDate(dateOfFirstActivity), dividend: dividend?.toNumber() ?? 0, grossPerformance: grossPerformance?.toNumber() ?? 0, @@ -680,19 +675,7 @@ export class PortfolioService { 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: @@ -702,8 +685,6 @@ export class PortfolioService { netPerformanceWithCurrencyEffect: netPerformanceWithCurrencyEffectMap?.[dateRange]?.toNumber() ?? 0, quantity: quantity.toNumber(), - sectors: assetProfile.sectors, - url: assetProfile.url, valueInBaseCurrency: valueInBaseCurrency.toNumber() }; } @@ -717,11 +698,7 @@ export class PortfolioService { withExcludedAccounts }); - if ( - filters?.length === 1 && - filters[0].id === TAG_ID_EMERGENCY_FUND && - filters[0].type === 'TAG' - ) { + if (filters?.length === 1 && filterByTag?.id === TAG_ID_EMERGENCY_FUND) { const emergencyFundCashPositions = this.getCashPositions({ cashDetails, userCurrency, @@ -794,11 +771,9 @@ export class PortfolioService { symbol, userId }: { - dataSource: DataSource; - impersonationId: string; - symbol: string; + impersonationId?: string; userId: string; - }): Promise { + } & AssetProfileIdentifier): Promise { userId = await this.getUserId(impersonationId, userId); const user = await this.userService.user({ id: userId }); const userCurrency = this.getUserCurrency(user); @@ -813,10 +788,24 @@ export class PortfolioService { return undefined; } - const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([ + const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([ { dataSource, symbol } ]); + const assetProfile = + symbolProfile ?? + ({ + dataSource, + symbol, + assetClass: AssetClass.LIQUIDITY, + assetSubClass: AssetSubClass.CASH, + countries: [], + currency: symbol, + holdings: [], + name: symbol, + sectors: [] + } as EnhancedAssetProfile); + const portfolioCalculator = this.calculatorFactory.createCalculator({ activities, userId, @@ -859,10 +848,10 @@ export class PortfolioService { timeWeightedInvestmentWithCurrencyEffect } = holding; - const activitiesOfHolding = activities.filter(({ SymbolProfile }) => { + const activitiesOfHolding = activities.filter((activity) => { return ( - SymbolProfile.dataSource === dataSource && - SymbolProfile.symbol === symbol + activity.assetProfile.dataSource === dataSource && + activity.assetProfile.symbol === symbol ); }); @@ -894,24 +883,25 @@ export class PortfolioService { new Date() ); + const [firstActivity] = activitiesOfHolding; + const referenceUnitPrice = + firstActivity?.unitPriceInAssetProfileCurrency ?? marketPrice; + const historicalDataArray: HistoricalDataItem[] = []; - let marketPriceMax = Math.max( - activitiesOfHolding[0].unitPriceInAssetProfileCurrency, - marketPrice - ); + let marketPriceMax = Math.max(referenceUnitPrice, marketPrice); let marketPriceMaxDate = - marketPrice > activitiesOfHolding[0].unitPriceInAssetProfileCurrency + marketPrice > referenceUnitPrice ? new Date() - : activitiesOfHolding[0].date; - let marketPriceMin = Math.min( - activitiesOfHolding[0].unitPriceInAssetProfileCurrency, - marketPrice - ); + : (firstActivity?.date ?? new Date()); + let marketPriceMin = Math.min(referenceUnitPrice, marketPrice); + + const historicalDataItems = + historicalData[getAssetProfileIdentifier({ dataSource, symbol })]; - if (historicalData[symbol]) { + if (historicalDataItems) { let j = -1; for (const [date, { marketPrice }] of Object.entries( - historicalData[symbol] + historicalDataItems )) { while ( j + 1 < transactionPoints.length && @@ -954,10 +944,10 @@ export class PortfolioService { } else { // Add historical entry for buy date, if no historical data available historicalDataArray.push({ - averagePrice: activitiesOfHolding[0].unitPriceInAssetProfileCurrency, + averagePrice: referenceUnitPrice, date: dateOfFirstActivity, - marketPrice: activitiesOfHolding[0].unitPriceInAssetProfileCurrency, - quantity: activitiesOfHolding[0].quantity + marketPrice: referenceUnitPrice, + quantity: firstActivity?.quantity ?? quantity.toNumber() }); } @@ -973,8 +963,19 @@ export class PortfolioService { marketPrice, marketPriceMax, marketPriceMin, - SymbolProfile, tags, + assetProfile: { + assetClass: assetProfile.assetClass, + assetSubClass: assetProfile.assetSubClass, + countries: assetProfile.countries, + currency: assetProfile.currency, + dataSource: assetProfile.dataSource, + isin: assetProfile.isin, + name: assetProfile.name, + sectors: assetProfile.sectors, + symbol: assetProfile.symbol, + userId: assetProfile.userId + }, averagePrice: averagePrice.toNumber(), dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0], dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), @@ -1013,7 +1014,7 @@ export class PortfolioService { } public async getPerformance({ - dateRange = 'max', + dateRange = DEFAULT_DATE_RANGE, filters, impersonationId, userId @@ -1044,7 +1045,7 @@ export class PortfolioService { if (accountBalanceItems.length === 0 && activities.length === 0) { return { chart: [], - firstOrderDate: undefined, + dateOfFirstActivity: undefined, hasErrors: false, performance: { currentNetWorth: 0, @@ -1101,7 +1102,7 @@ export class PortfolioService { chart, errors, hasErrors, - firstOrderDate: parseDate(historicalData[0]?.date), + dateOfFirstActivity: parseDate(historicalData[0]?.date), performance: { netPerformance, netPerformanceWithCurrencyEffect, @@ -1134,6 +1135,8 @@ export class PortfolioService { withSummary: true }); + const hasOpenHoldings = Object.keys(holdings).length > 0; + const marketsAdvancedTotalInBaseCurrency = getSum( Object.values(marketsAdvanced).map(({ valueInBaseCurrency }) => { return new Big(valueInBaseCurrency); @@ -1193,26 +1196,25 @@ export class PortfolioService { id: 'rule.currencyClusterRisk.category', languageCode: userSettings.language }), - rules: - summary.activityCount > 0 - ? await this.rulesService.evaluate( - [ - new CurrencyClusterRiskBaseCurrencyCurrentInvestment( - this.exchangeRateDataService, - this.i18nService, - Object.values(holdings), - userSettings.language - ), - new CurrencyClusterRiskCurrentInvestment( - this.exchangeRateDataService, - this.i18nService, - Object.values(holdings), - userSettings.language - ) - ], - userSettings - ) - : undefined + rules: hasOpenHoldings + ? await this.rulesService.evaluate( + [ + new CurrencyClusterRiskBaseCurrencyCurrentInvestment( + this.exchangeRateDataService, + this.i18nService, + Object.values(holdings), + userSettings.language + ), + new CurrencyClusterRiskCurrentInvestment( + this.exchangeRateDataService, + this.i18nService, + Object.values(holdings), + userSettings.language + ) + ], + userSettings + ) + : undefined }, { key: 'assetClassClusterRisk', @@ -1220,26 +1222,25 @@ export class PortfolioService { id: 'rule.assetClassClusterRisk.category', languageCode: userSettings.language }), - rules: - summary.activityCount > 0 - ? await this.rulesService.evaluate( - [ - new AssetClassClusterRiskEquity( - this.exchangeRateDataService, - this.i18nService, - userSettings.language, - Object.values(holdings) - ), - new AssetClassClusterRiskFixedIncome( - this.exchangeRateDataService, - this.i18nService, - userSettings.language, - Object.values(holdings) - ) - ], - userSettings - ) - : undefined + rules: hasOpenHoldings + ? await this.rulesService.evaluate( + [ + new AssetClassClusterRiskEquity( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + Object.values(holdings) + ), + new AssetClassClusterRiskFixedIncome( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + Object.values(holdings) + ) + ], + userSettings + ) + : undefined }, { key: 'accountClusterRisk', @@ -1274,28 +1275,27 @@ export class PortfolioService { id: 'rule.economicMarketClusterRisk.category', languageCode: userSettings.language }), - rules: - summary.activityCount > 0 - ? await this.rulesService.evaluate( - [ - new EconomicMarketClusterRiskDevelopedMarkets( - this.exchangeRateDataService, - this.i18nService, - marketsTotalInBaseCurrency, - markets.developedMarkets.valueInBaseCurrency, - userSettings.language - ), - new EconomicMarketClusterRiskEmergingMarkets( - this.exchangeRateDataService, - this.i18nService, - marketsTotalInBaseCurrency, - markets.emergingMarkets.valueInBaseCurrency, - userSettings.language - ) - ], - userSettings - ) - : undefined + rules: hasOpenHoldings + ? await this.rulesService.evaluate( + [ + new EconomicMarketClusterRiskDevelopedMarkets( + this.exchangeRateDataService, + this.i18nService, + marketsTotalInBaseCurrency, + markets.developedMarkets.valueInBaseCurrency, + userSettings.language + ), + new EconomicMarketClusterRiskEmergingMarkets( + this.exchangeRateDataService, + this.i18nService, + marketsTotalInBaseCurrency, + markets.emergingMarkets.valueInBaseCurrency, + userSettings.language + ) + ], + userSettings + ) + : undefined }, { key: 'regionalMarketClusterRisk', @@ -1303,49 +1303,48 @@ export class PortfolioService { id: 'rule.regionalMarketClusterRisk.category', languageCode: userSettings.language }), - rules: - summary.activityCount > 0 - ? await this.rulesService.evaluate( - [ - new RegionalMarketClusterRiskAsiaPacific( - this.exchangeRateDataService, - this.i18nService, - userSettings.language, - marketsAdvancedTotalInBaseCurrency, - marketsAdvanced.asiaPacific.valueInBaseCurrency - ), - new RegionalMarketClusterRiskEmergingMarkets( - this.exchangeRateDataService, - this.i18nService, - userSettings.language, - marketsAdvancedTotalInBaseCurrency, - marketsAdvanced.emergingMarkets.valueInBaseCurrency - ), - new RegionalMarketClusterRiskEurope( - this.exchangeRateDataService, - this.i18nService, - userSettings.language, - marketsAdvancedTotalInBaseCurrency, - marketsAdvanced.europe.valueInBaseCurrency - ), - new RegionalMarketClusterRiskJapan( - this.exchangeRateDataService, - this.i18nService, - userSettings.language, - marketsAdvancedTotalInBaseCurrency, - marketsAdvanced.japan.valueInBaseCurrency - ), - new RegionalMarketClusterRiskNorthAmerica( - this.exchangeRateDataService, - this.i18nService, - userSettings.language, - marketsAdvancedTotalInBaseCurrency, - marketsAdvanced.northAmerica.valueInBaseCurrency - ) - ], - userSettings - ) - : undefined + rules: hasOpenHoldings + ? await this.rulesService.evaluate( + [ + new RegionalMarketClusterRiskAsiaPacific( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + marketsAdvancedTotalInBaseCurrency, + marketsAdvanced.asiaPacific.valueInBaseCurrency + ), + new RegionalMarketClusterRiskEmergingMarkets( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + marketsAdvancedTotalInBaseCurrency, + marketsAdvanced.emergingMarkets.valueInBaseCurrency + ), + new RegionalMarketClusterRiskEurope( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + marketsAdvancedTotalInBaseCurrency, + marketsAdvanced.europe.valueInBaseCurrency + ), + new RegionalMarketClusterRiskJapan( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + marketsAdvancedTotalInBaseCurrency, + marketsAdvanced.japan.valueInBaseCurrency + ), + new RegionalMarketClusterRiskNorthAmerica( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + marketsAdvancedTotalInBaseCurrency, + marketsAdvanced.northAmerica.valueInBaseCurrency + ) + ], + userSettings + ) + : undefined }, { key: 'fees', @@ -1382,19 +1381,13 @@ 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); - + } & AssetProfileIdentifier) { await this.activitiesService.assignTags({ dataSource, symbol, @@ -1471,31 +1464,29 @@ export class PortfolioService { for (const [, position] of Object.entries(holdings)) { const value = position.valueInBaseCurrency; - if (position.assetClass !== AssetClass.LIQUIDITY) { - if (position.countries.length > 0) { - markets.developedMarkets.valueInBaseCurrency += - position.markets.developedMarkets * value; - markets.emergingMarkets.valueInBaseCurrency += - position.markets.emergingMarkets * value; - markets.otherMarkets.valueInBaseCurrency += - position.markets.otherMarkets * value; - - marketsAdvanced.asiaPacific.valueInBaseCurrency += - position.marketsAdvanced.asiaPacific * value; - marketsAdvanced.emergingMarkets.valueInBaseCurrency += - position.marketsAdvanced.emergingMarkets * value; - marketsAdvanced.europe.valueInBaseCurrency += - position.marketsAdvanced.europe * value; - marketsAdvanced.japan.valueInBaseCurrency += - position.marketsAdvanced.japan * value; - marketsAdvanced.northAmerica.valueInBaseCurrency += - position.marketsAdvanced.northAmerica * value; - marketsAdvanced.otherMarkets.valueInBaseCurrency += - position.marketsAdvanced.otherMarkets * value; - } else { - markets[UNKNOWN_KEY].valueInBaseCurrency += value; - marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency += value; - } + if (position.assetProfile.countries.length > 0) { + markets.developedMarkets.valueInBaseCurrency += + position.markets.developedMarkets * value; + markets.emergingMarkets.valueInBaseCurrency += + position.markets.emergingMarkets * value; + markets.otherMarkets.valueInBaseCurrency += + position.markets.otherMarkets * value; + + marketsAdvanced.asiaPacific.valueInBaseCurrency += + position.marketsAdvanced.asiaPacific * value; + marketsAdvanced.emergingMarkets.valueInBaseCurrency += + position.marketsAdvanced.emergingMarkets * value; + marketsAdvanced.europe.valueInBaseCurrency += + position.marketsAdvanced.europe * value; + marketsAdvanced.japan.valueInBaseCurrency += + position.marketsAdvanced.japan * value; + marketsAdvanced.northAmerica.valueInBaseCurrency += + position.marketsAdvanced.northAmerica * value; + marketsAdvanced.otherMarkets.valueInBaseCurrency += + position.marketsAdvanced.otherMarkets * value; + } else { + markets[UNKNOWN_KEY].valueInBaseCurrency += value; + marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency += value; } } @@ -1597,7 +1588,7 @@ export class PortfolioService { ...new Set(cashDetails.accounts.map(({ currency }) => currency)) ]; - return cashSymbols.map((currency) => { + return cashSymbols.map((currency) => { const account = cashDetails.accounts.find( ({ currency: accountCurrency }) => { return accountCurrency === currency; @@ -1611,7 +1602,7 @@ export class PortfolioService { assetSubClass: AssetSubClass.CASH, countries: [], createdAt: account.createdAt, - dataSource: DataSource.MANUAL, + dataSource: this.dataProviderService.getDataSourceForExchangeRates(), holdings: [], id: currency, isActive: true, @@ -1718,11 +1709,8 @@ export class PortfolioService { currency: string; }): PortfolioPosition { return { - currency, activitiesCount: 0, allocationInPercentage: 0, - assetClass: AssetClass.LIQUIDITY, - assetSubClass: AssetSubClass.CASH, assetProfile: { currency, assetClass: AssetClass.LIQUIDITY, @@ -1734,35 +1722,25 @@ export class PortfolioService { sectors: [], symbol: currency }, - countries: [], - dataSource: undefined, dateOfFirstActivity: undefined, dividend: 0, grossPerformance: 0, grossPerformancePercent: 0, grossPerformancePercentWithCurrencyEffect: 0, grossPerformanceWithCurrencyEffect: 0, - holdings: [], investment: balance, marketPrice: 0, - name: currency, netPerformance: 0, netPerformancePercent: 0, netPerformancePercentWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0, quantity: 0, - sectors: [], - symbol: currency, tags: [], valueInBaseCurrency: balance }; } - private getMarkets({ - assetProfile - }: { - assetProfile: EnhancedSymbolProfile; - }) { + private getMarkets({ assetProfile }: { assetProfile: EnhancedAssetProfile }) { const markets = { [UNKNOWN_KEY]: 0, developedMarkets: 0, @@ -1914,7 +1892,7 @@ export class PortfolioService { for (const activity of activities) { if ( - activity.account?.isExcluded || + (activity.account && isAccountExcluded(activity.account)) || activity.tags?.some(({ id }) => { return id === TAG_ID_EXCLUDE_FROM_ANALYSIS; }) @@ -1926,9 +1904,10 @@ export class PortfolioService { } const { - currentValueInBaseCurrency, + totalCashInBaseCurrency, totalInvestment, - totalInvestmentWithCurrencyEffect + totalInvestmentWithCurrencyEffect, + currentValueInBaseCurrency: totalAssetsInBaseCurrency } = await portfolioCalculator.getSnapshot(); const { performance } = await this.getPerformance({ @@ -1937,6 +1916,7 @@ export class PortfolioService { }); const { + currentValueInBaseCurrency, netPerformance, netPerformancePercentage, netPerformancePercentageWithCurrencyEffect, @@ -2003,13 +1983,19 @@ export class PortfolioService { .plus(totalOfExcludedActivities) .toNumber(); - const netWorth = new Big(balanceInBaseCurrency) - .plus(currentValueInBaseCurrency) + // Exclude emergency fund from the financial independence calculation + const fireWealthInBaseCurrency = new Big(totalAssetsInBaseCurrency).minus( + totalEmergencyFund + ); + + const netWorth = new Big(totalAssetsInBaseCurrency) .plus(excludedAccountsAndActivities) .minus(liabilities) .toNumber(); - const daysInMarket = differenceInDays(new Date(), dateOfFirstActivity); + const daysInMarket = dateOfFirstActivity + ? differenceInDays(new Date(), dateOfFirstActivity) + : 0; const annualizedPerformancePercent = getAnnualizedPerformancePercent({ daysInMarket, @@ -2028,6 +2014,7 @@ export class PortfolioService { annualizedPerformancePercent, annualizedPerformancePercentWithCurrencyEffect, cash, + currentValueInBaseCurrency, dateOfFirstActivity, excludedAccountsAndActivities, netPerformance, @@ -2039,7 +2026,6 @@ export class PortfolioService { activityCount: activities.filter(({ type }) => { return ['BUY', 'SELL'].includes(type); }).length, - currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(), dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), emergencyFund: { assets: emergencyFundHoldingsValueInBaseCurrency, @@ -2055,9 +2041,9 @@ export class PortfolioService { : undefined, fireWealth: { today: { - valueInBaseCurrency: new Big(currentValueInBaseCurrency) - .minus(emergencyFundHoldingsValueInBaseCurrency) - .toNumber() + valueInBaseCurrency: fireWealthInBaseCurrency.gt(0) + ? fireWealthInBaseCurrency.toNumber() + : 0 } }, grossPerformance: new Big(netPerformance).plus(fees).toNumber(), @@ -2068,6 +2054,8 @@ export class PortfolioService { .toNumber(), interestInBaseCurrency: interest.toNumber(), liabilitiesInBaseCurrency: liabilities.toNumber(), + totalAssetsInBaseCurrency: totalAssetsInBaseCurrency.toNumber(), + totalCashInBaseCurrency: totalCashInBaseCurrency.toNumber(), totalInvestment: totalInvestment.toNumber(), totalInvestmentValueWithCurrencyEffect: totalInvestmentWithCurrencyEffect.toNumber(), @@ -2089,11 +2077,11 @@ export class PortfolioService { .filter(({ isDraft, type }) => { return isDraft === false && type === activityType; }) - .map(({ currency, quantity, SymbolProfile, unitPrice }) => { + .map(({ assetProfile, currency, quantity, unitPrice }) => { return new Big( this.exchangeRateDataService.toCurrency( new Big(quantity).mul(unitPrice).toNumber(), - currency ?? SymbolProfile.currency, + currency ?? assetProfile.currency, userCurrency ) ); @@ -2155,16 +2143,17 @@ export class PortfolioService { const accounts: PortfolioDetails['accounts'] = {}; const platforms: PortfolioDetails['platforms'] = {}; - let currentAccounts: (Account & { + let currentAccounts: (AccountWithBalance & { Order?: Order[]; platform?: Platform; + tags?: Tag[]; })[] = []; if (filters.length === 0) { currentAccounts = await this.accountService.getAccounts(userId); } else if (filters.length === 1 && filters[0].type === 'ACCOUNT') { currentAccounts = await this.accountService.accounts({ - include: { platform: true }, + include: { platform: true, tags: true }, where: { id: filters[0].id } }); } else { @@ -2181,61 +2170,60 @@ export class PortfolioService { ); currentAccounts = await this.accountService.accounts({ - include: { platform: true }, + include: { platform: true, tags: true }, where: { id: { in: accountIds } } }); } currentAccounts = currentAccounts.filter((account) => { - return withExcludedAccounts || account.isExcluded === false; + return withExcludedAccounts || !isAccountExcluded(account); }); - for (const account of currentAccounts) { + // Iterate over the accounts plus a null entry to group activities without + // an account into the unknown bucket + for (const account of [...currentAccounts, null]) { const ordersByAccount = activities.filter(({ accountId }) => { - return accountId === account.id; + return account ? accountId === account.id : !accountId; }); - accounts[account.id] = { - balance: account.balance, - currency: account.currency, - name: account.name, - valueInBaseCurrency: this.exchangeRateDataService.toCurrency( - account.balance, - account.currency, - userCurrency - ) - }; - - if (platforms[account.platformId || UNKNOWN_KEY]?.valueInBaseCurrency) { - platforms[account.platformId || UNKNOWN_KEY].valueInBaseCurrency += - this.exchangeRateDataService.toCurrency( - account.balance, - account.currency, - userCurrency - ); - } else { - platforms[account.platformId || UNKNOWN_KEY] = { + if (account) { + accounts[account.id] = { balance: account.balance, currency: account.currency, - name: account.platform?.name, + name: account.name, valueInBaseCurrency: this.exchangeRateDataService.toCurrency( account.balance, account.currency, userCurrency ) }; + + if (platforms[account.platformId || UNKNOWN_KEY]?.valueInBaseCurrency) { + platforms[account.platformId || UNKNOWN_KEY].valueInBaseCurrency += + this.exchangeRateDataService.toCurrency( + account.balance, + account.currency, + userCurrency + ); + } else { + platforms[account.platformId || UNKNOWN_KEY] = { + balance: account.balance, + currency: account.currency, + name: account.platform?.name, + valueInBaseCurrency: this.exchangeRateDataService.toCurrency( + account.balance, + account.currency, + userCurrency + ) + }; + } } - for (const { - account, - quantity, - SymbolProfile, - type - } of ordersByAccount) { + for (const { account, assetProfile, quantity, type } of ordersByAccount) { const currentValueOfSymbolInBaseCurrency = getFactor(type) * quantity * - (portfolioItemsNow[SymbolProfile.symbol]?.marketPriceInBaseCurrency ?? + (portfolioItemsNow[assetProfile.symbol]?.marketPriceInBaseCurrency ?? 0); if (accounts[account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) { 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 d0e3228b7..8d56c7c51 100644 --- a/apps/api/src/app/redis-cache/redis-cache.module.ts +++ b/apps/api/src/app/redis-cache/redis-cache.module.ts @@ -1,3 +1,4 @@ +import { getRedisConnectionUrl } from '@ghostfolio/api/helper/redis.helper'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; @@ -14,16 +15,8 @@ import { RedisCacheService } from './redis-cache.service'; imports: [ConfigurationModule], inject: [ConfigurationService], useFactory: async (configurationService: ConfigurationService) => { - const redisPassword = encodeURIComponent( - configurationService.get('REDIS_PASSWORD') - ); - return { - stores: [ - createKeyv( - `redis://${redisPassword ? `:${redisPassword}` : ''}@${configurationService.get('REDIS_HOST')}:${configurationService.get('REDIS_PORT')}/${configurationService.get('REDIS_DB')}` - ) - ], + stores: [createKeyv(getRedisConnectionUrl(configurationService))], ttl: configurationService.get('CACHE_TTL') }; } diff --git a/apps/api/src/app/redis-cache/redis-cache.service.mock.ts b/apps/api/src/app/redis-cache/redis-cache.service.mock.ts index feb669ab0..2a3c1cc7a 100644 --- a/apps/api/src/app/redis-cache/redis-cache.service.mock.ts +++ b/apps/api/src/app/redis-cache/redis-cache.service.mock.ts @@ -18,6 +18,9 @@ export const RedisCacheServiceMock = { return `portfolio-snapshot-${userId}${filtersHash > 0 ? `-${filtersHash}` : ''}`; }, + reset: () => { + RedisCacheServiceMock.cache.clear(); + }, set: (key: string, value: string): Promise => { RedisCacheServiceMock.cache.set(key, 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 619d23fc5..b87740f8c 100644 --- a/apps/api/src/app/redis-cache/redis-cache.service.ts +++ b/apps/api/src/app/redis-cache/redis-cache.service.ts @@ -10,6 +10,8 @@ import { createHash, randomUUID } from 'node:crypto'; @Injectable() export class RedisCacheService { + private readonly logger = new Logger(RedisCacheService.name); + private client: Keyv; public constructor( @@ -27,7 +29,7 @@ export class RedisCacheService { }; this.client.on('error', (error) => { - Logger.error(error, 'RedisCacheService'); + this.logger.error(error); }); } @@ -101,7 +103,7 @@ export class RedisCacheService { return true; } catch (error) { - Logger.error(error?.message, 'RedisCacheService'); + this.logger.error(error?.message); return false; } finally { diff --git a/apps/api/src/app/subscription/subscription.controller.ts b/apps/api/src/app/subscription/subscription.controller.ts index e1c705fdd..a70fe8791 100644 --- a/apps/api/src/app/subscription/subscription.controller.ts +++ b/apps/api/src/app/subscription/subscription.controller.ts @@ -33,6 +33,8 @@ import { SubscriptionService } from './subscription.service'; @Controller('subscription') export class SubscriptionController { + private readonly logger = new Logger(SubscriptionController.name); + public constructor( private readonly configurationService: ConfigurationService, private readonly propertyService: PropertyService, @@ -52,7 +54,9 @@ export class SubscriptionController { } let coupons = - (await this.propertyService.getByKey(PROPERTY_COUPONS)) ?? []; + (await this.propertyService.getByKey(PROPERTY_COUPONS, { + skipCache: true + })) ?? []; const coupon = coupons.find((currentCoupon) => { return currentCoupon.code === couponCode; @@ -80,9 +84,8 @@ export class SubscriptionController { value: JSON.stringify(coupons) }); - Logger.log( - `Subscription for user '${this.request.user.id}' has been created with a coupon for ${coupon.duration}`, - 'SubscriptionController' + this.logger.log( + `Subscription for user '${this.request.user.id}' has been created with a coupon for ${coupon.duration}` ); return { @@ -100,10 +103,11 @@ export class SubscriptionController { request.query.checkoutSessionId as string ); - Logger.log( - `Subscription for user '${userId}' has been created via Stripe`, - 'SubscriptionController' - ); + if (userId) { + this.logger.log( + `Subscription for user '${userId}' has been created via Stripe` + ); + } response.redirect( `${this.configurationService.get( @@ -114,17 +118,17 @@ export class SubscriptionController { @Post('stripe/checkout-session') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) - public createStripeCheckoutSession( + public async createStripeCheckoutSession( @Body() { couponId, priceId }: { couponId?: string; priceId: string } ): Promise { try { - return this.subscriptionService.createStripeCheckoutSession({ + return await this.subscriptionService.createStripeCheckoutSession({ couponId, priceId, user: this.request.user }); } catch (error) { - Logger.error(error, 'SubscriptionController'); + this.logger.error(error); throw new HttpException( getReasonPhrase(StatusCodes.BAD_REQUEST), diff --git a/apps/api/src/app/subscription/subscription.service.ts b/apps/api/src/app/subscription/subscription.service.ts index 07795d0d1..1dba93d47 100644 --- a/apps/api/src/app/subscription/subscription.service.ts +++ b/apps/api/src/app/subscription/subscription.service.ts @@ -3,7 +3,8 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { DEFAULT_LANGUAGE_CODE, - PROPERTY_STRIPE_CONFIG + PROPERTY_STRIPE_CONFIG, + SUPPORTED_LANGUAGE_CODES } from '@ghostfolio/common/config'; import { SubscriptionType } from '@ghostfolio/common/enums'; import { parseDate } from '@ghostfolio/common/helper'; @@ -17,13 +18,15 @@ import { } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; -import { Subscription } from '@prisma/client'; +import { Prisma, Subscription } from '@prisma/client'; import { addMilliseconds, isBefore } from 'date-fns'; import ms, { StringValue } from 'ms'; import Stripe from 'stripe'; @Injectable() export class SubscriptionService { + private readonly logger = new Logger(SubscriptionService.name); + private stripe: Stripe; public constructor( @@ -35,7 +38,7 @@ export class SubscriptionService { this.stripe = new Stripe( this.configurationService.get('STRIPE_SECRET_KEY'), { - apiVersion: '2026-03-25.dahlia' + apiVersion: '2026-06-24.dahlia' } ); } @@ -73,10 +76,7 @@ export class SubscriptionService { quantity: 1 } ], - locale: - (user.settings?.settings - ?.language as Stripe.Checkout.SessionCreateParams.Locale) ?? - DEFAULT_LANGUAGE_CODE, + locale: this.getStripeLocale(user.settings?.settings?.language), metadata: subscriptionOffer ? { subscriptionOffer: JSON.stringify(subscriptionOffer) } : {}, @@ -108,11 +108,13 @@ export class SubscriptionService { duration = '1 year', durationExtension, price, + stripeCheckoutSessionId, userId }: { duration?: StringValue; durationExtension?: StringValue; price: number; + stripeCheckoutSessionId?: string; userId: string; }) { let expiresAt = addMilliseconds(new Date(), ms(duration)); @@ -125,6 +127,7 @@ export class SubscriptionService { data: { expiresAt, price, + stripeCheckoutSessionId, user: { connect: { id: userId @@ -136,28 +139,44 @@ export class SubscriptionService { public async createSubscriptionViaStripe(aCheckoutSessionId: string) { try { - let durationExtension: StringValue; - const session = await this.stripe.checkout.sessions.retrieve(aCheckoutSessionId); + if (session.payment_status !== 'paid' || session.status !== 'complete') { + throw new Error( + `Stripe Checkout Session '${aCheckoutSessionId}' has not been paid (status=${session.status}, payment_status=${session.payment_status})` + ); + } + const subscriptionOffer: SubscriptionOffer = JSON.parse( - session.metadata.subscriptionOffer ?? '{}' + session.metadata?.subscriptionOffer ?? '{}' ); - if (subscriptionOffer) { - durationExtension = subscriptionOffer.durationExtension; - } + const durationExtension = subscriptionOffer?.durationExtension; - await this.createSubscription({ - durationExtension, - price: session.amount_total / 100, - userId: session.client_reference_id - }); + try { + await this.createSubscription({ + durationExtension, + price: session.amount_total / 100, + stripeCheckoutSessionId: session.id, + userId: session.client_reference_id + }); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + this.logger.log( + `Stripe Checkout Session '${session.id}' has already been redeemed` + ); + } else { + throw error; + } + } return session.client_reference_id; } catch (error) { - Logger.error(error, 'SubscriptionService'); + this.logger.error(error); } } @@ -225,4 +244,28 @@ export class SubscriptionService { isRenewal: key.startsWith('renewal') }; } + + private getStripeLocale( + languageCode: string + ): Stripe.Checkout.SessionCreateParams.Locale { + const unsupportedLanguageCodes: Record< + Exclude< + (typeof SUPPORTED_LANGUAGE_CODES)[number], + Stripe.Checkout.SessionCreateParams.Locale + >, + true + > = { + ca: true, + uk: true + }; + + if ( + (SUPPORTED_LANGUAGE_CODES as readonly string[]).includes(languageCode) && + !(languageCode in unsupportedLanguageCodes) + ) { + return languageCode as Stripe.Checkout.SessionCreateParams.Locale; + } + + return DEFAULT_LANGUAGE_CODE; + } } diff --git a/apps/api/src/app/symbol/symbol.controller.ts b/apps/api/src/app/symbol/symbol.controller.ts index 501692ae5..a1351dbed 100644 --- a/apps/api/src/app/symbol/symbol.controller.ts +++ b/apps/api/src/app/symbol/symbol.controller.ts @@ -14,6 +14,7 @@ import { HttpException, Inject, Param, + ParseIntPipe, Query, UseGuards, UseInterceptors @@ -64,12 +65,14 @@ export class SymbolController { * Must be after /lookup */ @Get(':dataSource/:symbol') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getSymbolData( @Param('dataSource') dataSource: DataSource, @Param('symbol') symbol: string, - @Query('includeHistoricalData') includeHistoricalData = 0 + @Query('includeHistoricalData', new ParseIntPipe({ optional: true })) + includeHistoricalData = 0 ): Promise { if (!DataSource[dataSource]) { throw new HttpException( diff --git a/apps/api/src/app/symbol/symbol.service.ts b/apps/api/src/app/symbol/symbol.service.ts index 15498e80d..98869797e 100644 --- a/apps/api/src/app/symbol/symbol.service.ts +++ b/apps/api/src/app/symbol/symbol.service.ts @@ -1,11 +1,20 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; -import { DATE_FORMAT } from '@ghostfolio/common/helper'; +import { + ghostfolioFearAndGreedIndexDataSourceCryptocurrencies, + ghostfolioFearAndGreedIndexSymbolCryptocurrencies, + ghostfolioFearAndGreedIndexSymbolStocks +} from '@ghostfolio/common/config'; +import { + DATE_FORMAT, + getAssetProfileIdentifier +} from '@ghostfolio/common/helper'; import { DataProviderHistoricalResponse, HistoricalDataItem, LookupResponse, + MarketDataOfMarketsResponse, SymbolItem } from '@ghostfolio/common/interfaces'; import { UserWithSettings } from '@ghostfolio/common/types'; @@ -15,6 +24,8 @@ import { format, subDays } from 'date-fns'; @Injectable() export class SymbolService { + private readonly logger = new Logger(SymbolService.name); + public constructor( private readonly dataProviderService: DataProviderService, private readonly marketDataService: MarketDataService @@ -22,15 +33,31 @@ export class SymbolService { public async get({ dataGatheringItem, - includeHistoricalData + includeHistoricalData, + useIntradayData = false }: { dataGatheringItem: DataGatheringItem; includeHistoricalData?: number; + useIntradayData?: boolean; }): Promise { - const quotes = await this.dataProviderService.getQuotes({ - items: [dataGatheringItem] - }); - const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {}; + let currency: string; + let marketPrice: number; + + if (useIntradayData) { + const latestMarketData = await this.marketDataService.getLatest({ + dataSource: dataGatheringItem.dataSource, + symbol: dataGatheringItem.symbol + }); + + marketPrice = latestMarketData?.marketPrice; + } else { + const quotes = await this.dataProviderService.getQuotes({ + items: [dataGatheringItem] + }); + + ({ currency, marketPrice } = + quotes[getAssetProfileIdentifier(dataGatheringItem)] ?? {}); + } if (dataGatheringItem.dataSource && marketPrice >= 0) { let historicalData: HistoricalDataItem[] = []; @@ -73,12 +100,17 @@ export class SymbolService { date = new Date(), symbol }: DataGatheringItem): Promise { + const assetProfileIdentifier = getAssetProfileIdentifier({ + dataSource, + symbol + }); + let historicalData: { - [symbol: string]: { + [assetProfileIdentifier: string]: { [date: string]: DataProviderHistoricalResponse; }; } = { - [symbol]: {} + [assetProfileIdentifier]: {} }; try { @@ -91,7 +123,54 @@ export class SymbolService { return { marketPrice: - historicalData?.[symbol]?.[format(date, DATE_FORMAT)]?.marketPrice + historicalData?.[assetProfileIdentifier]?.[format(date, DATE_FORMAT)] + ?.marketPrice + }; + } + + public async getMarketDataOfMarkets({ + includeHistoricalData + }: { + includeHistoricalData: number; + }): Promise { + if (await this.dataProviderService.isDataProviderGhostfolioConfigured()) { + return this.dataProviderService.getMarketDataOfMarkets({ + includeHistoricalData + }); + } + + const [ + marketDataFearAndGreedIndexCryptocurrencies, + marketDataFearAndGreedIndexStocks + ] = await Promise.all([ + this.get({ + includeHistoricalData, + dataGatheringItem: { + dataSource: ghostfolioFearAndGreedIndexDataSourceCryptocurrencies, + symbol: ghostfolioFearAndGreedIndexSymbolCryptocurrencies + }, + useIntradayData: true + }), + this.get({ + includeHistoricalData, + dataGatheringItem: { + dataSource: + this.dataProviderService.getDataSourceForFearAndGreedIndexStocks(), + symbol: ghostfolioFearAndGreedIndexSymbolStocks + }, + useIntradayData: true + }) + ]); + + return { + fearAndGreedIndex: { + CRYPTOCURRENCIES: { + ...marketDataFearAndGreedIndexCryptocurrencies + }, + STOCKS: { + ...marketDataFearAndGreedIndexStocks + } + } }; } @@ -119,7 +198,7 @@ export class SymbolService { results.items = items; return results; } catch (error) { - Logger.error(error, 'SymbolService'); + this.logger.error(error); throw error; } diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index 6346ce43a..2b679f34c 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -1,11 +1,18 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { decodeDataSource } from '@ghostfolio/api/helper/data-source.helper'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.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 { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; -import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; +import { + HEADER_KEY_IMPERSONATION, + THROTTLE_SIGNUP_LIMIT, + THROTTLE_SIGNUP_TTL +} from '@ghostfolio/common/config'; import { DeleteOwnUserDto, UpdateOwnAccessTokenDto, @@ -37,6 +44,7 @@ import { import { REQUEST } from '@nestjs/core'; import { JwtService } from '@nestjs/jwt'; import { AuthGuard } from '@nestjs/passport'; +import { Throttle } from '@nestjs/throttler'; import { User as UserModel } from '@prisma/client'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { merge, size } from 'lodash'; @@ -113,6 +121,7 @@ export class UserController { @Get() @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseInterceptors(RedactValuesInResponseInterceptor) + @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getUser( @Headers('accept-language') acceptLanguage: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string @@ -128,6 +137,13 @@ export class UserController { } @Post() + @Throttle({ + default: { + limit: THROTTLE_SIGNUP_LIMIT, + ttl: THROTTLE_SIGNUP_TTL + } + }) + @UseGuards(CustomThrottlerGuard) public async signupUser(): Promise { const isUserSignupEnabled = await this.propertyService.isUserSignupEnabled(); @@ -152,6 +168,7 @@ export class UserController { @Put('setting') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(TransformDataSourceInResponseInterceptor) public async updateUserSetting(@Body() data: UpdateUserSettingDto) { if ( size(data) === 1 && @@ -179,6 +196,12 @@ export class UserController { data ); + if (userSettings['filters.dataSource']) { + userSettings['filters.dataSource'] = decodeDataSource( + userSettings['filters.dataSource'] + ); + } + for (const key in userSettings) { if (userSettings[key] === false || userSettings[key] === null) { delete userSettings[key]; diff --git a/apps/api/src/app/user/user.module.ts b/apps/api/src/app/user/user.module.ts index 3f4e898fc..156630964 100644 --- a/apps/api/src/app/user/user.module.ts +++ b/apps/api/src/app/user/user.module.ts @@ -1,6 +1,7 @@ import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; @@ -30,7 +31,8 @@ import { UserService } from './user.service'; PropertyModule, RedactValuesInResponseModule, SubscriptionModule, - TagModule + TagModule, + TransformDataSourceInResponseModule ], providers: [UserService] }) diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 4ad22a043..8fb11ad34 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -26,15 +26,22 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { DEFAULT_CURRENCY, + DEFAULT_DATE_RANGE, DEFAULT_LANGUAGE_CODE, + DEFAULT_LOCALE, + PROPERTY_API_KEY_GHOSTFOLIO, PROPERTY_IS_READ_ONLY_MODE, + PROPERTY_MAX_DAILY_REQUESTS, + PROPERTY_REFERRAL_PARTNERS, PROPERTY_SYSTEM_MESSAGE, TAG_ID_EXCLUDE_FROM_ANALYSIS, - locale as defaultLocale + THROTTLE_DAILY_KEY, + THROTTLE_DAILY_TTL } from '@ghostfolio/common/config'; import { SubscriptionType } from '@ghostfolio/common/enums'; import { User as IUser, + ReferralPartner, SystemMessage, UserSettings } from '@ghostfolio/common/interfaces'; @@ -46,15 +53,18 @@ import { import { UserWithSettings } from '@ghostfolio/common/types'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; -import { Prisma, Role, User } from '@prisma/client'; +import { InjectThrottlerStorage, ThrottlerStorage } from '@nestjs/throttler'; +import { Prisma, Role, Settings, User } from '@prisma/client'; import { differenceInDays, subDays } from 'date-fns'; -import { without } from 'lodash'; +import { isNil, without } from 'lodash'; import { createHmac } from 'node:crypto'; @Injectable() export class UserService { + private readonly logger = new Logger(UserService.name); + public constructor( private readonly activitiesService: ActivitiesService, private readonly configurationService: ConfigurationService, @@ -63,7 +73,9 @@ export class UserService { private readonly prismaService: PrismaService, private readonly propertyService: PropertyService, private readonly subscriptionService: SubscriptionService, - private readonly tagService: TagService + private readonly tagService: TagService, + @InjectThrottlerStorage() + private readonly throttlerStorage: ThrottlerStorage ) {} public async count(args?: Prisma.UserCountArgs) { @@ -99,7 +111,7 @@ export class UserService { public async getUser({ impersonationUserId, - locale = defaultLocale, + locale = DEFAULT_LOCALE, user }: { impersonationUserId: string; @@ -108,7 +120,14 @@ export class UserService { }): Promise { const { id, permissions, settings, subscription } = user; - const userData = await Promise.all([ + const [ + access, + accounts, + activitiesCount, + firstActivity, + impersonationUserSettings, + tagsForUser + ] = await Promise.all([ this.prismaService.access.findMany({ include: { user: true @@ -117,6 +136,7 @@ export class UserService { where: { granteeUserId: id } }), this.prismaService.account.findMany({ + include: { platform: true }, orderBy: { name: 'asc' }, @@ -133,16 +153,28 @@ export class UserService { }, where: { userId: impersonationUserId || user.id } }), + impersonationUserId + ? this.prismaService.settings.findUnique({ + where: { userId: impersonationUserId } + }) + : Promise.resolve(null), this.tagService.getTagsForUser(impersonationUserId || user.id) ]); - const access = userData[0]; - const accounts = userData[1]; - const activitiesCount = userData[2]; - const firstActivity = userData[3]; - let tags = userData[4].filter((tag) => { - return tag.id !== TAG_ID_EXCLUDE_FROM_ANALYSIS; - }); + const baseCurrency = + (impersonationUserSettings?.settings as UserSettings)?.baseCurrency ?? + (settings.settings as UserSettings)?.baseCurrency; + + let referralPartners: ReferralPartner[]; + + if ( + this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + subscription.type === SubscriptionType.Basic + ) { + referralPartners = await this.propertyService.getByKey( + PROPERTY_REFERRAL_PARTNERS + ); + } let systemMessage: SystemMessage; @@ -155,17 +187,22 @@ export class UserService { systemMessage = systemMessageProperty; } + let tags = tagsForUser; + if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && subscription.type === SubscriptionType.Basic ) { - tags = []; + tags = tags.filter(({ id }) => { + return id === TAG_ID_EXCLUDE_FROM_ANALYSIS; + }); } return { activitiesCount, id, permissions, + referralPartners, subscription, systemMessage, tags, @@ -182,6 +219,7 @@ export class UserService { dateOfFirstActivity: firstActivity?.date ?? new Date(), settings: { ...(settings.settings as UserSettings), + baseCurrency, locale: (settings.settings as UserSettings)?.locale ?? locale } }; @@ -199,25 +237,42 @@ export class UserService { return usersWithAdminRole.length > 0; } + public async isDailyRequestLimitExceeded({ + user + }: { + user: UserWithSettings; + }) { + if (user.subscription?.type === SubscriptionType.Premium) { + return false; + } + + const maxDailyRequests = await this.getMaxDailyRequests(); + + if (maxDailyRequests === undefined) { + return false; + } + + try { + const { isBlocked } = await this.throttlerStorage.increment( + `${THROTTLE_DAILY_KEY}-${user.id}`, + THROTTLE_DAILY_TTL, + maxDailyRequests, + THROTTLE_DAILY_TTL, + THROTTLE_DAILY_KEY + ); + + return isBlocked; + } catch (error) { + this.logger.error(error); + + return false; + } + } + public async user( userWhereUniqueInput: Prisma.UserWhereUniqueInput ): Promise { - const { - _count, - accessesGet, - accessToken, - accounts, - analytics, - authChallenge, - createdAt, - id, - provider, - role, - settings, - subscriptions, - thirdPartyId, - updatedAt - } = await this.prismaService.user.findUnique({ + const userFromDatabase = await this.prismaService.user.findUnique({ include: { _count: { select: { @@ -235,6 +290,27 @@ export class UserService { where: userWhereUniqueInput }); + if (!userFromDatabase) { + return null; + } + + const { + _count, + accessesGet, + accessToken, + accounts, + analytics, + authChallenge, + createdAt, + id, + provider, + role, + settings, + subscriptions, + thirdPartyId, + updatedAt + } = userFromDatabase; + const activitiesCount = _count?.activities ?? 0; const user: UserWithSettings = { @@ -251,19 +327,19 @@ export class UserService { updatedAt, activityCount: analytics?.activityCount, dataProviderGhostfolioDailyRequests: - analytics?.dataProviderGhostfolioDailyRequests + analytics?.dataProviderGhostfolioDailyRequests ?? 0 }; - if (user?.settings) { + if (user.settings) { if (!user.settings.settings) { user.settings.settings = {}; } - } else if (user) { + } else { // Set default settings if needed user.settings = { settings: {}, updatedAt: new Date(), - userId: user?.id + userId: user.id }; } @@ -281,7 +357,8 @@ 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 ?? + DEFAULT_DATE_RANGE); // Set default value for performance calculation type if (!(user.settings.settings as UserSettings)?.performanceCalculationType) { @@ -473,9 +550,11 @@ export class UserService { currentPermissions, permissions.accessHoldingsChart, permissions.createAccess, + permissions.createAssetProfileSplitOfOwnAssetProfile, permissions.createMarketDataOfOwnAssetProfile, permissions.createOwnTag, permissions.createWatchlistItem, + permissions.deleteAssetProfileSplitOfOwnAssetProfile, permissions.readAiPrompt, permissions.readMarketDataOfOwnAssetProfile, permissions.updateMarketDataOfOwnAssetProfile @@ -506,9 +585,26 @@ export class UserService { user.subscription.offer.label = undefined; } + if ( + !hasRole(user, Role.DEMO) && + (user.provider !== 'ANONYMOUS' || + user.subscription?.type === SubscriptionType.Premium) + ) { + currentPermissions.push(permissions.requestOwnUserDeletion); + } + if (hasRole(user, Role.ADMIN)) { currentPermissions.push(permissions.syncDemoUserAccount); } + } else { + if ( + this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX') || + (await this.propertyService.getByKey( + PROPERTY_API_KEY_GHOSTFOLIO + )) + ) { + currentPermissions.push(permissions.readMarketDataOfMarkets); + } } if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) { @@ -729,4 +825,26 @@ export class UserService { return settings; } + + private async getMaxDailyRequests() { + const value = await this.propertyService.getByKey( + PROPERTY_MAX_DAILY_REQUESTS + ); + + if (isNil(value) || value === '') { + return undefined; + } + + const maxDailyRequests = Number(value); + + if (!Number.isInteger(maxDailyRequests) || maxDailyRequests < 0) { + this.logger.warn( + `The property ${PROPERTY_MAX_DAILY_REQUESTS} is not a non-negative integer ("${value}"), the daily request limit is not applied` + ); + + return undefined; + } + + return maxDailyRequests; + } } diff --git a/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json b/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json index ea88dd4c1..9eac78aa4 100644 --- a/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json +++ b/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json @@ -6,7 +6,7 @@ "8": "8", "21": "2131KOBUSHIDE", "32": "Project 32", - "42": "Semantic Layer", + "42": "42-coin", "47": "President Trump", "67": "The Official 67 Coin", "300": "300 token", @@ -18,7 +18,7 @@ "777": "Jackpot", "808": "808", "888": "888", - "1337": "EliteCoin", + "1337": "E1337", "1717": "1717 Masonic Commemorative Token", "2015": "2015 coin", "2016": "2016 coin", @@ -31,7 +31,8 @@ "50501": "50501movement", "$MAID": "MaidCoin", "$TREAM": "World Stream Finance", - "00": "ZER0ZER0", + ".ALPHA": ".Alpha", + "00": "00 Token", "007": "007 coin", "0DOG": "Bitcoin Dogs", "0G": "0G", @@ -41,23 +42,29 @@ "0X0": "0x0.ai", "0X1": "0x1.tools: AI Multi-tool Plaform", "0X63SPIKE": "Spike", + "0XA": "0xApe", "0XBTC": "0xBitcoin", "0XCOCO": "0xCoco", "0XDEV": "DEVAI", + "0XENCRYPT": "Encryption AI", "0XG": "0xGpu.ai", "0XGAS": "0xGasless", "0XL": "0x Leverage", + "0XMR": "0xMonero", "0XOS": "0xOS AI", + "0XS": "0xS", "0XSEARCH": "Search", "0XVOX": "HashVox AI", + "0xBTC": "0xBitcoin", "0xDIARY": "The 0xDiary Token", "0xVPN": "0xVPN.org", - "1-UP": "1-UP", - "1000SATS": "SATS", + "1-UP": "1-UP Platform", + "1000SATS": "1000SATS (Ordinals)", "1000X": "1000x by Virtuals", "101M": "101M", "10SET": "Tenset", - "1ART": "ArtWallet", + "10SHARE": "10SHARE", + "1ART": "OneArt", "1CAT": "Bitcoin Cats", "1COIN": "1 coin can change your life", "1CR": "1Credit", @@ -68,13 +75,14 @@ "1GOLD": "1irstGold", "1GUY": "1GUY", "1HUB": "1HubAI", - "1INCH": "1inch", + "1INCH": "1inch Network", "1IQ": "People with 1 IQ", "1IRST": "1irstcoin", + "1MB": "1minBET", "1MCT": "MicroCreditToken", "1MDC": "1MDC", "1MIL": "1MillionNFTs", - "1MT": "1Move", + "1MT": "1Million Token", "1NFT": "1NFT", "1ON8": "Little Dragon", "1OZT": "Tala", @@ -96,8 +104,8 @@ "2BASED": "2Based Finance", "2CRZ": "2crazyNFT", "2DAI": "2DAI.io", - "2GCC": "2G Carbon Coin", - "2GIVE": "2GiveCoin", + "2GCC": "2G CARBON COIN", + "2GIVE": "2GIVE", "2GT": "2GETHER", "2KEY": "2key.network", "2LC": "2local", @@ -105,7 +113,7 @@ "2OMB": "2omb Finance", "2SHARES": "2SHARE", "2TF": "2TF", - "2Z": "DoubleZero", + "2Z": "DoubleZero USD Price", "300F": "300FIT", "314DAO": "Tonken 314 DAO", "32BIT": "32Bitcoin", @@ -123,7 +131,8 @@ "3KM": "3 Kingdoms Multiverse", "3P": "Web3Camp", "3RDEYE": "3rd Eye", - "3ULL": "3ULL Coin", + "3ULL": "PLAYA3ULL GAMES", + "3ULL26863": "PLAYA3ULL GAMES", "3ULLV1": "Playa3ull Games v1", "3XD": "3DChain", "401JK": "401jk", @@ -156,7 +165,7 @@ "8BITCOIN": "8-Bit COIN", "8BT": "8 Circuit Studios", "8LNDS": "8Lends", - "8PAY": "8Pay", + "8PAY": "8PAY", "8X8": "8X8 Protocol", "99BTC": "99 Bitcoins", "9BIT": "The9bit", @@ -166,8 +175,10 @@ "A": "Vaulta", "A1INCH": "1inch (Arbitrum Bridge)", "A2A": "A2A", + "A2E": "Hey Floki AI", "A2I": "Arcana AI", "A2Z": "Arena-Z", + "A36462": "Vaulta", "A4": "A4 Finance", "A47": "AGENDA 47", "A4M": "AlienForm", @@ -176,22 +187,22 @@ "A7A5": "A7A5", "A8": "Ancient8", "AA": "ARAI Token", - "AAA": "Moon Rabbit", + "AAA": "Abulaba", "AAAHHM": "Plankton in Pain", "AAAI": "AAAI_agent by Virtuals", "AAB": "AAX Token", "AABL": "Abble", "AAC": "Double-A Chain", - "AAG": "AAG Ventures", + "AAG": "AAG", "AAI": "AutoAir AI", "AALON": "American Airlines Group (Ondo Tokenized)", "AAPLON": "Apple (Ondo Tokenized)", - "AAPLX": "Apple xStock", - "AAPX": "AMPnet", + "AAPLX": "Apple tokenized stock (xStock)", + "AAPX": "AMPnet Asset Platform and Exchange", "AARBWBTC": "Aave Arbitrum WBTC", "AARDY": "Baby Aardvark", "AARK": "Aark", - "AART": "ALL.ART", + "AART": "All.Art Protocol", "AAST": "AASToken", "AASTEROID": "Alien Asteroid", "AAT": "Agricultural Trade Chain", @@ -201,12 +212,13 @@ "AAVEGOTCHIFOMO": "Aavegotchi FOMO", "AAX": "Academic Labs", "AAZ": "ATLAZ", - "AB": "Newton", + "AB": "Arma The Battle Ground USD Price", "AB1INCH": "1inch (Avalanche Bride)", "ABA": "EcoBall", + "ABAT": "Aave BAT", "ABBC": "ABBC Coin", "ABBVX": "AbbVie xStock", - "ABC": "ABC Chain", + "ABC": "Abell Coin", "ABCC": "ABCC Token", "ABCD": "Crypto Inu", "ABCM": "ABCMETA", @@ -222,6 +234,7 @@ "ABL": "Airbloc", "ABLE": "Able Finance", "ABLINK": "Chainlink (Arbitrum Bridge)", + "ABLOCK": "The Blocknet", "ABN": "Antofy", "ABO": "Albino", "ABOND": "ApeBond", @@ -229,7 +242,7 @@ "ABR": "Allbridge", "ABSIMPSON": "abstract simpson", "ABSTER": "Abster", - "ABT": "ArcBlock", + "ABT": "Arcblock", "ABTC": "aBTC", "ABTX": "Abbott xStock", "ABUL": "Abulaba", @@ -238,23 +251,25 @@ "ABX": "Arbidex", "ABY": "ArtByte", "ABYS": "Trinity Of The Fabled", - "ABYSS": "Abyss Finance", - "AC": "Asia Coin", + "ABYSS": "Abyss", + "AC": "ACoconut", "AC3": "AC3", - "ACA": "Acala", + "ACA": "Acala Token", "ACALAUSD": "Acala Dollar (Acala)", "ACAT": "Alphacat", "ACATO": "ACA Token", - "ACCEL": "Accel Defi", + "ACCEL": "ACCEL", "ACCES": "Metacces", "ACCN": "Accelerator Network", "ACD": "Alliance Cargo Direct", "ACDC": "Volt", "ACE": "Fusionist", + "ACE22307": "ACEToken", + "ACE28674": "Fusionist", "ACEENTERTAIN": "ACE Entertainment Token", "ACEN": "Acent", "ACEO": "Ace of Pentacles", - "ACES": "AcesCoin", + "ACES": "Aces", "ACET": "Acet", "ACETH": "Acether", "ACH": "Alchemy Pay", @@ -264,10 +279,10 @@ "ACID": "AcidCoin", "ACK": "Arcade Kingdoms", "ACL": "Auction Light", - "ACM": "AC Milan Fan Token", - "ACN": "AvonCoin", + "ACM": "Actinium", + "ACN": "Acorn Protocol", "ACNX": "Accenture xStock", - "ACOIN": "ACoin", + "ACOIN": "Acoin", "ACOLYT": "Acolyte by Virtuals", "ACP": "Arena Of Faith", "ACPT": "Crypto Accept", @@ -275,21 +290,25 @@ "ACRE": "Arable Protocol", "ACRED": "Apollo Diversified Credit Securitize Fund", "ACRIA": "Acria.AI", - "ACS": "Access Protocol", + "ACS": "ACryptoS", + "ACS23195": "Access Protocol", "ACSI": "ACryptoSI", - "ACT": "Act I The AI Prophecy", + "ACT": "Achain", + "ACT33566": "Act I : The AI Prophecy", "ACTA": "Acta Finance", "ACTIN": "Actinium", "ACTN": "Action Coin", "ACU": "Acurast Token", "ACX": "Across Protocol", + "ACX22620": "Across Protocol", "ACXT": "ACDX Exchange Token", "ACYC": "All Coins Yield Capital", - "AD": "ADreward", + "AD": "ADToken", "ADA": "Cardano", "ADAB": "Adab Solutions", + "ADABOY": "ADA BOY", "ADACASH": "ADACash", - "ADAI": "Aave Interest bearing DAI", + "ADAI": "Aave DAI", "ADAIV1": "Aave DAI", "ADAM": "Adam Back", "ADANA": "Adanaspor Fan Token", @@ -302,19 +321,20 @@ "ADB": "Adbank", "ADC": "AudioCoin", "ADCO": "Advertise Coin", - "ADD": "ADD.xyz", + "ADD": "Add.xyz", "ADDAMS": "ADDAMS AI", "ADDY": "Adamant", "ADE": "AADex Finance", "ADEL": "Akropolis Delphi", "ADF": "Art de Finance", "ADH": "Adhive", - "ADI": "ADI", + "ADI": "Aditus", "ADITUS": "Aditus", "ADIX": "Adix Token", "ADK": "Aidos Kuneen", "ADL": "Adel", "ADM": "ADAMANT Messenger", + "ADMC": "Adamant", "ADN": "Aladdin", "ADNT": "Aiden", "ADO": "ADO Protocol", @@ -326,36 +346,40 @@ "ADRI": "AdRise", "ADRX": "Adrenaline Chain", "ADS": "Adshares", - "ADT": "AdToken", + "ADT": "Dot Arcade", "ADUX": "Adult X Token", "ADVT": "Advantis", - "ADX": "Ambire AdEx", + "ADX": "AdEx", "ADXX": "AnonyDoxx", "ADZ": "Adzcoin", - "AE": "Aeternity", + "AE": "Æternity", "AEC": "AcesCoin", "AEG": "Aether Games", "AEGGS": "aEGGS", "AEGIS": "Aegis", "AEGS": "Aegisum", + "AEL": "Spantale", "AELIN": "Aelin", "AEN": "Aenco", + "AENJ": "Aave Enjin", "AENS": "AEN Smart", "AENT": "AEN", - "AEON": "AEON", + "AEON": "Aeon", "AER": "Aeryus", - "AERGO": "AERGO", + "AERA": "Aerarium Fi", + "AERGO": "Aergo", "AERGOV1": "Aergo v1", "AERM": "Aerium", - "AERO": "Aerodrome Finance", + "AERO": "Aerochain V2", + "AERO29270": "Aerodrome Finance", "AEROBUD": "Aerobud", "AEROCOIN": "Aero Coin", "AEROME": "AeroMe", "AEROT": "AEROTYME", "AES": "Artis Aes Evolution", "AET": "AfterEther", - "AETH": "Aave ETH", - "AETHC": "Ankr Reward-Bearing Staked ETH", + "AETH": "Aave Ethereum", + "AETHC": "Ankr Reward Bearing Staked ETH", "AETHERV2": "AetherV2", "AETHRA": "Aethra AI", "AETHUSDT": "Aave Ethereum USDT", @@ -365,8 +389,9 @@ "AEVUM": "Aevum", "AFB": "A Fund Baby", "AFC": "Arsenal Fan Token", + "AFC1": "Arsenal Fan Token", "AFCT": "Allforcrypto", - "AFEN": "AFEN Blockchain", + "AFEN": "AFEN Blockchain Network", "AFFC": "Affil Coin", "AFG": "Army of Fortune Gem", "AFIN": "Asian Fintech", @@ -383,10 +408,11 @@ "AFT": "AIFlow Token", "AFTT": "Africa Trading Chain", "AFX": "Afrix", + "AFY": "Artify", "AFYON": "Afyonspor Fan Token", "AG": "AGAME", - "AG8": "ATROMG8", - "AGA": "Agora DEX Token", + "AG8": "AtromG8", + "AGA": "AGA Token", "AGATA": "Agatech", "AGATOKEN": "AGA Token", "AGB": "Apes Go Bananas", @@ -414,6 +440,7 @@ "AGNT": "iAgent Protocol", "AGO": "AgoDefi", "AGON": "AGON Agent", + "AGORA": "Agora Defi", "AGORK": "@gork", "AGOV": "Answer Governance", "AGPC": "AGPC", @@ -421,8 +448,8 @@ "AGRICULTURALUNIONS": "Agricultural Unions", "AGRO": "Bit Agro", "AGRS": "Agoras Token", - "AGS": "Aegis", - "AGT": "Alaya Governance Token", + "AGS": "Collector Coin", + "AGT": "AGRITECH", "AGURI": "Aguri-Chan", "AGUSTO": "Agusto", "AGV": "Astra Guild Ventures", @@ -432,14 +459,15 @@ "AHARWBTC": "Aave Harmony WBTC", "AHOO": "Ahoolee", "AHT": "AhaToken", - "AI": "Sleepless", - "AI16Z": "ElizaOS", + "AI": "Flourishing AI", + "AI16Z": "ai16z", "AI21X": "ai21x", "AI23T": "23 Turtles", + "AI28846": "Sleepless AI", "AI3": "Autonomys Network", "AI4": "AI⁴", "AI69SAKURA": "Sakura", - "AIA": "DeAgentAI", + "AIA": "AIA Chain", "AIACHAIN": "AIA Chain", "AIAF": "AI Agent Factory", "AIAGENT": "AI Agents", @@ -449,7 +477,7 @@ "AIAO": "AlgosOne AI Token", "AIAT": "AI Analysis Token", "AIAV": "AI AVatar", - "AIB": "AdvancedInternetBlock", + "AIB": "Advanced Internet Blocks", "AIBABYDOGE": "AIBabyDoge", "AIBB": "AiBB", "AIBCOIN": "AIBLOCK", @@ -472,6 +500,7 @@ "AIDOC": "AI Doctor", "AIDOG": "AiDoge", "AIDOGE": "ArbDoge AI", + "AIDOGEMINI": "AI DogeMini", "AIDOGEX": "AI DogeX", "AIDOGEXLM": "AIDOGE Stellar", "AIDT": "AIDUS TOKEN", @@ -495,16 +524,16 @@ "AIM": "ModiHost", "AIMAGA": "Presidentexe", "AIMARKET": "Acria.AI AIMARKET", - "AIMBOT": "AimBot AI", + "AIMBOT": "AimBot", "AIMEE": "AIMEE", "AIMET": "AI Metaverse", "AIMONICA": "Aimonica Brands", "AIMR": "MeromAI", "AIMS": "HighCastle Token", - "AIMX": "MindMatrix", + "AIMX": "Aimedis", "AIMXV1": "Aimedis v1", "AIMXV2": "Aimedis", - "AIN": "Infinity Ground", + "AIN": "AI Network", "AINA": "Ainastasia", "AINET": "AI Network", "AINFT": "EthernaFi", @@ -528,7 +557,7 @@ "AIPIN": "AI PIN", "AIPO": "Aipocalypto", "AIPUMP": "aiPump", - "AIR": "Altair", + "AIR": "AirCoin", "AIRB": "BillionAir", "AIRBTC": "AIRBTC", "AIRDROP": "AIRDROP2049", @@ -548,8 +577,8 @@ "AISHIB": "ARBSHIB", "AIST": "Artificial intelligence staking token", "AISW": "AISwap", - "AIT": "AIT Protocol", - "AITECH": "Artificial Intelligence Utility Token", + "AIT": "AICHAIN", + "AITECH": "Solidus Ai Tech", "AITEK": "AI Technology", "AITHEON": "Aitheon", "AITHER": "Aither Protocol", @@ -570,7 +599,7 @@ "AIWS": "AIWS", "AIX": "Ai Xovia", "AIX9": "AthenaX9", - "AIXBT": "aixbt by Virtuals", + "AIXBT": "aixbt", "AIXCB": "aixCB by Virtuals", "AIXDROP": "AIXDROP", "AIXERC": "AI-X", @@ -595,11 +624,11 @@ "AKREP": "Antalyaspor Token", "AKRO": "Akropolis", "AKT": "Akash Network", - "AKTIO": "AKTIO Coin", + "AKTIO": "Akt.io", "AKUMA": "Akuma Inu", "AKV": "Akiverse Governance", "AL": "ArchLoot", - "ALA": "Alanyaspor Fan Token", + "ALA": "AladiEx", "ALAN": "Alan the Alien", "ALASKA": "Alaska", "ALATOKEN": "ALA", @@ -610,6 +639,7 @@ "ALBON": "Albemarle (Ondo Tokenized)", "ALBT": "AllianceBlock", "ALC": "Arab League Coin", + "ALCA": "AliceNet", "ALCAZAR": "Alcazar", "ALCE": "Alcedo", "ALCH": "Alchemist AI", @@ -620,32 +650,33 @@ "ALD": "AladdinDAO", "ALDIN": "Alaaddin.ai", "ALE": "Ailey", - "ALEO": "ALEO", + "ALEO": "Aleo", "ALEPH": "Aleph.im", "ALEX": "ALEX Lab", "ALEXANDRITE": "Alexandrite", "ALEXIUS": "Alexius Maximus", - "ALF": "AlphaCoin", - "ALG": "Algory", + "ALF": "AlphaFi", + "ALFA": "alfa.society", + "ALG": "Algory Project", "ALGB": "Algebra", "ALGERIA": "Algeria", "ALGO": "Algorand", "ALGOBLK": "AlgoBlocks", "ALGOW": "Algowave", "ALH": "AlloHash", - "ALI": "Alethea Artificial Liquid Intelligence Token", + "ALI": "Artificial Liquid Intelligence", "ALIAS": "Alias", "ALIBABAAI": "Alibaba AI Agent", "ALIC": "AliCoin", - "ALICE": "My Neighbor Alice", + "ALICE": "MyNeighborAlice", "ALICEA": "Alice AI", "ALICEW": "Alice Weidel", - "ALIEN": "AlienCoin", + "ALIEN": "Alien Inu", "ALIENPEP": "Alien Pepe", "ALIENS": "Aliens", "ALIENX": "ALIENX", - "ALIF": " ALIF COIN", - "ALINK": "Aave LINK v1", + "ALIF": "ALIF COIN", + "ALINK": "Aave LINK", "ALIS": "ALISmedia", "ALIT": "Alitas", "ALITA": "Alita Network", @@ -657,7 +688,8 @@ "ALLBI": "ALL BEST ICO", "ALLC": "All Crypto Mechanics", "ALLEY": "NFT Alley", - "ALLIN": "All in", + "ALLF": "Alliance Fan Token", + "ALLIN": "All In", "ALLMEE": "All.me", "ALLO": "Allora", "ALLOCA": "Alloca", @@ -668,19 +700,19 @@ "ALME": "Alita", "ALMEELA": "Almeela", "ALMOND": "Almond", - "ALN": "Aluna", + "ALN": "Aluna.Social", "ALNV1": "Aluna v1", "ALOHA": "Aloha", "ALOKA": "ALOKA", "ALON": "Alon", "ALOR": "The Algorix", "ALOT": "Dexalot", - "ALP": "Alphacon", - "ALPA": "Alpaca", + "ALP": "CoinAlpha", + "ALPA": "Alpaca City", "ALPACA": "Alpaca Finance", "ALPACAS": "Bitcoin Mascot", "ALPH": "Alephium", - "ALPHA": "Alpha Finance Lab", + "ALPHA": "Stella", "ALPHAAI": "Alpha AI", "ALPHABET": "Alphabet", "ALPHAC": "Alpha Coin", @@ -690,11 +722,12 @@ "ALPHAPETTO": "Alpha Petto Shells", "ALPHAPLATFORM": "Alpha Token", "ALPHAS": "Alpha Shards", - "ALPHR": "Alphr", + "ALPHR": "Alphr finance", "ALPINE": "Alpine F1 Team Fan Token", "ALPRO": "Assets Alphabet", "ALPS": "Alpenschillling", "ALT": "Altlayer", + "ALT29073": "Altlayer", "ALTA": "Alta Finance", "ALTB": "Altbase", "ALTCOIN": "ALTcoin", @@ -706,7 +739,7 @@ "ALTSZN": "ALTSEASON", "ALTT": "Altcoinist", "ALU": "Altura", - "ALUSD": "Alchemix USD", + "ALUSD": "Alchemix", "ALUX": "Alux Bank", "ALV": "Allive", "ALV1": "ArchLoot v1", @@ -715,8 +748,9 @@ "ALWAYS": "Always Evolving", "ALX": "ALAX", "ALY": "Ally", - "AM": "Aston Martin Cognizant", - "AMA": "MrWeb", + "ALYA": "ALYATTES", + "AM": "Aston Martin Cognizant Fan Token", + "AMA": "AMATEN", "AMADEUS": "AMADEUS", "AMAL": "AMAL", "AMAPT": "Amnis Finance", @@ -733,9 +767,9 @@ "AMC": "AI Meta Coin", "AMCON": "AMC Entertainment (Ondo Tokenized)", "AMDC": "Allmedi Coin", - "AMDG": "AMDG", + "AMDG": "AMDG Token", "AMDX": "AMD xStock", - "AME": "Amepay", + "AME": "AME Chain", "AMEP": "America Party", "AMER": "America", "AMERI": "AMERICAN EAGLE", @@ -745,7 +779,7 @@ "AMERICANCOIN": "AmericanCoin", "AMETA": "Alpha City", "AMF": "AddMeFast", - "AMG": "DeHeroGame Amazing Token", + "AMG": "Amgen", "AMI": "AMMYI Coin", "AMIO": "Amino Network", "AMIS": "AMIS", @@ -761,9 +795,10 @@ "AMOS": "Amos", "AMP": "Amp", "AMPL": "Ampleforth", + "AMPLE": "AmpleSwap (old)", "AMPLIFI": "AmpliFi", "AMR": "Amero", - "AMS": "Amsterdam Coin", + "AMS": "AmsterdamCoin", "AMT": "Acumen", "AMU": "Amulet", "AMV": "Avatar Musk Verse", @@ -771,15 +806,16 @@ "AMY": "Amygws", "AMZE": "The Amaze World", "AMZNON": "Amazon (Ondo Tokenized)", - "AMZNX": "Amazon xStocks", + "AMZNX": "Amazon tokenized stock (xStock) USD Price", "ANA": "Nirvana ANA", "ANAL": "AnalCoin", "ANALOS": "analoS", "ANALY": "Analysoor", "ANARCHISTS": "Anarchists Prime", + "ANARCHY": "Anarchy", "ANAT": "Anatolia Token", - "ANB": "Ant BlockChain", - "ANC": "Anchor Protocol", + "ANB": "Anubit", + "ANC": "Anoncoin", "ANCHOR": "AnchorSwap", "ANCIENTKING": "Ancient Kingdom", "ANCP": "Anacrypt", @@ -798,16 +834,18 @@ "ANDYMAN": "ANDYMAN", "ANDYSOL": "Andy on SOL", "ANEX": "AstroNexus", - "ANGEL": "Crypto Angel", + "ANG": "Aureus Nummus Gold", + "ANGEL": "Polylauncher", "ANGL": "Angel Token", - "ANGLE": "ANGLE", + "ANGLE": "Angle", "ANGO": "Aureus Nummus Gold", "ANGRYSLERF": "ANGRYSLERF", "ANGRYTOKEN": "Angryb", - "ANI": "Ani Grok Companion (anicompanion.net)", + "ANI": "Anime Token", "ANIM": "Animalia", "ANIMA": "Realm Anima", "ANIME": "Animecoin", + "ANIME35319": "Animecoin USD Price", "ANIMECOIN": "Animecoin", "ANIMEONBASE": "Anime", "ANIMETOKEN": "Anime Token (animetoken.in)", @@ -817,7 +855,7 @@ "ANK": "AlphaLink", "ANKA": "Ankaragücü Fan Token", "ANKORUS": "Ankorus Token", - "ANKR": "Ankr Network", + "ANKR": "Ankr", "ANKRBNB": "Ankr Staked BNB", "ANKRETH": "Ankr Staked ETH", "ANKRFTM": "Ankr Staked FTM", @@ -829,6 +867,7 @@ "ANOA": "ANOA", "ANOME": "Anome", "ANON": "HeyAnon", + "ANON30846": "Anon", "ANONCOIN": "Anoncoin", "ANONCRYPTO": "ANON", "ANRX": "AnRKey X", @@ -846,6 +885,7 @@ "ANTS": "ANTS Reloaded", "ANTT": "Antara Token", "ANTV1": "Aragon v1", + "ANTX": "AntNetworX", "ANUBHAV": "Anubhav Trainings", "ANUS": "URANUS", "ANV": "Aniverse", @@ -857,6 +897,8 @@ "ANYONE": "ANyONe Protocol", "ANZENUSD": "Anzen Finance", "AO": "AO", + "AO35386": "AO Computer", + "AOA": "Aurora", "AOC": "Alickshundra Occasional-Cortex", "AOE": "Agentic Open Economy", "AOG": "AgeOfGods", @@ -866,22 +908,27 @@ "AOPTWBTC": "Aave Optimism WBTC", "AOS": "AOS", "AOT": "Age of Tanks", - "AP": "America Party", + "AP": "AppleSwap AI", "AP3X": "Apex token", "APAD": "Anypad", "APC": "AlpaCoin", "APCG": "ALLPAYCOIN", - "APD": "APD", + "APD": "ApeParkDAO", "APE": "ApeCoin", + "APE18876": "ApeCoin", + "APE3": "ApeCoin", "APED": "Aped", "APEDEV": "The dev is an Ape", + "APEFI": "Ape Finance", "APEFUN": "Ape", + "APEIN": "Ape In", "APEMAN": "APEMAN", "APEPE": "Ape and Pepe", "APES": "APES", "APETARDIO": "Apetardio", "APEWIFHAT": "ApeWifHat", - "APEX": "ApeX Protocol", + "APEX": "ApeXit Finance", + "APEX19843": "ApeX Protocol", "APEXA": "Apex AI", "APEXCOIN": "ApexCoin", "APEXT": "ApexToken", @@ -908,13 +955,15 @@ "APPLE": "AppleSwap", "APPLESWAPAI": "AppleSwap AI", "APPX": "AppLovin xStock", - "APR": "aPriori", + "APR": "Apricot Finance", + "APR38569": "aPriori", "APRCOIN": "APR Coin", "APRICOT": "Apricot Finance", "APRIL": "April", "APRS": "Aperios", "APS": "APRES", "APT": "Aptos", + "APT21794": "Aptos", "APTCOIN": "Aptcoin", "APTESG": "AppleTree Token", "APTM": "Apertum", @@ -924,7 +973,7 @@ "APU": "Apu Apustaja", "APUAPU": "APU", "APUGURL": "APU GURL", - "APW": "APWine", + "APW": "APWine Finance", "APX": "ApolloX", "APXP": "APEX Protocol", "APXT": "ApolloX", @@ -932,20 +981,21 @@ "APY": "APY.Finance", "APYS": "APYSwap", "APZ": "Alprockz", - "AQA": " AQA Token", + "AQA": "AQA Token", "AQDC": "AQDC", "AQT": "Alpha Quark Token", "AQTIS": "AQTIS", - "AQU": "aQuest", - "AQUA": "Aquarius", + "AQU": "Aquarius Protocol", + "AQUA": "Planet", + "AQUA14112": "Aquarius", "AQUAC": "Aquachain", "AQUACITY": "Aquacity", - "AQUAGOAT": "Aqua Goat", + "AQUAGOAT": "AquaGoat.Finance", "AQUAGOATV1": "Aqua Goat v1", "AQUAP": "Planet Finance", "AQUARI": "Aquari", "AR": "Arweave", - "ARA": "Ara Token", + "ARA": "Ara Blocks", "ARABCLUB": "The Arab Club Token", "ARABIANDRAGON": "Arabian Dragon", "ARACOIN": "Ara", @@ -953,15 +1003,20 @@ "ARATA": "Arata", "ARAW": "Araw", "ARB": "Arbitrum", - "ARBI": "Arbipad", + "ARB11841": "Arbitrum", + "ARBI": "ArbiPad", "ARBINU": "ArbInu", "ARBIT": "Arbit Coin", + "ARBITEN": "ArbiTen", "ARBITROVE": "Arbitrove Governance Token", "ARBP": "ARB Protocol", + "ARBPAD": "Arbitrum Pad", "ARBS": "Arbswap", "ARBT": "ARBITRAGE", "ARBUZ": "ARBUZ", + "ARBYS": "Arbys Token", "ARC": "AI Rig Complex", + "ARC23486": "Arcadeum USD Price", "ARCA": "Legend of Arcadia", "ARCAD": "Arcadeum", "ARCADE": "ARCADE", @@ -971,17 +1026,19 @@ "ARCAI": "ARCAI", "ARCANE": "Arcane Token", "ARCAS": "Arcas", - "ARCH": "Archway", + "ARCH": "Archimedes Finance", "ARCHA": "ArchAngel Token", "ARCHAI": "ArchAI", "ARCHCOIN": "ArchCoin", "ARCHE": "Archean", "ARCHETHIC": "Archethic Universal Coin", + "ARCHI": "Archi Finance", "ARCHIVE": "Chainback", "ARCINTEL": "Arc", "ARCO": "AquariusCoin", "ARCONA": "Arcona", "ARCOS": "ArcadiaOS", + "ARCS": "Arbitrum Charts", "ARCT": "ArbitrageCT", "ARCTICCOIN": "ArcticCoin", "ARCX": "ARC Governance", @@ -990,26 +1047,27 @@ "ARE": "Aurei", "AREA": "Areon Network", "AREN": "Arenon", - "ARENA": "Alpha Arena", + "ARENA": "Arena Token", "ARENAT": "ArenaToken", "AREPA": "Arepacoin", - "ARES": "ARES", + "ARES": "Ares Protocol", "ARESP": "Ares Protocol", "ARG": "Argentine Football Association Fan Token", "ARGENTUM": "Argentum", - "ARGO": "ArGoApp", + "ARGO": "ArGo", "ARGOCOIN": "Argocoin", "ARGON": "Argon", - "ARGUS": "ArgusCoin", + "ARGUS": "Argus", "ARI": "AriCoin", "ARI10": "Ari10", "ARIA": "ARIA.AI", "ARIA20": "Arianee", "ARIAIP": "Aria", "ARIO": "AR.IO Network", + "ARION": "Arion", "ARIT": "ArithFi", "ARIX": "Arix", - "ARK": "ARK", + "ARK": "Ark", "ARKDEFAI": "ARK", "ARKEN": "Arken Finance", "ARKER": "Arker", @@ -1023,7 +1081,7 @@ "ARMOR": "ARMOR", "ARMR": "ARMR", "ARMS": "2Acoin", - "ARMY": "Army of Fortune Coin", + "ARMY": "BabyDogeARMY", "ARNA": "ARNA Panacea", "ARNC": "Arnoya classic", "ARNM": "Arenum", @@ -1036,9 +1094,9 @@ "AROR": "Arora", "AROS": "Aros", "AROX": "OFFICIAL AROX", - "ARPA": "ARPA Chain", + "ARPA": "ARPA", "ARPAC": "ArpaCoin", - "ARQ": "ArQmA", + "ARQ": "Arqma", "ARQX": "ARQx AI", "ARR": "ARROUND", "ARRI": "Arris", @@ -1052,8 +1110,8 @@ "ARTC": "Artcoin", "ARTDECO": "ARTDECO", "ARTDRAW": "ArtDraw", - "ARTE": "Artemine", - "ARTEM": "Artem", + "ARTE": "ethArt", + "ARTEM": "Artem Coin", "ARTEMIS": "OFFICIAL ARTEMIS", "ARTEON": "Arteon", "ARTEQ": "artèQ", @@ -1061,7 +1119,7 @@ "ARTF": "Artfinity Token", "ARTFI": "ARTFI", "ARTG": "Goya Giant Token", - "ARTH": "ARTH", + "ARTH": "ARTH Valuecoin", "ARTHERA": "Arthera", "ARTI": "Arti Project", "ARTIF": "Artificial Intelligence", @@ -1072,14 +1130,16 @@ "ARTP": "ArtPro", "ARTR": "Artery Network", "ARTT": "ARTT Network", - "ARTX": "Ultiland", + "ARTX": "ARTX Trading", "ARTY": "Artyfact", + "ARTY23751": "Artyfact", "ARV": "Ariva", "ARW": "Arowana Token", "ARX": "ARCS", "ARY": "Block Array", "AS": "AmaStar", "ASA": "ASA Coin", + "ASAFE": "AllSafe", "ASAFE2": "Allsafe", "ASAN": "ASAN VERSE", "ASAP": "Asap Sniper Bot", @@ -1087,7 +1147,7 @@ "ASC": "All InX SMART CHAIN", "ASCEND": "Ascend", "ASCN": "AlphaScan", - "ASD": "AscendEX Token", + "ASD": "ASD", "ASDEX": "AstraDEX", "ASEED": "aUSD SEED (Acala)", "ASETQU": "AsetQu", @@ -1095,6 +1155,7 @@ "ASG": "Asgard", "ASGC": "ASG", "ASH": "ASH", + "ASH22321": "AshSwap", "ASHS": "AshSwap", "ASI": "Sender AI Token", "ASIA": "Asia Coin", @@ -1111,10 +1172,10 @@ "ASN": "Ascension Coin", "ASNT": "Assent Protocol", "ASP": "Aspecta", - "ASPC": "Astropup Coin", + "ASPC": "Astropup coin", "ASPIRE": "Aspire", "ASPIRIN": "Aspirin", - "ASPO": "ASPO Shards", + "ASPO": "ASPO World", "ASQT": "ASQ Protocol", "ASR": "AS Roma Fan Token", "ASRR": "Assisterr AI", @@ -1126,7 +1187,9 @@ "ASST": "AssetStream", "AST": "AirSwap", "ASTA": "ASTA", + "ASTAR": "AceStarter", "ASTER": "Aster", + "ASTER36341": "Aster", "ASTERINU": "Aster INU", "ASTEROID": "Asteroid Shiba", "ASTEROIDBOT": "Asteroid Bot", @@ -1145,7 +1208,7 @@ "ASTRAFERV1": "Astrafer v1", "ASTRAL": "Astral", "ASTRALAB": "Astra Labs", - "ASTRO": "Astroport", + "ASTRO": "AstroTools", "ASTROC": "Astroport Classic", "ASTROLION": "AstroLion", "ASTRONAUT": "Astronaut", @@ -1158,10 +1221,11 @@ "ASUSHI": "Sushi (Arbitrum Bridge)", "ASVA": "Asva", "ASW": "AdaSwap", + "ASX": "AllStars Digital", "ASY": "ASYAGRO", - "AT": "APRO oracle Token", - "ATA": "Automata", - "ATB": "ATB coin", + "AT": "ABCC Token", + "ATA": "Automata Network", + "ATB": "ATBCoin", "ATC": "AutoBlock", "ATCC": "ATC Coin", "ATD": "A2DAO", @@ -1173,6 +1237,7 @@ "ATFI": "Atlantic Finance Token", "ATFS": "ATFS Project", "ATH": "Aethir", + "ATH30083": "Aethir", "ATHCAT": "ATH CAT", "ATHE": "Atheios", "ATHEN": "Athenas AI", @@ -1188,7 +1253,7 @@ "ATLASD": "Atlas DEX", "ATLASOFUSA": "Atlas", "ATLX": "Atlantis Loans Polygon", - "ATM": "Atletico de Madrid Fan Token", + "ATM": "Atletico De Madrid Fan Token", "ATMA": "ATMA", "ATMBSC": "ATM", "ATMC": "Autumncoin", @@ -1208,6 +1273,7 @@ "ATOS": "Atoshi", "ATOZ": "Race Kingdom", "ATP": "Atlas Protocol", + "ATPAD": "AtomPad", "ATPAY": "AtPay", "ATR": "Artrade", "ATRI": "Atari Token", @@ -1215,7 +1281,7 @@ "ATROFA": "Atrofarm", "ATRS": "Attarius Network", "ATRV1": "Artrade v1", - "ATS": "Alltoscan", + "ATS": "Atlas DEX", "ATT": "Attila", "ATTR": "Attrace", "ATTRA": "Attractor", @@ -1226,21 +1292,22 @@ "AUA": "ArubaCoin", "AUC": "Auctus", "AUCO": "Advanced United Continent", - "AUCTION": "Bounce", + "AUCTION": "Bounce Token", "AUDC": "Aussie Digital", "AUDD": "Australian Digital Dollar", "AUDF": "Forte AUD", "AUDIO": "Audius", "AUDM": "Macropod Stablecoin", - "AUDT": "Auditchain", + "AUDT": "Australian Dollar Token", "AUDX": "eToro Australian Dollar", "AUK": "Aukcecoin", "AUKI": "Auki Labs", "AUN": "Authoreon", "AUNIT": "Aunit", "AUPC": "Authpaper", - "AUR": "Aurix", - "AURA": "aura", + "AUR": "Auroracoin", + "AURA": "Aurora Finance", + "AURA31843": "Aura", "AURABAL": "Aura BAL", "AURAF": "Aura Finance", "AURANET": "Aura Network", @@ -1261,14 +1328,17 @@ "AUTISM": "autism", "AUTISMTOKEN": "AUTISM", "AUTO": "Auto", + "AUTO1": "Auto", + "AUTOFARM": "Auto", "AUTOMATIC": "Automatic Treasury Machine", "AUTONO": "Autonomi", "AUTOS": "CryptoAutos", "AUTUMN": "Autumn", "AUVERSE": "AuroraVerse", "AUX": "Auxilium", - "AV": "Avatar Coin", - "AVA": "Travala", + "AV": "AvatarCoin", + "AVA": "AVA", + "AVA34326": "AVA Chiang Mai Night Safari", "AVAAI": "Ava AI", "AVACN": "AVACOIN", "AVAI": "Orca AVAI", @@ -1293,14 +1363,14 @@ "AVENT": "Aventa", "AVEROPAY": "Averopay", "AVERY": "Avery Games", - "AVG": "Avocado DAO", - "AVGOX": "Broadcom xStock", + "AVG": "Avocado DAO Token", + "AVGOX": "Broadcom tokenized stock (xStock)", "AVH": "Animation Vision Cash", "AVI": "Aviator", - "AVICI": "Avici", + "AVICI": "Avici USD Price", "AVINOC": "AVINOC", "AVIVE": "Avive World", - "AVL": "AVL", + "AVL": "Aston Villa Fan Token", "AVLT": "Altura Vault Tokens", "AVM": "AVM (Atomicals)", "AVME": "AVME", @@ -1312,7 +1382,7 @@ "AVS": "Aves", "AVT": "Aventus", "AVTM": "Aventis Metaverse", - "AVXL": "Avaxlauncher", + "AVXL": "AvaXlauncher", "AVXT": "Avaxtars Token", "AWARDCOIN": "Award", "AWARE": "ChainAware.ai", @@ -1320,7 +1390,7 @@ "AWAX": "AWAX", "AWBTC": "Aave interest bearing WBTC", "AWC": "Atomic Wallet Coin", - "AWE": "AWE Network", + "AWE": "AWE Network USD Price", "AWK": "Awkward Monkey Base", "AWM": "Another World", "AWNEX": "AWNEX token", @@ -1331,17 +1401,18 @@ "AWS": "Agentwood Studios", "AWT": "Abyss World", "AWX": "AurusX", - "AX": "AlphaX", + "AX": "AurusX", "AXC": "AXIA Coin", "AXE": "Axe", "AXEL": "AXEL", "AXGT": "AxonDAO Governance Token", - "AXIAL": "AXiaL", + "AXIAL": "Axial", "AXIAV3": "Axia", - "AXIOM": "Axiom Coin", + "AXIOM": "Axiom", "AXIS": "Axis DeFi", "AXIST": "AXIS Token", "AXL": "Axelar", + "AXL17799": "Axelar", "AXLINU": "AXL INU", "AXLUSDC": "Axelar Wrapped USDC", "AXLW": "Axel Wrapped", @@ -1353,10 +1424,10 @@ "AXOME": "Axolotl Meme", "AXON": "AxonDAO Governance Token", "AXP": "aXpire v1", - "AXPR": "aXpire", + "AXPR": "Moola", "AXPRV2": "aXpire v2", "AXR": "AXRON", - "AXS": "Axie Infinity Shards", + "AXS": "Axie Infinity", "AXSV1": "Axie Infinity Shards v1", "AXT": "AIX", "AXYS": "Axys", @@ -1381,7 +1452,7 @@ "AZUR": "Azuro Protocol", "AZURE": "Azure Wallet", "AZY": "Amazy", - "B": "BUILDon", + "B": "BUILDon USD Price", "B01": "b0rder1ess", "B1P": "B ONE PAYMENT", "B2": "B² Network", @@ -1402,6 +1473,7 @@ "BABI": "Babylons", "BABL": "Babylon Finance", "BABY": "Babylon", + "BABY32198": "Babylon", "BABYANDY": "Baby Andy", "BABYASTER": "Baby Aster", "BABYB": "Baby Bali", @@ -1416,6 +1488,7 @@ "BABYBOME": "Book of Baby Memes", "BABYBOMEOW": "Baby of BOMEOW", "BABYBONK": "Baby Bonk", + "BABYBOO": "BabyBoo", "BABYBOOM": "BabyBoomToken", "BABYBOSS": "Baby Boss", "BABYBROC": "Baby Broccoli", @@ -1434,7 +1507,7 @@ "BABYCZHAO": "Baby Czhao", "BABYD": "Baby Dragon", "BABYDENG": "Baby Moo Deng", - "BABYDOGE": "BabyDoge", + "BABYDOGE": "Baby Doge Coin", "BABYDOGE2": "Baby Doge 2.0", "BABYDOGEINU": "BABY DOGE INU", "BABYDOGEZILLA": "BabyDogeZilla", @@ -1442,7 +1515,7 @@ "BABYELON": "BabyElon", "BABYETH": "Baby Ethereum", "BABYFB": "Baby Floki Billionaire", - "BABYFLOKI": "BabyFloki", + "BABYFLOKI": "Baby Floki (BSC)", "BABYFLOKIZILLA": "BabyFlokiZilla", "BABYFROG": "Baby Frog Coin", "BABYG": "BabyGME", @@ -1519,7 +1592,7 @@ "BACHI": "Bachi on Base", "BACK": "DollarBack", "BACOIN": "BACoin", - "BACON": "BaconDAO (BACON)", + "BACON": "BaconDAO", "BACX": "Bank of America xStock", "BAD": "Bad Idea AI", "BADA": "Bad Alien Division", @@ -1531,17 +1604,18 @@ "BADM": "Badmad Robots", "BAFC": "BabyApeFunClub", "BAG": "Bag", + "BAGEL": "Bagels Finance", "BAGS": "Basis Gold Share", "BAGWORK": "Bagwork", "BAHAMAS": "Bahamas", "BAHIA": "Esporte Clube Bahia Fan Token", - "BAI": "BearAI", + "BAI": "Based AI", "BAICA": "Baica", "BAJU": "Bajun Network", "BAK": "BaconCoin", "BAKAC": "Baka Casino", "BAKE": "BakeryToken", - "BAKED": "Baked", + "BAKED": "reBaked", "BAKEDB": "Baked Beans Token", "BAKEDTOKEN": "Baked", "BAKENEKO": "BAKENEKO", @@ -1556,18 +1630,21 @@ "BALL": "BitBall", "BALLTZE": "BALLTZE", "BALLZ": "Wolf Wif", - "BALN": "Balanced", + "BALN": "Balance Tokens", "BALPHA": "bAlpha", "BALT": "Brett's cat", "BALTO": "Balto Token", + "BALVEY": "Baby Alvey", "BALVI": "Balvi", "BAMA": "BabyAMA", "BAMBIT": "BAMBIT", "BAMBOO": "BambooDeFi", "BAMF": "BAMF", "BAMITCOIN": "Bamit", - "BAN": "Comedian", - "BANANA": "Banana Gun", + "BAN": "Banano", + "BAN33881": "Comedian", + "BANANA": "Banana", + "BANANA28066": "Banana Gun", "BANANACHARITY": "BANANA", "BANANAF": "Banana For Scale", "BANANAGUY": "BananaGuy", @@ -1575,7 +1652,7 @@ "BANANAS31": "Banana For Scale", "BANANO": "Banano", "BANC": "Babes and Nerds", - "BANCA": "BANCA", + "BANCA": "Banca", "BANCORUSD": "USD Bancor", "BAND": "Band Protocol", "BANDEX": "Banana Index", @@ -1583,7 +1660,7 @@ "BANDO": "Bandot", "BANG": "BANG", "BANGY": "BANGY", - "BANK": "Lorenzo Protocol", + "BANK": "Bankcoin", "BANKA": "Bank AI", "BANKBRC": "BANK Ordinals", "BANKC": "Bankcoin", @@ -1594,18 +1671,23 @@ "BANNER": "BannerCoin", "BANUS": "Banus.Finance", "BANX": "Banx.gg", - "BAO": "Bao Token V2", + "BAO": "Bao Finance (old)", "BAOBAO": "BaoBao", "BAOE": "Business Age of Empires", "BAOM": "Battle of Memes", "BAOS": "BaoBaoSol", "BAOV1": "BaoToken v1", "BAP3X": "bAP3X", + "BAPE": "BAPE Social Club", + "BAPTOS": "Baby Aptos", "BAR": "FC Barcelona Fan Token", "BARA": "Capybara Nation", + "BARA34141": "Capybara Nation", "BARAKATUH": "Barakatuh", + "BARB": "Baby Arbitrum", "BARC": "The Blu Arctic Water Company", "BARD": "Lombard", + "BARD38408": "Lombard", "BAREBEARS": "BAREBEARS", "BARIO": "Bario", "BARK": "Bored Ark", @@ -1615,12 +1697,13 @@ "BART": "BarterTrade", "BARTKRC": "BART Token", "BARY": "Bary", - "BAS": "BNB Attestation Service", + "BAS": "Basis Share", + "BASE": "Base Protocol", "BASEAI": "BaseAI", "BASEBEAR": "BBQ", "BASECAT": "BASE CAT", "BASECOIN": "BASECOIN", - "BASED": "Based Token", + "BASED": "Based Finance", "BASEDAI": "BasedAI", "BASEDALF": "Based Alf", "BASEDB": "Based Bonk", @@ -1646,7 +1729,7 @@ "BASIC": "BASIC", "BASID": "Basid Coin", "BASIL": "Basilisk", - "BASIS": "Basis", + "BASIS": "basis.markets", "BASISCOIN": "Basis Coin", "BASISSHAREV1": "Basis Share", "BASISSHAREV2": "Basis Share", @@ -1664,17 +1747,20 @@ "BAX": "BABB", "BAXS": "BoxAxis", "BAXV1": "BABB v1", - "BAY": "Marina Protocol", + "BAY": "CryptoBay", "BAYSE": "coynbayse", "BAZED": "Bazed Games", "BB": "BounceBit", + "BB-A-DAI": "Balancer Boosted Aave DAI", + "BB-A-WETH": "Balancer Aave v3 Boosted Pool (WETH)", "BB1": "Bitbond", + "BB30746": "BounceBit", "BBADGER": "Badger Sett Badger", "BBAION": "BigBear.ai Holdings (Ondo Tokenized)", - "BBANK": "BlockBank", + "BBANK": "blockbank", "BBB": "BitBullBot", "BBBTC": "Big Back Bitcoin", - "BBC": "Bull BTC Club", + "BBC": "BigBang Core", "BBCC": "BaseballCardCoin", "BBCG": "BBC Gold Coin", "BBCH": "Binance Wrapped BCH", @@ -1696,6 +1782,7 @@ "BBOB": "BabyBuilder", "BBONK": "BitBonk", "BBOS": "Blackbox Foundation", + "BBOT": "BetBot", "BBP": "BiblePay", "BBQ": "BBQ COIN", "BBR": "Boolberry", @@ -1705,11 +1792,11 @@ "BBSNEK": "BabySNEK", "BBSOL": "Bybit Staked SOL", "BBT": "BurgerBlastToken", - "BBTC": "Binance Wrapped BTC", + "BBTC": "Baby Bitcoin", "BBTF": "Block Buster Tech Inc", "BBUSD": "BounceBit USD", "BBYDEV": "The Dev is a Baby", - "BC": "Blood Crystal", + "BC": "Bitcoin Confidential", "BC3M": "Backed GOVIES 0-6 Months Euro Investment Grade", "BC400": "Bitcoin Cultivator 400", "BCA": "Bitcoin Atom", @@ -1741,27 +1828,27 @@ "BCLAT": "BOMBOCLAT", "BCMC": "Blockchain Monster Hunt", "BCMC1": "BeforeCoinMarketCap", - "BCN": "ByteCoin", + "BCN": "Bytecoin", "BCNA": "BitCanna", "BCNT": "Bincentive", "BCNX": "BCNEX", "BCO": "BridgeCoin", - "BCOIN": "Ball3", + "BCOIN": "Bombcrypto", "BCOINBNB": "Bombcrypto", "BCOINM": "Bomb Crypto (MATIC)", "BCOINSOL": "Bomb Crypto (SOL)", "BCOINTON": "Bomb Crypto (TON)", "BCONG": "BabyCong", "BCOQ": "BLACK COQINU", - "BCP": "BlockChainPeople", + "BCP": "Bitcashpay (old)", "BCPAY": "Bitcashpay", - "BCPT": "BlockMason Credit Protocol", + "BCPT": "Blockmason Credit Protocol", "BCPV1": "BitcashPay", "BCR": "BitCredit", "BCRO": "Bonded Cronos", "BCS": "Business Credit Substitute", "BCSPX": "Backed CSPX Core S&P 500", - "BCT": "Buy Coin Token", + "BCT": "Toucan Protocol: Base Carbon Tonne", "BCUBE": "B-cube.ai", "BCUG": "Blockchain Cuties Universe Governance", "BCUT": "bitsCrunch", @@ -1773,9 +1860,11 @@ "BD": "BlastDEX", "BD20": "BRC-20 DEX", "BDAG": "BlockDAG", + "BDAMM": "Bonded dAMM", "BDAY": "Birthday Cake", "BDB": "Big Data Block", "BDC": "BILLION•DOLLAR•CAT", + "BDC31668": "BILLION•DOLLAR•CAT", "BDCA": "BitDCA", "BDCC": "BDCC COIN", "BDCLBSC": "BorderCollieBSC", @@ -1786,7 +1875,7 @@ "BDOG": "BurnDog", "BDOGITO": "BullDogito", "BDOT": "Binance Wrapped DOT", - "BDP": "Big Data Protocol", + "BDP": "BidiPass", "BDPI": "Interest Bearing Defi Pulse Index", "BDR": "BlueDragon", "BDRM": "Bodrumspor Fan Token", @@ -1800,14 +1889,17 @@ "BEACH": "BeachCoin", "BEAI": "BeNFT Solutions", "BEAM": "Beam", + "BEAM28298": "Beam", "BEAMMW": "Beam", "BEAN": "Bean", - "BEANS": "SUNBEANS (BEANS)", + "BEANS": "Moonbeans", "BEAR": "3X Short Bitcoin Token", "BEARIN": "Bear in Bathrobe", "BEARINU": "Bear Inu", "BEAST": "MrBeast", + "BEAST33564": "MrBeast", "BEAT": "Beat Token", + "BEAT38837": "Audiera", "BEATAI": "eBeat AI", "BEATLES": "JohnLennonC0IN", "BEATS": "Sol Beats", @@ -1823,6 +1915,7 @@ "BECKOS": "Beckos", "BECN": "Beacon", "BECO": "BecoSwap Token", + "BECOIN": "bePAY Finance", "BECX": "BETHEL", "BED": "Bankless BED Index", "BEDROCK": "Bedrock", @@ -1856,19 +1949,20 @@ "BELLE": "Isabelle", "BELLS": "Bellscoin", "BELR": "Belrium", - "BELT": "Belt", + "BELT": "Belt Finance", "BELUGA": "Beluga", "BEM": "BEMIL Coin", "BEMC": "BemChain", "BEMD": "Betterment Digital", "BEN": "Ben", - "BEND": "BendDao", + "BEND": "BendDAO", "BENDER": "BENDER", "BENDOG": "Ben the Dog", "BENG": "Based Peng", "BENI": "Beni", "BENJACOIN": "Benjacoin", "BENJI": "Basenji", + "BENJI30193": "Basenji", "BENJIROLLS": "BenjiRolls", "BENK": "BENK", "BENT": "Bent Finance", @@ -1880,24 +1974,26 @@ "BEPE": "Blast Pepe", "BEPR": "Blockchain Euro Project", "BEPRO": "BEPRO Network", - "BERA": "Berachain", + "BERA": "Berachain USD Price", "BERAETH": "Berachain Staked ETH", "BERASTONE": "StakeStone Berachain Vault Token", + "BERC": "Fair BERC20", "BERF": "BERF", "BERG": "Bloxberg", "BERN": "BERNcash", "BERNIE": "BERNIE SENDERS", "BERRIE": "Berrie Token", - "BERRY": "Strawberry AI", + "BERRY": "Rentberry", "BERRYS": "BerrySwap", "BERRYSTORE": "Berry", "BERT": "Bertram The Pomeranian", "BES": "battle esports coin", "BESA": "Besa Gaming", "BESHARE": "Beshare Token", - "BEST": "Best Wallet Token", + "BEST": "Bitpanda Ecosystem Token", "BESTC": "BestChain", "BETA": "Beta Finance", + "BETA1": "PolyBeta Finance", "BETACOIN": "BetaCoin", "BETBOX": "betbox", "BETF": "Betform", @@ -1907,8 +2003,8 @@ "BETR": "BetterBetting", "BETROCK": "Betrock", "BETS": "BetSwirl", - "BETT": "Bettium", - "BETU": "Betu", + "BETT": "BedlingtonTerrierToken", + "BETU": "BetU", "BETURA": "BETURA", "BETZ": "Bet Lounge", "BEX": "BEX token", @@ -1916,7 +2012,7 @@ "BEYOND": "Beyond Protocol", "BEZ": "Bezop", "BEZOGE": "Bezoge Earth", - "BF": "BitForex Token", + "BF": "Bitforex", "BFC": "Bifrost", "BFCH": "Big Fun Chain", "BFDT": "Befund", @@ -1924,22 +2020,22 @@ "BFG": "BFG Token", "BFHT": "BeFaster Holder Token", "BFI": "BlockFi-Ai", - "BFIC": "Bficoin", + "BFIC": "Best Fintech Investment Coin", "BFICGOLD": "BFICGOLD", "BFK WARZONE": "BFK Warzone", - "BFLOKI": "BurnFloki", + "BFLOKI": "Burn Floki", "BFLY": "Butterfly Protocol", "BFM": "BenefitMine", - "BFR": "Buffer Token", - "BFT": "BF Token", + "BFR": "Buffer Finance", + "BFT": "BnkToTheFuture", "BFTB": "Brazil Fan Token", "BFTC": "BITS FACTOR", "BFTOKEN": "BOSS FIGHTERS", "BFUSD": "BFUSD", "BFWOG": "Based Fwog (basedfwog.info)", "BFX": "BitFinex Tokens", - "BG": "BunnyPark Game", - "BGB": "Bitget token", + "BG": "Bagus Wallet", + "BGB": "Bitget Token", "BGBG": "BigMouthFrog", "BGBP": "Binance GBP Stable Coin", "BGBTC": "Bitget Wrapped BTC", @@ -1958,7 +2054,7 @@ "BGSC": "BugsCoin", "BGSOL": "Bitget SOL Staking", "BGUY": "The Big Guy", - "BGVT": "Bit Game Verse Token", + "BGVT": "BIT GAME VERSE TOKEN", "BHAO": "Bithao", "BHAT": "BH Network", "BHAX": "Bithashex", @@ -1970,7 +2066,7 @@ "BHIGH": "Backed HIGH € High Yield Corp Bond", "BHIRE": "BitHIRE", "BHIVE": "Hive", - "BHO": "Bholdus Token", + "BHO": "Bholdus", "BHP": "Blockchain of Hash Power", "BHPC": "BHPCash", "BIAFRA": "Biafra Coin", @@ -1982,6 +2078,7 @@ "BIBI2025": "Bibi", "BIBIBSC": "BIBI", "BIBL": "Biblecoin", + "BIBLE": "Bible", "BIBO": "Bible of Memes", "BIBTA": "Backed IBTA $ Treasury Bond 1-3yr", "BIC": "Bikercoins", @@ -1990,6 +2087,7 @@ "BICO": "Biconomy", "BICS": "Biceps", "BID": "CreatorBid", + "BID35430": "CreatorBid", "BIDAO": "Bidao", "BIDCOM": "Bidcommerce", "BIDEN": "Dark Brandon", @@ -1997,10 +2095,12 @@ "BIDI": "Bidipass", "BIDP": "BID Protocol", "BIDR": "Binance IDR Stable Coin", + "BIDS": "BIDSHOP", "BIDUON": "Baidu (Ondo Tokenized)", "BIDZ": "BIDZ Coin", "BIDZV1": "BIDZ Coin v1", - "BIFI": "Beefy.Finance", + "BIFI": "Beefy", + "BIFI7311": "Beefy", "BIFIF": "BiFi", "BIFIV1": "Beefy v1", "BIG": "Big Eyes", @@ -2016,6 +2116,7 @@ "BIGLEZ": "THE BIG LEZ SHOW", "BIGMIKE": "Big Mike", "BIGOD": "BinGold Token", + "BIGONE-TOKEN": "BigONE Token", "BIGPUMP": "Big Pump", "BIGSB": "BigShortBets", "BIGTIME": "Big Time", @@ -2030,7 +2131,7 @@ "BILL": "TillBilly", "BILLI": "Billi", "BILLICAT": "BilliCat", - "BILLY": "Billy ", + "BILLY": "Billy", "BILLYBSC": "BILLY", "BIM": "BitminerCoin", "BIN": "Binemon", @@ -2049,20 +2150,21 @@ "BINTEX": "Bintex Futures", "BINU": "Blast Inu", "BIO": "Bio Protocol", + "BIO34812": "Bio Protocol", "BIOB": "BioBar", "BIOC": "BioCrypt", "BIOCOIN": "Biocoin", "BIOFI": "Biometric Financial", "BIOP": "Biop", - "BIOS": "BiosCrypto", - "BIOT": "Bio Passport", - "BIP": "Minter", + "BIOS": "0x_nodes", + "BIOT": "BioPassport Token", + "BIP": "Minter Network", "BIPC": "BipCoin", "BIPX": "Bispex", "BIR": "Birake", - "BIRB": "Moonbirds", + "BIRB": "Birb", "BIRBV1": "Birb", - "BIRD": "BIRD", + "BIRD": "Bird.Money", "BIRDCHAIN": "Birdchain", "BIRDD": "BIRD DOG", "BIRDDOG": "Bird Dog", @@ -2074,11 +2176,12 @@ "BISO": "BISOSwap", "BIST": "Bistroo", "BISTOX": "Bistox Exchange Token", - "BIT": "BitDAO", + "BIT": "BitRewards", + "BIT1": "BitDAO", "BIT16": "16BitCoin", "BITAIR": "Bitair", "BITASEAN": "BitAsean", - "BITB": "BeanCash", + "BITB": "Bean Cash", "BITBAY": "BitBay", "BITBEDR": "Bitcoin EDenRich", "BITBO": "BitBook", @@ -2098,12 +2201,16 @@ "BITCM": "Bitcomo", "BITCNY": "bitCNY", "BITCO": "Bitcoin Black Credit Card", + "BITCOIN": "HarryPotterObamaSonic10Inu (ETH)", + "BITCOIN-FILE": "Bitcoin File", + "BITCOIN25220": "HarryPotterObamaSonic10Inu (ERC-20)", "BITCOINC": "Bitcoin Classic", "BITCOINCONFI": "Bitcoin Confidential", "BITCOINOTE": "BitcoiNote", "BITCOINP": "Bitcoin Private", "BITCOINSCRYPT": "Bitcoin Scrypt", "BITCOINV": "BitcoinV", + "BITCOIVA": "Bitcoiva", "BITCONNECT": "BitConnect Coin", "BITCORE": "BitCore", "BITCRATIC": "Bitcratic Token", @@ -2137,6 +2244,7 @@ "BITSERIAL": "BitSerial", "BITSILVER": "bitSilver", "BITSPACE": "Bitspace", + "BITSTAR": "Bitstar", "BITSZ": "Bitsz", "BITT": "BiTToken", "BITTO": "BITTO", @@ -2149,13 +2257,13 @@ "BITVOLT": "BitVolt", "BITWHITE": "BitWhite", "BITWORLD": "Bit World Token", - "BITX": "BitScreener", + "BITX": "BitScreener Token", "BITXOXO": "Bitxoxo", "BITZ": "MARBITZ", "BITZBIZ": "Bitz Coin", "BIUT": "Bit Trust System", "BIVE": "BIZVERSE", - "BIX": "BiboxCoin", + "BIX": "Bibox Token", "BIXB": "BIXBCOIN", "BIXI": "Bixi", "BIXV1": "BiboxCoin v1", @@ -2182,6 +2290,7 @@ "BLACK": "BLACKHOLE PROTOCOL", "BLACKD": "Blackder AI", "BLACKDRAGON": "Black Dragon", + "BLACKDT": "BlackDragon", "BLACKP": "BlackPool Token", "BLACKR": "BLACK ROCK", "BLACKROCK": "BlackRock", @@ -2192,9 +2301,10 @@ "BLADE": "BladeGames", "BLADEW": "BladeWarrior", "BLAKEBTC": "BlakeBitcoin", - "BLANK": "Blank Token", + "BLANK": "BlockWallet", "BLAS": "BlakeStar", - "BLAST": "BLAST", + "BLAST": "Blast", + "BLAST28480": "Blast", "BLASTA": "BlastAI", "BLASTUP": "BlastUP", "BLAUNCH": "B-LAUNCH", @@ -2211,7 +2321,7 @@ "BLEPE": "Blepe", "BLERF": "BLERF", "BLES": "Blind Boxes", - "BLESS": "Bless Token", + "BLESS": "Bless", "BLET": "Brainlet", "BLF": "Baby Luffy", "BLHC": "BlackholeCoin", @@ -2221,21 +2331,21 @@ "BLIN": "Blin Metaverse", "BLIND": "Blindsight", "BLING": "PLEB DREKE", - "BLINK": "BlockMason Link", + "BLINK": "Blockmason Link", "BLINU": "Baby Lambo Inu", - "BLITZ": "BlitzCoin", + "BLITZ": "Blitz Labs", "BLITZP": "BlitzPredict", "BLK": "BlackCoin", - "BLKC": "BlackHat Coin", + "BLKC": "BlackHat", "BLKD": "Blinked", "BLKS": "Blockshipping", "BLM": "BLM coin", - "BLN": "Bulleon", + "BLN": "Balance Network", "BLNM": "Bolenum", "BLOB": "B.O.B the Blob", "BLOBERC20": "Blob", "BLOC": "Blockcloud", - "BLOCK": "Block", + "BLOCK": "Blocknet", "BLOCKASSET": "Blockasset", "BLOCKB": "Block Browser", "BLOCKBID": "Blockbid", @@ -2265,23 +2375,24 @@ "BLOOM": "BloomBeans", "BLOOMT": "Bloom Token", "BLOVELY": "Baby Lovely Inu", - "BLOX": "BLOX", + "BLOX": "Blox Token", "BLOXT": "Blox Token", "BLOXWAP": "BLOXWAP", "BLP": "BullPerks", "BLPAI": "BullPerks AI", "BLPT": "Blockprompt", "BLRY": "BillaryCoin", - "BLS": "BloodLoop", + "BLS": "Blocks Space", "BLST": "Crypto Legions Bloodstone", - "BLT": "Blocto Token", + "BLT": "Bloom", "BLTC": "BABYLTC", "BLTG": "Block-Logic", "BLTV": "BLTV Token", "BLU": "BlueCoin", "BLUAI": "Bluwhale AI", "BLUB": "BLUB", - "BLUE": "Bluefin", + "BLUE": "Blue Protocol", + "BLUE8724": "Bluefin", "BLUEBASE": "Blue", "BLUEBUTT": "BLUE BUTT CHEESE", "BLUEG": "Blue Guy", @@ -2290,7 +2401,7 @@ "BLUEPROTOCOL": "Blue Protocol", "BLUES": "Blueshift", "BLUESC": "BluesCrypto", - "BLUESPARROW": "BlueSparrow Token", + "BLUESPARROW": "BlueSparrow Token (Old)", "BLUESPARROWOLD": "BlueSparrowToken", "BLUEW": "Blue Whale", "BLUEY": "BlueyonBase", @@ -2310,6 +2421,7 @@ "BLZ": "Bluzelle", "BLZD": "Blizzard.money", "BLZE": "BLAZE TOKEN", + "BLZZ": "Blizz Finance", "BM": "BitMoon", "BMAGA": "Baby Maga", "BMARS": "Binamars", @@ -2317,10 +2429,11 @@ "BMB": "Beamable Network Token", "BMBO": "Bamboo Coin", "BMC": "Blackmoon Crypto", + "BMCC": "Binance Multi-Chain Capital", "BMCHAIN": "BMChain", "BMDA": "Bermuda", "BME": "BitcoMine", - "BMEX": "BitMEX", + "BMEX": "BitMEX Token", "BMF": "MetaFame", "BMG": "Borneo", "BMH": "BlockMesh", @@ -2343,9 +2456,9 @@ "BMXT": "Bitmxittz", "BMXX": "Multiplier", "BN": "TNA Protocol", - "BNA": "BananaTok", + "BNA": "Bananatok", "BNANA": "Chimpion", - "BNB": "Binance Coin", + "BNB": "BNB", "BNBAI": "BNB Agents", "BNBAICLUB": "BNB AI Agent", "BNBBONK": "BNB BONK", @@ -2370,19 +2483,21 @@ "BNBSNAKE": "BNB SNAKE", "BNBSONGOKU": "BNBsongoku", "BNBTC": "BNbitcoin", + "BNBTIGER": "BNBTiger", "BNBULL": "BNBULL", "BNBVEGETA": "BNB VEGETA", "BNBWHALES": "BNB Whales", "BNBX": "Stader BNBx", "BNBXBT": "BNBXBT", - "BNC": "Bifrost Native Coin", + "BNC": "Bionic", "BND": "Bened", "BNF": "BonFi", "BNFT": "APENFT (BitTorrent Bridge)", + "BNI": "Bitindi Chain", "BNIU": "Backed Niu Technologies", "BNIX": "BNIX Token", "BNK": "Bankera", - "BNKR": "BankrCoin", + "BNKR": "Bankroll Network", "BNKV1": "Bankera v1", "BNL": "BitNational Token", "BNN": "Banyan Network", @@ -2396,20 +2511,22 @@ "BNSAI": "bonsAI Network", "BNSD": "BNSD Finance", "BNSOL": "Binance Staked SOL", - "BNSOLD": "BNS token ", + "BNSOLD": "BNS token", "BNSV1": "BNS token v1", "BNSX": "Bitcoin Name Service System", - "BNT": "Bancor Network Token", + "BNT": "Bancor", "BNTE": "Bountie", "BNTN": "Blocnation", + "BNTX": "Bintex Futures", "BNTY": "Bounty0x", "BNU": "ByteNext", "BNUSD": "Balanced Dollars", "BNVDA": "Backed NVIDIA", "BNX": "BinaryX", + "BNX23635": "BinaryX", "BNXV1": "BinaryX v1", "BNY": "TaskBunny", - "BOA": "BOSAGORA", + "BOA": "BOSagora", "BOAI": "BOLICAI", "BOAM": "BOOK OF AI MEOW", "BOARD": "SurfBoard Finance", @@ -2428,9 +2545,9 @@ "BOBL2": "BOB", "BOBLS": "Boblles", "BOBMARLEY": "Bob Marley Meme", - "BOBO": "BOBO", + "BOBO": "Bobo Cash", "BOBOT": "Bobo The Bear", - "BOBR": "Based BOBR", + "BOBR": "Bob's Repair", "BOBS": "Bob's Repair", "BOBT": "BOB Token", "BOBTHE": "Bob The Builder", @@ -2451,7 +2568,7 @@ "BODYP": "Body Profile", "BOE": "Bodhi", "BOF": "Balls of Fate", - "BOG": "Bogged Finance", + "BOG": "Bogged", "BOGCOIN": "Bogcoin", "BOGD": "Bogdanoff", "BOGE": "Boge", @@ -2467,13 +2584,13 @@ "BOKU": "Boryoku Dragonz", "BOLBOL": "BOLBOL", "BOLD": "Bold", - "BOLI": "BolivarCoin", - "BOLT": "Bolt", + "BOLI": "Bolivarcoin", + "BOLT": "BOLT", "BOLTAI": "Bolt AI", "BOLTT": "BolttCoin", "BOM": "Book Of Matt Furie", "BOMA": "Book of Maga", - "BOMB": "Bombie", + "BOMB": "BOMB", "BOMBC": "BombCoin", "BOMBLOONG": "Bombloong", "BOMBM": "Bomb Money", @@ -2493,10 +2610,11 @@ "BONA": "Bonafi", "BOND": "BarnBridge", "BONDAPPETIT": "BondAppetit", - "BONDLY": "Bondly", + "BONDLY": "Forj(Bondly)", "BONDLYV1": "Bondly Finance", "BONDX": "BondX", - "BONE": "Bone ShibaSwap", + "BONE": "Shibarium Wrapped BONE", + "BONE11865": "Bone ShibaSwap USD Price", "BONEBONE": "Bone", "BONES": "Moonshots Farm", "BONESCOIN": "BonesCoin", @@ -2519,12 +2637,12 @@ "BONKONBASE": "Bonk on Base", "BONKONETH": "Bonk On ETH", "BONKW": "bonkwifhat", - "BONO": "Bonorum Coin", + "BONO": "Bonorum", "BONTE": "Bontecoin", "BONUS": "BonusBlock", "BONUSCAKE": "Bonus Cake", "BONZI": "Bonzi PFP Cult", - "BOO": "Spookyswap", + "BOO": "SpookySwap", "BOOB": "BooBank", "BOOCHIE": "Boochie by Matt Furie", "BOOE": "Book of Ethereum", @@ -2541,11 +2659,12 @@ "BOOMCOIN": "Boom Token", "BOOMDAO": "BOOM DAO", "BOOMER": "Boomer", + "BOOMER31082": "Boomer", "BOONS": "BOONSCoin", "BOOP": "BOOP", "BOOPA": "Boopa", "BOOS": "Boost Trump Campaign", - "BOOST": "Boost", + "BOOST": "Boosted Finance", "BOOSTCO": "Boost", "BOOSTO": "BOOSTO", "BOOT": "Bostrom", @@ -2578,7 +2697,7 @@ "BOSSIE": "BOSSIE", "BOST": "BoostCoin", "BOSU": "Bosu Inu", - "BOT": "HyperBot", + "BOT": "Bot Planet", "BOTC": "BotChain", "BOTIFY": "BOTIFY", "BOTPLANET": "Bot Planet", @@ -2587,6 +2706,7 @@ "BOTX": "BOTXCOIN", "BOU": "Boulle", "BOUNCE": "Bounce Token", + "BOUNTIE": "Bountie Hunter", "BOUNTY": "ChainBounty", "BOUNTYK": "BOUNTYKINDS", "BOUTS": "BoutsPro", @@ -2594,7 +2714,7 @@ "BOWE": "Book of Whales", "BOWSC": "BowsCoin", "BOWSER": "Bowser", - "BOX": "DeBoxToken", + "BOX": "ContentBox", "BOXABL": "BOXABL", "BOXCAT": "BOXCAT", "BOXETH": "Cat-in-a-Box Ether", @@ -2617,7 +2737,7 @@ "BPD": "Beautiful Princess Disorder", "BPDAI": "Binance-Peg Dai (Binance Bridge)", "BPDOGE": "Binance-Peg DogeZilla (Binance Bridge)", - "BPEPE": "BABY PEPE", + "BPEPE": "BASEDPEPE", "BPEPEF": "Baby Pepe Floki", "BPET": "BPET", "BPINKY": "BPINKY", @@ -2630,7 +2750,7 @@ "BPNEAR": "Binance-Peg NEAR Protocol", "BPOKO": "BabyPoko", "BPRIVA": "Privapp Network", - "BPRO": "BitCloud Pro", + "BPRO": "B.Protocol", "BPS": "BitcoinPoS", "BPSCRT": "Secret (Binance Bridge)", "BPSHIB": "Binance-Peg Shiba Inu (Binance Bridge)", @@ -2667,7 +2787,7 @@ "BRCP": "BRCP Token", "BRCST": "BRCStarter", "BRCT": "BRC App", - "BRD": "Bread token", + "BRD": "Bread", "BRDD": "BeardDollars", "BRDG": "Bridge Protocol", "BREAD": "Breadchain Cooperative", @@ -2676,7 +2796,8 @@ "BRENT": "Brent Crude", "BREPE": "BREPE", "BRETARDIO": "Bretardio", - "BRETT": "Brett Base", + "BRETT": "Brett", + "BRETT29743": "Brett", "BRETTA": "Bretta", "BRETTFYI": "Brett", "BRETTGOLD": "Brett Gold", @@ -2687,6 +2808,7 @@ "BREWERY": "Brewery Consortium Coin", "BREWLABS": "Brewlabs", "BRG": "Bridge Oracle", + "BRG.X": "Bridge$", "BRGE": "OrdBridge", "BRGX": "Bridge$", "BRI": "Baroin", @@ -2695,11 +2817,11 @@ "BRIANWIF": "Brianwifhat", "BRIBE": "Bribe Protocol", "BRIC": "Redbrick", - "BRICK": "Brickchain FInance", + "BRICK": "r/FortNiteBR Bricks", "BRICKS": "MyBricks", "BRICS": "BRICS Chain", - "BRIDGE": "Bridge Bot", - "BRIGHT": "Bright Token", + "BRIDGE": "Cross-Chain Bridge Token", + "BRIGHT": "Bright Union", "BRIGHTCOIN": "BrightCoin", "BRIGHTU": "Bright Union", "BRIK": "BrikBit", @@ -2713,7 +2835,7 @@ "BRIX": "OpenBrix", "BRK": "BreakoutCoin", "BRKBX": "Berkshire Hathaway xStock", - "BRKL": "Brokoli Token", + "BRKL": "Brokoli Network", "BRL1": "BRL1", "BRLV": "High Velocity BRLY", "BRLY": "Yield Bearing BRL", @@ -2760,13 +2882,13 @@ "BRZ": "Brazilian Digital Token", "BRZE": "Breezecoin", "BRZN": "Brayzin", - "BS": "BlackShadowCoin", + "BS": "Black Stallion", "BSAFE": "BlockSafe", "BSAFU": "BlockSAFU", "BSAI": "Bitcoin Silver AI", "BSATOSHI": "BabySatoshi", "BSB": "Block Street", - "BSC": "BSC Layer", + "BSC": "BowsCoin", "BSCAKE": "Bunscake", "BSCBURN": "BSCBURN", "BSCC": "BSCCAT", @@ -2775,9 +2897,10 @@ "BSCM": "BSC MemePad", "BSCPAD": "BSCPAD", "BSCPAY": "BSC PAYMENTS", - "BSCS": "BSC Station", + "BSCS": "BSCStation", "BSCST": "Starter", "BSCV": "Bscview", + "BSCX": "BSCEX", "BSDETH": "Based ETH", "BSE": "base season", "BSEN": "Baby Sen by Sentio", @@ -2791,7 +2914,7 @@ "BSI": "Bali Social Integrated", "BSK": "BTCSKR", "BSKT": "BasketCoin", - "BSL": "BankSocial", + "BSL": "BSClaunch", "BSOL": "BlazeStake Staked SOL", "BSOP": "Bsop", "BSOV": "BitcoinSoV", @@ -2805,17 +2928,17 @@ "BSTC": "BST Chain", "BSTER": "Bster", "BSTK": "BattleStake", - "BSTN": "BitStation", + "BSTN": "Bastion Protocol", "BSTR": "BSTR", - "BSTS": "Magic Beasties", - "BSTY": "GlobalBoost", + "BSTS": "Magic beasties", + "BSTY": "GlobalBoost-Y", "BSU": "Baby Shark Universe Token", "BSV": "Bitcoin SV", "BSVBRC": "BSVBRC", "BSW": "Biswap", "BSWAP": "BaseSwap", "BSWT": "BaySwap", - "BSX": "BSX", + "BSX": "Basilisk", "BSY": "Bestay", "BSYS": "BSYS", "BT": "BT.Finance", @@ -2825,12 +2948,14 @@ "BTAD": "Bitcoin Adult", "BTAF": "BTAF token", "BTAMA": "Basetama", - "BTB": "BitBar", + "BTB": "BitBall", "BTBL": "Bitball", "BTBS": "BitBase Token", "BTBTX": "Bit Digital xStock", "BTC": "Bitcoin", + "BTC.B": "Bitcoin Avalanche Bridged", "BTC2": "Bitcoin 2", + "BTC2X-FLI": "BTC 2x Flexible Leverage Index", "BTC2XFLI": "BTC 2x Flexible Leverage Index", "BTC6900": "Bitcoin 6900", "BTC70000": "BTC 70000", @@ -2841,7 +2966,8 @@ "BTCAS": "BitcoinAsia", "BTCAT": "Bitcoin Cat", "BTCB": "Bitcoin BEP2", - "BTCBAM": "BitCoin Bam", + "BTCB31647": "Bitcoin on Base", + "BTCBAM": "Bitcoin Bam", "BTCBASE": "Bitcoin on Base", "BTCBR": "Bitcoin BR", "BTCBRV1": "Bitcoin BR v1", @@ -2865,7 +2991,7 @@ "BTCN": "Bitcorn", "BTCNOW": "Blockchain Technology Co.", "BTCONETH": "bitcoin on Ethereum", - "BTCP": "Bitcoin Palladium", + "BTCP": "Bitcoin Private", "BTCPAY": "Bitcoin Pay", "BTCPR": "Bitcoin Pro", "BTCPT": "Bitcoin Platinum", @@ -2875,7 +3001,7 @@ "BTCRY": "BitCrystal", "BTCS": "BTCs", "BTCSR": "BTC Strategic Reserve", - "BTCST": "BTC Standard Hashrate Token", + "BTCST": "Bitcoin Standard Hashrate Token", "BTCTOKEN": "Bitcoin Token", "BTCUS": "Bitcoinus", "BTCV": "Bitcoin Vault", @@ -2913,7 +3039,7 @@ "BTP": "Bitpaid", "BTPL": "Bitcoin Planet", "BTQ": "BitQuark", - "BTR": "BTRIPS", + "BTR": "Bitrue Coin", "BTRC": "Bitro Coin", "BTRFLY": "Redacted Cartel", "BTRL": "BitcoinRegular", @@ -2923,21 +3049,21 @@ "BTRST": "Braintrust", "BTRU": "Biblical Truth", "BTRUMP": "Baron Trump", - "BTS": "Bitshares", + "BTS": "BitShares", "BTSC": "BTS Chain", "BTSE": "BTSE Token", "BTSG": "BitSong", "BTSGV1": "BitSong v1", "BTSLA": "Backed Tesla", - "BTT": "BitTorrent", + "BTT": "BitTorrent(New)", "BTTF": "Coin to the Future", - "BTTOLD": "BitTorrent", + "BTTOLD": "BitTorrent (old)", "BTTR": "BitTiger", "BTTY": "Bitcointry Token", "BTU": "BTU Protocol", "BTV": "Bitvote", - "BTW": "Bitway", - "BTX": "Bitradex Token", + "BTW": "BitWhite", + "BTX": "BitCore", "BTXC": "Bettex coin", "BTXEX": "BTXEX", "BTY": "Bityuan", @@ -2946,7 +3072,7 @@ "BTZC": "BeatzCoin", "BTZN": "Bitzon", "BU": "BUMO", - "BUB": "BUBCAT", + "BUB": "Bubble", "BUBB": "Bubb", "BUBBA": "Bubba", "BUBBLE": "Bubble", @@ -2955,7 +3081,7 @@ "BUBU": "BUBU", "BUBV1": "BUBCAT v1", "BUC": "Beau Cat", - "BUCK": "GME Mascot", + "BUCK": "Arbucks", "BUCKAZOIDS": "Buckazoids", "BUCKS": "SwagBucks", "BUCKY": "Bucky", @@ -3027,7 +3153,7 @@ "BUNNYP": "BunnyPark", "BUNNYROCKET": "BunnyRocket", "BURG": "Burger", - "BURGER": "Burger Swap", + "BURGER": "BurgerCities", "BURN": "BurnedFi", "BURNDOGE": "BurnDoge", "BURNIFYAI": "BurnifyAI", @@ -3036,6 +3162,7 @@ "BURNS": "Burnsdefi", "BURNZ": "BURNZ", "BURP": "CoinBurp", + "BURROW": "MMF Money", "BURRRD": "BURRRD", "BURT": "BURT", "BUSD": "Binance USD", @@ -3046,24 +3173,26 @@ "BUTT": "Buttercat", "BUTTC": "Buttcoin", "BUTTCOIN": "The Next Bitcoin", + "BUTTER": "Butter TOken", "BUTTHOLE": "Butthole Coin", "BUTTPLUG": "fartcoin killer", "BUTWHY": "ButWhy", - "BUX": "BUX", + "BUX": "BUX Token", "BUXCOIN": "Buxcoin", "BUY": "Burency", "BUYI": "Buying.com", "BUYT": "Buy the DIP", "BUZ": "BUZ", - "BUZZ": "Hive AI", + "BUZZ": "BUZZCoin", "BUZZCOIN": "BuzzCoin", "BV3A": "Buccaneer V3 Arbitrum", "BVC": "BeaverCoin", - "BVM": "BVM", + "BVM": "Base Velocimeter", "BVND": "Binance VND", "BVO": "BRAVO Pay", "BVT": "BovineVerse Token", "BWB": "Bitget Wallet Token", + "BWB31503": "Bitget Wallet Token", "BWEN": "Baby Wen", "BWF": "Beowulf", "BWJ": "Baby WOJ", @@ -3100,7 +3229,7 @@ "BYT": "ByteAI", "BYTE": "Byte", "BYTES": "Neo Tokyo", - "BYTHER": "Bytether ", + "BYTHER": "Bytether", "BYTS": "Bytus", "BYTZ": "BYTZ", "BZ": "Bit-Z", @@ -3109,17 +3238,18 @@ "BZET": "Bzetcoin", "BZKY": "Bizkey", "BZL": "BZLCoin", + "BZN": "Benzene", "BZNT": "Bezant", "BZR": "Bazaars", "BZRX": "bZx Protocol", "BZX": "Bitcoin Zero", - "BZZ": "Swarmv", + "BZZ": "Swarm", "BZZONE": "Bzzone", "C": "Chainbase Token", "C1USD": "Currency One USD", "C1USDV1": "Currency One USD", - "C2": "Coin.2", - "C20": "Crypto20", + "C2": "Coin2.1", + "C20": "CRYPTO20", "C25": "C25 Coin", "C2H6": "Ethane", "C2X": "C2X", @@ -3127,7 +3257,7 @@ "C98": "Coin98", "CA": "Coupon Assets", "CAAVE": "cAAVE", - "CAB": "CabbageUnit", + "CAB": "Cabbage", "CABO": "CatBonk", "CABS": "CryptoABS", "CACAO": "Maya Protocol", @@ -3146,7 +3276,7 @@ "CAG": "Change", "CAGA": "Crypto Asset Governance Alliance", "CAH": "Moon Tropica", - "CAI": "CharacterX", + "CAI": "Club Atletico Independiente", "CAID": "ClearAid", "CAILA": "Caila", "CAIR": "Crypto-AI-Robo.com", @@ -3182,7 +3312,7 @@ "CAND": "Canary Dollar", "CANDLE": "Candle TV", "CANDLECAT": "Candle Cat", - "CANDY": "UnicornGo Candy", + "CANDY": "Rare Candy", "CANDYLAD": "Candylad", "CANN": "CannabisCoin", "CANNF": "CANNFINITY", @@ -3190,7 +3320,7 @@ "CANTO": "CANTO", "CANYA": "CanYaCoin", "CAOCAO": "CaoCao", - "CAP": "Capverto", + "CAP": "Cap", "CAPA": "Cake Panda", "CAPD": "Capdax", "CAPO": "IL CAPO OF CRYPTO", @@ -3210,12 +3340,12 @@ "CARATSTOKEN": "Carats Token", "CARBLOCK": "CarBlock", "CARBO": "CleanCarbon", - "CARBON": "Carbon", + "CARBON": "Carboncoin", "CARBONCOIN": "Carboncoin", "CARBONGEMS": "Carbon GEMS", "CARBONUSD": "Carbon", "CARD": "Cardstack", - "CARDS": "Collector Crypt", + "CARDS": "CARD.STARTER", "CARDSTARTER": "Cardstarter", "CARDSWAP": "CardSwap", "CARE": "CareCoin", @@ -3235,7 +3365,7 @@ "CARTIER": "Cartier", "CARV": "CARV", "CAS": "Cashaa", - "CASH": "CASH", + "CASH": "Litecash", "CASHCOIN": "CashCoin", "CASHIO": "Cashio Dollar", "CASHLY": "Cashly", @@ -3248,7 +3378,7 @@ "CAST": "CAST ORACLES", "CASTELLOCOIN": "Castello Coin", "CASTLE": "bitCastle", - "CAT": "Simon's Cat", + "CAT": "SimonsCat", "CATA": "CATAMOTO", "CATABSC": "CATA BSC", "CATAI": "Catgirl AI", @@ -3268,7 +3398,7 @@ "CATCOINV2": "CatCoin Cash", "CATDOG": "Cat-Dog", "CATDOGE": "CAT DOGE", - "CATE": "Cate on ETH", + "CATE": "CateCoin", "CATEC": "Cate Coin", "CATECOIN": "CateCoin", "CATELON": "CatElonMars", @@ -3281,7 +3411,7 @@ "CATGOLD": "Cat Gold Miner", "CATGPT": "CatGPT", "CATHAT": "catwifhat", - "CATHEON": "Catheon Gaming", + "CATHEON": "Artisse", "CATHERO": "Cat Hero", "CATI": "Catizen", "CATINU": "CAT INU", @@ -3300,7 +3430,7 @@ "CATSV1": "CatCoin Token v1", "CATSV2": "CatCoin Token", "CATSY": "CAT SYLVESTER", - "CATT": "Catex", + "CATT": "Catex Token", "CATTO": "Cat Token", "CATTON": "Catton AI", "CATVAX": "Catvax", @@ -3316,9 +3446,9 @@ "CAV1": "Coupon Assets v1", "CAVA": "Cavapoo", "CAVADA": "Cavada", - "CAVE": "Deepcave", + "CAVE": "Crypto Cavemen Club", "CAVO": "Excavo Finance", - "CAW": "A Hunters Dream", + "CAW": "CAW(A Hunters Dream)", "CAWCEO": "CAW CEO", "CB": "COINBIG", "CBAB": "CreBit", @@ -3326,6 +3456,7 @@ "CBANK": "Crypto Bank", "CBAT": "Compound Basic Attention Token", "CBBTC": "Coinbase Wrapped BTC", + "CBBTC32994": "Coinbase Wrapped BTC", "CBBTCBASE": "cbBTC", "CBC": "Casino Betting Coin", "CBD": "CBD Crystals", @@ -3335,6 +3466,7 @@ "CBE": "The Chain of Business Entertainment", "CBET": "CryptoBet", "CBETH": "Coinbase Wrapped Staked ETH", + "CBFINU": "CBFINU", "CBFT": "CoinBene Future Token", "CBG": "Chainbing", "CBIXP": "Cubiex Power", @@ -3361,6 +3493,7 @@ "CBYTE": "CBYTE", "CC": "Canton Coin", "CC10": "Cryptocurrency Top 10 Tokens Index", + "CC37263": "Canton", "CCA": "CCA", "CCAKE": "CheeseCake Swap", "CCAR": "CryptoCars", @@ -3381,7 +3514,7 @@ "CCN": "CannaCoin", "CCO": "Ccore", "CCO2": "Carbon Capture", - "CCOIN": "Creditcoin", + "CCOIN": "Crypteriumcoin", "CCOMM": "Crypto Commonwealth", "CCOMP": "cCOMP", "CCOS": "CrowdCoinage", @@ -3389,10 +3522,10 @@ "CCRB": "CryptoCarbon", "CCT": "Carbon Credit", "CCTN": "Connectchain", - "CCV2": "CelebrityCoinV2", + "CCV2": "CryptoCart V2", "CCX": "Conceal", "CCXC": "CoolinDarkCoin", - "CCXX": "CounosX", + "CCXX": "CCX", "CDAG": "CannDollar", "CDAI": "Compound Dai", "CDBIO": "CDbio", @@ -3406,16 +3539,18 @@ "CDOGE": "cyberdoge", "CDPT": "Creditor Data Platform", "CDRX": "CDRX", + "CDS": "Capital DAO Protocol", + "CDT": "CheckDot", "CDX": "CDX Network", "CDY": "Bitcoin Candy", "CDragon": "Clumsy Dragon", "CEC": "Counterfire Economic Coin", "CEDEX": "CEDEX Coin", - "CEEK": "CEEK Smart VR Token", + "CEEK": "CEEK VR", "CEFS": "CryptopiaFeeShares", "CEICAT": "CEILING CAT", "CEJI": "Ceji", - "CEL": "Celsius Network", + "CEL": "Celsius", "CELA": "Cellula Token", "CELB": "Celb Token", "CELEB": "CELEBPLUS", @@ -3427,7 +3562,7 @@ "CEN": "Coinsuper Ecosystem Network", "CENNZ": "Centrality Token", "CENS": "Censored Ai", - "CENT": "CENTERCOIN", + "CENT": "CENTER COIN", "CENTA": "Centaurify", "CENTRA": "Centra", "CENTS": "Centience", @@ -3461,22 +3596,23 @@ "CFN": "Cockfight Network", "CFT": "CryptoForecast", "CFTY": "Crafty", - "CFX": "Conflux Network", + "CFX": "Conflux", "CFXQ": "CFX Quantum", "CFXT": "Chainflix", "CFun": "CFun", "CGA": "Cryptographic Anomaly", "CGAI": "GDAI Agent", "CGAR": "CryptoGuards", - "CGG": "Chain Guardians", + "CGC": "HeroesTD CGC", + "CGG": "ChainGuardians", "CGL": "Crypto Gladiator Shards", - "CGLD": "Celo Gold", + "CGLD": "Celo", "CGN": "CYGNUS", "CGO": "Comtech Gold", "CGPT": "ChainGPT", "CGPU": "ChainGPU", "CGS": "Crypto Gladiator Shards", - "CGT": "Coin Gabbar Token", + "CGT": "CACHE Gold", "CGTV1": "Curio Governance", "CGTV2": "Curio Gas Token", "CGU": "Crypto Gaming United", @@ -3484,7 +3620,7 @@ "CGX": "Forkast", "CHA": "Charity Coin", "CHACHA": "Chacha", - "CHAD": "Chad Coin", + "CHAD": "GigaChad", "CHADCAT": "CHAD CAT", "CHADETTE": "Chadette", "CHADS": "CHADS VC", @@ -3494,13 +3630,13 @@ "CHAINSOFWAR": "Chains of War", "CHAL": "Chalice Finance", "CHAM": "Champion", - "CHAMP": "Super Champs", + "CHAMP": "NFT Champions", "CHAMPZ": "Champz", "CHAN": "ChanCoin", "CHANCE": "Ante Casino", "CHANEL": "Chanel", "CHANG": "Chang", - "CHANGE": "ChangeX", + "CHANGE": "Changex", "CHAO": "23 Skidoo", "CHAOS": "chaos and disorder", "CHAPZ": "Chappyz", @@ -3511,7 +3647,7 @@ "CHARLIE": "Charlie Kirk", "CHARM": "Charm Coin", "CHARS": "CHARS", - "CHART": "BetOnChart", + "CHART": "ChartEx", "CHARTA": "CHARTAI", "CHARTIQ": "ChartIQ", "CHAS": "Chasm", @@ -3525,6 +3661,7 @@ "CHBR": "CryptoHub", "CHC": "ChainCoin", "CHD": "CharityDAO", + "CHE": "CherrySwap", "CHECK": "Checkmate", "CHECKDOT": "CheckDot", "CHECKR": "CheckerChain", @@ -3533,7 +3670,7 @@ "CHEDDA": "Chedda", "CHEEKS": "CHEEKS", "CHEEL": "Cheelee", - "CHEEMS": "Cheems (cheems.pet)", + "CHEEMS": "Cheems", "CHEEMSCO": "Cheems", "CHEEMSV1": "Cheems (cheems.pet) v1", "CHEEPEPE": "CHEEPEPE", @@ -3545,10 +3682,11 @@ "CHEF": "CoinChef", "CHEFDOTFUN": "Chefdotfun", "CHENG": "Chengshi", - "CHEQ": "CHEQD Network", + "CHEQ": "cheqd", "CHER": "Cherry Network", "CHERRY": "CherrySwap", - "CHESS": "Tranchess", + "CHESS": "ChessCoin", + "CHESS10974": "Tranchess", "CHESSCOIN": "ChessCoin", "CHET": "ChetGPT", "CHEW": "CHEWY", @@ -3560,11 +3698,11 @@ "CHFU": "Upper Swiss Franc", "CHFX": "eToro Swiss Franc", "CHH": "Chihuahua Token", - "CHI": "Chi Gastoken", + "CHI": "Xaya", "CHIB": "Chiba Inu", "CHIBI": "Chibification", "CHICA": "CHICA", - "CHICKS": "SolChicks", + "CHICKS": "SolChicks Token", "CHIDO": "Chinese Doge Wow", "CHIE": "Chief Pepe Officer", "CHIEF": "TheChiefCoin", @@ -3577,7 +3715,7 @@ "CHILL": "ChillPill", "CHILLAX": "Chillax", "CHILLCAT": "Chillchat", - "CHILLGUY": "Chill Guy", + "CHILLGUY": "Just a chill guy USD Price", "CHILLHOUSE": "Chill House", "CHIM": "Chimera", "CHINA": "China Coin", @@ -3586,6 +3724,7 @@ "CHINGON": "Mexico Chingon", "CHINU": "Chubby Inu", "CHIP": "Chip", + "CHIP39870": "USD.AI", "CHIPI": "chipi", "CHIPP": "Chip", "CHIPPY": "Chippy", @@ -3598,13 +3737,14 @@ "CHIWAWA": "Chiwawa", "CHK": "Chek", "CHKN": "Chickencoin", + "CHLI": "ChilliSwap", "CHLOE": "Pnut's Sister", "CHLT": "Chellitcoin", "CHMB": "Chumbi Valley", "CHMPZ": "Chimpzee", "CHN": "Chain", "CHNG": "Chainge Finance", - "CHO": "Choise", + "CHO": "Choise.com", "CHOKE": "Artichoke Protocol", "CHOMP": "ChompCoin", "CHON": "Chonk The Cat", @@ -3621,9 +3761,10 @@ "CHOY": "Bok Choy", "CHP": "CoinPoker Token", "CHPD": "Chirppad", - "CHR": "Chroma", + "CHR": "Chromia", "CHRETT": "Chinese BRETT", "CHRISPUMP": "Christmas Pump", + "CHRO": "Chronicum", "CHRONOEFFE": "Chronoeffector", "CHRP": "Chirpley", "CHS": "Chainsquare", @@ -3654,7 +3795,7 @@ "CIN": "CinderCoin", "CIND": "Cindrum", "CINNI": "CINNICOIN", - "CINU": "CHEEMS INU", + "CINU": "Cheems Inu", "CINUV1": "CHEEMS INU v1", "CINX": "CINDX", "CIOTX": "Crosschain IOTX", @@ -3664,7 +3805,7 @@ "CIRCLE": "You Looked", "CIRCUS": "Cirque Du Sol", "CIRRUS": "Cirrus", - "CIRUS": "Cirus", + "CIRUS": "Cirus Foundation", "CIRX": "Circular Protocol", "CITADAIL": "Griffain New Hedge Fund", "CITI": "CITI Fediverse", @@ -3673,7 +3814,7 @@ "CIVIT": "Civitas Protocol", "CIX": "Cryptonetix", "CIX100": "Cryptoindex", - "CJ": "CryptoJacks", + "CJ": "Cryptojacks", "CJC": "CryptoJournal", "CJL": "Cjournal", "CJR": "Conjure", @@ -3688,7 +3829,7 @@ "CKUSD": "CKUSD", "CL": "CoinLancer", "CLA": "ClaimSwap", - "CLAM": "CLAMS", + "CLAM": "Clams", "CLANKER": "tokenbot", "CLAP": "Clap Cat", "CLAS": "Classic USDC", @@ -3732,13 +3873,14 @@ "CLND": "COLEND", "CLNX": "Coloniume Network", "CLNY": "Colony", - "CLO": "Yei Finance", + "CLO": "Callisto Network", "CLOA": "Cloak", "CLOAK": "CloakCoin", "CLOKI": "CATLOKI", "CLOOTS": "CryptoLoots", "CLORE": "Clore.ai", "CLOUD": "Cloud", + "CLOUD32299": "Cloud", "CLOUDCHAT": "CloudChat", "CLOUDGPU": "CloudGPU", "CLOUT": "BitClout", @@ -3748,13 +3890,13 @@ "CLR": "CopperLark", "CLRTY": "Clarity", "CLS": "Coldstack", - "CLT": "CoinLoan", + "CLT": "Cexlt", "CLU": "CluCoin", "CLUB": "ClubCoin", "CLUD": "CludCoin", "CLUSTR": "Clustr Labs", "CLUTCH": "Clutch", - "CLV": "Clover Finance", + "CLV": "CLV", "CLVA": "Clever DeFi", "CLVX": "Calvex", "CLX": "Celeum", @@ -3766,7 +3908,7 @@ "CMCT": "Crowd Machine", "CMCX": "CORE MultiChain", "CMDX": "Comdex", - "CMERGE": "CoinMerge", + "CMERGE": "CoinMerge (ERC-20)", "CMETH": "Mantle Restaked Ether", "CMFI": "Compendium", "CMINER": "ChainMiner", @@ -3794,7 +3936,7 @@ "CNAME": "Cloudname", "CNB": "Coinsbit Token", "CNBC": "Cash & Back Coin", - "CNC": "ChinaCoin", + "CNC": "Global China Cash", "CNCL": "The Ordinals Council", "CNCT": "CONNECT", "CND": "Cindicator", @@ -3810,7 +3952,7 @@ "CNNS": "CNNS", "CNO": "Coino", "CNRG": "CryptoEnergy", - "CNS": "Centric Cash", + "CNS": "Centric Swap", "CNT": "Centurion", "CNTM": "Connectome", "CNTR": "Centaur", @@ -3823,21 +3965,23 @@ "CO2": "CO2 Token", "COA": "Alliance Games", "COAI": "ChainOpera AI", + "COAI38489": "ChainOpera AI USD Price", "COAL": "BitCoal", "COB": "Cobinhood", "COBE": "Castle of Blackwater", "COBY": "Coby", - "COC": "Coin of the champions", + "COC": "The CocktailBar", "COCA": "COCA", "COCAINE": "THE GOOD STUFF", "COCK": "Shibacock", - "COCO": "coco", + "COCO": "Coco Swap", "COCOCOIN": "COCO COIN", "COCONUT": "Coconut", "COCOR": "Cocoro", "COCORO": "Cocoro", "COCOROBNB": "Cocoro", "COCOROERC": "COCORO", + "COCOS": "Cocos-BCX", "COD": "Chief of Deswamp", "CODA": "CODA", "CODAI": "CODAI", @@ -3862,6 +4006,7 @@ "COGI": "COGI", "COGS": "Cogmento", "COI": "Coinnec", + "COIN": "Coin Artist", "COINAI": "Coinbase AI Agent", "COINB": "Coinbidex", "COINBANK": "CoinBank", @@ -3888,7 +4033,8 @@ "COKE": "Cocaine Cowboy Shards", "COKEONS": "Coke on Sol", "COL": "Clash of Lilliput", - "COLA": "Cola", + "COLA": "Colawork", + "COLI": "Coliquidity", "COLISEUM": "Coliseum", "COLL": "Collateral Pay", "COLLAB": "Collab.Land", @@ -3898,18 +4044,21 @@ "COLLEA": "Colle AI", "COLLECT": "Collect on Fanable", "COLLG": "Collateral Pay Governance", + "COLLIE": "Collie Inu", "COLON": "Colon", "COLR": "colR Coin", "COLS": "Cointel", "COLT": "Collateral Network", - "COLX": "ColossusCoinXT", + "COLX": "ColossusXT", "COM": ".com", "COMAI": "Commune AI", - "COMB": "Combo", + "COMB": "Combine.finance", "COMBO": "COMBO", + "COMBO8259": "Furucombo", "COMBOX": "ComBox", "COMC": "ComCrica Token", "COME": "Community of Meme", + "COMET": "Comet", "COMEW": "Coin In Meme World", "COMFI": "CompliFi", "COMM": "Community Coin", @@ -3917,6 +4066,7 @@ "COMMS": "CallofMeme", "COMMUNITYCOIN": "Community Coin", "COMP": "Compound", + "COMP5692": "Compound", "COMPCOIN": "Compcoin", "COMPD": "Compound Coin", "COMPU": "Compute Network", @@ -3926,7 +4076,7 @@ "CONCHO": "Sapo Concho", "CONDENSATE": "Condensate", "CONDO": "CONDO", - "CONE": "BitCone", + "CONE": "HoneyWood", "CONG": "The Conglomerate Capital", "CONI": "CoinBene", "CONJ": "Conjee", @@ -3938,13 +4088,14 @@ "CONTROL": "Control Token", "CONV": "Convergence", "CONVO": "Prefrontal Cortex Convo Agent by Virtuals", - "CONX": "Connex", + "CONX": "Concoin", "CONY": "Cony", "COO": "Cool Cats MILK", "COOCHIE": "Cucci", "COOHA": "CoolHash", - "COOK": "COOK", - "COOKIE": "Cookie", + "COOK": "Cook Finance", + "COOK33720": "mETH Protocol", + "COOKIE": "CookieSale", "COOKTOKEN": "Cook", "COOL": "CoolCoin", "COOP": "Coop Network", @@ -3964,17 +4115,18 @@ "CORALPAY": "CoralPay", "CORALSWAP": "Coral Swap", "CORE": "Core", + "CORE23254": "Core", "COREC": "CoreConnect", "COREDAO": "coreDAO", "COREG": "Core Group Asset", "COREK": "Core Keeper", "COREUM": "Coreum", - "CORGI": "Corgi Inu", + "CORGI": "Corgidoge", "CORGIAI": "CorgiAI", "CORGIB": "The Corgi of PolkaBridge", "CORION": "Corion", "CORL": "Coral Finance", - "CORN": "Corn", + "CORN": "CORN", "CORNELLA": "CORNELLA", "CORNFIELDFARM": "CORN", "CORSI": "Cane Corso", @@ -3985,7 +4137,7 @@ "COSHI": "CoShi Inu", "COSM": "CosmoChain", "COSMI": "Cosmic FOMO", - "COSMIC": "CosmicSwap", + "COSMIC": "Cosmic Coin", "COSMICN": "Cosmic Network", "COSP": "Cosplay Token", "COSS": "COS", @@ -3993,6 +4145,7 @@ "COSX": "Cosmecoin", "COT": "CoTrader", "COTI": "COTI", + "COTK": "Colligo", "COTS": "Children Of The Sky", "COU": "Couchain", "COUNOS": "Counos Coin", @@ -4004,13 +4157,14 @@ "COV": "Covesting", "COVA": "COVA", "COVAL": "Circuits of Value", - "COVER": "Cover Protocol", + "COVER": "COVER Protocol", "COVERV1": "Cover Protocol (old)", "COVEX": "CoVEX", "COVIDTOKEN": "Covid Token", "COVIR": "COVIR", "COVN": "Covenant", - "COW": "CoW Protocol", + "COW": "CoinWind", + "COW19269": "CoW Protocol", "COWRIE": "MYCOWRIE", "COX": "CobraCoin", "COY": "Coin Analyst", @@ -4020,7 +4174,7 @@ "CPA": "CryptoPulse AdBot", "CPAD": "Cronospad", "CPAN": "CryptoPlanes", - "CPAY": "CryptoPay", + "CPAY": "Chainpay", "CPC": "CPChain", "CPCOIN": "CPCoin", "CPD": "CoinsPaid", @@ -4099,10 +4253,10 @@ "CRE8": "Creaticles", "CREA": "CreativeChain", "CREAL": "Celo Brazilian Real", - "CREAM": "Cream", + "CREAM": "Cream Finance", "CREAML": "Creamlands", "CREATIVE": "Creative Token", - "CRED": "Credia Layer", + "CRED": "CRED COIN PAY", "CREDI": "Credefi", "CREDIT": "Credit", "CREDITS": "Credits", @@ -4128,6 +4282,7 @@ "CRHT": "CryptHub", "CRI": "Criptodólar", "CRI3X": "CRI3X", + "CRIC": "Cricket Foundation", "CRICKETS": "Kermit", "CRIME": "Crime Gold", "CRIMINGO": "Criminal Flamingo", @@ -4152,22 +4307,26 @@ "CRON": "Cryptocean", "CRONA": "CronaSwap", "CRONK": "CRONK", + "CROOM": "Cryptosroom", + "CROP": "FarmerDoge", "CROPPER": "CropperFinance", "CROS": "Cros Token", "CROSS": "Cross", "CROW": "cr0w by Virtuals", - "CROWD": "CrowdCoin", + "CROWD": "CrowdSwap", "CROWDWIZ": "Crowdwiz", "CROWN": "Crown by Third Time Games", + "CROWN25714": "Crown by Third Time Games", "CROWWITH": "crow with knife", "CROX": "CroxSwap", "CRP": "Crypton", "CRPS": "CryptoPennies", "CRPT": "Crypterium", "CRPTC": "CRPT Classic", - "CRS": "CYRUS", + "CRS": "Crypto Rewards Studio", "CRSP": "CryptoSpots", "CRT": "Carr.Finance", + "CRT21286": "Cantina Royale", "CRTAI": "CRT AI Network", "CRTB": "Coritiba F.C. Fan Token", "CRTM": "Cryptum", @@ -4177,16 +4336,17 @@ "CRUD": "CRUDE OIL BRENT", "CRUIZ": "Cruiz", "CRUMP": "Crypto Trump", + "CRUSADER": "Crusaders of Crypto", "CRUX": "CryptoMines Reborn", "CRV": "Curve DAO Token", "CRVE": "Curve DAO Token (Avalanche Bridge)", "CRVUSD": "crvUSD", "CRVY": "Curve Inu", - "CRW": "Crown Coin", + "CRW": "Crown", "CRWD": "CRWD Network", "CRWDX": "CrowdStrike xStock", - "CRWNY": "Crowny Token", - "CRX": "ChronosCoin", + "CRWNY": "Crowny", + "CRX": "CryptEx", "CRY": "Crypto News Flash AI", "CRYBB": "CryBaby", "CRYN": "CRYN", @@ -4196,7 +4356,7 @@ "CRYPT": "CryptCoin", "CRYPTAL": "CrypTalk", "CRYPTER": "Crypteriumcoin", - "CRYPTO": "Cryptocurrency Coin", + "CRYPTO": "Big Crypto Game", "CRYPTOA": "CryptoAI", "CRYPTOAGENT": "CRYPTO AGENT TRUMP", "CRYPTOAI": "CryptoAI", @@ -4231,7 +4391,8 @@ "CRYSTALCLEAR": "Crystal Clear Token", "CRYSTALS": "CRYSTALS", "CRYSTL": "Crystl Finance", - "CS": "Child Support", + "CRYY": "Cry Cat Coin", + "CS": "Credits", "CSAC": "Credit Safe Application Chain", "CSAI": "Compound SAI", "CSAS": "csas (Ordinals)", @@ -4240,15 +4401,15 @@ "CSEN": "Sentient Coin", "CSH": "CashOut", "CSI": "CSI888", - "CSIX": "Carbon Browser", + "CSIX": "Carbon browser", "CSM": "Crust Shadow", "CSMIC": "Cosmic", "CSNO": "BitDice", "CSNP": "CrowdSale Network", "CSOV": "Crown Sovereign", "CSP": "Caspian", - "CSPN": "Crypto Sports", - "CSPR": "Casper Network", + "CSPN": "Crypto Sports Network", + "CSPR": "Casper", "CSQ": "cosquare", "CSR": "Cashera", "CSS": "CoinSwap Token", @@ -4260,9 +4421,9 @@ "CSUSDL": "Coinshift USDL Morpho Vault", "CSUSHI": "cSUSHI", "CSW": "Crosswalk", - "CSWAP": "ChainSwap", + "CSWAP": "CrossSwap", "CSX": "Coinstox", - "CT": "CryptoTwitter", + "CT": "Cojam", "CTA": "Cross The Ages", "CTAG": "CTAGtoken", "CTASK": "CryptoTask", @@ -4272,11 +4433,12 @@ "CTE": "Crypto Tron", "CTEX": "Crypto tex", "CTF": "CyberTime Finance", - "CTG": "City Tycoon Games", + "CTG": "CRYPTORG", "CTH": "Changcoin", "CTI": "ClinTex CTi", "CTIC": "Coinmatic", - "CTK": "Shentu", + "CTK": "Cryptyk Token", + "CTK4807": "Shentu", "CTKN": "Curaizon", "CTL": "Citadel", "CTLS": "Chaintools", @@ -4298,27 +4460,28 @@ "CTSI": "Cartesi", "CTT": "Castweet", "CTW": "Citowise", - "CTX": "Cryptex", + "CTX": "Cryptex Finance", "CTXC": "Cortex", "CTY": "Connecty", "CTYN": "Canyont", "CU": "Crypto Unicorns", "CUAN": "CuanSwap.com", "CUB": "Cub Finance", - "CUBE": "Somnium Space CUBEs", + "CUBE": "Somnium Space Cubes", + "CUBE1": "Somnium Space Cubes", "CUBEAUTO": "Cube", "CUBEB": "CubeBase", "CUBENETWORK": "Cube Network", "CUCCI": "Cat in Gucci", "CUCK": "Cuckadoodledoo", "CUDIS": "Cudis", - "CUDOS": "Cudos", + "CUDOS": "CUDOS", "CUE": "CUE Protocol", "CUEX": "Cuex", "CUFF": "Jail Cat", "CULO": "CULO", "CULOETH": "CULO", - "CULT": "Milady Cult Coin", + "CULT": "Cult DAO", "CULTDAO": "Cult DAO", "CULTUR": "Cultur", "CUM": "Cumbackbears", @@ -4334,16 +4497,16 @@ "CUSD": "Celo Dollar", "CUSDC": "Compound USD Coin", "CUSDO": "Compounding Open Dollar", - "CUSDT": "cUSDT", + "CUSDT": "Compound USDT", "CUSDTBULL": "3X Long Compound USDT Token", "CUST": "Custody Token", "CUT": "CUTcoin", "CUTE": "Blockchain Cuties Universe", "CUUT": "CUTTLEFISHY", "CUZ": "Cool Cousin", - "CV": "CarVertical", + "CV": "carVertical", "CVA": "Crypto Village Accelerator", - "CVAG": "Crypto Village Accelerator CVAG", + "CVAG": "Crypto Village Accelerator", "CVAULT": "cVault.finance", "CVC": "Civic", "CVCC": "CryptoVerificationCoin", @@ -4354,9 +4517,10 @@ "CVNC": "CovenCoin", "CVNG": "Crave-NG", "CVNT": "Conscious Value Network", - "CVP": "PowerPool Concentrated Voting Power", + "CVNX": "Crypviser", + "CVP": "PowerPool", "CVPT": "Concentrated Voting Power", - "CVR": "Polkacover", + "CVR": "CoverCompared", "CVS": "CoinVisa", "CVSHOT": "CV SHOTS", "CVT": "CyberVein", @@ -4367,12 +4531,14 @@ "CVXFXS": "Convex FXS", "CVXON": "Chevron (Ondo Tokenized)", "CVXX": "Chevron xStock", - "CW": "CardWallet", + "CW": "CWallet", "CWA": "Chris World Asset", + "CWAP": "DeFIRE", "CWAR": "Cryowar Token", "CWBTC": "Compound Wrapped BTC", "CWD": "CROWD", "CWDV1": "Linkflow", + "CWE": "Chain Wars", "CWEB": "Coinweb", "CWEX": "Crypto Wine Exchange", "CWIF": "catwifhat", @@ -4394,12 +4560,13 @@ "CXO": "CargoX", "CXP": "Caixa Pay", "CXPAD": "CoinxPad", - "CXT": "Covalent X Token", + "CXT": "Coinonat", "CY97": "Cyclops97", "CYB": "CYBERTRUCK", "CYBA": "CYBRIA", "CYBE": "Cyberlete", - "CYBER": "CyberConnect", + "CYBER": "Cyberpunk City", + "CYBER24781": "CyberConnect", "CYBERA": "Cyber Arena", "CYBERC": "CyberCoin", "CYBERD": "Cyber Doge", @@ -4408,7 +4575,7 @@ "CYBERWAY": "CyberWay", "CYBONK": "CYBONK", "CYBR": "CYBR", - "CYBRO": "Cybro Token", + "CYBRO": "CYBRO", "CYC": "Cycle Network Token", "CYCAT": "Chi Yamada Cat", "CYCE": "Crypto Carbon Energy", @@ -4431,8 +4598,8 @@ "CYPR": "Cypher", "CYRS": "Cyrus Token", "CYRUS": "Cyrus Exchange", - "CYS": "Cysic", - "CYT": "Cryptokenz", + "CYS": "Cykura", + "CYT": "Coinary Token", "CZ": "CHANGPENG ZHAO (changpengzhao.club)", "CZAI": "CZ AI Agent", "CZBOOK": "CZ BOOK", @@ -4443,12 +4610,12 @@ "CZGOAT": "CZ THE GOAT", "CZKING": "CZKING", "CZOL": "Czolana", - "CZR": "CanonChain", + "CZR": "CZRED", "CZRX": "Compound 0x", "CZSHARES": "CZshares", - "CZUSD": "CZUSD", + "CZUSD": "CZodiac Stabletoken", "CZZ": "ClassZZ", - "D": "Dar Open Network", + "D": "Denarius", "D11": "DeFi11", "D2O": "DAM Finance", "D2T": "Dash 2 Trade", @@ -4460,12 +4627,13 @@ "DAC": "Davinci Coin", "DACASH": "DACash", "DACAT": "daCat", - "DACC": "Decentralized Accessible Content Chain", + "DACC": "DACC", "DACC2": "DACC2", "DACH": "DACH Coin", "DACKIE": "DackieSwap", "DACS": "Dacsee", - "DACXI": "Dacxi", + "DACT": "Decentralized Activism", + "DACXI": "DACXI", "DAD": "DAD", "DADA": "DADACOIN", "DADACOINTOP": "DADA", @@ -4475,7 +4643,7 @@ "DADI": "Edge", "DAETA": "DÆTA", "DAF": "DaFIN", - "DAFI": "Dafi Protocol", + "DAFI": "DAFI Protocol", "DAFT": "DaftCoin", "DAG": "Constellation", "DAGESTAN": "Dagestan And Forget", @@ -4488,17 +4656,17 @@ "DAILY": "Coindaily", "DAILYS": "DailySwap Token", "DAIMO": "Diamond Token", - "DAIN": "Dain Token", + "DAIN": "DAIN", "DAIQ": "Daiquilibrium", "DAISY": "Daisy Launch Pad", "DAIWO": "D.A.I.Wo", "DAK": "dak", "DAKU": "Der Daku", "DAL": "DAOLaunch", - "DALI": "Dalichain", + "DALI": "Dali", "DALMA": "Dalma Inu", - "DAM": "Reservoir", - "DAMEX": "DAMEX", + "DAM": "Datamine", + "DAMEX": "Damex Token", "DAMN": "Sol Killer", "DAMO": "Coinzen", "DAMOON": "Damoon Coin", @@ -4522,7 +4690,7 @@ "DAOSQUARE": "DAOSquare Governance Token", "DAOVC": "DAO.VC", "DAOX": "Daox", - "DAPP": "Pencils Protocol", + "DAPP": "LiquidApps", "DAPPSY": "Dappsy", "DAPPT": "Dapp Token", "DAPPTOKEN": "LiquidApps", @@ -4532,13 +4700,14 @@ "DARA": "Immutable", "DARAM": "Daram", "DARB": "Darb Token", - "DARC": "Konstellation", + "DARC": "Konstellation Network", "DARCRUS": "Darcrus", "DARE": "The Dare", "DARED": "Daredevil Dog", "DARICO": "Darico", "DARIK": "Darik", - "DARK": "Dark Eclipse", + "DARK": "Dark Frontiers", + "DARK-ENERGY-CRYSTALS": "Dark.Build v1", "DARKCOIN": "Dark", "DARKEN": "Dark Energy Crystals", "DARKF": "Dark Frontiers", @@ -4568,7 +4737,7 @@ "DATP": "Decentralized Asset Trading Platform", "DATX": "DATx", "DAUMEN": "Daumenfrosch", - "DAV": "DAV", + "DAV": "DAV Coin", "DAVE": "DAVE", "DAVID": "David", "DAVINC": "DaVinci Protocol", @@ -4591,14 +4760,15 @@ "DBCCOIN": "Datablockchain", "DBD": "Day By Day", "DBEAR": "DBear Coin", - "DBET": "Decent.bet", + "DBET": "DecentBet", "DBI": "Don't Buy Inu", "DBIC": "DubaiCoin", "DBIX": "DubaiCoin", "DBL": "Doubloon", + "DBNB": "DecentraBNB", "DBOE": "DBOE", - "DBOX": "DefiBox", - "DBR": "deBridge", + "DBOX": "Decentra Box", + "DBR": "DOLA Borrowing Right", "DBTC": "DebitCoin", "DBTN": "Universa Native token", "DBUND": "DarkBundles", @@ -4607,6 +4777,7 @@ "DBY": "Dobuy", "DBZ": "Diamond Boyz Coin", "DC": "Dogechain", + "DC21414": "Dogechain", "DCA": "AutoDCA", "DCAR": "Dragon Crypto Argenti", "DCARD": "DECENTRACARD", @@ -4623,7 +4794,7 @@ "DCHF": "DeFi Franc", "DCI": "Decentralized Cloud Infrastructure", "DCIP": "Decentralized Community Investment Protocol", - "DCK": "DexCheck AI", + "DCK": "DexCheck", "DCLOUD": "DecentraCloud", "DCM": "Ducky City", "DCN": "Dentacoin", @@ -4636,6 +4807,7 @@ "DCS.": "deCLOUDs", "DCT": "Decent", "DCTO": "Decentralized Crypto Token", + "DCU": "DecentralizedUnited", "DCX": "DeCEX", "DCY": "Dinastycoin", "DD": "DuckDAO", @@ -4660,9 +4832,9 @@ "DDUSDV1": "Decentralized USD", "DDX": "DerivaDAO", "DEA": "Degas Coin", - "DEAI": "Zero1 Lab", + "DEAI": "Zero1 Labs", "DEAL": "iDealCash", - "DEB": "Debitum Token", + "DEB": "Debitum", "DEBASE": "Debase", "DEBT": "DebtCoin", "DEC": "Decentr", @@ -4681,6 +4853,7 @@ "DEED": "Deed (Ordinals)", "DEEM": "iShares MSCI Emerging Markets ETF Defichain", "DEEP": "DeepBook Protocol", + "DEEP33391": "DeepBook Protocol", "DEEPCLOUD": "DeepCloud AI", "DEEPG": "Deep Gold", "DEEPS": "DeepSeek AI", @@ -4701,6 +4874,7 @@ "DEFC": "Defi Coin", "DEFEND": "Blockdefend AI", "DEFI": "DeFi", + "DEFI29200": "DeFi", "DEFI5": "DEFI Top 5 Tokens Index", "DEFIDO": "DeFido", "DEFIK": "DeFi Kingdoms JADE", @@ -4714,6 +4888,7 @@ "DEFLCT": "Deflect", "DEFLECT": "Deflect Harbor AI", "DEFLY": "Deflyball", + "DEFO": "DefHold", "DEFROGS": "DeFrogs", "DEFT": "DeFi Factory Token", "DEFX": "DeFinity", @@ -4741,24 +4916,26 @@ "DELIGHTPAY": "DelightPay", "DELON": "Dark Elon", "DELOT": "DELOT.IO", - "DELTA": "Delta Financial", + "DELREY": "Delrey Inu", + "DELTA": "Delta", "DELTAC": "DeltaChain", - "DEM": "eMark", + "DEM": "Deutsche eMark", "DEMI": "DeMi", "DEMIR": "Adana Demirspor Token", "DEMOS": "DEMOS", + "DENA": "Decentralized Nations", "DENARIUS": "Denarius", "DENT": "Dent", "DENTX": "DENTNet", "DEO": "Demeter", "DEOD": "Decentrawood", "DEOR": "Decentralized Oracle", - "DEP": "DEAPCOIN", + "DEP": "DEAPcoin", "DEPAY": "DePay", "DEPIN": "DEPIN", "DEPINU": "Depression Inu", "DEPLOYR": "Deployr", - "DEPO": "Depo", + "DEPO": "DePocket", "DEPTH": "Depth Token", "DEQ": "Dequant", "DER": "Deri Trade", @@ -4782,7 +4959,7 @@ "DEURO": "DecentralizedEURO", "DEUS": "DEUS Finance", "DEUSD": "Elixir deUSD", - "DEV": "Deviant Coin", + "DEV": "Dev Protocol", "DEVAI": "DEV AI", "DEVCOIN": "DevCoin", "DEVE": "Develocity Finance", @@ -4792,23 +4969,25 @@ "DEVVE": "Devve", "DEVX": "Developeo", "DEW": "DEW", - "DEX": "DEX", + "DEX": "Newdex Token", "DEX223": "DEX223", "DEXA": "DEXA COIN", "DEXC": "DexCoyote Legends", "DEXE": "DeXe", "DEXEV1": "DeXe v1", - "DEXG": "Dextoken Governance", + "DEXF": "Dexfolio", + "DEXG": "Dextoken", "DEXIO": "Dexioprotocol", "DEXM": "Dexmex", "DEXNET": "DexNet", "DEXO": "DEXO", "DEXSHARE": "dexSHARE", "DEXT": "DEXTools", - "DEXTF": "DEXTF", + "DEXTF": "Domani Protocol", "DEXTV1": "DEXTools V1", "DF": "dForce", "DFA": "DeFine", + "DFAI": "DEFIAI", "DFB": "Facebook Tokenized Stock Defichain", "DFBT": "DentalFix", "DFC": "DeFinder Capital", @@ -4816,6 +4995,7 @@ "DFDVSOL": "DFDV Staked SOL", "DFDVX": "DFDV xStock", "DFG": "Defigram", + "DFG19590": "Defigram", "DFGL": "DeFi Gold", "DFH": "DeFiHorse", "DFI": "DeFiChain", @@ -4830,18 +5010,19 @@ "DFSM": "DFS MAFIA", "DFSOCIAL": "DefiSocial (OLD)", "DFSPORTS": "Digital Fantasy Sports", - "DFT": "DigiFinexToken", + "DFT": "DraftCoin", "DFTV1": "DigiFinexToken v1", "DFUN": "DashFun Coin", - "DFX": "DFX Finance", + "DFX": "Definitex", "DFY": "Defi For You", "DFYN": "Dfyn Network", - "DG": "Decentral Games", + "DG": "Decentral Games [Old]", "DGB": "DigiByte", - "DGC": "DecentralGPT", + "DGC": "Digitalcoin", "DGCL": "DigiCol Token", "DGD": "Digix DAO", "DGDC": "DarkGold", + "DGE": "DragonSea", "DGEN": "The MVP Society", "DGH": "Digihealth", "DGI": "DGI Game", @@ -4861,10 +5042,11 @@ "DGPT": "DigiPulse", "DGRAM": "Datagram", "DGTA": "Digitra.com Token", - "DGTX": "Digitex Token", + "DGTX": "Digitex Games", "DGV1": "Decentral Games v1", "DGVC": "DegenVC", - "DGX": "Digix Gold token", + "DGX": "Digix Gold Token", + "DHB": "DeHub", "DHLT": "DeHealth", "DHN": "Dohrnii", "DHP": "dHealth", @@ -4881,7 +5063,7 @@ "DIAMND": "Projekt Diamond", "DIAMO": "Diamond Launch", "DIAMON": "Diamond", - "DIAMOND": "Diamond Coin", + "DIAMOND": "DiamondToken", "DIAMONDINU": "Diamond", "DIBBLE": "Dibbles", "DIBC": "DIBCOIN", @@ -4914,6 +5096,7 @@ "DIGIMONRABBIT": "Digimon Rabbit", "DIGIT": "Digital Asset Rights Token", "DIGITAL": "Digital Reserve Currency", + "DIGITAL-RESERVE-CURRENCY": "Digital Reserve Currency", "DIGITALCOIN": "Digitalcoin", "DIGITS": "Digits DAO", "DIGIV": "Digiverse", @@ -4926,18 +5109,19 @@ "DILIGENT": "Diligent Pepe", "DILL": "dillwifit", "DIM": "DIMCOIN", - "DIME": "DIME", + "DIME": "Dimecoin", "DIMECOIN": "DimeCoin", "DIMO": "DIMO", - "DIN": "DIN", + "DIN": "Dinero", "DINE": "Dinero", "DINER": "TESLA DINER", - "DINERO": "Dinero", + "DINERO": "Dinerobet", "DINEROBET": "Dinerobet", "DINGER": "Dinger Token", - "DINGO": "Dingocoin", + "DINGO": "DINGO TOKEN (old)", "DINNER": "Trump Dinner", - "DINO": "DINO", + "DINO": "DinoSwap", + "DINOEGG": "DinoEGG", "DINOLFG": "DinoLFG", "DINOS": "Dinosaur Inu", "DINOSOL": "DINOSOL", @@ -4946,12 +5130,13 @@ "DINU": "Dogey-Inu", "DINW": "Dinowars", "DIO": "Decimated", - "DIONE": "Dione", + "DION": "Dionpay", + "DIONE": "Dione Protocol", "DIONEV1": "Dione v1", - "DIP": "Etherisc", + "DIP": "Etherisc DIP Token", "DIPA": "Doge Ipa", "DIRTY": "Dirty Street Cats", - "DIS": "DisChain", + "DIS": "TosDis", "DISCO": "Disco By Matt Furie", "DISCOVERY": "DiscoveryIoT", "DISK": "Dark Lisk", @@ -4962,8 +5147,8 @@ "DIT": "Ditcoin", "DITH": "Dither AI", "DIVA": "DIVA Protocol", - "DIVER": "Divergence Protocol", - "DIVI": "Divi Project", + "DIVER": "Divergence", + "DIVI": "Divi", "DIVO": "DIVO Token", "DIVX": "Divi Exchange Token", "DIW": "DIWtoken", @@ -4976,7 +5161,7 @@ "DKA": "dKargo", "DKC": "DarkKnightCoin", "DKD": "Dekado", - "DKEY": "DKEY Bank", + "DKEY": "DKEY BANK", "DKKT": "DKK Token", "DKNIGHT": "Dark Knight", "DKP": "Dragginz", @@ -4987,16 +5172,18 @@ "DLA": "Dolla", "DLANCE": "DeeLance", "DLB": "DiemLibre", - "DLC": "DeepLink", + "DLC": "Dollarcoin", "DLCBTC": "DLC.Link", + "DLEGENDS": "My DeFi Legends", "DLISK": "Dlisk", "DLLR": "Sovryn Dollar", "DLO": "Delio", "DLORD": "DORK LORD", "DLPD": "DLP Duck Token", "DLPT": "Deliverers Power Token", + "DLQ": "Deliq Finance", "DLR": "DollarOnline", - "DLT": "Agrello Delta", + "DLT": "Agrello", "DLTA": "delta.theta", "DLX": "DAppLinks", "DLXV": "Delta-X", @@ -5009,9 +5196,9 @@ "DMAR": "DMarket", "DMC": "DeLorean", "DMCC": "DiscoverFeed", - "DMCH": "DARMA Cash", + "DMCH": "Darma Cash", "DMCK": "Diamond Castle", - "DMD": "DMD", + "DMD": "Diamond", "DMG": "DMM: Governance", "DMGBULL": "3X Long DMM Governance Token", "DMIND": "DecentraMind", @@ -5021,16 +5208,17 @@ "DMOON": "Dollarmoon", "DMR": "dmr", "DMS": "Documentchain", - "DMT": "Dream Machine Token", + "DMT": "DMarket", + "DMT25653": "Sanko GameCorp", "DMTC": "Demeter Chain", "DMTR": "Dimitra", "DMX": "Dymmax", "DMZ": "DeMon Token", "DN": "DeepNode", "DN8": "Pldgr", - "DNA": "Metaverse", + "DNA": "EncrypGen", "DNAPEPE": "DNA PEPE", - "DND": "Diamond DND", + "DND": "Dungeonswap", "DNET": "DeNet", "DNF": "DNFT Protocol", "DNFLX": "Netflix Tokenized Stock Defichain", @@ -5058,7 +5246,7 @@ "DOCAINEURON": "Doc.ai Neuron", "DOCC": "Doc Coin", "DOCCOM": "DOC.COM", - "DOCK": "Dock.io", + "DOCK": "Dock", "DOCSWAP": "Dex on Crypto", "DOCT": "DocTailor", "DOCTO": "DoctorX", @@ -5068,9 +5256,12 @@ "DODO": "DODO", "DODOT": "Dodo the Black Swan", "DOE": "Dogs Of Elon", + "DOEX": "DOEX", "DOFI": "Doge Floki Coin", - "DOG": " DOG•GO•TO•THE•MOON", - "DOGA": "Dogami", + "DOG": "Dog (Runes)", + "DOG11557": "The Doge NFT", + "DOG30933": "Dog (Runes) USD Price", + "DOGA": "DOGAMÍ", "DOGACOIN": "DogaCoin", "DOGAI": "Dogai", "DOGALD": "dogald trump", @@ -5113,7 +5304,7 @@ "DOGEINU": "Doge Inu", "DOGEIUS": "DOGEIUS", "DOGEJ": "Dogecoin (JustCrypto)", - "DOGEKING": "DogeKing", + "DOGEKING": "DogeKing Metaverse", "DOGELEGION": "DOGE LEGION", "DOGEM": "Doge Matrix", "DOGEMARS": "DOGE TO MARS", @@ -5142,7 +5333,7 @@ "DOGG": "Doggo", "DOGGO": "DOGGO", "DOGGS": "Doggensnout", - "DOGGY": "Doggy", + "DOGGY": "DOGGY", "DOGGYCOIN": "DOGGY", "DOGH": "a dog in a hoodie", "DOGI": "dogi", @@ -5161,20 +5352,24 @@ "DOGPU": "DogeGPU", "DOGRMY": "DogeArmy", "DOGS": "Dogs", + "DOGS32698": "DOGS", "DOGSROCK": "Dogs Rock", "DOGSS": "DOGS SOL", "DOGSSO": "DOGS Solana", "DOGSWAG": "DogSwaghat", + "DOGTIC": "Dogtick", + "DOGU": "Dogu Inu", "DOGUN": "Dogun", "DOGW": "DOGWIFHOOD", "DOGWIFHAT": "dogwifhat", "DOGWIFSEAL": "dogwifseal", "DOGY": "Dogy", "DOGZ": "Dogz", - "DOJO": "ProjectDojo", + "DOJO": "DOJO", + "DOKE": "Doke Inu", "DOKI": "Doki Doki Finance", "DOKY": "Donkey King", - "DOLA": "Dola USD Stablecoin", + "DOLA": "DOLA", "DOLAN": "Dolan Duck", "DOLLAR": "Dollar", "DOLLARCOIN": "DollarCoin", @@ -5187,7 +5382,7 @@ "DOME": "Everdome", "DOMI": "Domi", "DOMO": "Dony Montana", - "DON": "TheDonato Token", + "DON": "Deonex Token", "DONA": "DONASWAP", "DONAL": "Donald Pump", "DONALD": "DONALD TRUMP", @@ -5219,9 +5414,10 @@ "DOPEC": "DOPE Coin", "DOPECOIN": "DopeCoin", "DOPEX": "DOPE", + "DOPF": "Dopple Finance", "DOPU": "DOPU The Dog with A Purpose", - "DOR": "Dorado", - "DORA": "DORA", + "DOR": "DoragonLand", + "DORA": "Dora Factory", "DORAEMON": "Doraemon", "DORAV1": "Dora Factory v1", "DORAV2": "Dora Factory", @@ -5236,23 +5432,24 @@ "DOTC": "Dotcoin", "DOTF": "Dot Finance", "DOTR": "Cydotori", + "DOTX": "DeFi of Thrones", "DOUG": "Doug The Duck", - "DOUGH": "PieDAO v2 (DOUGH)", - "DOV": "DOVU", + "DOUGH": "PieDAO DOUGH v2", + "DOV": "Dovu", "DOVI": "Dovi(Ordinals)", "DOVIS": "Dovish Finance", "DOVU": "DOVU", "DOWS": "Shadows", "DOYOUR": "Do Your Own Research", "DOYR": "DOYR", - "DP": "DigitalPrice", + "DP": "Dragon Pool", "DPAD": "Dpad Finance", - "DPAY": "Devour", + "DPAY": "PayDex", "DPCORE": "DeepCore AI", "DPDBC": "PDBC Defichain", "DPET": "My DeFi Pet", "DPEX": "DPEX", - "DPI": "DeFiPulse Index", + "DPI": "DeFi Pulse Index", "DPIE": "DeFiPie", "DPIN": "DPIN", "DPINO": "DarkPino", @@ -5269,7 +5466,7 @@ "DPY": "Delphy", "DQQQ": "Invesco QQQ Trust Defichain", "DRA": "Decentralized Retirement Account", - "DRAC": "Drac", + "DRAC": "DRAC Network", "DRACE": "DeathRoad", "DRACO": "DT Token", "DRACOO": "DracooMaster", @@ -5290,32 +5487,36 @@ "DRAW": "Drawshop Kingdom Reverse", "DRB": "DebtReliefBot", "DRBT": "DeFi-Robot", - "DRC": "DRC Mobility", - "DRCT": "Ally Direct", + "DRC": "Dracula Token", + "DRCT": "Ally Direct Token", "DRDR": "DRDR Token", "DRE": "DoRen", - "DREAM": "DREAM", + "DREAM": "Dream", "DREAM21": "Dream21", + "DREAMCOIN": "Dreamcoin", + "DREAMPAD": "DreamPad Capital", "DREAMS": "Dreams Quest", - "DREP": "DREP", + "DREP": "Drep [new]", "DRESS": "Dress", - "DRF": "Drife", + "DRF": "DRIFE", "DRG": "Dragon Coin", "DRGN": "Dragonchain", - "DRIFT": "Drift protocol", + "DRIFT": "Drift", + "DRIFT31278": "Drift", "DRINK": "DRINK", "DRINKCHAIN": "DrinkChain", - "DRIP": "Metadrip", + "DRIP": "Drip Network", "DRIPNET": "Drip Network", "DRIV": "DRIVEZ", "DRIVECRYPTO": "Drive Crypto", + "DRK": "Draken", "DRKC": "DarkCash", "DRKT": "DarkTron", "DRM": "DoDreamChain", "DRM8": "Dream8Coin", "DROGGY": "Droggy", "DRONE": "Drone Coin", - "DROP": "DROP", + "DROP": "DropArb", "DROPIL": "Dropil", "DROPS": "Drops", "DROVERS": "Drover Inu", @@ -5326,6 +5527,7 @@ "DRT": "DomRaider", "DRUGS": "Big Pharmai", "DRV": "Derive", + "DRV35014": "Derive USD Price", "DRX": "DRX Token", "DRXNE": "Droxne", "DRZ": "Droidz", @@ -5336,6 +5538,7 @@ "DSCP": "Dreamscape", "DSCVR": "DSCVR.Finance", "DSD": "Dynamic Set Dollar", + "DSETH": "Diversified Staked Ethereum Index", "DSFR": "Digital Swiss Franc", "DSG": "Dinosaureggs", "DSH": "Dashcoin", @@ -5345,7 +5548,9 @@ "DSK": "Darüşşafaka Spor Kulübü Token", "DSLA": "DSLA Protocol", "DSLV": "iShares Silver Trust Defichain", - "DSQ": "Dsquared.finance", + "DSM": "Desmos", + "DSP": "Delio DSP", + "DSQ": "DSquared Governance Token", "DSR": "Desire", "DSRUN": "Derby Stars", "DST": "Double Swap Token", @@ -5356,7 +5561,7 @@ "DSYNC": "Destra Network", "DT": "Drift Zone", "DT1": "Dollar Token 1", - "DTA": "Data", + "DTA": "DATA", "DTB": "Databits", "DTC": "Data Transaction", "DTCT": "DetectorToken", @@ -5375,10 +5580,11 @@ "DTRC": "Datarius", "DTRUMP": "Degen Trump", "DTSLA": "Tesla Tokenized Stock Defichain", + "DTUBE": "Dtube Coin", "DTV": "DraperTV", - "DTX": "DataBroker DAO", + "DTX": "Databroker", "DUA": "Brillion", - "DUAL": "DUAL", + "DUAL": "Dual Finance", "DUALDAOTOKEN": "Dual Finance", "DUALV1": "BLOCKv", "DUB": "DubCoin", @@ -5389,20 +5595,21 @@ "DUBX": "DUBXCOIN", "DUC": "DucatusCoin", "DUCAT": "Ducat", - "DUCATO": "Ducato Protocol Token", - "DUCK": "DuckChain Token", + "DUCATO": "Ducato Finance Token", + "DUCK": "Unit Protocol Duck", "DUCKAI": "Duck AI", "DUCKC": "DuckCoin", "DUCKD": "DuckDuckCoin", - "DUCKER": "Ducker", - "DUCKIES": "Yellow Duckies", + "DUCKER": "Duckereum", + "DUCKIES": "Duckies, the canary network for Yellow", "DUCKO": "Duck Off Coin", "DUCKV1": "UNITPROV1", "DUCKY": "Ducky", "DUCKY0X71": "Ducky Duck", "DUCX": "DucatusX", "DUDE": "DuDe", - "DUEL": "GameGPT", + "DUEL": "Duel Network", + "DUEL28868": "GameGPT", "DUELERS": "Block Duelers", "DUELN": "Duel Network", "DUELV1": "Duel Network v1", @@ -5423,9 +5630,11 @@ "DUREV": "Povel Durev", "DUROV": "FREE DUROV", "DURTH": "iShares MSCI World ETF Tokenized Stock Defichain", - "DUSD": "StandX DUSD", - "DUSK": "Dusk Network", + "DUSD": "DefiDollar", + "DUSK": "Dusk", "DUST": "Dust", + "DUST18802": "Dust Protocol", + "DUST23156": "DeDust", "DUSTPROTOCOL": "DUST Protocol", "DUSTY": "Dusty", "DUX": "DuxCoin", @@ -5433,15 +5642,17 @@ "DV": "Dreamverse", "DVC": "DragonVein", "DVDX": "Derived", - "DVF": "Rhino.fi", + "DVF": "rhino.fi", "DVG": "DAOventures", "DVI": "Dvision Network", + "DVILLE": "DogeVille", "DVINCI": "Davinci Jeremie", "DVK": "Devikins", "DVL": "Develad", - "DVNQ": "Vanguard Real Estate Tokenized Stock Defichain ()", + "DVNQ": "Vanguard Real Estate Tokenized Stock Defichain", "DVOO": "Vanguard S&P 500 ETF Tokenized Stock Defichain", "DVP": "Decentralized Vulnerability Platform", + "DVPN": "Sentinel", "DVRS": "DaoVerse", "DVS": "Davies", "DVT": "DeVault", @@ -5460,15 +5671,16 @@ "DXA": "DEXART", "DXB": "DefiXBet", "DXC": "DixiCoin", - "DXCT": "DNAxCAT", + "DXCT": "DNAxCAT Token", "DXD": "DXdao", "DXF": "Dexfin", "DXG": "DexAge", - "DXGM": "DEXGame", + "DXGM": "DexGame", "DXH": "Daxhund", "DXL": "Dexlab", "DXN": "DEXON", "DXO": "Dextro", + "DXP": "Dexpools", "DXR": "DEXTER", "DXS": "Dx Spot", "DXT": "Dexit Finance", @@ -5514,7 +5726,7 @@ "EAG": "Emerging Assets Group", "EAGLE": "Eagle Token", "EAGS": "EagsCoin", - "EAI": "Eagle AI", + "EAI": "Edain", "EARLY": "Early Risers", "EARLYF": "EarlyFans", "EARN": "Earn Network", @@ -5542,7 +5754,7 @@ "EBIT": "eBit", "EBITCOIN": "eBitcoin", "EBK": "Ebakus", - "EBOX": "Ethbox Token", + "EBOX": "ebox", "EBS": "EbolaShare", "EBSC": "EarlyBSC", "EBSHIB": "Wrapped Energy Shiba Inu (Energi Bridge)", @@ -5566,6 +5778,7 @@ "ECHO": "Echo", "ECHOBOT": "ECHO BOT", "ECHOD": "EchoDEX", + "ECHOES": "Echoes", "ECHON": "iShares MSCI Chile ETF (Ondo Tokenized)", "ECHT": "e-Chat", "ECI": "Euro Cup Inu", @@ -5579,8 +5792,9 @@ "ECOC": "ECOcoin", "ECOCH": "ECOChain", "ECOFI": "EcoFi", - "ECOIN": "Ecoin", + "ECOIN": "Ecoin official", "ECOM": "Omnitude", + "ECOP": "Eco DeFi", "ECOR": "Ecorpay token", "ECOREAL": "Ecoreal Estate", "ECOTERRA": "ecoterra", @@ -5599,13 +5813,13 @@ "EDDIE": "Eddie coin", "EDE": "El Dorado Exchange", "EDEL": "Edel", - "EDEN": "Eden Token", + "EDEN": "Eden", "EDENA": "EDENA", "EDENNETWORK": "EDEN", "EDEXA": "edeXa Security Token", "EDFI": "EdFi", "EDG": "Edgeless", - "EDGE": "edgeX", + "EDGE": "Edge", "EDGEACTIVITY": "EDGE Activity Token", "EDGEAI": "EdgeAI", "EDGEN": "LayerEdge", @@ -5620,45 +5834,46 @@ "EDOG": "EDOG", "EDOGE": "ElonDoge", "EDOM": "EDOM", - "EDR": "Endor Protocol Token", + "EDR": "Endor Protocol", "EDRC": "EDRCoin", "EDSE": "Eddie Seal", "EDT": "E-Drive Token", - "EDU": "Open Campus", + "EDU": "Open Campus (EDU)", + "EDU24613": "Open Campus", "EDUC": "EducoinV", "EDUCOIN": "EduCoin", "EDUM": "EDUM", "EDUX": "Edufex", "EDWIN": "Edwin", - "EDX": "Equilibrium", + "EDX": "EduBits", "EEFS": "Eefs", "EEG": "EEG Token", "EER": "Ethereum eRush", - "EETH": "ether fi", - "EEUR": "ARYZE eEUR", + "EETH": "ether.fi Staked ETH", + "EEUR": "e-Money EUR", "EFBAI": "EuroFootball AI", "EFC": "Everton Fan Token", "EFCR": "EFLANCER", "EFFECT": "Effect AI", - "EFFT": "Effort Economy ", - "EFI": "Efinity", + "EFFT": "Effort Economy", + "EFI": "Efinity Token", "EFIL": "Ethereum Wrapped Filecoin", "EFK": "ReFork", - "EFL": "E-Gulden", + "EFL": "e-Gulden", "EFR": "End Federal Reserve", - "EFT": "ETH Fan Token Ecosystem", - "EFX": "The Effect.ai", + "EFT": "EFT.finance", + "EFX": "Effect Network", "EFYT": "Ergo", "EG": "EG Token", - "EGAME": "Every Game", + "EGAME": "EVERY GAME", "EGAS": "ETHGAS", "EGAX": "Egochain", "EGAZ": "EGAZ", - "EGC": "Eagle Coin", + "EGC": "EverGrow", "EGCC": "Engine", "EGDC": "EasyGuide", "EGEM": "EtherGem", - "EGG": "Goose Finance", + "EGG": "Nestree", "EGGC": "EggCoin", "EGGMAN": "Eggman Inu", "EGGP": "Eggplant Finance", @@ -5667,7 +5882,7 @@ "EGI": "eGame", "EGL": "The Eagle Of Truth", "EGL1": "EGL1", - "EGLD": "eGold", + "EGLD": "MultiversX", "EGO": "Paysenger EGO", "EGOCOIN": "EGOcoin", "EGOD": "EgodCoin", @@ -5678,7 +5893,7 @@ "EGRN": "Energreen", "EGS": "EdgeSwap", "EGT": "Egretia", - "EGX": "Enegra", + "EGX": "Enegra (EGX)", "EGY": "Egypt Cat", "EHASH": "EHash", "EHIVE": "eHive", @@ -5768,9 +5983,9 @@ "ELONRWA": "ElonRWA", "ELONTRUMP": "ELON TRUMP", "ELP": "Ellerium", - "ELS": "Ethlas", + "ELS": "Elysian", "ELSA": "Elsa", - "ELT": "Element Black", + "ELT": "Elite Swap", "ELTC2": "eLTC", "ELTCOIN": "ELTCOIN", "ELTG": "Graphen", @@ -5778,10 +5993,10 @@ "ELUSKMON": "Elusk Mon", "ELV": "Elvantis", "ELVIS": "ELVIS", - "ELVN": "11Minutes", + "ELVN": "ElevenToken", "ELX": "Elixir Network", - "ELY": "Elysium", - "ELYS": "Elys Network", + "ELY": "Elysian", + "ELYS": "Elysium", "ELYSIAN": "Elysian", "ELYSIUM": "Elysium", "EM": "Eminer", @@ -5794,11 +6009,12 @@ "EMBER": "Ember", "EMBERCOIN": "EmberCoin", "EMBR": "Embr", - "EMC": "Edge Matrix Computing", + "EMC": "Emercoin", "EMC2": "Einsteinium", - "EMD": "Emerald", + "EMD": "Emerald Crypto", "EMDR": "Ethereum MDR", "EMERCOIN": "Emercoin", + "EMGS": "EMG SuperApp", "EMIGR": "EmiratesGoldCoin", "EMILY": "Emily", "EMIT": "Time Machine NFTs", @@ -5820,7 +6036,9 @@ "EMR": "Emorya Finance", "EMRLD": "The Emerald Company", "EMRX": "Emirex Token", - "EMT": "EMAIL Token", + "EMS": "Ethereum Message Search", + "EMT": "Emanate", + "EMTRG": "Meter Governance mapped by Meter.io", "EMU": "eMusic", "EMV": "Ethereum Movie Venture", "EMX": "EMX", @@ -5843,6 +6061,7 @@ "ENERGYX": "Safe Energy", "ENEXSPACE": "ENEX", "ENF": "enfineo", + "ENFT": "RCD Espanyol Fan Token", "ENG": "Enigma", "ENGT": "Engagement Token", "ENIGMA": "ENIGMA", @@ -5850,7 +6069,7 @@ "ENJV1": "Enjin Coin v1", "ENK": "Enkidu", "ENNO": "ENNO Cash", - "ENO": "Enotoken", + "ENO": "ENO", "ENOKIFIN": "Enoki Finance", "ENQ": "Enecuum", "ENQAI": "enqAI", @@ -5858,9 +6077,9 @@ "ENRON": "Enron", "ENRX": "Enrex", "ENS": "Ethereum Name Service", - "ENSO": "Enso", + "ENSO": "Enso USD Price", "ENT": "Eternity", - "ENTC": "EnterButton", + "ENTC": "ENTERBUTTON", "ENTER": "EnterCoin", "ENTR": "EnterDAO", "ENTRC": "ENTER COIN", @@ -5874,13 +6093,14 @@ "ENVIENTA": "Envienta", "ENVION": "Envion", "ENVOY": "Envoy A.I", - "ENX": "Enigma", + "ENX": "Equinox", + "ENXS": "EtherNexus", "EOC": "EveryonesCoin", "EON": "Exscudo", "EONC": "Dimension", "EOS": "EOS", "EOSBLACK": "eosBLACK", - "EOSC": "EOSForce", + "EOSC": "EOS Force", "EOSDAC": "eosDAC", "EOSDT": "EOSDT", "EOST": "EOS TRUST", @@ -5891,11 +6111,13 @@ "EPENDLE": "Equilibria Pendle", "EPEP": "Epep", "EPETS": "Etherpets", - "EPIC": "Epic Chain", + "EPHIAT": "Phiat.io", + "EPIC": "Epic Cash", "EPICCASH": "Epic Cash", "EPICV1": "Ethernity Chain", - "EPIK": "EPIK Token", + "EPIK": "EPIK Prime", "EPIKO": "Epiko", + "EPILLO": "Epillo", "EPIX": "Byepix", "EPK": "EpiK Protocol", "EPS": "Ellipsis (OLD)", @@ -5904,8 +6126,9 @@ "EPTT": "Evident Proof Transaction Token", "EPX": "Ellipsis X", "EPY": "Empyrean", - "EQ": "Equilibrium Games", + "EQ": "Equilibrium", "EQ9": "EQ9", + "EQB": "Equilibria Finance", "EQC": "Ethereum Qchain Token", "EQL": "EQUAL", "EQM": "Equilibrium Coin", @@ -5914,16 +6137,17 @@ "EQT": "EquiTrader", "EQTYX": "WisdomTree Siegel Global Equity Digital Fund", "EQU": "Equation", - "EQUAD": "Quadrant Protocol", + "EQUAD": "QuadrantProtocol", "EQUAL": "Equalizer DEX", "EQUALCOIN": "EqualCoin", "EQUI": "EQUI", "EQUIL": "Equilibrium", "EQUITOKEN": "EQUI Token", - "EQX": "EQIFi", + "EQX": "EQIFI", "EQZ": "Equalizer", - "ERA": "Caldera", + "ERA": "Era Token (Era7)", "ERA7": "Era Token", + "ERAS": "Era Swap", "ERASWAP": "Era Swap Token", "ERB": "ERBCoin", "ERBB": "Exchange Request for Bitbon", @@ -5937,14 +6161,16 @@ "ERIC": "Elon's Pet Fish ERIC", "ERIS": "Eristica", "ERK": "Eureka Coin", + "ERN": "Ethernity", "ERO": "Eroscoin", "ERON": "ERON", "EROTICA": "Erotica", + "EROWAN": "SifChain", "ERR": "Coinerr", "ERROR": "484 Fund", "ERROR404": "ERROR404 MEME", "ERRORCOIN": "ErrorCoin", - "ERSDL": "UnFederalReserve", + "ERSDL": "unFederalReserve", "ERT": "Esports.com", "ERTH": "Erth Point", "ERTHA": "Ertha", @@ -5952,10 +6178,12 @@ "ERY": "Eryllium", "ERZ": "Erzurumspor Token", "ES": "Eclipse", + "ES2": "EverSAFUv2", "ESAI": "Ethscan AI", "ESBC": "ESBC", "ESCC": "Eos Stable Coin Chain", "ESCE": "Escroco Emerald", + "ESCO": "Esco Coin", "ESCROW": "Cryptegrity DAO", "ESCU": "EYESECU AI", "ESD": "Empty Set Dollar", @@ -5977,7 +6205,7 @@ "ESPR": "Espresso Bot", "ESRC": "ESR Coin", "ESS": "Essentia", - "EST": "ESports Chain", + "EST": "Esports Token", "ESTATE": "AgentMile", "ESTEE": "Kaga No Fuuka Go Sapporo Kagasou", "ESW": "eSwitch®", @@ -6003,6 +6231,7 @@ "ETH": "Ethereum", "ETH2": "Eth 2.0 Staking by Pool-X", "ETH2X-FLI": "ETH 2x Flexible Leverage Index", + "ETH2X-FLI-P": "ETH 2x Flexible Leverage Index (Polygon)", "ETH6900": "ETH6900", "ETHA": "ETHA Lend", "ETHAX": "ETHAX", @@ -6010,6 +6239,7 @@ "ETHBN": "EtherBone", "ETHD": "Ethereum Dark", "ETHDOG": "Ethereumdog", + "ETHDYDX": "dYdX (ethDYDX)", "ETHER": "Etherparty", "ETHERBTC": "EtherBTC", "ETHERDELTA": "EtherDelta", @@ -6020,17 +6250,19 @@ "ETHEREUMSCRYPT": "EthereumScrypt", "ETHERINC": "EtherInc", "ETHERKING": "Ether Kingdoms Token", + "ETHERNAL": "Ethernal", "ETHERNITY": "Ethernity Chain", "ETHEROLL": "Etheroll", "ETHERW": "Ether Wars", "ETHF": "EthereumFair", "ETHFAI": "ETHforestAI", - "ETHFI": "Ether.fi", + "ETHFI": "ether.fi", + "ETHFIN": "Ethernal Finance", "ETHI": "Ethical Finance", "ETHIX": "EthicHub", "ETHJ": "Ethereum (JustCrypto)", "ETHM": "Ethereum Meta", - "ETHO": "The Etho Protocol", + "ETHO": "Etho Protocol", "ETHOS": "Ethos Project", "ETHP": "ETHPlus", "ETHPAD": "ETHPad", @@ -6042,19 +6274,22 @@ "ETHR": "Ethereal", "ETHS": "Ethscriptions", "ETHSHIB": "Eth Shiba", + "ETHUP": "ETHUP", "ETHV": "Ethverse", - "ETHW": "Ethereum PoW", + "ETHW": "EthereumPoW", "ETHX": "Stader ETHx", "ETHY": "Ethereum Yield", + "ETHYS": "Ethereum Stake", "ETI": "Etica", "ETK": "Energi Token", "ETKN": "EasyToken", - "ETL": "EtherLite", + "ETL": "Etherlite", "ETM": "En-Tan-Mo", "ETN": "Electroneum", "ETNA": "ETNA Network", "ETNY": "Ethernity", - "ETP": "Metaverse", + "ETO": "EcoTool", + "ETP": "Metaverse ETP", "ETPOS": "EtherPOS", "ETR": "Electric Token", "ETRL": "Ethereal", @@ -6062,7 +6297,7 @@ "ETS": "ETH Share", "ETSC": "Ether star blockchain", "ETT": "EncryptoTel", - "ETX": "Ethrix", + "ETX": "ETXInfinity", "ETY": "Ethereum Cloud", "ETZ": "EtherZero", "EU24": "EURO2024", @@ -6074,9 +6309,9 @@ "EULER": "Euler Tools", "EUM": "Elitium", "EUNO": "EUNO", - "EURA": "EURA", + "EURA": "Angle Protocol (EURA)", "EURAU": "AllUnity EUR", - "EURC": "Euro Coin", + "EURC": "EURC", "EURCV": "EUR CoinVertible", "EURCVV1": "EUR CoinVertible v1", "EURD": "Quantoz EURD", @@ -6087,6 +6322,7 @@ "EURN": "NOKU EUR", "EURO3": "EURO3", "EUROB": "Etherfuse EUROB", + "EUROC": "Euro Coin", "EUROCUP": "EURO CUP INU", "EUROE": "EUROe Stablecoin", "EUROP": "EURØP", @@ -6094,9 +6330,9 @@ "EURQ": "Quantoz EURQ", "EURR": "StablR Euro", "EURRV1": "StablR Euro v1", - "EURS": "STASIS EURS", + "EURS": "STASIS EURO", "EURST": "EURO Stable Token", - "EURT": "Euro Tether", + "EURT": "Tether EURt", "EURTV1": "Euro Tether v1", "EURU": "Upper Euro", "EURX": "eToro Euro", @@ -6113,9 +6349,10 @@ "EVAN": "Evanesco Network", "EVAULT": "EthereumVault", "EVAV1": "Evadore v1", - "EVC": "Eventchain", + "EVC": "EventChain", "EVCC": "Eco Value Coin", "EVCOIN": "EverestCoin", + "EVD": "Evmos Domains", "EVDC": "Electric Vehicle Direct Currency", "EVE": "Devery", "EVEAI": "EVEAI", @@ -6124,7 +6361,7 @@ "EVENT": "Event Token", "EVER": "Everscale", "EVEREST": "Everest", - "EVERETH": "EverETH Reflect", + "EVERETH": "EverETH", "EVERGREEN": "EverGreenCoin", "EVERGROW": "EverGrowCoin", "EVERLIFE": "EverLife.AI", @@ -6132,12 +6369,12 @@ "EVERRISE": "EverRise", "EVERV": "EverValue Coin", "EVERY": "Everyworld", - "EVIL": "EvilCoin", + "EVIL": "Evil Coin", "EVILPEPE": "Evil Pepe", "EVIN": "Evin Token", "EVMOS": "Evmos", - "EVN": "Evn Token", - "EVO": "Devomon", + "EVN": "EvenCoin", + "EVO": "Evolution", "EVOAI": "EvolveAI", "EVOC": "EVOCPLUS", "EVOL": "EVOL NETWORK", @@ -6145,10 +6382,11 @@ "EVOSIM": "EvoSimGame", "EVOVERSES": "EvoVerses", "EVR": "Everus", + "EVRF": "EverReflect", "EVRICE": "Evrice", "EVRM": "Evrmore", "EVRT": "Everest Token", - "EVRY": "Evrynet", + "EVRY": "EVRYNET", "EVT": "EveriToken", "EVU": "Evulus Token", "EVX": "Everex", @@ -6161,7 +6399,7 @@ "EWTT": "Ecowatt", "EXA": "Exactly Protocol", "EXB": "ExaByte (EXB)", - "EXC": "Eximchain", + "EXC": "Excalibur", "EXCC": "ExchangeCoin", "EXCHANGEN": "ExchangeN", "EXCL": "Exclusive Coin", @@ -6178,7 +6416,7 @@ "EXM": "EXMO Coin", "EXMR": "EXMR FDN", "EXN": "Exeno", - "EXNT": "EXNT", + "EXNT": "ExNetwork Token", "EXO": "Exosis", "EXODON": "Exodus Movement (Ondo Tokenized)", "EXOS": "Exobots", @@ -6187,19 +6425,21 @@ "EXPERIENCE": "Experience Points", "EXPERT": "EXPERT_MONEY", "EXPO": "Exponential Capital", - "EXRD": "Radix", + "EXRD": "e-Radix", "EXRN": "EXRNchain", + "EXRT": "EXRT Network", + "EXT": "ExodusExt", "EXTN": "Extensive Coin", "EXTP": "TradePlace", "EXTRA": "Extra Finance", "EXVG": "Exverse", "EXY": "Experty", "EXZO": "ExzoCoin 2.0", - "EYE": "MEDIA EYE", - "EYES": "Eyes Protocol", + "EYE": "Behodler", + "EYES": "EYES Protocol", "EYETOKEN": "EYE Token", "EYWA": "EYWA", - "EZ": "EasyFi V2", + "EZ": "EasyFi", "EZC": "EZCoin", "EZEIGEN": "Restaked EIGEN", "EZETH": "Renzo Restaked ETH", @@ -6215,17 +6455,18 @@ "F": "SynFutures", "F16": "F16Coin", "F1C": "Future1coin", - "F2C": "Ftribe Fighters", + "F2C": "Ftribe Fighters (F2 NFT)", "F2K": "Farm2Kitchen", "F3": "Friend3", "F5": "F5-promoT5", "F7": "Five7", "F9": "Falcon Nine", - "FAB": "FABRK Token", + "FAB": "Fast Access Blockchain", "FABA": "Faba Invest", "FABIENNE": "Fabienne", "FABRIC": "MetaFabric", "FAC": "Flying Avocado Cat", + "FACE": "Faceter", "FACEDAO": "FaceDAO", "FACETER": "Faceter", "FACT": "Orcfax", @@ -6240,8 +6481,8 @@ "FAFOSOL": "Fafo", "FAG": "PoorFag", "FAH": "Falcons", - "FAI": "Freysa AI", - "FAIR": "FairCoin", + "FAI": "Fairum", + "FAIR": "FairGame", "FAIR3": "Fair and Free", "FAIRC": "Faireum Token", "FAIRG": "FairGame", @@ -6252,7 +6493,7 @@ "FALCONS": "Falcon Swaps", "FALX": "FalconX", "FAM": "Family", - "FAME": "Fame MMA", + "FAME": "FARM ME", "FAMEC": "FameCoin", "FAMILY": "The Bitcoin Family", "FAML": "FAML", @@ -6273,6 +6514,7 @@ "FAPTAX": "Faptax", "FAR": "Farmland Protocol", "FARA": "FaraLand", + "FARB": "ARB FURBO", "FARCA": "Farcana", "FARM": "Harvest Finance", "FARMA": "FarmaTrust", @@ -6288,7 +6530,7 @@ "FARTING": "Farting Unicorn", "FARTLESS": "FARTLESS COIN", "FAS": "fast construction coin", - "FAST": "Fastswap", + "FAST": "FastSwap", "FASTAI": "Fast And Ai", "FASTMOON": "FastMoon", "FASTUSD": "Sei fastUSD", @@ -6304,20 +6546,23 @@ "FAYD": "Fayda", "FAYRE": "Fayre", "FAZZ": "FazzCoin", - "FB": "Fractal Bitcoin", + "FB": "Facebook tokenized stock FTX", + "FB11308": "Fenerbahçe Token", + "FB2": "Fenerbahçe Token", "FBA": "Firebird Aggregator", "FBB": "FilmBusinessBuster", "FBD": "Fiboard", "FBG": "Fort Block Games", + "FBL": "Football Battle", "FBN": "Five balance", "FBNB": "ForeverBNB", "FBOMB": "fBomb", "FBOMBV1": "fBomb v1", "FBURN": "Forever Burn", - "FBX": "Finance Blocks", + "FBX": "ForthBox", "FC": "Facecoin", "FC2": "Fuel2Coin", - "FCC": "Freechat", + "FCC": "FarmerCrypto", "FCD": "FreshCut Diamond", "FCF": "French Connection Finance", "FCH": "Freecash", @@ -6331,11 +6576,13 @@ "FCP": "FILIPCOIN", "FCQ": "Fortem Capital", "FCS": "CryptoFocus", - "FCT": "FirmaChain", + "FCT": "Factom", "FCTC": "FaucetCoin", - "FCTR": "FactorDAO", + "FCTR": "Factor Dao", "FCXON": "Freeport-McMoRan (Ondo Tokenized)", - "FDC": "FDrive Coin", + "FD": "First Digital USD", + "FDAO": "Figure DAO", + "FDC": "Fidance", "FDGC": "FINTECH DIGITAL GOLD COIN", "FDLS": "FIDELIS", "FDM": "Fandom", @@ -6343,21 +6590,21 @@ "FDR": "French Digital Reserve", "FDS": "Foodie Squirrel", "FDT": "Frutti Dino", - "FDUSD": "First Digital USD", + "FDUSD": "First Digital", "FDX": "fidentiaX", "FDZ": "Friendz", - "FEAR": "Fear", + "FEAR": "FEAR", "FEARNOT": "FEAR NOT", "FEATHER": "FeatherCoin", "FECES": "FECES", - "FEED": "Feeder Finance", + "FEED": "Feeder.finance", "FEENIXV2": "ProjectFeenixv2", "FEES": "UNIFEES", "FEFE": "Fefe", - "FEG": "FEED EVERY GORILLA", + "FEG": "FEG Token", "FEGV1": "FEG Token v1", "FEGV2": "FEG Token", - "FEI": "Fei Protocol", + "FEI": "Fei USD", "FELIS": "Felis", "FELIX": "FelixCoin", "FELIX2": "Felix 2.0 ETH", @@ -6368,11 +6615,11 @@ "FENTANYL": "Chinese Communist Dragon", "FER": "Ferro", "FERC": "FairERC20", - "FERMA": "Ferma", + "FERMA": "FERMA SOSEDI", "FERT": "Chikn Fert", "FERZAN": "Ferzan", "FESS": "Fesschain", - "FET": "Artificial Superintelligence Alliance", + "FET": "Fetch.ai", "FETCH": "Fetch", "FETS": "FE TECH", "FETV1": "Fetch v1", @@ -6380,8 +6627,9 @@ "FEVR": "RealFevr", "FEX": "FEX Token", "FEY": "Feyorra", - "FF": "Falcon Finance", + "FF": "Forefront", "FF1": "Two Prime FF1 Token", + "FF38482": "Falcon Finance USD Price", "FFA": "Cryptofifa", "FFC": "FireflyCoin", "FFCT": "FortFC", @@ -6393,8 +6641,9 @@ "FFYI": "Fiscus FYI", "FGC": "FantasyGold", "FGD": "Freedom God DAO", + "FGHT": "Fight Out", "FGM": "Feels Good Man", - "FGPT": "FurGPT", + "FGPT": "Floki GPT", "FGT": "Flozo Game Token", "FGZ": "Free Game Zone", "FHB": "FHB", @@ -6407,6 +6656,7 @@ "FIBOS": "FIBOS", "FIBRE": "FIBRE", "FIC": "Filecash", + "FICO": "Fish Crypto", "FID": "Fidira", "FIDA": "Bonfida", "FIDANCE": "Fidance", @@ -6414,6 +6664,7 @@ "FIDLE": "Fidlecoin", "FIDO": "FIDO", "FIDU": "Fidu", + "FIEF": "Fief", "FIELD": "Fieldcoin", "FIERO": "Fieres", "FIF": "flokiwifhat", @@ -6421,24 +6672,27 @@ "FIFTY": "FIFTYONEFIFTY", "FIG": "FlowCom", "FIGH": "FIGHT FIGHT FIGHT", - "FIGHT": "FIGHT", + "FIGHT": "Crypto Fight Club", "FIGHT2MAGA": "Fight to MAGA", "FIGHTMAGA": "FIGHT MAGA", "FIGHTPEPE": "FIGHT PEPE", "FIGHTRUMP": "FIGHT TRUMP", + "FIGMA": "Figments Club", + "FIGRHELOC": "Figure HELOC USD Price", "FIH": "Fidelity House", "FIII": "Fiii", - "FIL": "FileCoin", + "FIL": "Filecoin", "FILDA": "Filda", "FILES": "Solfiles", "FILEST": "FileStar", "FILL": "Fillit", - "FILM": "Filmpass", + "FILM": "Decentralized Pictures", "FILST": "Filecoin Standard Hashrate Token", "FIN": "DeFiner", "FINA": "Defina Finance", "FINALE": "Ben's Finale", "FINAN": "FINANCIAL TRANSACTION SYSTEM", + "FINANCEAI": "Finance AI", "FINB": "Finblox", "FINC": "Finceptor", "FIND": "FindCoin", @@ -6451,21 +6705,23 @@ "FINOMNOM": "Finom NOM Token", "FINS": "AutoShark DEX", "FINT": "FintraDao", - "FINU": "Formula Inu", + "FINU": "Fifa Inu", "FINVESTA": "Finvesta", "FIO": "FIO Protocol", "FIONA": "Fiona", "FIONABSC": "Fiona", "FIR": "Fireverse", "FIRA": "Defira", - "FIRE": "Matr1x Fire", + "FIRE": "Fireball", + "FIRE-PROTOCOL": "Fireball", "FIRECOIN": "FireCoin", "FIREP": "Fire Protocol", "FIREW": "Fire Wolf", + "FIRMACHAIN": "FirmaChain", "FIRO": "Firo", "FIRSTHARE": "FirstHare", "FIRU": "Firulais Finance", - "FIS": "Stafi", + "FIS": "StaFi", "FISH": "Polycat Finance", "FISH2": "FISH2", "FISHK": "Fishkoin", @@ -6479,7 +6735,7 @@ "FITT": "Fitmint", "FIU": "beFITTER", "FIUSD": "Sygnum FIUSD Liquidity Fund", - "FIWA": "Defi Warrior", + "FIWA": "DeFi Warrior (FIWA)", "FIX00": "FIX00", "FJB": "Freedom. Jobs. Business.", "FJC": "FujiCoin", @@ -6492,7 +6748,7 @@ "FKPEPE": "Fuck Pepe", "FKR": "Flicker", "FKRPRO": "FlickerPro", - "FKSK": "Fatih Karagümrük SK", + "FKSK": "Fatih Karagümrük SK Fan Token", "FKX": "FortKnoxster", "FL": "Freeliquid", "FLA": "Flappy", @@ -6515,7 +6771,7 @@ "FLEA": "FLEABONE", "FLEPE": "Floki VS Pepe", "FLETA": "FLETA", - "FLEX": "FLEX Coin", + "FLEX": "FLEX", "FLEXUSD": "flexUSD", "FLG": "Folgory Coin", "FLIBERO": "Fantom Libero Financial", @@ -6531,11 +6787,13 @@ "FLL": "Feellike", "FLLW": "Follow Coin", "FLM": "Flamingo", + "FLM1": "Flamingo", "FLMC": "FOLM coin", "FLN": "Falcon", "FLO": "Flo", - "FLOAT": "Float Protocol", + "FLOAT": "Float Protocol: Float", "FLOATBANK": "Float Protocol", + "FLOBO": "FlokiBonk", "FLOCHI": "Flochi", "FLOCK": "FLock.io", "FLOCKA": "Waka Flocka", @@ -6543,9 +6801,11 @@ "FLOCO": "flocoin", "FLOKA": "FLOKA", "FLOKEI": "FLOKEI", - "FLOKI": "Floki Inu", + "FLOKI": "FLOKI", "FLOKIBURN": "FlokiBurn", "FLOKICASH": "Floki Cash", + "FLOKICEO": "FLOKI CEO", + "FLOKIDASH": "FlokiDash", "FLOKIM": "Flokimooni", "FLOKIMOON": "FLOKIMOON", "FLOKINY": "Floki New Year", @@ -6555,6 +6815,7 @@ "FLOKIV2": "Floki v2", "FLOKIV3": "Floki v3", "FLOKIX": "FLOKI X", + "FLONA": "Flona", "FLOOF": "FLOOF", "FLOOR": "FloorDAO", "FLOP": "Big Floppa", @@ -6563,9 +6824,10 @@ "FLORK": "FLORK BNB", "FLORKY": "Florky", "FLOSHIDO": "FLOSHIDO INU", - "FLOT": "FireLotto", + "FLOT": "Fire Lotto", "FLOTUS47": "Melania Trump", "FLOURI": "Flourishing AI", + "FLOV": "Valentine Floki", "FLOVI": "Flovi inu", "FLOVM": "FLOV MARKET", "FLOW": "Flow", @@ -6573,7 +6835,7 @@ "FLOWM": "Flowmatic", "FLOWP": "Flow Protocol", "FLOYX": "Floyx", - "FLP": "Gameflip", + "FLP": "FLIP", "FLQLON": "Franklin US Large Cap Multifactor Index ETF (Ondo Tokenized)", "FLR": "Flare", "FLRBRG": "Floor Cheese Burger", @@ -6582,11 +6844,13 @@ "FLSH": "FlashWash", "FLT": "Fluence", "FLTTX": "WisdomTree Floating Rate Treasury Digital Fund", + "FLUF": "Fluffington", "FLUFFI": "Fluffington", "FLUFFY": "FLUFFY", "FLUFFYS": "Fluffys", "FLUI": "Fluidity", - "FLUID": "Fluid", + "FLUID": "FluidFi", + "FLUID10508": "Fluid", "FLUIDTRADE": "Fluid", "FLURRY": "Flurry Finance", "FLUT": "Flute", @@ -6596,8 +6860,8 @@ "FLUXT": "Flux Token", "FLUZ": "FluzFluz", "FLVR": "FlavorCoin", - "FLX": "Reflexer Ungovernance Token", - "FLY": "Fly.trade", + "FLX": "Felixo Coin", + "FLY": "Franklin", "FLYBNB": "FlyBNB", "FLYCOIN": "FlyCoin", "FLZ": "Fellaz", @@ -6613,41 +6877,43 @@ "FMT": "Finminity", "FN": "Filenet", "FNA": "FinTech AI", - "FNB": "FNB protocol", + "FNB": "FNB Protocol", "FNC": "Fancy Games", "FNCT": "Financie Token", "FNCY": "FNCY", "FND": "Rare FND", - "FNDZ": "FNDZ Token", + "FNDZ": "FNDZ", "FNF": "FunFi", - "FNK": "FunKeyPay", + "FNK": "FNK wallet", "FNL": "Finlocale", "FNLX": "Fignal X", "FNO": "Fonero", "FNP": "FlipNpik", "FNS": "FAUNUS", "FNSA": "FINSCHIA", + "FNT": "Falcon Project", "FNTB": "FinTab", "FNX": "FinNexus", "FNXAI": "Finanx AI", "FNZ": "Fanzee", - "FO": "Official FO", + "FO": "FIBOS", "FOA": "Fragments of arker", - "FOAM": "Foam", + "FOAM": "FOAM", "FOC": "TheForce Trade", "FOCAI": "focai.fun", "FOCV": "FOCV", - "FODL": "Fodl Finance", + "FODL": "FODL Finance", "FOF": "Future Of Fintech", "FOFAR": "FoFar", "FOFARBASE": "FOFAR", "FOFARIO": "Fofar", - "FOFO": "FOFO", + "FOFO": "FOFO Token", "FOFOTOKEN": "FOFO Token", "FOG": "FOGnet", "FOGE": "Fat Doge", "FOGO": "Fogo", "FOGV1": "FOGnet v1", + "FOHO": "FOHO Coin", "FOIN": "Foin", "FOL": "Folder Protocol", "FOLD": "Manifold Finance", @@ -6656,15 +6922,17 @@ "FOLKS": "Folks Finance", "FOLO": "Alpha Impact", "FOM": "FOMO BULL CLUB", - "FOMO": "Fomo", + "FOMO": "Aavegotchi FOMO", "FOMON": "FOMO Network", "FOMOSOL": "FOMOSolana", - "FON": "INOFI", - "FONE": "Fone", + "FON": "Force of Nature", + "FONE": "FONE", "FONS": "FONSmartChain", "FONT": "Font", "FONZ": "FonzieCoin", - "FOOD": "FoodCoin", + "FONZY": "Fonzy", + "FOO": "Foobar (Friend.tech)", + "FOOD": "FoodChain Global", "FOODC": "Food Club", "FOOM": "FOOM", "FOOX": "Foox", @@ -6682,14 +6950,17 @@ "FOREVERFOMO": "ForeverFOMO", "FOREVERPUMP": "Forever Pump", "FOREVERUP": "ForeverUp", - "FOREX": "handle.fi", + "FOREX": "handleFOREX", "FOREXCOIN": "FOREXCOIN", + "FORGE": "Forge Finance", "FORK": "Gastro Advisor Token", - "FORM": "Four", + "FORM": "Formation Fi", + "FORM23635": "Four USD Price", "FORMATION": "Formation FI", "FORMNET": "Form", - "FORS": "Forus", + "FORS": "Foresight", "FORT": "Forta", + "FORT20622": "Forta", "FORTH": "Ampleforth Governance Token", "FORTHB": "ForthBox", "FORTKNOX": "Fort Knox", @@ -6704,8 +6975,8 @@ "FOUND": "ccFound", "FOUNDER": "Founder", "FOUNTAIN": "Fountain", - "FOUR": "4", - "FOX": "ShapeShift FOX Token", + "FOUR": "4THPILLAR TECHNOLOGIES", + "FOX": "Shapeshift FOX Token", "FOXAI": "FOXAI", "FOXD": "Foxdcoin", "FOXE": "Foxe", @@ -6717,6 +6988,7 @@ "FOXV2": "FoxFinanceV2", "FOXXY": "FOXXY", "FOXY": "Foxy", + "FOXY30591": "Foxy", "FP": "Forgotten Playland", "FPAD": "FantomPAD", "FPC": "Futurepia", @@ -6735,21 +7007,22 @@ "FRANK": "Frank", "FRANKLIN": "Franklin", "FRATT": "Frogg and Ratt", - "FRAX": "Frax Share", + "FRAX": "Frax", "FRAXLEGACY": "Frax", "FRAZ": "FrazCoin", - "FRBK": " FreeBnk", - "FRC": "FireRoosterCoin", + "FRB": "Freebie Life Finance", + "FRBK": "FreeBnk", + "FRC": "Freicoin", "FRD": "Farad", "FRDX": "Frodo Tech", "FRE": "FreeCoin", "FREAK": "Freakoff", "FREC": "Freyrchain", "FRECNX": "FreldoCoinX", - "FRED": "First Convicted Raccon Fred", + "FRED": "FRED Energy", "FREDDY": "FREDDY", "FREDE": "FREDEnergy", - "FREE": "FREE coin", + "FREE": "FREEdom Coin", "FREED": "FreedomCoin", "FREEDO": "Freedom", "FREEDOG": "Freedogs", @@ -6767,7 +7040,7 @@ "FRENCH": "French On Base", "FRENLY": "Frenly", "FRENPET": "Fren Pet", - "FRENS": "Farmer Friends", + "FRENS": "Frens", "FRESCO": "Fresco", "FRF": "France REV Finance", "FRGB": "Pepe's Frogbar", @@ -6776,7 +7049,7 @@ "FRIC": "Fric", "FRICTION": "Frictionless", "FRIEND": "Friend.tech", - "FRIES": "Soltato FRIES", + "FRIES": "fry.world", "FRIN": "Fringe Finance", "FRK": "Franko", "FRKT": "FRAKT Token", @@ -6814,14 +7087,14 @@ "FRTS": "Fruits", "FRV": "Fitrova", "FRWC": "Frankywillcoin", - "FRXETH": "Frax Ether", + "FRXETH": "Frax Finance - Frax Ether", "FRXUSD": "Frax USD", "FRZ": "Frozy Inu", "FRZSS": "Frz Solar System", "FRZSSCOIN": "FRZ Solar System Coin", "FS": "FantomStarter", "FSBT": "Forty Seven Bank", - "FSC": "FriendshipCoin", + "FSC": "Five Star Coin", "FSCC": "Fisco Coin", "FSHN": "Fashion Coin", "FSM": "Floki SafeMoon", @@ -6829,26 +7102,28 @@ "FSNV1": "Fusion v1", "FSO": "FSociety", "FSOLON": "Fidelity Solana Fund (Ondo Tokenized)", - "FST": "FreeStyle Token", + "FST": "1irstcoin", "FSTC": "FastCoin", "FSTR": "Fourth Star", "FSW": "Falconswap", + "FSXU": "FlashX Ultra", "FT": "Flying Tulip", "FTB": "Fit&Beat", - "FTC": "Futurex", + "FTC": "Feathercoin", "FTD": "42DAO", "FTG": "fantomGO", "FTH": "Fintyhub Token", "FTHM": "Fathom Protocol", "FTI": "FansTime", "FTK": "FToken", - "FTM": "Fantom", + "FTM": "Sonic (prev. FTM)", + "FTML": "FTMlaunch", "FTMO": "Fantom Oasis", "FTMX": "FUCK THE MATRIX", "FTN": "Fasttoken", "FTO": "FuturoCoin", "FTON": "Fanton", - "FTP": "FuturePoints", + "FTP": "Fountain Protocol", "FTPY": "FTPY TOKEN", "FTR": "Fautor", "FTRB": "Faith Tribe", @@ -6860,26 +7135,27 @@ "FTUM": "Fatum", "FTVT": "FashionTV Token", "FTW": "FutureWorks", - "FTX": "FintruX", + "FTX": "FintruX Network", "FTXAI": "FTX AI Agent", "FTXT": "FUTURAX", "FU": "FU Money", "FUBAO": "FUBAO", "FUCK": "Fuck Token", "FUCKTRUMP": "FUCK TRUMP", - "FUD": "Fud the Pug", + "FUD": "Aavegotchi FUD", "FUDFINANCE": "FUD.finance", "FUEGO": "FUEGO", - "FUEL": "Fuel Network", + "FUEL": "Etherparty", + "FUEL24087": "Fuel Network", "FUELX": "Fuel", - "FUFU": "Fufu Token", + "FUFU": "FUFU", "FUG": "FUG", "FUJIN": "Fujinto", "FUKU": "FUKU-KUN", "FUL": "Fulcrom Finance", "FULLSEND": "Fullsend Community Coin", "FUMO": "Alien Milady Fumo", - "FUN": "FUN Token", + "FUN": "FUNToken", "FUNASSYI": "Funassyi", "FUNC": "FunCoin", "FUNCH": "FUNCH", @@ -6890,6 +7166,7 @@ "FUNDX": "Funder One Capital", "FUNDYOUR": "FundYourselfNow", "FUNDZ": "FundFantasy", + "FUNEX": "Funex", "FUNG": "Fungify", "FUNGI": "Fungi", "FUNK": "Cypherfunks Coin", @@ -6905,32 +7182,34 @@ "FUSAKA": "Fusaka", "FUSD": "Fantom USD", "FUSDC": "Fluidity", - "FUSE": "Fuse Network Token", + "FUSE": "Fuse", "FUSIO": "FUSIO", "FUSION": "FusionBot", "FUSO": "Fusotao", "FUT": "FuturesAI", "FUTC": "FutCoin", "FUTUR": "Future Token", - "FUTURE": "FutureCoin", + "FUTURE": "FUTURECOIN", "FUTUREAI": "Future AI", "FUTURESWAP": "Futureswap", "FUZE": "FUZE Token", "FUZEX": "FuzeX", "FUZN": "Fuzion", - "FUZZ": "Fuzzballs", - "FVT": "Finance Vote", + "FUZZ": "FuzzBalls", + "FVT": "Finance.Vote", "FWATCH": "Foliowatch", "FWB": "Friends With Benefits Pro", "FWBV1": "Friends With Benefits Pro v1", - "FWC": "Qatar 2022", + "FWC": "Football World Community", "FWCL": "Legends", "FWH": "FigureWifHat", "FWOG": "Fwog", - "FWT": "FadeWallet Token", + "FWOG33291": "Fwog (SOL)", + "FWT": "Freeway Token", "FWW": "Farmers World Wood", "FWX": "Future Warriors X", "FX": "Function X", + "FX1": "FANZY", "FXAKV": "Akiverse Governance", "FXB": "FxBox", "FXC": "Flexacoin", @@ -6940,6 +7219,7 @@ "FXI": "FX1 Sports", "FXN": "FXN", "FXP": "FXPay", + "FXS": "Frax Share", "FXST": "FX Stock Token", "FXT": "Frog X Toad 6900", "FXUSD": "f(x) Protocol fxUSD", @@ -6949,6 +7229,7 @@ "FYDO": "Fly Doge", "FYN": "Affyn", "FYP": "FlypMe", + "FYT": "FloraChain", "FYZ": "Fyooz", "FYZNFT": "Fyooz NFT", "G": "Gravity", @@ -6961,15 +7242,15 @@ "GAC": "Green Art Coin", "GAD": "Green App Development", "GAFA": "Gafa", - "GAFI": "GameFi", + "GAFI": "GameFi.org", "GAG": "GAG Token", "GAGA": "Gaga", - "GAI": "GraphAI", - "GAIA": "Gaia Token", + "GAI": "Generaitiv", + "GAIA": "GAIA Everworld", "GAIAE": "Gaia Everworld", "GAIAPLATFORM": "GAIA Platform", "GAIB": "GAIB", - "GAIN": "GriffinAI", + "GAIN": "Gain Protocol", "GAINFY": "Gainfy", "GAINS": "Gains", "GAINSV1": "Gains v1", @@ -6977,13 +7258,15 @@ "GAJ": "Gaj Finance", "GAKH": "GAKHcoin", "GAL": "Galxe", + "GAL11877": "Galxe", "GALA": "Gala", "GALATA": "Galatasaray Fan Token", "GALAV1": "Gala v1", "GALAX": "Galaxy Finance", "GALAXIS": "Galaxis", - "GALAXY": "GalaxyCoin", + "GALAXY": "Galaxy Coin", "GALEON": "Galeon", + "GALGO": "Governance ALGO", "GALI": "Galilel", "GALO": "Clube Atlético Mineiro Fan Token", "GALT": "Galtcoin", @@ -6993,7 +7276,7 @@ "GAMBI": "Gambi Fi", "GAMBIT": "Gambit", "GAMBL": "Metagamble", - "GAME": "GameBuild", + "GAME": "GameCredits", "GAME5BALL": "Game 5 BALL", "GAMEBUD": "GAMEBUD", "GAMEBYV": "GAME by Virtuals", @@ -7002,12 +7285,12 @@ "GAMECOIN": "Game Coin", "GAMECRED": "GameCredits", "GAMEF": "Game Fantasy Token", - "GAMEFI": "GameFi Token", + "GAMEFI": "Revenant", "GAMEFORK": "GameFork", "GAMEIN": "Game Infinity", "GAMER": "GameStation", "GAMERFI": "GamerFI", - "GAMES": "GAME•OF•BITCOIN", + "GAMES": "Gaming Stars", "GAMEST": "GameStop Coin", "GAMESTARS": "Game Stars", "GAMESTARTER": "Gamestarter", @@ -7031,7 +7314,7 @@ "GARFIELD": "Garfield Cat", "GARI": "Gari Network", "GARK": "Game Ark", - "GART": "Griffin Art", + "GART": "Griffin Art Ecosystem", "GARTS": "Glink Arts Share", "GARU": "Garuda Coin", "GARUDA": "GarudaSwap", @@ -7040,7 +7323,7 @@ "GAS": "Gas", "GASDAO": "Gas DAO", "GASG": "Gasgains", - "GASP": "GASP", + "GASP": "gAsp", "GASPCOIN": "gAsp", "GASS": "Gasspas", "GAST": "Gas Town", @@ -7048,7 +7331,7 @@ "GAT": "Gather", "GATA": "Gata", "GATCOIN": "GATCOIN", - "GATE": "GATENet", + "GATE": "GATE", "GATEUSD": "GUSD", "GATEWAY": "Gateway Protocol", "GATHER": "Gather", @@ -7066,7 +7349,7 @@ "GBCR": "Gold BCR", "GBD": "Great Bounty Dealer", "GBE": "Godbex", - "GBEX": "Globiance Exchange", + "GBEX": "Globiance Exchange Token", "GBG": "Golos Gold", "GBIT": "GravityBit", "GBK": "Goldblock", @@ -7092,7 +7375,7 @@ "GCC": "GuccioneCoin", "GCCO": "GCCOIN", "GCME": "GoCryptoMe", - "GCN": "gCn Coin", + "GCN": "GCN Coin", "GCOIN": "Galaxy Fight Club", "GCOTI": "COTI Governance Token", "GCR": "Global Currency Reserve", @@ -7113,11 +7396,12 @@ "GDS": "Grat Deal Coin", "GDSC": "Golden Safety Coin", "GDT": "Globe Derivative Exchange", - "GDX": "VanEck Vectors Gold Miners Etf", + "GDX": "Gridex", "GE": "GEchain", "GEA": "Goldea", "GEAR": "Gearbox Protocol", - "GEC": "Gecko Inu", + "GEAR16360": "Gearbox Protocol", + "GEC": "Green Energy Coin", "GECKO": "Gecko Coin", "GECKY": "Gecky", "GECO": "GECOIN", @@ -7127,22 +7411,26 @@ "GEGE": "Gege", "GEIST": "Geist Finance", "GEKKO": "Gekko HQ", + "GEL": "Gelato", "GELATO": "Gelato", "GELO": "Grok Elo", - "GEM": "Gemie", + "GEM": "Gems", "GEMA": "Gemera", "GEME": "GEME", - "GEMG": "GemGuardian", + "GEMG": "Gem Guardian", "GEMI": "Gemini Inu", "GEMINI": "Gemini Ai", "GEMINIT": "Gemini", "GEMO": "Gemo", - "GEMS": "Gems VIP", + "GEMS": "Safegem", "GEMSTON": "GEMSTON", + "GEMX": "GEMX", "GEMZ": "Gemz Social", "GEN": "DAOstack", "GENAI": "Gen AI BOT", - "GENE": "Genopets", + "GENCAP": "GenCoin Capital", + "GENE": "Gene Source Code Chain", + "GENE1": "Genopets", "GENECTO": "Gene", "GENESIS": "Genesis Worlds", "GENI": "Genius", @@ -7151,15 +7439,17 @@ "GENIESWAP": "GenieSwap", "GENIESWAPV1": "GenieSwap v1", "GENIFYART": "Genify ART", - "GENIUS": "Genius", + "GENIUS": "Genius Terminal", "GENIX": "Genix", "GENO": "GenomeFi", "GENOME": "GenomesDao", "GENS": "Genshiro", "GENSLR": "Good Gensler", "GENSTAKE": "Genstake", + "GENSX": "Genius X", "GENSYN": "Gensyn", "GENT": "Gentleman", + "GENW": "Gen Wealth", "GENX": "Genx Token", "GENXNET": "Genesis Network", "GENZ": "GENZ Token", @@ -7179,34 +7469,38 @@ "GERO": "GeroWallet", "GES": "Galaxy eSolutions", "GESE": "Gese", - "GET": "Global Entertainment Token", + "GET": "GET Protocol", "GETA": "Getaverse", "GETH": "Guarded Ether", "GETLIT": "LIT", "GETRICHQUICK": "GET RICH QUICK", "GETX": "Guaranteed Ethurance Token Extra", + "GEURO": "GEURO", "GEX": "Gexan", "GEZY": "EZZY GAME GEZY", - "GF": "GuildFi", + "GF": "Good Fire Token", "GFAL": "Games for a Living", - "GFARM2": "Gains V2", + "GFARM2": "Gains Farm", "GFCE": "GFORCE", "GFCS": "Global Funeral Care", - "GFI": "Goldfinch", + "GFI": "Gravity Finance", + "GFI13967": "Goldfinch", + "GFLOKI": "GenshinFlokiInu", "GFLY": "BattleFly", "GFM": "GoFundMeme", "GFN": "Graphene", "GFOX": "Galaxy Fox", "GFT": "Gifto", "GFUN": "GoldFund", - "GFX": "GamyFi Token", + "GFX": "GamyFi Platform", "GFY": "go fu*k yourself", "GG": "Reboot", "GGAVAX": "GoGoPool AVAX", "GGB": "GGEBI", + "GGBOND": "GGBOND", "GGBR": "Goldfish", "GGC": "Global Game Coin", - "GGCM": "Gold Guaranteed Coin", + "GGCM": "Gold Guaranteed Coin Mining", "GGEZ1": "GGEZ1", "GGG": "Good Games Guild", "GGGG": "Good Game Gary Gensler", @@ -7214,10 +7508,11 @@ "GGM": "Monster Galaxy", "GGMT": "GG MetaGame", "GGOLD": "GramGold Coin", + "GGP": "Geegoopuzzle", "GGPT": "Generative GPT", - "GGR": "GGRocket", + "GGR": "Gagarin", "GGS": "Gilgam", - "GGT": "Goat Gang", + "GGT": "GARD Governance Token", "GGTK": "GGDApp", "GGTKN": "GG Token", "GGX": "GG3", @@ -7240,11 +7535,12 @@ "GHNY": "Grizzly Honey", "GHO": "GHO", "GHOAD": "GhoadCoin", - "GHOST": "GhostwareOS", + "GHOST": "Ghost", "GHOSTBY": "GhostbyMcAfee", "GHOSTCOIN": "GhostCoin", "GHOSTM": "GhostMarket", "GHOUL": "Ghoul Coin", + "GHSP": "Ghospers Game", "GHST": "Aavegotchi", "GHSY": "Ghosty Cash", "GHT": "Global Human Trust", @@ -7255,10 +7551,11 @@ "GIB": "Bible Coin", "GIC": "Giant", "GICT": "GICTrade", - "GIF": "Gift Token", + "GIF": "GIF DAO", "GIFT": "GiftNet", "GIG": "GigaCoin", "GIGA": "Gigachad", + "GIGA30063": "Gigachad USD Price", "GIGABRAIN": "Gigabrain by virtuals", "GIGACAT": "GIGACAT", "GIGACHAD": "GigaChad", @@ -7305,18 +7602,20 @@ "GLAX": "BLOCK GALAXY NETWORK", "GLAZE": "Glaze", "GLB": "Golden Ball", - "GLC": "GoldCoin", + "GLC": "Goldcoin", "GLCH": "Glitch", "GLD": "Goldario", "GLDGOV": "Gold DAO", + "GLDN": "Gold Retriever", "GLDR": "Golder Coin", "GLDS": "Glades", - "GLDX": "Gold xStock", + "GLDX": "Goldex Token", "GLDY": "Buzzshow", "GLE": "Green Life Energy", "GLEEC": "Gleec Coin", "GLF": "Galaxy Finance", "GLFT": "Global Fan Token", + "GLG": "Gilgeous", "GLI": "GLI TOKEN", "GLIDE": "Glide Finance", "GLIDR": "Glidr", @@ -7324,7 +7623,7 @@ "GLINK": "Gemlink", "GLINT": "BeamSwap", "GLIZZY": "GLIZZY", - "GLM": "Golem Network Token", + "GLM": "Golem", "GLMR": "Moonbeam", "GLMV1": "Golem Network Token v1", "GLN": "Galion Token", @@ -7337,15 +7636,17 @@ "GLOS": "GLOS", "GLOWSHA": "GlowShares", "GLP1": "GLP1", - "GLQ": "GraphLinq Protocol", + "GLQ": "Graphlinq Chain", "GLR": "Glory Finance", "GLS": "Glacier", "GLT": "GlobalToken", "GLTRON": "abrdn Physical Precious Metals Basket Shares ETF (Ondo Tokenized)", "GLUE": "Glue", "GLX": "GalaxyCoin", + "GLXIA": "GalaxiaVerse", + "GLXY": "Galaxy", "GLYPH": "GlyphCoin", - "GM": "GOMBLE", + "GM": "GM Holding", "GMA": "Goldchip Mining Asset", "GMAC": "Gemach", "GMAT": "GoWithMi", @@ -7354,14 +7655,15 @@ "GMC": "Gridmaster", "GMCN": "GambleCoin", "GMCOIN": "GMCoin", - "GMDP": "GMD Protocol", + "GMDP": "The Coop Network", "GME": "GameStop", "GMEE": "GAMEE", + "GMEME": "GoodMeme", "GMEON": "GameStop (Ondo Tokenized)", "GMEPEPE": "GAMESTOP PEPE", "GMETHERFRENS": "GM", "GMETRUMP": "GME TRUMP", - "GMEX": "Gamestop xStock", + "GMEX": "Game Coin", "GMFAM": "GMFAM", "GMFI": "Golden Magfi", "GMI": "GamiFi", @@ -7378,17 +7680,19 @@ "GMRV2": "GAMER v2", "GMRX": "Gaimin", "GMS": "Gemstra", - "GMT": "STEPN", + "GMT": "GMT", + "GMT18069": "GMT", "GMTO": "Game Meteor Coin", "GMTT": "GMT Token", "GMUBARAK": "Ghibli Mubarak", "GMUSD": "GND Protocol", "GMWAGMI": "GM", "GMX": "GMX", + "GMX11857": "GMX", "GN": "GN", "GNBT": "Genebank Token", "GNC": "Greenchie", - "GND": "GND Protoco", + "GND": "GND Protocol", "GNFT": "GNFT", "GNG": "GreenGold", "GNJ": "GanjaCoin V2", @@ -7397,27 +7701,29 @@ "GNOME": "GNOME", "GNOMY": "Gnomy", "GNON": "Numogram", + "GNP": "Genie Protocol", "GNR": "Gainer", "GNS": "Gains Network", "GNT": "GreenTrust", - "GNTO": "GoldeNugget Token", + "GNTO": "GoldeNugget", "GNUS": "GENIUS TOKEN", "GNX": "Genaro Network", "GNY": "GNY", "GO": "GoChain", "GO4": "GameonForge", "GOA": "GoaCoin", - "GOAL": "TopGoal Token", + "GOAL": "TopGoal", "GOALBON": "Goal Bonanza", "GOALS": "UnitedFans", "GOALTOKEN": "GOAL token", "GOAT": "Goatseus Maximus", + "GOAT33440": "Goatseus Maximus", "GOATAI": "GOAT AI", "GOATCOIN": "Goat", "GOATED": "Goat Network", "GOATS": "GOATS", "GOATSE": "GOATSE", - "GOB": "gob", + "GOB": "Goons of Balatroon", "GOBL": "GOBL", "GOC": "GoCrypto", "GOCHU": "Gochujangcoin", @@ -7439,8 +7745,9 @@ "GOGE": "GOLD DOGE", "GOGLZ": "GOGGLES", "GOGLZV1": "GOGGLES v1", - "GOGO": "GOGO Finance", + "GOGO": "GOGO.finance", "GOGU": "GOGU Coin", + "GOHM": "Governance OHM", "GOHOME": "GOHOME", "GOIN": "GOinfluencer", "GOJOCOIN": "Gojo Coin", @@ -7448,7 +7755,7 @@ "GOKUINU": "Goku (gokuinu.io)", "GOL": "GogolCoin", "GOLC": "GOLCOIN", - "GOLD": "CyberDragon Gold", + "GOLD": "Golden Token", "GOLDCAT": "GOLD CAT", "GOLDCOINETH": "Gold", "GOLDE": "GOLDEN AGE", @@ -7470,12 +7777,13 @@ "GOLOS": "Golos", "GOLOSBLOCKCHAIN": "Golos Blockchain", "GOM": "Gomics", - "GOM2": "GoMoney2", + "GOM2": "AnimalGo", "GOMA": "GOMA Finance", "GOMAV1": "GOMA Finance v1", "GOMAV2": "GOMA Finance v2", "GOMD": "GOMDori", "GOME": "Game of Memes", + "GOMINING": "Gomining", "GOMT": "GoMeat", "GOMV1": "GoMoney", "GONDOLA": "Gondola", @@ -7489,7 +7797,8 @@ "GOODMO": "Good Morning", "GOOG": "Googly Cat", "GOOGLE": "Deepmind Ai", - "GOOGLX": "Alphabet xStock", + "GOOGLON": "Alphabet Class A Tokenized Stock (Ondo)", + "GOOGLX": "Alphabet tokenized stock (xStock)", "GOOGLY": "Googly Cat", "GOOMPY": "Goompy by Matt Furie", "GOON": "Goonies", @@ -7498,7 +7807,7 @@ "GOP": "The Republican Party", "GOPX": "GOPX Token", "GOR": "Gorbagana", - "GORA": "Gora", + "GORA": "Goracle", "GOREC": "GoRecruit", "GORGONZOLA": "Heroes 3 Foundation", "GORGONZOLAV1": "Heroes 3 Foundation v1", @@ -7535,6 +7844,7 @@ "GPM": "GOLD PUMP MEME", "GPN": "Gamepass Network", "GPO": "GoldPesa Option", + "GPOOL": "Genesis Pool", "GPPT": "Pluto Project Coin", "GPRO": "GoldPro", "GPS": "GoPlus Security", @@ -7552,12 +7862,12 @@ "GPUNET": "GPUnet", "GPX": "GPEX", "GQ": "Galactic Quadrant", - "GR": "GROM", + "GR": "Grom", "GRAB": "GRABWAY", "GRABON": "Grab Holdings (Ondo Tokenized)", "GRACY": "Gracy", "GRAI": "Gravita Protocol", - "GRAIL": "Camelot Token", + "GRAIL": "Grail", "GRAIN": "Granary", "GRAM": "Gram", "GRAND": "Grand Theft Ape", @@ -7565,21 +7875,24 @@ "GRANDMA": "Minecraft Grandma Fund", "GRANDMASOL": "Grandma", "GRANT": "GrantiX Token", - "GRAPE": "GrapeCoin", + "GRAPE": "Grapeswap", "GRAPHGRAIAI": "GraphGrail AI", "GRASS": "Grass", + "GRASS32956": "Grass", "GRAV": "Graviton", "GRAVITAS": "Gravitas", "GRAVITYF": "Gravity Finance", "GRAYLL": "GRAYLL", + "GRB": "Garbi Protocol", "GRBE": "Green Beli", "GRBT": "Grinbit", - "GRC": "GreenCoin.AI", + "GRC": "Gridcoin", "GRDM": "GridiumAI", + "GRDN": "GARDEN", "GRE": "GreenCoin", "GREARN": "GrEarn", "GREE": "Green God Candle", - "GREEN": "GreenX", + "GREEN": "Greeneum Network", "GREENCOIN": "Greencoin", "GREENH": "Greenheart CBD", "GREENMMT": "Green Mining Movement Token", @@ -7591,15 +7904,17 @@ "GREMLYART": "Gremly", "GREXIT": "GrexitCoin", "GREY": "Grey Token", + "GREYHOUND": "Greyhound", "GRFT": "Graft Blockchain", "GRG": "RigoBlock", + "GRIC": "Gric Coin", "GRID": "Grid+", "GRIDCOIN": "GridCoin", "GRIDZ": "GridZone.io", - "GRIFFAIN": "GRIFFAIN", + "GRIFFAIN": "GRIFFAIN USD Price", "GRIFT": "ORBIT", - "GRIM": "GrimHustle", - "GRIMACE": "Grimace", + "GRIM": "GrimToken", + "GRIMACE": "GrimaceCoin", "GRIMEVO": "Grim EVO", "GRIMEX": "SpaceGrime", "GRIMREAPER": "GRIMREAPER", @@ -7613,10 +7928,11 @@ "GRN": "GRN Grid", "GRND": "SuperWalk", "GRNV1": "GRN Grid v1", - "GRO": "Gro DAO Token", + "GRO": "Growth DeFi", "GROGGO": "Groggo By Matt Furie", "GROK": "Grok", "GROK2": "GROK 2.0", + "GROK28394": "Grok", "GROK3": "Grok 3", "GROKAI": "Grok AI Agent", "GROKBANK": "Grok Bank", @@ -7641,6 +7957,7 @@ "GROKX": "GROKX", "GROKXAI": "Grok X Ai", "GRON": "Gron Digital", + "GROOMER": "Hamster Groomers", "GROOOOOK": "Groooook", "GROOVE": "GROOVE", "GROW": "Grow Token", @@ -7653,16 +7970,20 @@ "GRPL": "Golden Ratio Per Liquidity", "GRS": "Groestlcoin", "GRT": "The Graph", - "GRUM": "Grumpy (Ordinals)", + "GRT6719": "The Graph", + "GRUM": "Grumpy", "GRUMPY": "Grumpy Finance", - "GRV": "GroveCoin", + "GRUMPYCAT": "Grumpy Cat", + "GRV": "Grove Coin", "GRVE": "Grave", "GRW": "GrowthCoin", "GRWI": "Growers International", "GRX": "Gold Reward Token", "GS": "Genesis Shards", "GS1": "NFTGamingStars", + "GSA": "GSA Coin", "GSC": "Global Social Chain", + "GSCARAB": "GScarab", "GSE": "GSENetwork", "GSHIBA": "Gambler Shiba", "GSI": "Globex SCI", @@ -7671,6 +7992,7 @@ "GSPI": "GSPI", "GSR": "GeyserCoin", "GST": "CoinGhost", + "GST16352": "Green Satoshi Token (SOL)", "GSTBSC": "Green Satoshi Token (BSC)", "GSTC": "GSTCOIN", "GSTETH": "Green Satoshi Token (ETH)", @@ -7680,10 +8002,10 @@ "GSTT": "GSTT", "GSWAP": "Gameswap", "GSWIFT": "GameSwift", - "GSX": "Goldman Sachs xStock", + "GSX": "Gold Secured Currency", "GSY": "GenesysCoin", - "GSYS": "Genesys", - "GT": "Gatechain Token", + "GSYS": "Genesys Token", + "GT": "GateToken", "GTA": "GTA Token", "GTA6": "GTA VI", "GTAI": "GT Protocol", @@ -7692,22 +8014,23 @@ "GTBOT": "Gaming-T-Bot", "GTBTC": "Gate Wrapped BTC", "GTC": "Gitcoin", + "GTC2": "Gitcoin", "GTCC": "GTC COIN", - "GTCOIN": "Game Tree", + "GTCOIN": "GAMETREE", "GTE": "GreenTek", "GTF": "GLOBALTRUSTFUND TOKEN", "GTFO": "DumpBuster", - "GTH": "Gath3r", + "GTH": "Gather", "GTIB": "Global Trust Coin", "GTK": "GoToken", "GTN": "GlitzKoin", "GTO": "Gifto", - "GTON": "GTON Capital", + "GTON": "GTONCapital", "GTR": "Gturbo", "GTRUMP": "Giga Trump", "GTSE": "Global Tourism Sharing Ecology", "GTTM": "Going To The Moon", - "GTX": "GALLACTIC", + "GTX": "GoalTime N", "GTY": "G-Agents AI", "GUA": "GUA", "GUAC": "Guacamole", @@ -7719,6 +8042,7 @@ "GUARDAI": "GuardAI", "GUC": "Green Universe Coin", "GUCCI": "GUCCI", + "GUCCIONECOIN": "GuccioneCoin", "GUDTEK": "ai16zterminalfartARCzereLLMswarm", "GUE": "GuerillaCoin", "GUESS": "Peerguess", @@ -7726,13 +8050,14 @@ "GUI": "Gui Inu", "GUILD": "BlockchainSpace", "GUISE": "GUISE", - "GULF": "GulfCoin", + "GULF": "Gulf Coin", "GULL": "GULL", "GUM": "Gourmet Galaxy", "GUMMIES": "GUMMIES", "GUMMY": "GUMMY", "GUMSHOOS": "GUMSHOOS TRUMP", "GUN": "GUNZ", + "GUNBET": "GunBet", "GUNCOIN": "GunCoin", "GUNS": "GeoFunders", "GUP": "Guppy", @@ -7741,10 +8066,10 @@ "GUS": "Gus", "GUSD": "Gemini Dollar", "GUSDT": "Global Utility Smart Digital Token", - "GUT": "Genesis Universe", + "GUT": "GuitarSwap", "GUUFY": "Guufy", "GUZUTA": "CLYDE", - "GVC": "Global Virtual Coin", + "GVC": "Galaxy Villains", "GVE": "Globalvillage Ecosystem", "GVL": "Greever", "GVNR": "GVNR", @@ -7755,18 +8080,22 @@ "GWD": "GreenWorld", "GWEI": "ETHGas", "GWGW": "GoWrap", + "GWINK": "Genesis Wink", + "GWP": "Gateway Protocol", "GWT": "Galaxy War", "GX": "GameX", "GX3": "GX3ai", - "GXA": "Galaxia", + "GXA": "GALAXIA", "GXC": "GXChain", - "GXE": "XENO Governance", + "GXE": "Galaxy Essential", + "GXP": "Game X Change Potion", "GXT": "Gem Exchange And Trading", "GYAT": "Gyat Coin", "GYEN": "GYEN", "GYM": "GYM Token", "GYMNET": "Gym Network", "GYMREW": "Gym Rewards", + "GYOSHI": "Gyoshi", "GYR": "Gyre Token", "GYRO": "Gyro", "GYROS": "Gyroscope GYD", @@ -7779,18 +8108,19 @@ "GZT": "Golden Zen Token", "GZX": "GreenZoneX", "Glo Dollar": "USDGLO", - "H": "Humanity", + "H": "Humanity Protocol USD Price", "H1": "Haven1", "H1DR4": "H1DR4 by Virtuals", - "H2O": "H2O Dao", + "H2O": "H2O DAO", "H2ON": "H2O Securities", "H3O": "Hydrominer", + "H3RO3S": "H3RO3S", "H4TOKEN": "Hold Ignore Fud", "HABIBI": "The Habibiz", "HAC": "Hackspace Capital", "HACD": "Hacash Diamond", "HACH": "Hachiko", - "HACHI": "Hachiko", + "HACHI": "Akita DAO", "HACHIK": "Hachiko", "HACHIKO": "Hachiko Inu Token", "HACHIKOINU": "Hachiko Inu", @@ -7798,17 +8128,18 @@ "HACHIONB": "Hachi On Base", "HACHITOKEN": "Hachi", "HACK": "HACK", - "HADES": "Hades", + "HADES": "Hades Money", "HAEDAL": "Haedal Protocol", "HAGGIS": "New Born Haggis Pygmy Hippo", "HAHA": "Hasaki", "HAHAYESRIZO": "Haha Yes Hedgehog", "HAI": "Hacken Token", "HAIO": "HAiO", - "HAIR": " HairDAO", + "HAIR": "HairDAO", "HAJIMI": "哈基米", "HAKA": "TribeOne", - "HAKKA": "Hakka Finance", + "HAKI": "Haki Token", + "HAKKA": "Hakka.Finance", "HAKU": "HakuSwap", "HAL": "Halcyon", "HALF": "0.5X Long Bitcoin Token", @@ -7817,13 +8148,13 @@ "HALIS": "Halis", "HALLO": "Halloween Coin", "HALLOWEEN": "HALLOWEEN", - "HALO": "Halo Coin", + "HALO": "Angel Protocol", "HALOPLATFORM": "Halo Platform", "HAM": "Hamster", "HAMBURG": "Hamburg Eyes", "HAMI": "Hamachi Finance", "HAMMY": "SAD HAMSTER", - "HAMS": "HamsterCoin", + "HAMS": "Space Hamster", "HAMSTER": "Hamster", "HAMSTERB": "HamsterBase", "HAMSTR": "Hamster Coin", @@ -7836,11 +8167,13 @@ "HAND": "ShowHand", "HANDY": "Handy", "HANK": "Hank", + "HANKEY": "Mr. Hankey", "HANU": "Hanu Yokia", "HAO": "HistoryDAO", "HAP": "Happy Train", - "HAPI": "HAPI", + "HAPI": "HAPI Protocol", "HAPPY": "Happy Cat", + "HAPPY33892": "Happy Cat", "HAPPYC": "HappyCoin", "HAR": "Harambe Coin", "HARAM": "HARAM", @@ -7867,7 +8200,7 @@ "HASHNET": "HashNet BitEco", "HASHT": "HASH Token", "HASUI": "Haedal", - "HAT": "TOP HAT", + "HAT": "Joe Hat Token", "HATAY": "Hatayspor Token", "HATCHY": "Hatchyverse", "HATI": "Hati", @@ -7882,17 +8215,18 @@ "HAWKPTAH": "Hawk Ptah", "HAWKTUAH": "Hawk Tuah", "HAXS": "Axie Infinity Shards (Harmony One Bridge)", + "HAY": "HayCoin", "HAYYA": "GO HAYYA", "HAZ": "Hazza", "HAZE": "HazeCoin", "HB": "HeartBout", - "HBAR": "Hedera Hashgraph", + "HBAR": "Hedera", "HBARBARIAN": "HBARbarian", "HBARX": "HBARX", - "HBB": "Hubble", + "HBB": "Hubble Protocol", "HBC": "HBTC Captain Token", "HBCH": "Huobi BCH", - "HBD": "Hive Dollar", + "HBD": "Hive Backed Dollar", "HBDC": "Happy Birthday Coin", "HBE": "healthbank", "HBIT": "HashBit", @@ -7903,7 +8237,7 @@ "HBSV": "Huobi BSV", "HBT": "Habitat", "HBTC": "Huobi BTC", - "HBX": "Hyperbridge", + "HBX": "HashBX", "HBZ": "HBZ Coin", "HC": "HyperCash", "HCAT": "Hover Cat", @@ -7912,35 +8246,38 @@ "HCXP": "HCX PAY", "HD": "HubDao", "HDAC": "Hdac", - "HDAO": "Hkd.com Dao", - "HDG": "Hedge Token", + "HDAO": "HyperDAO", + "HDG": "Hedge", + "HDL": "HEADLINE", "HDN": "Hydranet", "HDRN": "Hedron", "HDRO": "Hydro Protocol", "HDV": "Hydraverse", - "HDX": "Home Depot xStock", + "HDX": "HydraDX", "HE": "Heroes & Empires", "HEA": "Healium", - "HEAL": "Etheal", + "HEAL": "Heal The World", "HEALT": "Healthmedi", - "HEART": "Humans", + "HEART": "Humans.ai", "HEARTBOUT": "HeartBout Pay", "HEARTN": "Heart Number", "HEARTR": "Heart Rate", "HEAT": "Heat Ledger", "HEAVEN": "Heaven Token", - "HEC": "Hector Finance", + "HEBE": "HebeBlock", + "HEC": "Hector Network", "HECT": "Hectic Turkey", "HECTA": "Hectagon", "HEDG": "HedgeTrade", "HEDGE": "Hedgecoin", - "HEEL": "HeelCoin", + "HEEL": "Good Dog", "HEFI": "HeFi", "HEGE": "Hege", "HEGG": "Hummingbird Egg", "HEGIC": "Hegic", "HEHE": "hehe", - "HEI": "Heima", + "HEI": "Sohei", + "HEI35724": "Heima", "HEL": "Hello Puppy", "HELA": "Science Cult Mascot", "HELI": "Helion", @@ -7948,8 +8285,10 @@ "HELIOS": "Mission Helios", "HELIOSAI": "HeliosAI", "HELL": "HELL COIN", - "HELLO": "HELLO", - "HELMET": "Helmet Insure", + "HELLO": "HELLO Labs", + "HELLSING": "Hellsing Inu", + "HELMET": "Helmet.insure", + "HELP": "GoHelpFund", "HELPS": "HelpSeed", "HEM": "Hemera", "HEMAN": "HE-MAN", @@ -7961,22 +8300,25 @@ "HENLO": "Henlo", "HENLOV1": "Henlo v1", "HEP": "Health Potion", - "HER": "Her.AI", - "HERA": "Hero Arena", + "HER": "HerityNetwork", + "HERA": "Hera Finance", "HERAF": "Hera Finance", - "HERB": "HerbCoin", + "HERB": "Herbalist Token", "HERBE": "Herbee", "HERME": "Hermes DAO", "HERMES": "Hermes Protocol", "HERMIONE": "Hermione", "HERMY": "Hermy The Stallion", "HERO": "Metahero", + "HERO10778": "Metahero", "HEROC": "HEROcoin", + "HEROEGG": "HeroFi", "HEROES": "Dehero Community Token", "HEROESAI": "HEROES AI", "HEROESC": "HeroesChained", "HEROI": "Heroic Saga Shiba", "HERONODE": "Hero Node", + "HEROS": "Heros Token", "HEST": "Hash Epoch Sports Token", "HET": "HavEther", "HETA": "HetaChain", @@ -7986,11 +8328,12 @@ "HEX": "HEX", "HEXC": "HexCoin", "HEYFLORK": "HeyFlork", - "HEZ": "Hermez Network Token", + "HEZ": "Hermez Network", "HF": "Have Fun", - "HFI": "Holder Finance", + "HFI": "HecoFi", "HFIL": "Huobi Fil", - "HFT": "Hashflow", + "HFT": "Hodl Finance", + "HFT22461": "Hashflow", "HFUN": "Hypurr Fun", "HGEN": "HGEN DAO", "HGET": "Hedget", @@ -8004,7 +8347,7 @@ "HH": "Holyheld", "HHEM": "Healthureum", "HHGTTG": "Douglas Adams", - "HI": "hi Dollar", + "HI": "HI", "HIAZUKI": "hiAZUKI", "HIBAKC": "hiBAKC", "HIBAYC": "hiBAYC", @@ -8013,13 +8356,14 @@ "HIBS": "Hiblocks", "HICLONEX": "hiCLONEX", "HICOOLCATS": "hiCOOLCATS", - "HID": "Hypersign Identity", + "HID": "Hypersign identity", "HIDE": "Hide Coin", "HIDOODLES": "hiDOODLES", "HIDU": "H-Education World", "HIENS3": "hiENS3", "HIENS4": "hiENS4", "HIFI": "Hifi Finance", + "HIFI23037": "Hifi Finance", "HIFIDENZA": "hiFIDENZA", "HIFLUF": "hiFLUF", "HIFRIENDS": "hiFRIENDS", @@ -8049,6 +8393,7 @@ "HIPENGUINS": "hiPENGUINS", "HIPP": "El Hippo", "HIPPO": "sudeng", + "HIPPO33258": "sudeng", "HIPUNKS": "hiPUNKS", "HIRE": "HireMatch", "HIRENGA": "hiRENGA", @@ -8067,6 +8412,7 @@ "HK": "Hongkong", "HKB": "HongKong BTC bank", "HKC": "HK Coin", + "HKDAO": "HongKongDAO", "HKDOGE": "HongKong Doge", "HKDX": "eToro Hong Kong Dollar", "HKFLOKI": "hong kong floki", @@ -8074,7 +8420,7 @@ "HKN": "Hacken", "HKU5": "New Coronavirus", "HLC": "HalalChain", - "HLD": "HyperLending", + "HLD": "Hackerlabs DAO", "HLDY": "HOLIDAY", "HLG": "Holograph", "HLINK": "Chainlink (Harmony One Bridge)", @@ -8085,7 +8431,7 @@ "HLP": "Purpose Coin", "HLPR": "HELPER COIN", "HLPT": "HLP Token", - "HLS": "Helios", + "HLS": "Halis", "HLT": "HyperLoot", "HLTC": "Huobi LTC", "HLX": "Helex", @@ -8102,19 +8448,19 @@ "HMRN": "Homerun", "HMST": "Hamster Marketplace Token", "HMSTR": "Hamster Kombat", - "HMT": "HUMAN Token", + "HMT": "Human", "HMTT": "Hype Meme Token", "HMU": "hit meeee upp", - "HMX": "HMX", + "HMX": "Hermes DAO", "HNB": "HNB Protocol", - "HNC": "Hellenic Coin", + "HNC": "HNC COIN", "HNCN": "Huncoin", "HND": "Hundred Finance", "HNO": "HNO Coin", "HNS": "Handshake", "HNST": "Honest", "HNT": "Helium", - "HNTR": "Hunter", + "HNTR": "Hunter Token / Digital Arms", "HNTV1": "Helium v1", "HNX": "HeartX Utility Token", "HNY": "Honey", @@ -8132,6 +8478,7 @@ "HOG": "Hog", "HOGE": "Hoge Finance", "HOGONSOLANA": "HOG", + "HOGT": "HOGT", "HOHOHO": "Santa Floki v2.0", "HOICHI": "Hoichi", "HOKA": "Hokkaido Inu", @@ -8145,10 +8492,11 @@ "HOLDON4": "HoldOn4DearLife", "HOLDS": "Holdstation", "HOLO": "Holoworld", + "HOLO38309": "Holoworld AI", "HOLON": "Holonus", "HOLY": "Holy Trinity", "HOM": "Homeety", - "HOME": "Home", + "HOME": "HOME Coin", "HOMEBREW": "Homebrew Robotics Club", "HOMER": "Homer Simpson", "HOMERB": "Homer BSC", @@ -8158,15 +8506,18 @@ "HOMIECOIN": "Homie Wars", "HOMMIES": "HOMMIES", "HOMS": "Heroes of memes", - "HON": "SoulSociety", + "HON": "Heroes of NFT", "HONESTCOIN": "HonestCoin", "HONEY": "Hivemapper", + "HONEY22850": "Hivemapper", "HONEYCOIN": "Honey", "HONG": "HongKongDAO", "HONK": "Honk", "HONKLER": "Honkler", "HONOR": "HonorLand", + "HONR": "DeltaFlare", "HONX": "Honeywell xStock", + "HOOD": "Hood AI", "HOODOG": "Hoodog", "HOODON": "Robinhood Markets (Ondo Tokenized)", "HOODRAT": "Hoodrat Coin", @@ -8176,9 +8527,11 @@ "HOOP": "Chibi Dinos", "HOOPS": "Hoops", "HOOT": "HOOT", - "HOP": "Hop Protocol", + "HOP": "HOPPY", + "HOPE": "Hope", "HOPECOIN": "Hopecoin", "HOPPY": "Hoppy", + "HOPPYINU": "HoppyInu", "HOPPYTOKEN": "Hoppy", "HOPR": "HOPR", "HOR": "HorizonDEX", @@ -8188,9 +8541,11 @@ "HOS": "Hotel of Secrets", "HOSHI": "Dejitaru Hoshi", "HOSICO": "Hosico Cat", - "HOSKY": "Hosky", + "HOSKY": "Hosky Token", "HOSTAI": "Host AI", "HOT": "Holo", + "HOT1": "Holo", + "HOT2682": "Holo USD Price", "HOTCROSS": "Hot Cross", "HOTDOGE": "Hot Doge", "HOTKEY": "HotKeySwap", @@ -8226,6 +8581,7 @@ "HRDG": "HRDGCOIN", "HRM": "Honorarium", "HRO": "HEROIC.com", + "HRP": "Harpoon", "HRSE": "The Winners Circle", "HRT": "HIRO", "HRTS": "YellowHeart Protocol", @@ -8239,13 +8595,13 @@ "HSP": "Horse Power", "HSR": "Hshare", "HSS": "Hashshare", - "HST": "Decision Token", + "HST": "HeadStarter", "HSUI": "Suicune", "HSUITE": "HbarSuite", "HSUSDC": "Holdstation USDC", "HT": "Huobi Token", "HTA": "Historia", - "HTB": "Hotbit", + "HTB": "Hotbit Token", "HTC": "Hitcoin", "HTD": "HeroesTD", "HTDF": "Orient Walt", @@ -8254,35 +8610,36 @@ "HTERM": "Hiero Terminal", "HTK": "Hard To Kill", "HTM": "Hatom", - "HTML": "HTML Coin", + "HTML": "HTMLCOIN", "HTMOON": "HTMOON", "HTN": "Hoosat Network", - "HTO": "Heavenland HTO", + "HTO": "Heavenland", "HTR": "Hathor", "HTS": "Home3", "HTT": "Hello Art", "HTX": "HTX", "HTZ": "Hertz Network", - "HUAHUA": "Chihuahua Chain", - "HUB": "Hub Token", + "HUAHUA": "Chihuahua", + "HUB": "Hub - Human Trust Protocol", "HUBII": "Hubii Network", "HUBSOL": "SolanaHub staked SOL", "HUC": "HunterCoin", "HUDI": "Hudi", "HUE": "Huebel Bolt", "HUGE": "HugeWin", - "HUGO": "Hugo Inu", + "HUGO": "Hugo Game", "HUH": "HUH Token", "HUHCAT": "huhcat", "HULEZHI": "HU LE ZHI", - "HUM": "Humanscape", + "HULK": "$HULK", + "HUM": "Hummus", "HUMA": "Huma Finance", "HUMAI": "Humanoid AI", "HUMP": "Hump", "HUMV1": "Humanscape v1", "HUND": "HUND MEME COIN", "HUNDRED": "HUNDRED", - "HUNNY": "Pancake Hunny", + "HUNNY": "HUNNY FINANCE", "HUNT": "HUNT", "HUR": "Hurify", "HUS": "HUSSY", @@ -8291,12 +8648,13 @@ "HUSH": "Hush", "HUSHR": "hushr", "HUSKY": "Husky", - "HUSL": "Hustle Token", + "HUSKY11463": "Husky Avax", + "HUSL": "The Hustle App", "HUSTLE": "Agent Hustle", "HUSTLEV1": "Tensorium", "HUT": "Hibiki Run", "HVC": "HeavyCoin", - "HVCO": "High Voltage Coin", + "HVCO": "High Voltage", "HVE": "UHIVE", "HVE2": "Uhive", "HVH": "HAVAH", @@ -8333,7 +8691,9 @@ "HYP": "HyperX", "HYPC": "HyperCycle", "HYPE": "Hyperliquid", - "HYPER": "Hyperlane", + "HYPE32196": "Hyperliquid", + "HYPER": "HyperChainX", + "HYPER36281": "Hyperlane", "HYPERAI": "HyperHash AI", "HYPERC": "HyperChainX", "HYPERCOIN": "HyperCoin", @@ -8346,7 +8706,7 @@ "HYPERSTAKE": "HyperStake", "HYPES": "Supreme Finance", "HYPEV1": "Hype v1", - "HYPR": "Hypr", + "HYPR": "Hypr Network", "HYPRNETWORK": "Hypr Network", "HYS": "Heiss Shares", "HYT": "HoryouToken", @@ -8368,7 +8728,7 @@ "IAOMIN": "Yao Ming", "IAUON": "iShares Gold Trust (Ondo Tokenized)", "IB": "Iron Bank", - "IBANK": "iBankCoin", + "IBANK": "iBank", "IBAT": "Battle Infinity", "IBERA": "Infrared Bera", "IBETH": "Interest Bearing ETH", @@ -8376,14 +8736,17 @@ "IBFK": "İstanbul Başakşehir Fan Token", "IBFN": "IBF Net", "IBFR": "iBuffer Token", - "IBG": "iBG Token", + "IBG": "iBG Finance", "IBGT": "Infrared BGT", "IBIT": "InfinityBit Token", "IBITON": "iShares Bitcoin Trust (Ondo Tokenized)", "IBMX": "International Business Machines xStock", "IBNB": "iBNB", "IBP": "Innovation Blockchain Payment", - "IBS": "Irbis Network", + "IBS": "IBStoken", + "IBTC": "iBTC", + "IBTC-FLI-P": "Inverse BTC Flexible Leverage Index", + "IBZ": "Ibiza Token", "IC": "Icy", "ICA": "Icarus Network", "ICAP": "ICAP Token", @@ -8391,14 +8754,14 @@ "ICB": "IceBergCoin", "ICBX": "ICB Network", "ICC": "Insta Cash Coin", - "ICE": "Ice Open Network", + "ICE": "Popsicle Finance", "ICEC": "IceCream", "ICECR": "Ice Cream Sandwich", "ICECREAM": "IceCream AI", "ICELAND": "ICE LAND", "ICETH": "Interest Compounding ETH Index", "ICG": "Invest Club Global", - "ICH": "IdeaChain", + "ICH": "Idea Chain Coin", "ICHI": "ICHI", "ICHN": "i-chain", "ICHX": "IceChain", @@ -8407,7 +8770,7 @@ "ICN": "Iconomi", "ICNT": "Impossible Cloud Network Token", "ICNX": "Icon.X World", - "ICOB": "Icobid", + "ICOB": "ICOBID", "ICOM": "iCommunity", "ICON": "Iconic", "ICONS": "SportsIcon", @@ -8415,25 +8778,27 @@ "ICOS": "ICOBox", "ICP": "Internet Computer", "ICPX": "Icrypex token", - "ICS": " ICPSwap Token", + "ICS": "ICPSwap Token", "ICSA": "Icosa", "ICST": "ICST", "ICT": "Intrachain", - "ICX": "ICON Project", - "ID": "SPACE", + "ICX": "ICON", + "ID": "SPACE ID", + "ID21846": "SPACE ID", "IDAC": "IDAC", "IDAP": "IDAP", "IDC": "IdealCoin", "IDEA": "Ideaology", "IDEAL": "Ideal Opportunities", + "IDEAS": "IDEAS", "IDEFI": "Inverse DeFi Index", "IDEX": "IDEX", - "IDH": "IndaHash", + "IDH": "indaHash", "IDHUB": "IDHUB", "IDIA": "Impossible Finance Launchpad", "IDICE": "iDice", "IDK": "IDK", - "IDLE": "IDLE", + "IDLE": "Idle", "IDM": "IDM", "IDNA": "Idena", "IDNG": "IDNGold", @@ -8456,6 +8821,7 @@ "IDYP": "iDypius", "IEC": "IvugeoEvolutionCoin", "IETH": "iEthereum", + "IETHEREUM": "iEthereum", "IF": "Impossible Finance", "IFAI": "InfluxAI Token", "IFBTC": "Ignition FBTC", @@ -8468,7 +8834,7 @@ "IFUM": "Infleum", "IFUND": "Unifund", "IFX": "IdeaFeX", - "IG": "IG Token ", + "IG": "IG Token", "IGCH": "IG-Crypto Holding", "IGG": "IG Gold", "IGGT": "The Invincible Game Token", @@ -8481,12 +8847,13 @@ "IGUP": "IguVerse", "IHC": "Inflation Hedging Coin", "IHF": "Invictus Hyperion Fund", - "IHT": "I-House Token", + "IHT": "IHT Real Estate Protocol", "IIC": "Intelligent Investment Chain", "IJC": "IjasCoin", "IJZ": "iinjaz", "IJZV1": "iinjaz v1", "IKA": "IKA Token", + "IKA37454": "Ika", "IKI": "ikipay", "IKIGAI": "Ikigai", "ILA": "Infinite Launch", @@ -8494,6 +8861,7 @@ "ILCT": "ILCoin Token", "ILK": "Inlock", "ILLUMINAT": "Illuminat", + "ILSI": "Invest Like Stakeborg Index", "ILT": "iOlite", "ILV": "Illuvium", "IMAGE": "Imagen AI", @@ -8528,11 +8896,12 @@ "IMPULSE": "IMPULSE by FDR", "IMS": "Independent Money System", "IMST": "Imsmart", - "IMT": "Immortal Token", + "IMT": "Moneytoken", "IMUSIFY": "imusify", "IMVR": "ImmVRse", - "IMX": "Immutable X", - "IN": "INFINIT", + "IMX": "Immutable", + "IMX10603": "Immutable", + "IN": "Invictus", "INA": "pepeinatux", "INARI": "Inari", "INB": "Insight Chain", @@ -8546,7 +8915,7 @@ "INCP": "InceptionCoin", "INCREMENTUM": "Incrementum", "INCX": "INCX Coin", - "IND": "Indorse", + "IND": "Indorse Token", "INDAON": "iShares MSCI India ETF (Ondo Tokenized)", "INDAY": "Independence Day", "INDEPENDENCEDAY": "Independence Day", @@ -8567,7 +8936,7 @@ "INET": "Insure Network", "INETH": "Inception Restaked ETH", "INEX": "Inex Project", - "INF": "Infinium", + "INF": "Sanctum Infinity (INF)", "INFC": "Influence Chain", "INFI": "Infinite", "INFINI": "Infinity Economics", @@ -8575,14 +8944,15 @@ "INFLR": "Inflr", "INFO": "Infomatix", "INFOFI": "WAGMI HUB", + "INFP": "InfinityPad", "INFR": "infraX", "INFRA": "Bware", - "INFT": "Infinito", + "INFT": "iNFT Platform", "INFTT": "iNFT Token", "INFX": "Influxcoin", "ING": "Infinity Games", "INI": "InitVerse", - "INIT": "Initia", + "INIT": "Initia USD Price", "INJ": "Injective", "INK": "Ink", "INN": "Innova", @@ -8607,6 +8977,7 @@ "INSP": "Inspect", "INSPI": "InspireAI", "INSR": "Insurabler", + "INST": "Instadapp", "INSTAMINE": "Instamine Nuggets", "INSTANTSPONSOR": "Instant Sponsor Token", "INSTAR": "Insights Network", @@ -8614,7 +8985,7 @@ "INSURANCE": "insurance", "INSURC": "InsurChain Coin", "INSUREDFIN": "Insured Finance", - "INT": "Internet Node token", + "INT": "INT", "INTCON": "Intel (Ondo Tokenized)", "INTCX": "Intel xStock", "INTD": "INTDESTCOIN", @@ -8627,7 +8998,7 @@ "INTR": "Interlay", "INTRO": "1INTRO", "INTX": "Intexcoin", - "INU": "INU Token", + "INU": "Hachiko Inu", "INUGA": "INUGAMI", "INUINU": "Inu Inu", "INUKO": "Inuko Finance", @@ -8635,6 +9006,7 @@ "INUYASHA": "Inuyasha", "INV": "Inverse Finance", "INVC": "Invacio", + "INVEST": "InvestDex", "INVESTEL": "Investelly token", "INVI": "INVI Token", "INVIC": "Invictus", @@ -8646,12 +9018,13 @@ "INXT": "Internxt", "INXTOKEN": "INX Token", "IO": "io.net", + "IO29835": "io.net", "IOC": "IOCoin", "IOEN": "Internet of Energy Network", "IOETH": "ioETH", "IOEX": "ioeX", "IOI": "IOI Token", - "ION": "Ionic", + "ION": "ION", "IONC": "IONChain", "IONOMY": "Ionomy", "IONP": "Ion Power Token", @@ -8660,12 +9033,13 @@ "IONZ": "IONZ", "IOP": "Internet of People", "IOSHIB": "IoTexShiba", - "IOST": "IOS token", + "IOST": "IOST", "IOSTV1": "IOSToken V1", "IOT": "Helium IOT", + "IOTA": "IOTA", "IOTAI": "IoTAI", "IOTW": "IOTW", - "IOTX": "IoTeX Network", + "IOTX": "IoTeX", "IOU": "IOU1", "IOUX": "IOU", "IOV": "Starname", @@ -8673,7 +9047,7 @@ "IOWN": "iOWN Token", "IP": "Story", "IP3": "Cripco", - "IPAD": "Infinity Pad", + "IPAD": "Infinity PAD", "IPAX": "Icopax", "IPC": "IPChain", "IPDN": "IPDnetwork", @@ -8686,7 +9060,7 @@ "IPUX": "IPUX", "IPV": "IPVERSE", "IPVOLD": "IPVERSE (Klaytn)", - "IPX": "InpulseX", + "IPX": "Tachyon Protocol", "IPXV1": "InpulseX v1", "IQ": "IQ", "IQ50": "IQ50", @@ -8699,16 +9073,18 @@ "IR": "Infrared Governance Token", "IRA": "Diligence", "IRC": "IRIS", + "IRD": "Iridium", "IRENA": "Irena Coin Apps", "IRENON": "IREN (Ondo Tokenized)", - "IRIS": "IRIS Network", + "IRIS": "IRISnet", "IRISTOKEN": "Iris Ecosystem", "IRL": "IrishCoin", "IRO": "Iro-Chan", "IRON": "Iron Fish", + "IRON18079": "Iron Fish", "IRONBSC": "Iron BSC", "IRONCOIN": "IRONCOIN", - "IRT": "Infinity Rocket", + "IRT": "Infinity Rocket Token", "IRWA": "IncomRWA", "IRYDE": "iRYDE COIN", "IRYS": "Irys", @@ -8720,9 +9096,12 @@ "ISHI": "Ishi", "ISHND": "StrongHands Finance", "ISIKC": "Isiklar Coin", + "ISK": "ISKRA Token", "ISKR": "ISKRA Token", + "ISKRA": "ISKRA Token", "ISKY": "Infinity Skies", "ISL": "IslaCoin", + "ISLA": "Defiville", "ISLAMI": "ISLAMICOIN", "ISLAND": "ISLAND Token", "ISLM": "Islamic Coin", @@ -8753,6 +9132,7 @@ "ITM": "intimate.io", "ITO": "Ito-chan", "ITOC": "ITOChain", + "ITP": "Interport Token", "ITR": "INTRO", "ITSB": "ITSBLOC", "ITU": "iTrue", @@ -8761,6 +9141,7 @@ "IUNGO": "Iungo", "IUS": "Iustitia Coin", "IUSD": "Indigo Protocol - iUSD", + "IUSDS": "Inflation Adjusted USDS", "IUX": "GeniuX", "IVANKA": "IVANKA TRUMP", "IVAR": "Ivar Coin", @@ -8769,7 +9150,7 @@ "IVFUN": "Invest Zone", "IVI": "IVIRSE", "IVIP": "iVipCoin", - "IVN": "IVN Security", + "IVN": "Investin", "IVPAY": "ivendPay", "IVT": "ivault Token", "IVY": "IvyKoin", @@ -8780,13 +9161,14 @@ "IWMON": "iShares Russell 2000 ETF (Ondo Tokenized)", "IWT": "IwToken", "IX": "X-Block", - "IXC": "IXcoin", + "IXC": "Ixcoin", "IXFI": "IXFI", "IXIR": "IXIR", + "IXO": "IXO", "IXORA": "IXORAPAD", "IXP": "IMPACTXPRIME", "IXS": "IX Swap", - "IXT": "iXledger", + "IXT": "IXT", "IYKYK": "IYKYK", "IZA": "Inzura", "IZE": "IZE", @@ -8805,7 +9187,7 @@ "JACKSON": "Jackson", "JACS": "JACS", "JACY": "JACY", - "JADE": "Jade Protocol", + "JADE": "Jade Currency", "JADEC": "Jade Currency", "JAE": "JaeCoin", "JAGER": "Jager Hunter", @@ -8815,7 +9197,8 @@ "JAIHOZ": "Jaihoz by Virtuals", "JAILSTOOL": "Stool Prisondente", "JAKE": "Jake The Dog", - "JAM": "Tune.Fm", + "JAM": "Tune.FM", + "JAMBO": "Jambo", "JAN": "Storm Warfare", "JANE": "JaneCoin", "JANET": "Janet", @@ -8847,6 +9230,7 @@ "JCR": "JustCarbon Removal", "JCT": "Janction", "JDAI": "Dai (TON Bridge)", + "JDB": "JDB", "JDC": "JustDatingSite", "JDO": "JINDO", "JDV": "JD Vance", @@ -8876,10 +9260,11 @@ "JESSECOIN": "jesse", "JEST": "Jester", "JESUS": "Jesus Coin", - "JET": "Jet Protocol", + "JET": "Jetcoin", "JETCAT": "Jetcat", "JETCOIN": "Jetcoin", "JETFUEL": "Jetfuel Finance", + "JETS": "JETOKEN", "JETTON": "JetTon Game", "JETUSD": "JETUSD", "JEUR": "Jarvis Synthetic Euro", @@ -8888,6 +9273,7 @@ "JEWELRY": "Jewelry Token", "JEX": "JEX Token", "JF": "Jswap.Finance", + "JFC": "JFIN", "JFI": "JackPool.finance", "JFIN": "JFIN Coin", "JFIVE": "Jonny Five", @@ -8907,7 +9293,7 @@ "JINDO": "JINDOGE", "JINDOGE": "Jindoge", "JIO": "JIO Token", - "JITOSOL": "Jito Staked SOL", + "JITOSOL": "Jito Staked SOL USD Price", "JIZZ": "JizzRocket", "JIZZLORD": "JizzLord", "JIZZUS": "JIZZUS CHRIST", @@ -8915,7 +9301,7 @@ "JK": "JK Coin", "JKC": "JunkCoin", "JKL": "Jackal Protocol", - "JLP": "Jupiter Perps LP", + "JLP": "Jupiter Perps LP USD Price", "JLY": "Jellyverse", "JM": "JustMoney", "JMC": "Junson Ming Chan Coin", @@ -8928,6 +9314,7 @@ "JNJX": "Johnson & Johnson xStock", "JNS": "Janus", "JNT": "Jibrel Network Token", + "JNTR": "Jointer", "JNX": "Janex", "JNY": "JNY", "JOB": "Jobchain", @@ -8938,13 +9325,14 @@ "JOC": "Speed Star JOC", "JOE": "JOE", "JOEB": "Joe Biden", - "JOEBIDEN2024 ": "JOEBIDEN2024", + "JOEBIDEN2024": "JOEBIDEN2024", "JOECOIN": "Joe Coin", "JOEY": "Joey Inu", "JOGECO": "Jogecodog", "JOHM": "Johm lemmon", "JOHN": "John Tsubasa Rivals", "JOHNNY": "Johnny The Bull", + "JOIN": "JoinCoin", "JOINCOIN": "JoinCoin", "JOINT": "Joint Ventures", "JOJO": "JOJOWORLD", @@ -8966,7 +9354,7 @@ "JOTCHUA": "Perro Dinero", "JOULE": "Joule", "JOWNES": "Alux Jownes", - "JOY": "Joystream", + "JOY": "Joystick", "JOYCAT": "JoyCat Coin", "JOYS": "JOYS", "JOYT": "JoyToken", @@ -8975,17 +9363,18 @@ "JPAW": "Jpaw Inu", "JPD": "JackpotDoge", "JPEG": "JPEG'd", + "JPG": "JPG NFT Index", "JPGC": "JPGold Coin", "JPMORGAN": "JPMorgan", "JPMX": "JPMorgan Chase xStock", "JPYC": "JPYC", "JPYX": "eToro Japanese Yen", "JRIT": "JERITEX", - "JRT": "Jarvis Reward Token", + "JRT": "Jarvis Network", "JSE": "JSEcoin", "JSET": "Jsetcoin", "JSM": "Joseon Mun", - "JSOL": "JPool Staked SOL", + "JSOL": "JPool Staked SOL (JSOL)", "JST": "JUST", "JT": "Jubi Token", "JTC": "Jurat", @@ -9014,6 +9403,7 @@ "JUNKIE": "Junkie Cats", "JUNO": "JUNO", "JUP": "Jupiter", + "JUP29210": "Jupiter", "JUPI": "Jupiter", "JUPSOL": "Jupiter Staked SOL", "JUPUSD": "Jupiter USD", @@ -9042,7 +9432,7 @@ "KAAI": "KanzzAI", "KAAS": "KAASY.AI", "KAB": "KABOSU", - "KABOSU": "X Meme Dog", + "KABOSU": "Kabosu", "KABOSUCOIN": "Kabosu", "KABOSUCOM": "Kabosu", "KABOSUFAMILY": "Kabosu Family", @@ -9051,8 +9441,9 @@ "KABUTO": "Kabuto", "KABY": "Kaby Arena", "KAC": "KACO Finance", - "KACY": "markkacy", + "KACY": "Kassandra", "KADYROV": "Ramzan", + "KAE": "Kanpeki", "KAF": "KAIF Platform", "KAG": "Silver", "KAGE": "Kage Network", @@ -9066,7 +9457,7 @@ "KAIM": "Kai Meme", "KAINET": "KAINET", "KAIRO": "Kairo", - "KAITO": "KAITO", + "KAITO": "KAITO USD Price", "KAKA": "KAKA NFT World", "KAKAXA": "KAKAXA", "KAKI": "Doge KaKi", @@ -9074,9 +9465,10 @@ "KALA": "Kalata Protocol", "KALAM": "Kalamint", "KALDI": "Kaldicoin", + "KALE": "Bluelight", "KALI": "Kalissa", "KALIS": "KALICHAIN", - "KALLY": "Polkally", + "KALLY": "Kally", "KALM": "KALM", "KALYCOIN": "KalyCoin", "KAM": "BitKAM", @@ -9087,13 +9479,13 @@ "KAMB": "Kambria", "KAMLA": "KAMALAMA (kamalama.org)", "KAMPAY": "KamPay", - "KAN": "Bitkan", + "KAN": "BitKan", "KANG": "Kangamoon", "KANG3N": "Kang3n", "KANGAL": "Kangal", "KANGO": "KANGO", "KAON": "Kaon", - "KAP": "KAP Games", + "KAP": "Kittens & Puppies", "KAPPA": "Kappa", "KAPPY": "Kappy", "KAPU": "Kapu", @@ -9103,7 +9495,7 @@ "KARATE": "Karate Combat", "KARATTOKEN": "Karat", "KAREN": "KarenCoin", - "KARMA": "Karma", + "KARMA": "KARMA", "KARMAD": "Karma DAO", "KARRAT": "KARRAT", "KART": "Dragon Kart", @@ -9117,16 +9509,17 @@ "KASSIAHOME": "Kassia Home", "KASTA": "Kasta", "KASTER": "King Aster", - "KAT": "Katana Network", + "KAT": "KatKoyn", "KATA": "Katana Inu", "KATANA": "Katana Finance", "KATCHU": "Katchu Coin", "KATT": "Katt Daddy", "KATYCAT": "Katy Perry Fans", "KATZ": "KATZcoin", - "KAU": "Kinesis Gold", + "KAU": "Kauri", + "KAU24382": "Kinesis Gold", "KAVA": "Kava", - "KAWA": "Kawakami Inu", + "KAWA": "Kawakami", "KAWS": "Kaws", "KAYI": "Kayı", "KAYYO": "Kayyo", @@ -9142,20 +9535,22 @@ "KBX": "KuBitX", "KC": "Kernalcoin", "KCAKE": "KittyCake", - "KCAL": "KCAL Token", + "KCAL": "KCAL", "KCALV2": "Phantasma Energy", "KCASH": "Kcash", "KCAT": "KING OF CATS", "KCATS": "KASPA CATS", "KCCM": "KCC MemePad", - "KCCPAD": "KCCPad", + "KCCPAD": "KCCPAD", "KCH": "Keep Calm and Hodl", + "KCN": "Kylacoin", "KCS": "KuCoin Token", "KCT": "Konnect", "KDA": "Kadena", "KDAG": "King DAG", "KDC": "Klondike Coin", - "KDG": "Kingdom Game 4.0", + "KDF": "KingDeFi", + "KDG": "KingdomStarter", "KDIA": "KDIA COIN", "KDK": "Kodiak Token", "KDOE": "Kudoe", @@ -9171,7 +9566,7 @@ "KEETARD": "Keetard", "KEI": "Keisuke Inu", "KEIRA": "Keira", - "KEK": "KekCoin", + "KEK": "Aavegotchi KEK", "KEKARMY": "Kek", "KEKE": "KEK", "KEKEC": "THE BALKAN DWARF", @@ -9196,13 +9591,14 @@ "KERNEL": "KernelDAO", "KEROSENE": "Kerosene", "KET": "Ket", + "KET35598": "yellow ket", "KETAMINE": "Ketamine", "KETAN": "Ketan", "KETCOIN": "KET", "KEVIN": "Kevin (kevinonbase.xyz)", "KEVINTOKENME": "KEVIN (kevintoken.me)", "KEVINTOKENNET": "Kevin", - "KEX": "Kira Network", + "KEX": "KIRA", "KEXCOIN": "KexCoin", "KEY": "SelfKey", "KEYC": "KeyCoin", @@ -9218,7 +9614,7 @@ "KGB": "KGB protocol", "KGC": "Krypton Galaxy Coin", "KGEN": "KGeN", - "KGO": "Kiwigo", + "KGO": "KIWIGO", "KGST": "KGST", "KGT": "Kaby Gaming Token", "KHAI": "khai", @@ -9230,8 +9626,8 @@ "KIBA": "Kiba Inu", "KIBAV1": "Kiba Inu v1", "KIBSHI": "KiboShib", - "KICK": "Kick", - "KICKS": "GetKicks", + "KICK": "KickToken", + "KICKS": "KicksPad", "KIDEN": "RoboKiden", "KIF": "KittenFinance", "KIKI": "KIKICat", @@ -9251,12 +9647,12 @@ "KIMCHICTO": "Kimchi", "KIMCHIFINANCE": "KIMCHI.finance", "KIMIAI": "Kimi AI Agent", - "KIN": "KinToken", + "KIN": "Kin", "KIND": "Kind Ads", - "KINE": "Kine Protocol", + "KINE": "KINE", "KINECOSYSTEM": "Kin", "KINET": "KinetixFi", - "KING": "LRT Squared", + "KING": "King Swap", "KING93": "King93", "KINGB": "King Bean", "KINGBONK": "King Bonk", @@ -9296,9 +9692,9 @@ "KISC": "Kaiser", "KISHIMOTO": "Kishimoto Inu", "KISHU": "Kishu Inu", - "KIT": "Kitsune", + "KIT": "DexKit", "KITA": "KITA INU", - "KITE": "Kite", + "KITE": "Kite USD Price", "KITEAI": "KITEAI", "KITEHAI": "Kite", "KITKAT": "Remember KitKat", @@ -9307,7 +9703,7 @@ "KITTENS": "Kitten Coin", "KITTENWIF": "KittenWifHat", "KITTI": "KITTI TOKEN", - "KITTY": "Roaring Kitt", + "KITTY": "Kitty Inu", "KITTYCOIN": "Kitty Coin", "KITTYINU": "Kitty Inu", "KITTYINUV1": "Kitty Inu v1", @@ -9319,6 +9715,7 @@ "KKT": "Kingdom Karnage", "KLAP": "Klap Finance", "KLAUS": "Klaus", + "KLAY": "Klaytn", "KLAYMORE": "Klaymore Stakehouse", "KLC": "KiloCoin", "KLD": "Koduck", @@ -9335,7 +9732,7 @@ "KLON": "Klondike Finance", "KLP": "Kulupu", "KLS": "Karlsen", - "KLT": "Kamaleont", + "KLT": "KLend", "KLUB": "KlubCoin", "KLV": "Klever", "KLY": "Klayr", @@ -9343,7 +9740,7 @@ "KMC": "Kitsumon", "KMD": "Komodo", "KML": "KinkyMilady", - "KMNO": "Kamino", + "KMNO": "Kamino Finance", "KMON": "Kryptomon", "KMX": "KiMex", "KNB": "Kronobit Networks Blockchain", @@ -9378,8 +9775,8 @@ "KOAI": "KOI", "KOALA": "KOALA", "KOBAN": "KOBAN", - "KOBE": "Shabu Shabu", - "KOBO": "KoboCoin", + "KOBE": "Shabu Shabu Finance", + "KOBO": "Kobocoin", "KOBUSHI": "Kobushi", "KODA": "Koda Cryptocurrency", "KODACHI": "Kodachi Token", @@ -9394,13 +9791,14 @@ "KOINETWORK": "Koi Network", "KOIP": "KoiPond", "KOJI": "Koji", - "KOK": "KOK Coin", + "KOK": "KOK", "KOKO": "KOALA AI", "KOKOK": "KoKoK The Roach", "KOKOSWAP": "KokoSwap", "KOL": "Kollect", "KOLANA": "KOLANA", "KOLION": "Kolion", + "KOLNET": "KOLnet", "KOLT": "Kolt", "KOLZ": "KOLZ", "KOM": "Kommunitas", @@ -9419,6 +9817,7 @@ "KORE": "KORE Vault", "KOREC": "Kore", "KORI": "Kori The Pom", + "KOROMARU": "KOROMARU", "KORRA": "KORRA", "KOS": "KONTOS", "KOSS": "Koss", @@ -9426,6 +9825,7 @@ "KOTO": "Koto", "KOX": "Coca-Cola xStock", "KOY": "Koyo", + "KOYO": "Yofune Nushi", "KOZ": "Kozjin", "KP3R": "Keep3rV1", "KP4R": "Keep4r", @@ -9452,7 +9852,7 @@ "KREST": "krest Network", "KRGN": "Kerrigan Network", "KRIDA": "KridaFans", - "KRIPTO": "Kripto", + "KRIPTO": "Kripto koin", "KRL": "Kryll", "KRM": "Karma", "KRN": "KRYZA Network", @@ -9488,6 +9888,7 @@ "KTA": "Keeta", "KTC": "KTX.Finance", "KTK": "KryptCoin", + "KTLYO": "Katalyo", "KTN": "Kattana", "KTO": "Kounotori", "KTON": "Darwinia Commitment Token", @@ -9496,7 +9897,7 @@ "KTT": "K-Tune", "KTX": "KwikTrust", "KUAI": "Kuai Token", - "KUB": "KUB Coin", + "KUB": "Bitkub Coin", "KUBE": "KubeCoin", "KUBO": "KUBO", "KUBOS": "KubosCoin", @@ -9533,7 +9934,7 @@ "KWH": "KWHCoin", "KWIK": "KwikSwap", "KWS": "Knight War Spirits", - "KWT": "Kawaii Island", + "KWT": "Kawaii Islands", "KXA": "Kryxivia", "KXC": "KingXChain", "KXUSD": "kxUSD", @@ -9547,17 +9948,18 @@ "KYUB": "Kyuubi", "KYVE": "KYVE Network", "KZC": "KZCash", - "KZEN": "Kaizen", + "KZEN": "Kaizen Finance", "L": "L inu", "L1": "Lamina1", "L1X": "Layer One X", "L2": "Leverj Gluon", "L2DAO": "Layer2DAO", "L3": "Layer3", + "L332470": "Layer3", "L3P": "Lepricon", "L3USD": "L3USD", "L7": "L7", - "LA": "Lagrange", + "LA": "LATOKEN", "LAB": "LAB", "LABORCRYPTO": "LaborCrypto", "LABRA": "LabraCoin", @@ -9578,6 +9980,7 @@ "LAELAPS": "Laelaps", "LAFFIN": "Laffin Kamala", "LAI": "LayerAI", + "LAI23846": "LayerAI", "LAIKA": "LAIKA", "LAIKAPROTOCOL": "Laika Protocol", "LAINESOL": "Laine Staked SOL", @@ -9590,7 +9993,7 @@ "LAN": "Lanify", "LANA": "LanaCoin", "LANC": "Lanceria", - "LAND": "Landshare", + "LAND": "Landbox", "LANDB": "LandBox", "LANDLORD": "LANDLORD RONALD", "LANDS": "Two Lands", @@ -9614,17 +10017,18 @@ "LARRY": "LarryCoin", "LAS": "LNAsolution Coin", "LASOL": "LamaSol", - "LAT": "PlatON Network", + "LAT": "PlatON", "LATINA": "Latina", "LATOKEN": "LATOKEN", "LATOM": "Liquid ATOM", "LATTE": "LatteSwap", "LATX": "Latium", "LAUGHCOIN": "Laughcoin", - "LAUNCH": "Launchblock.com", + "LAUNCH": "SuperLauncher", "LAUNCHCOIN": "Launch Coin on Believe", "LAUNCHMOBY": "Moby", "LAVA": "Lava Network", + "LAVA32722": "Lava Network", "LAVASWAP": "Lavaswap", "LAVAX": "LavaX Labs", "LAVE": "Lavandos", @@ -9633,20 +10037,21 @@ "LAWO": "Law Of Attraction", "LAX": "LAPO", "LAY3R": "AutoLayer", - "LAYER": "Solayer", + "LAYER": "UniLayer", + "LAYER35429": "Solayer", "LAZ": "Lazarus", "LAZHUZHU": "LAZHUZHU", - "LAZIO": "Lazio Fan Token", + "LAZIO": "S.S. Lazio Fan Token", "LAZYCAT": "LAZYCAT", "LB": "LoveBit", "LBA": "Cred", "LBAI": "Lemmy The Bat", "LBC": "LBRY Credits", - "LBK": "LBK", + "LBK": "Liberbank", "LBL": "LABEL Foundation", "LBLOCK": "Lucky Block", "LBM": "Libertum", - "LBR": "Lybra Finance", + "LBR": "Little Bunny Rocket", "LBRV1": "Lybra Finance v1", "LBT": "Law Blocks", "LBTC": "Lombard Staked BTC", @@ -9655,7 +10060,7 @@ "LC4": "LEOcoin", "LCASH": "LitecoinCash", "LCAT": "Lion Cat", - "LCC": "LitecoinCash", + "LCC": "Litecoin Cash", "LCD": "Lucidao", "LCG": "LCG", "LCI": "LOVECHAIN", @@ -9673,7 +10078,7 @@ "LCX": "LCX", "LD": "Long Dragon", "LDC": "LeadCoin", - "LDFI": "LenDeFi Token", + "LDFI": "Lendefi", "LDM": "Ludum token", "LDN": "Ludena Protocol", "LDO": "Lido DAO", @@ -9684,10 +10089,10 @@ "LEA": "LeaCoin", "LEAD": "Lead Wallet", "LEAF": "LeafCoin", - "LEAG": "LeagueDAO Governance Token", + "LEAG": "LeagueDAO", "LEAN": "Lean Management", "LEASH": "Doge Killer", - "LED": "LEDGIS", + "LED": "LedgerScore", "LEDGER": "Ledger Ai", "LEDU": "Education Ecosystem", "LEE": "Love Earn Enjoy", @@ -9717,8 +10122,10 @@ "LENFI": "Lenfi", "LENIN": "LeninCoin", "LENS": "Len Sassaman (len-sassaman.vip)", - "LEO": "LEO Token", + "LEO": "UNUS SED LEO", "LEOCOIN": "LEO", + "LEON": "Leonicorn Swap ( LEON )", + "LEONIDAS": "Leonidas Token", "LEOPARD": "Leopard", "LEOS": "Leonicorn Swap", "LEOX": "Galileo", @@ -9738,7 +10145,7 @@ "LETSGETHAI": "Let's Get HAI", "LETSGO": "Lets Go Brandon", "LEU": "CryptoLEU", - "LEV": "Levante U.D. Fan Token", + "LEV": "Lever Token", "LEVE": "Leve Invest", "LEVELG": "LEVELG", "LEVER": "LeverFi", @@ -9774,7 +10181,7 @@ "LGR": "Logarithm", "LGX": "Legion Network", "LHB": "Lendhub", - "LHC": "LHCoin", + "LHC": "Lightcoin", "LHD": "LitecoinHD", "LHINU": "Love Hate Inu", "LHT": "LHT Coin", @@ -9787,7 +10194,7 @@ "LIBFX": "Libfx", "LIBRA": "FUCK LIBRA", "LIBRAP": "Libra Protocol", - "LIBRE": "Libre", + "LIBRE": "Libre DeFi", "LIC": "Ligercoin", "LICK": "PetLFG", "LICKER": "LICKER", @@ -9812,7 +10219,7 @@ "LIGMA": "Ligma Node", "LIGO": "Ligo", "LIHUA": "LIHUA", - "LIKE": "Only1", + "LIKE": "LikeCoin", "LIKEC": "LikeCoin", "LILA": "LiquidLayer", "LILB": "Lil Brett", @@ -9826,11 +10233,13 @@ "LIMITEDCOIN": "Limited Coin", "LIMO": "Limoverse", "LIMX": "LimeCoinX", - "LINA": "Linear", + "LINA": "Lina Network", + "LINA7102": "Linear Finance", "LINANET": "Lina", "LINDA": "Metrix", "LINDACEO": "LindaYacc Ceo", "LINEA": "Linea", + "LINEAR": "Linear", "LING": "Lingose", "LINGO": "Lingo", "LINK": "Chainlink", @@ -9843,6 +10252,7 @@ "LINX": "Linde xStock", "LIO": "Lio", "LION": "Loaded Lions", + "LION35954": "Loaded Lions", "LIONT": "Lion Token", "LIORA": "Liora", "LIPC": "LIpcoin", @@ -9862,13 +10272,15 @@ "LISTA": "Lista DAO", "LISTEN": "Listen", "LISUSD": "lisUSD", - "LIT": "Lighter", + "LIT": "Lition", + "LIT1": "Litentry", + "LIT39125": "Lighter USD Price", "LITCOIN": "Litcoin", "LITE": "Lite USD", "LITEBTC": "LiteBitcoin", "LITENETT": "Litenett", "LITENTRY": "Litentry", - "LITH": "Lithium Finance", + "LITH": "Lithium", "LITHIUM": "Lithium", "LITHO": "Lithosphere", "LITION": "Lition", @@ -9876,7 +10288,7 @@ "LITTLEGUY": "just a little guy", "LITTLEMANYU": "Little Manyu", "LIV": "LiviaCoin", - "LIVE": "SecondLive", + "LIVE": "TRONbetLive", "LIVENCOIN": "LivenPay", "LIVESEY": "Dr. Livesey", "LIVESTARS": "Live Stars", @@ -9913,31 +10325,31 @@ "LMF": "Lamas Finance", "LMQ": "Lightning McQueen", "LMR": "Lumerin", - "LMT": "LIMITUS", + "LMT": "Lympo Market Token", "LMTOKEN": "LM Token", "LMTON": "Lockheed (Ondo Tokenized)", "LMTS": "Limitless Official Token", - "LMWR": "LimeWire Token", + "LMWR": "LimeWire", "LMXC": "LimonX", - "LMY": "Lunch Money", - "LN": "Lnfi Network", + "LMY": "LunchMoney", + "LN": "LINK", "LNC": "Blocklancer", "LNCHM": "Launchium", "LND": "Lendingblock", "LNDRR": "Lendr Network", "LNDRY": "LNDRY", - "LNDX": "LandX Finance", + "LNDX": "LandX Governance Token", "LNGVX": "WisdomTree Siegel Longevity Digital Fund", "LNK": "Ethereum.Link", "LNKC": "Linker Coin", "LNL": "LunarLink", "LNQ": "LinqAI", - "LNR": "LNR", + "LNR": "Lunar", "LNRV2": "Lunar", "LNS": "LIFE Coin", "LNT": "Lottonation", "LNX": "Lunox Token", - "LOA": "League of Ancients", + "LOA": "LOA Protocol", "LOAF": "LOAF CAT", "LOAFCAT": "LOAFCAT", "LOAN": "Lendoit", @@ -9949,15 +10361,16 @@ "LOCC": "Low Orbit Crypto Cannon", "LOCG": "LOCGame", "LOCI": "LociCoin", - "LOCK": "Contracto", - "LOCKIN": "LOCK IN", + "LOCK": "Meridian Network", + "LOCKIN": "LOCK IN USD Price", "LOCO": "Loco", "LOCOM": "Locomotir", "LOCUS": "Locus Chain", - "LODE": "Lodestar", + "LODE": "LODE Token", "LOE": "Legends of Elysium", "LOF": "Land of Fantasy", "LOFI": "LOFI", + "LOFI34187": "LOFI USD Price", "LOFIBUZZ": "LOFI", "LOG": "Wood Coin", "LOGO": "LOGOS", @@ -9965,8 +10378,9 @@ "LOGT": "Lord of Dragons Governance Token", "LOGX": "LogX Network", "LOIS": "Lois Token", - "LOKA": "League of Kingdoms", - "LOKR": "Polkalokr", + "LOKA": "League of Kingdoms Arena", + "LOKI": "Loki", + "LOKR": "Lokr", "LOKY": "Loky by Virtuals", "LOL": "LOL", "LOLA": "Lola", @@ -9977,7 +10391,7 @@ "LOLLYBOMB": "LollyBomb", "LOLO": "Lolo", "LOLONBSC": "LOL", - "LON": "Tokenlon", + "LON": "Tokenlon Network Token", "LONG": "LONG", "LONGDRINK": "Longdrink Finance", "LONGEVITY": "longevity", @@ -9996,12 +10410,13 @@ "LOOPIN": "LooPIN Network", "LOOPMARKETS": "LOOP", "LOOPY": "Loopy", - "LOOT": "LootBot", + "LOOT": "Lootex", "LOOTEX": "Lootex", "LOPES": "Leandro Lopes", - "LORD": "MEMELORD", + "LORD": "Overlord", "LORDS": "LORDS", "LORDZ": "Meme Lordz", + "LORE": "Gitopia", "LORGY": "Memeolorgy", "LORY": "Yield Parrot", "LOS": "Lord Of SOL", @@ -10017,7 +10432,7 @@ "LOULOU": "LOULOU", "LOV": "LoveChain", "LOVE": "Love Monster", - "LOVELY": "Lovely finance", + "LOVELY": "Lovely Inu Finance", "LOVELYV1": "Lovely Inu Finance", "LOVESNOOPY": "I LOVE SNOOPY", "LOWB": "Loser Coin", @@ -10042,7 +10457,7 @@ "LQBTC": "Liquid Bitcoin", "LQD": "Liquid", "LQDN": "Liquidity Network", - "LQDR": "LiquidDriver", + "LQDR": "Liquid Driver", "LQDX": "Liquid Crypto", "LQNA": "The Queen of Hyperliquid", "LQR": "Laqira Protocol", @@ -10055,10 +10470,10 @@ "LRN": "Loopring [NEO]", "LRT": "LandRocker", "LSC": "LS Coin", - "LSD": "LSD", + "LSD": "Liquid Staking Derivatives", "LSDOGE": "LSDoge", "LSETH": "Liquid Staked ETH", - "LSHARE": "LSHARE", + "LSHARE": "LIF3 LSHARE", "LSILVER": "Lyfe Silver", "LSK": "Lisk", "LSKV1": "Lisk v1", @@ -10084,7 +10499,7 @@ "LTCH": "Litecoin Cash", "LTCJ": "Litecoin (JustCrypto)", "LTCP": "LitecoinPro", - "LTCR": "LiteCreed", + "LTCR": "Litecred", "LTCU": "LiteCoin Ultra", "LTCX": "LitecoinX", "LTD": "Living the Dream", @@ -10107,12 +10522,12 @@ "LTT": "LocalTrade", "LTX": "Lattice Token", "LTZ": "Litecoinz", - "LUA": "Lua Token", + "LUA": "LuaSwap", "LUAUSD": "Lumi Finance", "LUBE": "Joe Lube Coin", - "LUC": "Play 2 Live", + "LUC": "Lucretius", "LUCA": "LUCA", - "LUCE": "Luce", + "LUCE": "LUCE", "LUCHOW": "LunaChow", "LUCI": "LUCI", "LUCIC": "Lucidum Coin", @@ -10146,6 +10561,8 @@ "LUMOS": "Lumos", "LUN": "Lunyr", "LUNA": "Terra", + "LUNA1": "Terra Classic", + "LUNA20314": "Terra", "LUNAB": "Luna by Virtuals", "LUNAR": "Lunar", "LUNARLENS": "Lunarlens", @@ -10174,14 +10591,14 @@ "LUXY": "Luxy", "LVG": "Leverage Coin", "LVIP": "Limitless VIP", - "LVL": "Level", + "LVL": "Level Finance", "LVLUSD": "Level USD", "LVLY": "LyvelyToken", "LVM": "LakeViewMeta", "LVN": "Levana Protocol", "LVVA": "Levva Protocol Token", "LVX": "Level01", - "LWA": "LUMIWAVE", + "LWA": "LumiWave", "LWC": "Linework Coin", "LWF": "Local World Forwarders", "LWFI": "Liberty world financial", @@ -10209,20 +10626,21 @@ "LYP": "Lympid Token", "LYQD": "eLYQD", "LYR": "Lyra", - "LYRA": "Lyra", + "LYRA": "Scrypta", "LYTX": "LYTIX", "LYUM": "Layerium", "LYVE": "Lyve Finance", "LYX": "LUKSO", - "LYXE": "LUKSO", + "LYXE": "LUKSO (Old)", "LYZI": "Lyzi", "LZ": "LaunchZone", "LZM": "LoungeM", "LZUSDC": "LayerZero Bridged USDC (Fantom)", - "M": "MemeCore", + "M": "MetaVerse-M", "M0": "M by M^0", "M1": "SupplyShock", "M2O": "M2O Token", + "M35491": "MemeCore USD Price", "M3H": "MehVerseCoin", "M3M3": "M3M3", "M87": "MESSIER", @@ -10272,6 +10690,7 @@ "MAGATRUMP": "MAGA Trump", "MAGE": "MetaBrands", "MAGIC": "Magic", + "MAGIC14783": "MAGIC", "MAGICF": "MagicFox", "MAGICK": "Cosmic Universe Magick", "MAGICV": "Magicverse", @@ -10286,11 +10705,12 @@ "MAGPAC": "MAGA Meme PAC", "MAH": "Mahabibi Bin Solman", "MAHA": "MahaDAO", - "MAI": "MAI", + "MAI": "Mindsync", "MAIA": "Maia", - "MAID": "MaidSafe Coin", + "MAID": "MaidSafeCoin", "MAIGA": "MAIGA Token", "MAIL": "CHAINMAIL", + "MAINST": "BuyMainStreet", "MAINSTON": "Ston", "MAIV": "MAIV", "MAJ": "Major Frog", @@ -10363,14 +10783,15 @@ "MARKETMOVE": "MarketMove", "MARLEY": "Marley Token", "MARMAJ": "marmaj", + "MARO": "Maro", "MAROV1": "TTC PROTOCOL", "MAROV2": "Maro", - "MARS": "MetaMars", + "MARS": "Marscoin", "MARS4": "MARS4", "MARSC": "MarsCoin", - "MARSCOIN": "MarsCoin", + "MARSCOIN": "Marscoin", "MARSERC": "Mars", - "MARSH": "Unmarshal", + "MARSH": "UnMarshal", "MARSMI": "MarsMi", "MARSO": "Marso.Tech", "MARSRISE": "MarsRise", @@ -10389,12 +10810,14 @@ "MARXCOIN": "MarxCoin", "MARYJ": "MaryJane Coin", "MAS": "Midas Protocol", + "MAS23862": "Massa", "MASA": "Masa", "MASHA": "Masha", "MASK": "Mask Network", + "MASK8536": "Mask Network", "MASP": "Market.space", "MASQ": "MASQ", - "MASS": "MASS", + "MASS": "Massnet", "MASSA": "Massa", "MASTER": "Mastercoin", "MASTERCOIN": "MasterCoin", @@ -10402,7 +10825,7 @@ "MASTERMIX": "Master MIX Token", "MASTERTRADER": "MasterTraderCoin", "MASYA": "MASYA", - "MAT": "Matchain", + "MAT": "My Master War", "MATA": "Ninneko", "MATAR": "MATAR AI", "MATCH": "Matching Game", @@ -10413,10 +10836,10 @@ "MATICX": "Stader MaticX", "MATPAD": "MaticPad", "MATR1X": "Matr1x", - "MATRIX": "Matrix One", + "MATRIX": "Matrix Labs", "MATRIXLABS": "Matrix Labs", "MATT": "Matt Furie", - "MATTER": "AntiMatter", + "MATTER": "AntiMatter Token", "MATTLE": "MattleFun", "MAU": "MAU", "MAUW": "MAUW", @@ -10426,7 +10849,8 @@ "MAW": "Mountain Sea World", "MAWA": "Kumala Herris", "MAWC": "Magawincat", - "MAX": "Mastercard xStock", + "MAX": "Maxcoin", + "MAX-EXCHANGE-TOKEN": "Maxcoin", "MAXAIAGENT": "MAX", "MAXCOIN": "MaxCoin", "MAXETH": "Max on ETH", @@ -10436,7 +10860,7 @@ "MAXR": "Max Revive", "MAXX": "MAXX Finance", "MAXXING": "Maxxing", - "MAY": "Mayflower", + "MAY": "Theresa May Coin", "MAYA": "Maya", "MAYACOIN": "MayaCoin", "MAYILONG": "Yi long ma", @@ -10477,35 +10901,35 @@ "MBOT": "MoonBot", "MBOX": "MOBOX", "MBOYS": "MoonBoys", - "MBP": "MobiPad", + "MBP": "Mobipad", "MBRS": "Embers", - "MBS": "MonkeyBall", + "MBS": "MonkeyLeague", "MBT": "Metablackout", "MBTCS": "MBTCs", "MBTX": "MinedBlock", - "MBX": "Marblex", + "MBX": "MobieCoin", "MC": "Merit Circle", - "MCA": "Mcashchain", + "MCA": "MoveCash", "MCADE": "Metacade", "MCAKE": "EasyCake", - "MCAP": "MCAP", + "MCAP": "Meta Capital", "MCAR": "MasterCar", - "MCASH": "Monsoon Finance", + "MCASH": "Mcashchain", "MCAT20": "Wrapped Moon Cats", "MCAU": "Meld Gold", - "MCB": "MCDEX", - "MCC": "Magic Cube Coin", + "MCB": "MUX Protocol", + "MCC": "MultiCoinCasino", "MCD": "McDonald's Job Application", "MCDAI": "Dai (Multichain)", "MCDULL": "McDull", - "MCDX": "McDonald’s xStock", + "MCDX": "McDonald's tokenized stock (xStock)", "MCELO": "Moola Celo", "MCEN": "Main Character Energy", "MCEUR": "Moola Celo EUR", "MCF": "MCFinance", "MCG": "MicroChains Gov Token", "MCGA": "Make CRO Great Again", - "MCH": "Meconcash", + "MCH": "MeconCash", "MCHC": "My Crypto Heroes", "MCI": "Musiconomi", "MCIV": "Mars Civ Project", @@ -10522,10 +10946,10 @@ "MCPC": "Mobile Crypto Pay Coin", "MCQ": "Mecha Conquest", "MCRC": "MyCreditChain", - "MCRN": "MacronCoin", + "MCRN": "MacaronSwap", "MCRT": "MagicCraft", "MCS": "MCS Token", - "MCT": "MyConstant", + "MCT": "Master Contract Token", "MCTO": "McToken", "MCTP": "Metacraft", "MCU": "MediChain", @@ -10533,10 +10957,11 @@ "MCV": "MCV Token", "MCX": "MachiX Token", "MD": "MetaDeck", - "MDA": "Moeda", + "MDA": "Moeda Loyalty Points", "MDAI": "MindAI", "MDAO": "MarsDAO", - "MDB": "Million Dollar Baby", + "MDAO18913": "MarsDAO", + "MDB": "MetaDubai", "MDC": "MedicCoin", "MDCL": "Medicalchain", "MDDN": "Modden", @@ -10555,17 +10980,18 @@ "MDTX": "Medtronic xStock", "MDU": "MDUKEY", "MDUS": "MEDIEUS", - "MDX": "Mdex (BSC)", + "MDX": "Mdex", "MDXH": "Mdex (HECO)", "ME": "Magic Eden", + "ME32197": "Magic Eden", "MEA": "MECCA", - "MEAN": "Meanfi", + "MEAN": "Mean DAO", "MEB": "Meblox Protocol", "MEC": "MegaCoin", "MECH": "Mech Master", "MECHA": "Mechanium", "MECI": "Meta Game City", - "MED": "Medibloc", + "MED": "MediBloc", "MEDAMON": "Medamon", "MEDI": "MediBond", "MEDIA": "Media Network", @@ -10574,7 +11000,8 @@ "MEDIT": "MediterraneanCoin", "MEDUSA": "MEDUSA", "MEE": "Medieval Empires", - "MEED": "Meeds DAO", + "MEEB": "Meeb Master", + "MEED": "Meeds", "MEER": "Qitmeer Network", "MEET": "CoinMeet", "MEETONE": "MEET.ONE", @@ -10583,7 +11010,8 @@ "MEFA": "Metaverse Face", "MEFAI": "META FINANCIAL AI", "MEFI": "Meo Finance", - "MEGA": "MegaFlash", + "MEGA": "MegaCryptoPolis", + "MEGA38770": "MegaETH USD Price", "MEGABOT": "Megabot", "MEGAD": "Mega Dice Casino", "MEGAHERO": "MEGAHERO", @@ -10596,10 +11024,12 @@ "MEI": "Mei Solutions", "MEIZHU": "GUANGZHOU ZOO NEW BABY PANDA", "MEL": "MELX", + "MELAN": "Melania Meme", "MELANIA": "Melania Meme", + "MELANIA35347": "Official Melania Meme USD Price", "MELANIATRUMP": "Melania Trump", "MELB": "Minelab", - "MELD": "MetaElfLand Token", + "MELD": "MELD", "MELDV1": "MELD v1", "MELDV2": "MELD", "MELI": "Meli Games", @@ -10616,7 +11046,8 @@ "MEMBERSHIP": "Membership Placeholders", "MEMD": "MemeDAO", "MEMDEX": "Memdex100", - "MEME": "Memecoin", + "MEME": "Memetic / PepeCoin", + "MEME28301": "Memecoin", "MEMEA": "MEME AI", "MEMEAI": "Meme Ai", "MEMEBRC": "MEME", @@ -10625,6 +11056,7 @@ "MEMECUP": "Meme Cup", "MEMEETF": "Meme ETF", "MEMEFI": "MemeFi", + "MEMEFI33464": "MemeFi", "MEMEFICASH": "MemeFi", "MEMEINU": "Meme Inu", "MEMEM": "Meme Man", @@ -10637,8 +11069,8 @@ "MEMESAI": "Memes AI", "MEMESQUAD": "Meme Squad", "MEMET": "MEMETOON", - "MEMETIC": "Memetic", - "MEMEX": "Meme Index", + "MEMETIC": "Memetic / PepeCoin", + "MEMEX": "MEMEX", "MEMHASH": "Memhash", "MEMORYCOIN": "MemoryCoin", "MEMUSIC": "MeMusic", @@ -10647,7 +11079,7 @@ "MENGO": "Flamengo Fan Token", "MENLO": "Menlo One", "MEO": "Meow Of Meme", - "MEOW": "Zero Tech", + "MEOW": "Meowshi", "MEOWCAT": "MeowCat", "MEOWETH": "Meow", "MEOWG": "MeowGangs", @@ -10655,7 +11087,7 @@ "MEOWM": "Meow Meow Coin", "MEOWME": "MEOW MEOW", "MEPAD": "MemePad", - "MER": "Mercurial Finance", + "MER": "Mercury", "MERCE": "MetaMerce", "MERCU": "Merculet", "MERCURY": "Mercury", @@ -10665,22 +11097,24 @@ "MERI": "Merebel", "MERIDIAN": "Meridian Network LOCK", "MERKLE": "Merkle Network", - "MERL": "Merlin Chain", + "MERL": "Merlin Chain USD", "MERLIN": "Oldest Raccoon", "MERY": "Mistery On Cro", - "MESA": "MetaVisa", + "MESA": "myMessage", "MESG": "MESG", "MESH": "MeshBox", "MESSI": "MESSI COIN", "MESSU": "Loinel Messu", - "MET": "Meteora", - "META": "MetaDAO", + "MET": "Metronome", + "MET38353": "Meteora USD Price", + "META": "Metadium", "METAA": "META ARENA", "METABOT": "Robot Warriors", "METABRAW": "Metabrawl", "METAC": "Metacoin", "METACA": "MetaCash", "METACAT": "MetaCat", + "METACEX": "Metaverse Exchange", "METACLOUD": "Metacloud", "METACR": "Metacraft", "METAD": "MetaDoge", @@ -10691,9 +11125,11 @@ "METAF": "MetaFastest", "METAFIGHTER": "MetaFighter", "METAG": "MetagamZ", + "METAGAMES": "Meta Games Coin", "METAGEAR": "MetaGear", "METAIVERSE": "MetAIverse", "METAL": "Metal Blockchain", + "METAL21769": "Metal Blockchain", "METALCOIN": "MetalCoin", "METAMEME": "met a meta metameme", "METAMUSK": "Musk Metaverse", @@ -10710,7 +11146,7 @@ "METATI": "Metatime Coin", "METATR": "MetaTrace Utility Token", "METAUFO": "MetaUFO", - "METAV": "METAVERSE", + "METAV": "MetaVPad", "METAV1": "META v1", "METAVE": "Metaverse Convergence", "METAVERSEM": "MetaVerse-M", @@ -10718,23 +11154,28 @@ "METAVIE": "Metavie", "METAVPAD": "MetaVPad", "METAW": "MetaWorth", - "METAX": "Meta xStock", + "METAX": "MetaverseX", "METEOR": "Meteorite Network", + "METF": "Mad Meerkat ETF", "METFI": "MetFi", - "METH": "Mantle Staked Ether", + "METH": "Mirrored Ether", "METI": "Metis", - "METIS": "Metis Token", + "METIS": "MetisDAO", "METM": "MetaMorph", - "METO": "Metafluence", + "METO": "Metoshi", + "METR": "Metria", "METRO": "Metropoly", "METRON": "Metronome", "METRONV1": "Metronome", + "METT": "MetaThings", "METYA": "Metya Token", "MEU": "MetaUnit", "MEV": "MEVerse", "MEVETH": "mevETH", + "MEVFREE": "MEVFree", "MEVR": "Metaverse VR", - "MEW": "cat in a dogs world", + "MEW": "Cats in a Dog World", + "MEW30126": "cat in a dogs world", "MEWC": "Meowcoin", "MEWING": "MEWING", "MEWSWIFHAT": "cats wif hats in a dogs world", @@ -10749,22 +11190,23 @@ "MF": "Moonwalk Fitness", "MF1": "Meta Finance", "MFAM": "Moonwell Apollo", - "MFC": "MFCoin", + "MFB": "Mirrored Facebook Inc", + "MFC": "Multi-Farm Capital", "MFER": "mfercoin", "MFERS": "MFERS", "MFET": "MultiFunctional Environmental Token", - "MFG": "SyncFab", + "MFG": "Smart MFG", "MFI": "Marginswap", "MFO": "Moonfarm Finance", "MFPS": "Meta FPS", "MFS": "Moonbase File System", - "MFT": "Hifi Finance (Old)", + "MFT": "Hifi Finance", "MFTM": "Fantom (Multichain)", "MFTU": "Mainstream For The Underground", "MFUN": "MemeMarket", "MFUND": "Memefund", "MFX": "MFChain", - "MG": "MinerGate Token", + "MG": "Mumon-Ginsen", "MG8": "Megalink", "MGAMES": "Meme Games", "MGAR": "Metagame Arena", @@ -10772,13 +11214,14 @@ "MGD": "MassGrid", "MGG": "MetaGaming Guild", "MGGT": "Maggie Token", + "MGH": "MetaGameHub DAO", "MGKL": "MAGIKAL.ai", "MGLC": "MetaverseMGL", "MGLD": "Metallurgy", - "MGN": "MagnaCoin", + "MGN": "Mugen Finance", "MGO": "Mango Network", "MGOD": "MetaGods", - "MGP": "MangoChain", + "MGP": "Magpie", "MGPT": "MotoGP Fan Token", "MGT": "Moongate", "MGUL": "Mogul Coin", @@ -10789,12 +11232,13 @@ "MHP": "MedicoHealth", "MHRD": "MacroHard", "MHT": "Mouse Haunt", + "MHUB": "CRODEX Metaverse Hub", "MHUNT": "MetaShooter", "MI": "XiaoMiCoin", - "MIA": "MIA", + "MIA": "MiamiCoin", "MIAMICOIN": "MiamiCoin", "MIAO": "MIAOCoin", - "MIB": "Mobile Integrated Blockchain", + "MIB": "MIB Coin", "MIBO": "miBoodle", "MIBR": "MIBR Fan Token", "MIC": "Mithril Cash", @@ -10802,7 +11246,7 @@ "MICHI": "michi", "MICK": "Mickey Meme", "MICKEY": "Steamboat Willie", - "MICRO": "Micro GPT", + "MICRO": "Micromines", "MICRODOGE": "MicroDoge", "MICROMINES": "Micromines", "MICROVISION": "MicroVisionChain", @@ -10831,10 +11275,11 @@ "MILC": "Micro Licensing Coin", "MILE": "milestoneBased", "MILEI": "MILEI", - "MILK": "MilkyWay", + "MILK": "Milk Token", "MILK2": "Spaceswap MILK2", "MILKBAG": "MILKBAG", "MILKSHAKE": "Milkshake Swap", + "MILKY": "Milky Token", "MILKYWAY": "MilkyWayZone", "MILLI": "Million", "MILLIM": "Millimeter", @@ -10847,12 +11292,13 @@ "MILODOG": "MILO DOG", "MILOP": "MILO Project", "MIM": "Magic Internet Money", + "MIMAS": "Mimas Finance", "MIMATIC": "MAI", "MIMI": "MIMI Money", - "MIMIR": "Mimir", - "MIMO": "MIMO Parallel Governance Token", + "MIMIR": "Mimir Token", + "MIMO": "MIMOSA", "MIN": "MINDOL", - "MINA": "Mina Protocol", + "MINA": "Mina", "MINAR": "Miner Arena", "MINC": "MinCoin", "MIND": "Morpheus Labs", @@ -10864,15 +11310,17 @@ "MINDGENE": "Mind Gene", "MINDS": "Minds", "MINDSYNC": "Mindsync", - "MINE": "SpaceMine", + "MINE": "Pylon Protocol", "MINEA": "Mine AI", + "MINECRAFT": "Synex Coin", "MINER": "MINER", "MINERALS": "Minerals Coin", + "MINERS": "Miners Defi", "MINES": "MINESHIELD", "MINETTE": "Vibe Cat", "MINEX": "Minex", "MINGO": "Mingo", - "MINI": "mini", + "MINI": "MiniSwap", "MINIAPPS": "MiniApps", "MINIBNBTIGER": "MiniBNBTiger", "MINID": "Mini Donald", @@ -10891,7 +11339,7 @@ "MINO": "MINO INU", "MINOCOINCTO": "MINO", "MINS": "Minswap", - "MINT": "Mintify", + "MINT": "MintCoin", "MINTCHAIN": "Mint", "MINTCOIN": "MintCoin", "MINTE": "Minter HUB", @@ -10900,7 +11348,7 @@ "MINTYS": "MintySwap", "MINU": "Minu", "MINUTE": "MINUTE Vault (NFTX)", - "MINX": "Modern Innovation Network Token", + "MINX": "InnovaMinex", "MIO": "Miner One token", "MIODIO": "MIODIOCOIN", "MIOTA": "IOTA", @@ -10911,18 +11359,18 @@ "MIRAI": "Project MIRAI", "MIRAIBUILD": "MIRAI", "MIRC": "MIR COIN", - "MIRROR": "Black Mirror", + "MIRROR": "Mirror Protocol", "MIRT": "MIR Token", "MIRX": "Mirada AI", - "MIS": "Mithril Share", - "MISA": "Sangkara", + "MIS": "Themis", + "MISA": "SANGKARA MISA", "MISCOIN": "MIScoin", "MISHA": "Vitalik's Dog", "MISHKA": "Mishka Token", "MISS": "MISS", "MISSION": "MissionPawsible", "MISSK": "Miss Kaka", - "MIST": "Mist", + "MIST": "Alchemist", "MISTCOIN": "MistCoin", "MISTE": "Mister Miggles", "MISTRAL": "Mistral AI", @@ -10931,14 +11379,15 @@ "MITH": "Mithril", "MITHRIL": "CLIMBERS", "MITO": "Mitosis", + "MITO38204": "Mitosis", "MITTENS": "Mittens", - "MITX": "Morpheus Infrastructure Token", + "MITX": "Morpheus Labs", "MIU": "MIU", "MIUONSOL": "Miu", "MIV": "MakeItViral", "MIVA": "Minerva Wallet", "MIVRS": "Minionverse", - "MIX": "MIXMARVEL", + "MIX": "MixMarvel", "MIXAI": "Mixcash AI", "MIXCOIN": "Mixaverse", "MIXER": "TON Mixer", @@ -10957,7 +11406,8 @@ "ML": "Mintlayer", "MLA": "Moola", "MLC": "My Lovely Planet", - "MLD": "MonoLend", + "MLC32035": "My Lovely Planet", + "MLD": "Hurrian Network", "MLEO": "LEO Token (Multichain)", "MLG": "360noscope420blazeit", "MLGC": "Marshal Lion Group Coin", @@ -10968,13 +11418,13 @@ "MLN": "Enzyme", "MLNK": "Malinka", "MLOKY": "MLOKY", - "MLP": "Matrix Layer Protocol", + "MLP": "My Liquidity Partner", "MLS": "CPROP", - "MLT": "MIcro Licensing Coin", + "MLT": "MILC Platform", "MLTC": "Litecoin (Multichain)", "MLTPX": "MoonLift Capital", "MLXC": "Marvellex Classic", - "MM": "MOMO.FUN", + "MM": "Millimeter", "MMA": "Meme Alliance", "MMAI": "MetamonkeyAi", "MMAON": "MMAON", @@ -10983,14 +11433,14 @@ "MMC": "Monopoly Millionaire Control", "MMDAO": "MMDAO", "MMETA": "Duckie Land Multi Metaverse", - "MMF": "MMFinance", + "MMF": "MM Finance (Cronos)", "MMG": "Monopoly Millionaire Game", "MMIP": "Memes Make It Possible", "MMIT": "MangoMan Intelligent", "MMNXT": "MMNXT", "MMO": "MMOCoin", "MMON": "Multiverse Monkey", - "MMPRO": "Market Making Pro", + "MMPRO": "MMPRO Token", "MMS": "Marsverse", "MMSC": "MMSC PLATFORM", "MMSS": "MMSS (Ordinals)", @@ -11004,29 +11454,31 @@ "MMXVI": "MMXVI", "MMY": "Mummy Finance", "MN": "Cryptsy Mining Contract", - "MNB": "MoneyBag", + "MNB": "Mineable", "MNBR": "MN Bridge", "MNC": "MainCoin", - "MND": "Mind", + "MND": "Mind Music", "MNDCC": "Mondo Community Coin", "MNDE": "Marinade", "MNE": "Minereum", - "MNEE": "MNEE USD Stablecoin ", + "MNEE": "MNEE USD Stablecoin", "MNEMO": "Mnemonics", "MNET": "MINE Network", - "MNFT": "Mongol NFT", + "MNFT": "ManuFactory", "MNFTS": "Marvelous NFTs", "MNG": "Moon Nation Game", - "MNGO": "Mango protocol", - "MNI": "Map Node", + "MNGO": "Mango", + "MNI": "MnICorp", "MNM": "Mineum", "MNR": "Mineral", "MNRB": "MoneyRebel", "MNRCH": "Monarch", "MNRY": "Moonray", - "MNS": "Monnos", + "MNS": "MONNOS", "MNSRY": "MANSORY", "MNST": "MoonStarter", + "MNT": "Mantle", + "MNT27075": "Mantle", "MNTA": "MantaDAO", "MNTC": "Manet Coin", "MNTG": "Monetas", @@ -11037,9 +11489,9 @@ "MNTX": "Minutes Network Token", "MNV": "MonetaVerde", "MNVM": "Novam", - "MNW": "Morpheus Network", + "MNW": "Morpheus.Network", "MNX": "MinexCoin", - "MNY": "MoonieNFT", + "MNY": "Moonie NFT", "MNZ": "Menzy", "MO": "Morality", "MOAC": "MOAC", @@ -11059,9 +11511,11 @@ "MOBY": "Moby AI", "MOBYONBASE": "Moby", "MOBYONBASEV1": "Moby v1", - "MOC": "Mossland", - "MOCA": "Moca Coin", + "MOC": "Moss Coin", + "MOCA": "Moca Network", + "MOCA31526": "Moca Network USD Price", "MOCHI": "Mochiswap", + "MOCHI14315": "Mochi", "MOCHICAT": "MochiCat", "MOCHIINU": "Mochi Inu", "MOCK": "Mock Capital", @@ -11084,7 +11538,7 @@ "MOF": "Molecular Future (TRC20)", "MOFI": "MobiFi", "MOFOLD": "Molecular Future (ERC20)", - "MOG": "Mog Coin", + "MOG": "MOG Coin", "MOGC": "MOG CAT", "MOGCO": "Mog Coin (mogcoinspl.com)", "MOGE": "Moge", @@ -11098,41 +11552,46 @@ "MOGX": "Mogu", "MOH": "Medal of Honour", "MOI": "MyOwnItem", + "MOIL": "Moovy", "MOIN": "MoinCoin", "MOJI": "Moji", - "MOJO": "Planet Mojo", + "MOJO": "MojoCoin", "MOJOB": "Mojo on Base", "MOJOCOIN": "Mojocoin", "MOK": "MocktailSwap", "MOL": "Molecule", - "MOLA": "MoonLana", + "MOLA": "Moonlana", "MOLI": "Mobile Liquidity", - "MOLK": "Mobilink Token", + "MOLK": "MobilinkToken", "MOLLARS": "MollarsToken", "MOLLY": "Molly", "MOLO": "MOLO CHAIN", "MOLT": "Moltbook", "MOLTID": "MoltID", - "MOM": "MOM", + "MOM": "Mother of Memes", "MOMA": "Mochi Market", + "MOMENTO": "Momento", "MOMIJI": "MAGA Momiji", "MOMO": "Momo", "MOMO2": "MOMO 2.0", "MOMO2025": "momo", - "MON": "Monad", + "MON": "PocMon", + "MON30495": "Monad USD Price", + "MON30950": "MON", "MONA": "MonaCoin", "MONAI": "MONAI", "MONAIZE": "Monaize", "MONARCH": "TRUEMONARCH", "MONART": "Monart", "MONAV": "Monavale", + "MONAVALE": "Monavale", "MONB": "MonbaseCoin", "MONDO": "mondo", "MONEROAI": "Monero AI", "MONEROCHAN": "Monerochan", "MONET": "Claude Monet Memeory Coin", "MONETA": "Moneta", - "MONEY": "MoneyCoin", + "MONEY": "MoneyTree", "MONEYBEE": "MONEYBEE", "MONEYBYTE": "MoneyByte", "MONEYGOD": "Money God One", @@ -11145,16 +11604,19 @@ "MONGY": "Mongy", "MONI": "Monsta Infinite", "MONIE": "Infiblue World", - "MONK": "Monkey Project", + "MONK": "MONK", "MONKAS": "Monkas", - "MONKE": "Monkecoin", + "MONKE": "Space Monkey Token", + "MONKED": "MONKED", + "MONKEX": "Monkex", "MONKEY": "Monkey", "MONKEYC": "Monkey Cult", "MONKEYS": "Monkeys Token", "MONKU": "Monku", "MONKY": "Wise Monkey", - "MONO": "MonoX", + "MONO": "The Monopolist", "MONOLITH": "Monolith", + "MONONOKE-INU": "Mononoke Inu", "MONONOKEINU": "Mononoke Inu", "MONOPOLY": "Meta Monopoly", "MONPRO": "MON Protocol", @@ -11165,23 +11627,25 @@ "MONSTRO": "Monstro DeFi", "MONT": "Monarch Token", "MONTE": "Monte", - "MOO": "MooMonster", + "MOO": "Moola Market", "MOOBIFI": "Staked BIFI", "MOOCAT": "MooCat", "MOODENG": "Moo Deng (moodengsol.com)", + "MOODENG33093": "Moo Deng USD Price", "MOODENGBNB": "MOODENG (moodengbnb.com)", "MOODENGSBS": "Moo Deng (moodeng.sbs)", "MOODENGSPACE": "MOO DENG", "MOODENGVIP": "MOO DENG (moodeng.vip)", "MOODENGWIF": "MOODENGWIF", - "MOOI": "Moonai", + "MOOI": "MOOI Network", "MOOLA": "Degen Forest", "MOOLAH": "Moolah", "MOOLYA": "moolyacoin", "MOOMEME": "MOO MOO", "MOOMOO": "MOOMOO THE BULL", - "MOON": "r/CryptoCurrency Moons", - "MOONARCH": "Moonarch", + "MOON": "MoonSwap", + "MOON7396": "r/CryptoCurrency Moons", + "MOONARCH": "Moonarch.app", "MOONB": "Moon Base", "MOONBEANS": "Moonbeans", "MOONBI": "Moonbix", @@ -11189,38 +11653,40 @@ "MOONC": "MoonCoin", "MOONCAT": "Mooncat", "MOONCOIN": "Mooncoin", - "MOOND": "Dark Moon", + "MOOND": "MoonsDust", "MOONDAY": "Moonday Finance", "MOONDO": "MOON DOGE", "MOONDOG": "MOONDOGE", "MOONDOGE": "MOONDOGE", "MOONED": "MoonEdge", "MOONER": "CoinMooner", - "MOONEY": "Moon DAO", + "MOONEY": "MoonDAO", "MOONI": "MOON INU", "MOONION": "Moonions", "MOONKIN": "MOONKIN", "MOONKIZE": "MoonKize", "MOONLIGHT": "Moonlight Token", "MOONPIG": "Moonpig", + "MOONPOT": "MoonPot Finance", "MOONR": "PulseMoonR", "MOONS": "Sailor Moons", "MOONSHOT": "Moonshot", "MOONSTAR": "MoonStar", "MOONW": "moonwolf.io", - "MOOO": "Hashtagger", + "MOOO": "Hashtagger.com", "MOOR": "MOOR TOKEN", - "MOOV": "dotmoovs", + "MOOV": "Dotmoovs", "MOOX": "Moox Protocol", "MOOXV1": "Moox Protocol v1", "MOPS": "Mops", "MOR": "Morpheus", "MORA": "Meliora", - "MORE": "Moonveil", + "MORE": "More Coin", "MORECOIN": "More Coin", "MOREGEN": "MoreGen FreeMoon", "MORFEY": "Morfey", "MORI": "MORI COIN", + "MORK": "MORK", "MOROS": "MOROS NET", "MORPH": "Morpheus Token", "MORPHIS": "MorphIS", @@ -11229,9 +11695,10 @@ "MORSE": "Morse", "MORTY": "Morty", "MOS": "MOS Coin", + "MOSOLID": "moSOLID", "MOSS": "MOSS AI", "MOST": "MOST Global", - "MOT": "Mobius Token", + "MOT": "Mobius Finance", "MOTA": "MotaCoin", "MOTG": "MetaOctagon", "MOTH": "MOTH", @@ -11243,15 +11710,17 @@ "MOTO": "Motocoin", "MOUND": "Mound Token", "MOUNTA": "Mountain Protocol", + "MOUSEWORM": "Mouseworm", "MOUTAI": "Moutai", - "MOV": "MovieCoin", + "MOV": "MOTIV Protocol", "MOVA": "MOVA", "MOVD": "MOVE Network", "MOVE": "Movement", + "MOVE32452": "Movement", "MOVER": "Mover", "MOVEUSD": "MoveMoney USD", - "MOVEY": "Movey", - "MOVEZ": "MoveZ", + "MOVEY": "Movey Token", + "MOVEZ": "MOVEZ", "MOVON": "MovingOn Finance", "MOVR": "Moonriver", "MOW": "mouse in a cats world", @@ -11282,11 +11751,11 @@ "MPT": "Miracleplay Token", "MPTV1": "Miracleplay Token v1", "MPWR": "Empower", - "MPX": "Morphex", + "MPX": "Mars Space X", "MPXT": "Myplacex", "MQL": "MiraQle", "MQST": "MonsterQuest", - "MR": "Meta Ruffy", + "MR": "MetaRuffy", "MRB": "MoonRabbits", "MRBASED": "MrBased", "MRBEAST": "X Super Official CEO", @@ -11296,7 +11765,7 @@ "MRDN": "Meridian", "MRF": "Moonradar.finance", "MRFOX": "Mr.FOX Token", - "MRHB": "MarhabaDeFi", + "MRHB": "MRHB DeFi", "MRI": "Marshall Inu", "MRK": "MARK.SPACE", "MRKX": "Merck xStock", @@ -11307,15 +11776,17 @@ "MRNA": "Moderna", "MRP": "MorpheusCoin", "MRPEPE": "Pepe Potato", - "MRS": "Metars Genesis", + "MRPH": "Morpheus Network", + "MRS": "Marsan Exchange token", "MRSA": "MrsaCoin", "MRSMIGGLES": "Mrs Miggles", + "MRSPEPE": "Mrs Pepe", "MRST": "Mars Token", "MRT": "MinersReward", "MRUN": "Metarun", "MRV": "Macroverse", "MRVLX": "Marvell xStock", - "MRX": "Metrix Coin", + "MRX": "Metrix", "MRXB": "Wrapped BNB Metrix", "MRXE": "Wrapped ETH Metrix", "MRY": "MurrayCoin", @@ -11327,9 +11798,10 @@ "MSD": "MSD", "MSFT": "Microsoft 6900", "MSFTON": "Microsoft (Ondo Tokenized)", - "MSFTX": "Microsoft xStock", + "MSFTX": "Microsoft tokenized stock (xStock)", "MSG": "MsgSender", "MSGO": "MetaSetGO", + "MSHARE": "Meerkat Shares", "MSHD": "MASHIDA", "MSHEESHA": "Sheesha Finance Polygon", "MSHIB": "Magic Shiba Starter", @@ -11342,25 +11814,28 @@ "MSPC": "MeowSpace", "MSQ": "MSquare Global", "MSR": "Masari", - "MST": "Idle Mystic", + "MST": "MustangCoin", "MSTABLEUSD": "mStable USD", "MSTAR": "MerlinStarter", "MSTETH": "Eigenpie mstETH", "MSTO": "Millennium Sapphire", + "MSTR": "Monsterra (MSTR)", "MSTRON": "MicroStrategy (Ondo Tokenized)", "MSTRX": "MicroStrategy xStock", "MSU": "MetaSoccer", "MSUSHI": "Sushi (Multichain)", "MSVP": "MetaSoilVerseProtocol", "MSWAP": "MoneySwap", - "MT": "Mint Token", - "MTA": "Meta", + "MT": "MyToken", + "MTA": "mStable Governance Token: Meta (MTA)", + "MTAO": "MEME TAO", "MTB": "MetaBridge", "MTBC": "Metabolic", - "MTC": "Matrix Chain", + "MTC": "DOC.COM", "MTCMN": "MTC Mesh", "MTCN": "Multiven", "MTD": "Minted", + "MTD21418": "Minted", "MTEL": "MEDoctor", "MTG": "MagnetGold", "MTGT": "MTG Token", @@ -11372,14 +11847,14 @@ "MTHT": "MetaHint", "MTIK": "MatikaToken", "MTIX": "Matrix Token", - "MTK": "Moya Token", - "MTL": "Metal", + "MTK": "Metakings", + "MTL": "Metal DAO", "MTLM3": "Metal Music v3", "MTLS": "eMetals", "MTLV1": "Metal v1", "MTLX": "Mettalex", "MTMS": "MTMS Network", - "MTN": "TrackNetToken", + "MTN": "Medicalchain", "MTO": "Merchant Token", "MTOS": "MomoAI", "MTP": "Multiple Network Token", @@ -11387,11 +11862,11 @@ "MTR": "Meter Stable", "MTRA": "MetaRare", "MTRC": "ModulTrade", - "MTRG": "Meter", + "MTRG": "Meter Governance", "MTRK": "Matrak Fan Token", "MTRM": "Materium", "MTRX": "Metarix", - "MTS": "Metastrike", + "MTS": "Metis", "MTSH": "Mitoshi", "MTSP": "Metasphere", "MTT": "MulTra", @@ -11399,17 +11874,17 @@ "MTV": "MultiVAC", "MTV1": "Mint Club", "MTVT": "Metaverser", - "MTW": "Meta Space 2045", + "MTW": "Meta World Game", "MTX": "Matryx", "MTXLT": "Tixl", "MTY": "Viddli", "MTZ": "Monetizr", - "MU": "Miracle Universe", + "MU": "Mu Continent", "MUA": "MUA DAO", "MUB": "Mubarak on Base", "MUBA": "mubarak", "MUBAR": "mubarak", - "MUBARAK": "mubarak", + "MUBARAK": "Mubarak", "MUBARAKAH": "Mubarakah", "MUBI": "Multibit", "MUC": "Multi Universe Central", @@ -11417,8 +11892,9 @@ "MUDRA": "MudraCoin", "MUE": "MonetaryUnit", "MUES": "MuesliSwap MILK", - "MULTI": "Multichain", + "MULTI": "Multigame", "MULTIBOT": "Multibot", + "MULTIBTC": "MultiBTC", "MULTIGAMES": "MultiGames", "MULTIV": "Multiverse", "MULTIWALLET": "MultiWallet Coin", @@ -11432,16 +11908,17 @@ "MUNITY": "Metahorse Unity", "MUNK": "Dramatic Chipmunk", "MUNSUN": "MUNSUN", + "MUNT": "MUNT", "MUON": "Micron Technology (Ondo Tokenized)", "MURA": "Murasaki", "MURATIAI": "MuratiAI", "MUSA": "Mansa AI", "MUSCAT": "MusCat", - "MUSD": "MetaMask USD", + "MUSD": "mStable USD", "MUSDC": "USD Coin (Multichain)", "MUSDCOIN": "MUSDcoin", - "MUSE": "Muse DAO", - "MUSIC": "Gala Music", + "MUSE": "Muse", + "MUSIC": "Smart Music", "MUSICAI": "MusicAI", "MUSICOIN": "Musicoin", "MUSK": "Musk", @@ -11449,16 +11926,17 @@ "MUSKIT": "Musk It", "MUSKMEME": "MUSK MEME", "MUSKVSZUCK": "Cage Match", - "MUST": "MUST Protocol", + "MUST": "Cometh", "MUSTANGC": "MustangCoin", "MUT": "Mutual Coin", + "MUTANT": "MUTANT PEPE", "MUTE": "Mute", "MUU": "MilkCoin", "MUZKI": "Muzki", "MUZZ": "MuzzleToken", "MV": "GensoKishi Metaverse", "MVC": "MileVerse", - "MVD": "Metavault", + "MVD": "MvPad", "MVDG": "MetaVerse Dog", "MVEDA": "MedicalVeda", "MVERSE": "MindVerse", @@ -11466,13 +11944,13 @@ "MVI": "Metaverse Index", "MVL": "MVL", "MVOYA": "VOYA (Merlin Bridge)", - "MVP": "MAGA VP", + "MVP": "Merculet", "MVPC": "MVP Coin", "MVRS": "Meta MVRS", "MVS": "Multiverse", "MVU": "meVu", "MVX": "Metavault Trade", - "MW": "MasterWin Coin", + "MW": "Metaworld", "MWAR": "MemeWars (MWAR)", "MWAT": "RED MegaWatt", "MWAVE": "MeshWave", @@ -11481,10 +11959,11 @@ "MWD": "MEW WOOF DAO", "MWETH": "Moonwell Flagship ETH (Morpho Vault)", "MWH": "Melania Wif Hat", + "MWS": "Multi Wallet Suite", "MWT": "Mountain Wolf Token", "MWXT": "MWX Token", - "MX": "MX Token", - "MXC": "MXC Token", + "MX": "MX TOKEN", + "MXC": "MXC", "MXCV1": "Machine Xchange Coin v1", "MXD": "Denarius", "MXGP": "MXGP Fan Token", @@ -11494,17 +11973,19 @@ "MXNBC": "Rekt Burgundy by Virtuals", "MXNT": "Tether MXNt", "MXRP": "Monsta XRP", - "MXT": "MixTrust", + "MXT": "MarteXcoin", "MXTC": "MartexCoin", "MXW": "Maxonrow", "MXX": "Multiplier", + "MXY": "Metaxy", "MXZ": "Maximus Coin", "MYB": "MyBit", - "MYC": "Mycelium", - "MYCE": "MY Ceremonial Event", + "MYC": "Myteamcoin", + "MYCE": "MYCE", "MYCELIUM": "Mycelium Token", "MYDFS": "MyDFS", "MYID": "My Identity Coin", + "MYIELD": "MuesliSwap Yield Token", "MYL": "MyLottoCoin", "MYLINX": "Linx", "MYLO": "MYLOCAT", @@ -11528,26 +12009,28 @@ "MYTOKEN": "MyToken", "MYTV": "MyTVchain", "MYX": "MYX Finance", + "MZ": "MetaZilla", "MZC": "MazaCoin", "MZERO": "MetaZero", "MZG": "Moozicore", "MZK": "Muzika Network", "MZM": "MetaZooMee", - "MZR": "Mazuri GameFi", + "MZR": "Mizar", "MZX": "Mosaic Network", "Medu": "Medusa", "N0031": "nYFI", "N1": "NFTify", "N3": "Network3", - "N3DR": "NeorderDAO ", + "N3DR": "NeorderDAO", "N3ON": "N3on", "N4T": "Nobel For Trump", "N64": "N64", "N7": "Number7", "N8V": "NativeCoin", + "NAAL": "Ethernaal", "NABOX": "Nabox", "NAC": "Nirvana Chain", - "NACHO": "Nacho the 𐤊at", + "NACHO": "Nacho", "NADA": "NADA Protocol Token", "NAFT": "Nafter", "NAGANO": "nagano", @@ -11559,18 +12042,18 @@ "NAKA": "Nakamoto Games", "NAKAV1": "Nakamoto Games v1", "NALA": "NALA", - "NALS": "NALS (Ordinals)", + "NALS": "NALS", "NAM": "Namacoin", "NAME": "PolkaDomain", "NAMEC": "Name Change Token", "NAMI": "Tsunami finance", "NAMO": "NamoCoin", "NAN": "NanoToken", - "NANA": "Bananace", + "NANA": "Chimp Fight", "NANAS": "BananaBits", "NANJ": "NANJCOIN", "NANO": "Nano", - "NAO": "Nettensor", + "NAO": "NFTDAO", "NAORIS": "Naoris Protocol", "NAOS": "NAOS Finance", "NAP": "Napoli Fan Token", @@ -11581,7 +12064,7 @@ "NASADOGE": "Nasa Doge", "NASDAQ420": "Nasdaq420", "NASH": "NeoWorld Cash", - "NASSR": "Alnassr FC Fan Token", + "NASSR": "Alnassr FC fan token", "NASTR": "Liquid ASTR", "NAT": "Natmin", "NATI": "IlluminatiCoin", @@ -11591,10 +12074,10 @@ "NATOR": "Pepenator", "NAUSICAA": "Nausicaa-Inu", "NAUT": "Nautilus Coin", - "NAV": "NavCoin", + "NAV": "Navcoin", "NAVAL": "NAVAL AI", "NAVC": "NavC token", - "NAVI": "Atlas Navi", + "NAVI": "Natus Vincere Fan Token", "NAVIA": "NaviAddress", "NAVIB": "Navibration", "NAVX": "NAVI Protocol", @@ -11602,6 +12085,7 @@ "NAWA": "Narwhale.finance", "NAWS": "NAWS.AI", "NAX": "NextDAO", + "NAXAR": "Boxch", "NAYM": "NAYM", "NAYUTA": "Nayuta Coin", "NAZ": "NAZDAQ", @@ -11612,26 +12096,27 @@ "NBABSC": "NBA BSC", "NBAI": "Nebula AI", "NBAR": "NOBAR", - "NBC": "Niobium", + "NBC": "Niobium Coin", "NBD": "Never Back Down", "NBISON": "Nebius Group (Ondo Tokenized)", "NBIT": "NetBit", "NBL": "Nobility", "NBLU": "NuriTopia", + "NBM": "NFTBlackMarket", "NBNG": "Nobunaga Token", "NBOT": "Naka Bodhi Token", "NBOX": "Unboxed", "NBP": "NFTBomb", "NBR": "Niobio Cash", "NBS": "New BitShares", - "NBT": "NanoByte", + "NBT": "NanoByte Token", "NBXC": "Nibble", "NC": "Nodecoin", "NCA": "NeuroCrypto Ads", - "NCASH": "Nucleus Vision", + "NCASH": "Nitro Network", "NCAT": "Neuracat", "NCC": "NeuroChain", - "NCDT": "Nuco.Cloud", + "NCDT": "Nuco.cloud", "NCN": "NeurochainAI", "NCO": "Nexacore", "NCOIN": "NatronZ", @@ -11644,7 +12129,7 @@ "NCT": "PolySwarm", "NCTR": "Nectar", "ND": "Nemesis Downfall", - "NDAU": "ndau", + "NDAU": "Ndau", "NDB": "NDB", "NDC": "NeverDie", "NDLC": "NeedleCoin", @@ -11656,12 +12141,13 @@ "NDX": "Indexed Finance", "NEADRAM": "The Ennead", "NEAL": "Coineal Token", - "NEAR": "Near", + "NEAR": "NEAR Protocol", "NEARK": "NearKat", "NEARX": "Stader NearX", "NEAT": "NEAT", "NEBL": "Neblio", "NEBNB": "Neuro BNB", + "NEBO": "CSP DAO", "NEBU": "Nebuchadnezzar", "NEC": "Nectar", "NEER": "Metaverse.Network Pioneer", @@ -11677,6 +12163,7 @@ "NEINEI": "Chinese Neiro", "NEIREI": "NeiRei", "NEIRO": "Neiro", + "NEIRO32521": "First Neiro On Ethereum", "NEIROC": "Neirocoin (neirocoin.club)", "NEIROCOIN": "Neiro Ethereum", "NEIROH": "NeiroWifHat", @@ -11693,11 +12180,14 @@ "NEMO": "NEMO", "NEMON": "Newmont (Ondo Tokenized)", "NEMS": "The Nemesis", - "NEO": "NEO", + "NEO": "Neo", + "NEOBOT": "NeoBot", + "NEOFI": "NeoFi", "NEOG": "NEO Gold", "NEOK": "NEOKingdom DAO", "NEOM": "New Earth Order Money", "NEON": "Neon EVM", + "NEON23015": "Neon EVM", "NEONAI": "NeonAI", "NEOS": "NeosCoin", "NEOX": "Neoxa", @@ -11705,17 +12195,19 @@ "NERD": "Nerd Bot", "NERDS": "NERDS", "NERF": "Neural Radiance Field", + "NERIAN": "Nerian Network", "NERO": "NERO Chain", "NEROTOKEN": "Nero Token", "NERVE": "NERVE", "NES": "Nest AI", - "NESS": "Ness LAB", - "NEST": "Nest Protocol", + "NESS": "Darkness Share", + "NEST": "NEST Protocol", + "NESTA": "Nest Arcade", "NESTREE": "Nestree", "NESTV1": "Nest Protocol v1", "NET": "NET", "NETA": "Negative Tax", - "NETC": "NetworkCoin", + "NETC": "Network Capital Token", "NETCOI": "NetCoin", "NETCOIN": "Netcoincapital", "NETCOINV1": "Netcoincapital v1", @@ -11744,6 +12236,7 @@ "NEVANETWORK": "Neva", "NEVE": "NEVER SURRENDER", "NEVER": "neversol", + "NEW": "Newton", "NEWB": "Newbium", "NEWBV1": "Newbium v1", "NEWC": "New Cat", @@ -11759,7 +12252,7 @@ "NEWT": "Newton Protocol", "NEWTON": "Newtonium", "NEWYORKCOIN": "NewYorkCoin", - "NEX": "Nash Exchange", + "NEX": "Nash", "NEXA": "Nexa", "NEXAI": "NexAI", "NEXBOX": "NexBox", @@ -11770,21 +12263,23 @@ "NEXM": "Nexum", "NEXMI": "NexMillionaires", "NEXMS": "NexMillionaires", - "NEXO": "NEXO", + "NEXO": "Nexo", "NEXOR": "Nexora", - "NEXT": "Connext Network", + "NEXT": "NEXT", "NEXTEX": "Next.exchange Token", "NEXTEXV1": "Next.exchange Token v1", "NEXTV1": "Connext Network", - "NEXUS": "Nexus", + "NEXUS": "Nexus Crypto Services", "NEXUSAI": "NexusAI", "NEXXO": "Nexxo", "NEZHA": "NEZHA", "NEZHATOKEN": "NezhaToken", + "NEZUKO": "Nezuko Inu", + "NFA": "NFTFundArt", "NFAI": "Not Financial Advice", "NFAIV1": "Not Financial Advice v1", "NFCR": "NFCore", - "NFD": "Feisty Doge NFT", + "NFD": "NIFDO Protocol", "NFE": "Edu3Labs", "NFLXON": "Netflix (Ondo Tokenized)", "NFLXX": "Netflix xStock", @@ -11792,9 +12287,11 @@ "NFN": "Nafen", "NFNT": "NFINITY AI", "NFP": "NFPrompt", + "NFP28778": "NFPrompt", "NFPV1": "Token NFPrompt Token v1", "NFT": "APENFT", "NFT11": "NFT11", + "NFT9816": "APENFT", "NFTART": "NFT Art Finance", "NFTB": "NFTb", "NFTBS": "NFTBooks", @@ -11802,6 +12299,7 @@ "NFTD": "NFTrade", "NFTE": "NFTEarthOFT", "NFTFI": "NFTfi", + "NFTFY": "Nftfy", "NFTI": "NFT Index", "NFTL": "NFTLaunch", "NFTLOOT": "NFTLootBox", @@ -11819,9 +12317,9 @@ "NFXC": "NFX Coin", "NFY": "Non-Fungible Yearn", "NGA": "NGA Tiger", - "NGC": "NagaCoin", + "NGC": "NAGA", "NGIN": "Ngin", - "NGL": "Entangle", + "NGL": "Gold Fever", "NGM": "e-Money", "NGMI": "NGMI Coin", "NGNT": "Naira Token", @@ -11845,14 +12343,16 @@ "NIFTYL": "Nifty League", "NIGELLA": "Nigella coin", "NIGHT": "Midnight", + "NIGHT39064": "Midnight", "NIGI": "Nigi", "NIH": "Nihao coin", "NIHAO": "NiHao", - "NII": "nahmii", + "NII": "Nahmii", "NIIFI": "NiiFi", "NIK": "NIKPLACE", "NIKO": "NikolAI", "NIL": "Nillion", + "NIL35702": "Nillion USD Price", "NILA": "MindWave", "NILE": "Nile", "NIM": "Nimiq", @@ -11877,6 +12377,7 @@ "NIQAB": "NIQAB WORLD ORDER", "NIRV": "Nirvana NIRV", "NIRVA": "Nirvana", + "NISHIB": "NitroShiba", "NIT": "Nesten", "NITEFEEDER": "Nitefeeder", "NITO": "Nitroken", @@ -11892,9 +12393,10 @@ "NKN": "NKN", "NKT": "NakomotoDark", "NKYC": "NKYC Token", - "NLC": "Nelore Coin", + "NLC": "NoLimitCoin", "NLC2": "NoLimitCoin", "NLG": "Gulden", + "NLIFE": "Night Life Crypto", "NLINK": "Neuralink", "NLK": "NuLink", "NLS": "Nolus", @@ -11908,14 +12410,15 @@ "NMKR": "NMKR", "NML": "No Mans Land", "NMR": "Numeraire", - "NMS": "Numus", + "NMS": "Nemesis Wealth Projects BSC", "NMSP": "Nemesis PRO", "NMT": "NetMind Token", - "NMX": "Nominex Token", + "NMT29447": "NetMind Token", + "NMX": "Nominex", "NNB": "NNB Token", "NNC": "NEO Name Credit", - "NNI": "NeoNomad Exchange", - "NNN": "Novem Gold", + "NNI": "NeoNomad", + "NNN": "Ninenoble", "NNT": "Nunu Spirits", "NOA": "NOA PLAY", "NOAH": "NOAHCOIN", @@ -11931,7 +12434,7 @@ "NODESYNAPSE": "NodeSynapse", "NODIDDY": "NODIDDY", "NODIS": "Nodis", - "NODL": "Nodle Network", + "NODL": "Nodle", "NOEL": "AskNoel", "NOGS": "Noggles", "NOHAT": "DogWifNoHat", @@ -11939,20 +12442,24 @@ "NOICE": "noice", "NOIRSHARES": "NoirShares", "NOIS": "Nois Network", + "NOISEGPT": "noiseGPT", "NOIZ": "NOIZ", "NOKA": "Noka Solana AI", "NOKU": "NOKU Master token", "NOKUV1": "NOKU Master token v1", "NOL": "NORDO MILE", "NOLA": "Nola", - "NOM": "Nomina", + "NOM": "Onomy Protocol", + "NOM38464": "Nomina", "NOMAI": "nomAI by Virtuals", "NOMNOM": "nomnom", "NOMOX": "NOMOEX Token", "NONE": "None Trading", + "NONI": "Farms of Ryoshi", "NOO": "Noocoin", "NOOB": "Blast Royale", "NOODS": "Noods", + "NOONE": "No one", "NOOOO": "NOOOO", "NOOT": "NOOT (Ordinals)", "NOPAIN": "No Pain No Gain", @@ -11970,7 +12477,7 @@ "NOTALION": "Not a lion, a...", "NOTC": "NOT", "NOTDOG": "NOTDOG", - "NOTE": "Republic Note", + "NOTE": "DNotes", "NOTECANTO": "Note", "NOTHING": "Youll own nothing & be happy", "NOTHINGCASH": "NOTHING", @@ -11980,12 +12487,14 @@ "NOV": "Novara Calcio Fan Token", "NOVA": "Nova Finance", "NOVAAI": "Nova AI", + "NOVO": "Novo", "NOW": "NOW Token", "NOWON": "ServiceNow (Ondo Tokenized)", "NOX": "NITRO", "NOXB": "Noxbox", "NPAS": "New Paradigm Assets Solution", "NPC": "Non-Playable Coin", + "NPC27960": "Non-Playable Coin", "NPCC": "NPCcoin", "NPCS": "Non-Playable Coin Solana", "NPER": "NPER", @@ -11994,9 +12503,9 @@ "NPLCV1": "PlusCoin v1", "NPM": "Neptune Mutual", "NPRO": "NPRO", - "NPT": "Neopin", + "NPT": "NEOPIN", "NPTX": "NeptuneX", - "NPX": "Napoleon X", + "NPX": "NaPoleonX", "NPXS": "Pundi X", "NPXSXEM": "Pundi X NEM", "NR1": "Number 1 Token", @@ -12018,10 +12527,11 @@ "NRX": "Neironix", "NS": "SuiNS Token", "NS2DRP": "New Silver Series 2 DROP", + "NS32942": "Sui Name Service", "NSBT": "Neutrino Token", "NSD": "Nasdacoin", "NSDX": "NASDEX", - "NSFW": "xxxNifty", + "NSFW": "Pleasure Coin", "NSH": "NOSHIT", "NSI": "nSights DeFi Trader", "NSIMPSON": "NeuraSimpson", @@ -12035,8 +12545,8 @@ "NSTK": "Unstake", "NSTR": "Nostra", "NSUR": "NSUR Coin", - "NSURE": "Nsure Network", - "NT": "NEXTYPE Finance", + "NSURE": "Nsure.Network", + "NT": "NEXTYPE", "NTB": "TokenAsset", "NTBC": "Note Blockchain", "NTC": "NineElevenTruthCoin", @@ -12046,25 +12556,28 @@ "NTK": "Neurotoken", "NTM": "NetM", "NTMPI": "Neutaro", + "NTN": "Naetion", "NTO": "Neton", - "NTR": "Nether", + "NTR": "Netrum", "NTRN": "Neutron", + "NTRN26680": "Neutron", "NTS": "Notarised", "NTV": "NativToken", "NTVRK": "Netvrk", "NTWK": "Network Token", - "NTX": "NuNet", + "NTX": "NitroEX", + "NTX13198": "NuNet USD", "NTY": "Nexty", "NU": "NuCypher", "NUA": "Neulaut Token", - "NUB": " nubcat", + "NUB": "nubcat", "NUBIS": "NubisCoin", "NUC": "NuCoin", "NUDE": "0xNude", "NUDES": "NUDES", "NUGGET": "Gegagedigedagedago", "NUKE": "NukeCoin", - "NULS": "Nuls", + "NULS": "NULS", "NUM": "Numbers Protocol", "NUMBERS": "NumbersCoin", "NUMI": "NUMINE Token", @@ -12077,16 +12590,16 @@ "NUT": "Native Utility Token", "NUTC": "Nutcash", "NUTGV2": "NUTGAIN", - "NUTS": "Thetanuts Finance", + "NUTS": "Squirrel Finance", "NUTSDAO": "NutsDAO", "NUTZ": "NUTZ", "NUUM": "MNet", "NUX": "Peanut", "NVA": "Neeva Defi", "NVB": "NovaBank", - "NVC": "NovaCoin", + "NVC": "Novacoin", "NVDAON": "NVIDIA (Ondo Tokenized)", - "NVDAX": "NVIDIA xStock", + "NVDAX": "NVIDIA tokenized stock (xStock)", "NVDX": "Nodvix", "NVG": "NightVerse Game", "NVG8": "Navigate", @@ -12099,7 +12612,7 @@ "NVT": "NerveNetwork", "NVX": "Novax Coin", "NVZN": "INVIZION", - "NWC": "Numerico", + "NWC": "Newscrypto", "NWCN": "NowCoin", "NWG": "NotWifGary", "NWIF": "neirowifhat", @@ -12110,28 +12623,30 @@ "NXD": "Nexus Dubai", "NXDT": "NXD Next", "NXE": "NXEcoin", - "NXM": "Nexus Mutual", + "NXM": "Wrapped NXM", "NXMC": "NextMindCoin", "NXN": "Naxion", - "NXPC": "NXPC", + "NXPC": "NEXPACE", "NXQ": "NexQloud", "NXRA": "AllianceBlock Nexera", "NXS": "Nexus", "NXT": "Nxt", "NXTI": "NXTI", - "NXTT": "Next Earth", + "NXTT": "NextEarth", "NXTTY": "NXTTY", "NYA": "Nya", - "NYAN": "NYAN", + "NYAN": "Nyancoin", + "NYAN-2": "Nyan V2", + "NYAN13140": "Nyan Heroes", "NYANCOIN": "NyanCoin", "NYANDOGE": "NyanDOGE International", "NYANTE": "Nyantereum International", "NYBBLE": "Nybble", - "NYC": "NYC", + "NYC": "NewYorkCoin", "NYCREC": "NYCREC", "NYE": "NewYork Exchange", "NYEX": "Nyerium", - "NYM": "Nym Token", + "NYM": "NYM", "NYN": "NYNJA", "NYS": "node.sys", "NYX": "NYXCOIN", @@ -12147,14 +12662,15 @@ "O3": "O3 Swap", "O4DX": "O4DX", "OAK": "Acorn Collective", - "OAS": "Oasis City", + "OAP": "OpenAlexa Protocol", + "OAS": "Oasys", "OASC": "Oasis City", "OASI": "Oasis Metaverse", - "OASIS": "OASIS", + "OASIS": "ProjectOasis", "OASISPLATFORM": "Oasis", "OAT": "OAT Network", - "OATH": "OATH Protocol", - "OAX": "Oax", + "OATH": "Oath", + "OAX": "OAX", "OB1INCH": "1inch (OmniBridge)", "OBABYTRUMP": "Official Baby Trump", "OBEMA": "burek obema", @@ -12164,12 +12680,12 @@ "OBOL": "Obol Network", "OBOT": "Obortech", "OBROK": "OBRok", - "OBS": "One Basis Cash", + "OBS": "One Basis", "OBSCURE": "Obscurebay", "OBSI": "Obsidium", - "OBSR": "OBSERVER Coin", + "OBSR": "Observer", "OBSUSHI": "Sushi (OmniBridge)", - "OBT": "Orbiter Token", + "OBT": "OB Token", "OBTC": "Obitan Chain", "OBVIOUS": "OBVIOUS COIN", "OBX": "OpenBlox", @@ -12178,9 +12694,9 @@ "OCAI": "Onchain AI", "OCAVU": "Ocavu Network Token", "OCB": "OneCoinBuy", - "OCC": "OccamFi", + "OCC": "Occam.Fi", "OCD": "On-Chain Dynamics", - "OCE": "OceanEX Token", + "OCE": "OceanEx Token", "OCEAN": "Ocean Protocol", "OCEANT": "Poseidon Foundation", "OCEANV1": "Ocean Protocol v1", @@ -12190,7 +12706,7 @@ "OCN": "Odyssey", "OCNEST": "OcNest AI", "OCO": "Owners Casino Online", - "OCP": "Omni Consumer Protocols", + "OCP": "OC Protocol", "OCPR": "OC Protocol", "OCRV": "Curve DAO Token (OmniBridge)", "OCT": "Octopus Network", @@ -12199,7 +12715,7 @@ "OCTAVUS": "Octavus Prime", "OCTAX": "OctaX", "OCTI": "Oction", - "OCTO": "OctonetAI", + "OCTO": "OctoFi", "OCTOCOIN": "Octocoin", "OCTOF": "OctoFi", "OCTOIN": "Octoin Coin", @@ -12212,7 +12728,7 @@ "ODGN": "OrdiGen", "ODIC": "ODIC Token", "ODIK": "ODIK", - "ODIN": "Odin Protocol", + "ODIN": "ODIN PROTOCOL", "ODMC": "ODMCoin", "ODN": "Obsidian", "ODNT": "Old Dogs New Tricks", @@ -12220,6 +12736,7 @@ "ODS": "Odesis", "ODX": "ODX Token", "ODYS": "OdysseyWallet", + "OETH": "Origin Ether", "OETHER": "Origin Ether", "OEX": "OEX", "OF": "OFCOIN", @@ -12233,6 +12750,7 @@ "OFFICI": "OFFICIAL BARRON", "OFFICIA": "Official Elon Coin", "OFFICIALUSA": "Official USA Token", + "OFI": "OFI.CASH", "OFINTOKEN": "OFIN Token", "OFN": "Openfabric AI", "OFT": "ONFA", @@ -12244,6 +12762,7 @@ "OGGY": "Oggy Inu", "OGLG": "OGLONG", "OGM": "OG Mickey", + "OGMF": "CryptoPirates", "OGN": "Origin Protocol", "OGO": "Origo", "OGOD": "GOTOGOD", @@ -12256,28 +12775,30 @@ "OGZ": "OGzClub", "OH": "Oh! Finance", "OHANDY": "Orbit Bridge Klaytn Handy", - "OHM": "Olympus", + "OHM": "Olympus v2", "OHMV2": "Olympus v2", "OHNO": "Oh no", "OHNOGG": "OHNHO (ohno.gg)", - "OHO": "OHO", + "OHO": "Oho", "OICOIN": "Osmium Investment Coin", "OIHON": "VanEck Oil Services ETF (Ondo Tokenized)", "OIIAOIIA": "spinning cat", "OIK": "Space Nation", - "OIL": "Oiler", + "OIL": "Oiler Network", "OILD": "OilWellCoin", "OILX": "OilX Token", "OIN": "OIN Finance", "OIO": "Online", "OJA": "Ojamu", "OJX": "Ojooo", - "OK": "OKCash", + "OK": "Okcash", "OKANE": "OKANE", "OKAYEG": "Okayeg", "OKB": "OKB", + "OKEN": "Okiku Kento", "OKG": "Ookeenga", "OKINAMI": "Kanagawa Nami", + "OKLG": "ok.lets.go.", "OKLP": "OkLetsPlay", "OKOIN": "OKOIN", "OKS": "Oikos", @@ -12293,13 +12814,14 @@ "OLE": "OpenLeverage", "OLEA": "Olea Token", "OLEV1": "OpenLeverage v1", - "OLIVE": "Olive", + "OLIVE": "Olive Cash", "OLIVIA": "AIGOV", + "OLO": "OolongSwap", "OLOID": "OLOID", "OLT": "OneLedger", "OLV": "OldV", "OLXA": "OLXA", - "OLY": "Olyseum", + "OLY": "Olyverse", "OLYMP": "OlympCoin", "OLYMPE": "OLYMPÉ", "OLYMPUSLABS": "Olympus Labs", @@ -12307,9 +12829,9 @@ "OM": "MANTRA", "OMA": "OmegaCoin", "OMALLEY": "O'Malley", - "OMAX": "Omax", + "OMAX": "Omax Coin", "OMAXV1": "Omax v1", - "OMC": "Omchain", + "OMC": "Ormeus Cash", "OMD": "OneMillionDollars", "OME": "o-mee", "OMEGA": "OMEGA", @@ -12318,12 +12840,15 @@ "OMG": "OMG Network", "OMGC": "OmiseGO Classic", "OMI": "ECOMI", + "OMI19075": "ECOMI", "OMIC": "Omicron", "OMIKAMI": "Amaterasu Omikami", "OMIX": "Omix", "OMMI": "Ommniverse", - "OMNI": "Omni Network", - "OMNIA": "OMNIA Protocol", + "OMN": "Omega Network", + "OMNI": "Omni", + "OMNI30315": "Omni Network", + "OMNIA": "OmniaVerse", "OMNIAV1": "OmniaVerse v1", "OMNIAV2": "OmniaVerse", "OMNIC": "OmniCat", @@ -12334,7 +12859,7 @@ "OMNIXIO": "OMNIX", "OMNOM": "Doge Eat Doge", "OMNOMN": "Omega Network", - "OMT": "Oracle Meta Technologies", + "OMT": "Open Meta Trade", "OMV1": "OM Token (v1)", "OMX": "Project Shivom", "OMZ": "Open Meta City", @@ -12346,8 +12871,9 @@ "ONDOAI": "Ondo DeFAI", "ONDSON": "Ondas Holdings (Ondo Tokenized)", "ONE": "Harmony", + "ONE3945": "Harmony", "ONEROOT": "OneRoot Network", - "ONES": "OneSwap DAO", + "ONES": "OneSwap DAO Token", "ONET": "ONE Token", "ONEX": "ONE TECH", "ONF": "ONF Token", @@ -12355,6 +12881,7 @@ "ONGAS": "Ontology Gas", "ONI": "ONINO", "ONIG": "Onigiri", + "ONIGI": "Onigiri Neko", "ONIGIRI": "Onigiri The Cat", "ONION": "DeepOnion", "ONIT": "ONBUFF", @@ -12369,17 +12896,18 @@ "ONOMY": "Onomy Protocol", "ONOT": "ONO", "ONS": "One Share", - "ONSTON": "Onston", + "ONSTON": "ONSTON", "ONT": "Ontology", "ONTACT": "OnTact", + "ONTOLOGY-GAS": "Ontology Gas", "ONUS": "ONUS", - "ONX": "OnX.finance", - "OOB": "Oobit", + "ONX": "Onix", + "OOB": "OOBIT", "OOBV1": "Oobit", "OOE": "OpenOcean", "OOFP": "OOFP", "OOGI": "OOGI", - "OOKI": "Ooki", + "OOKI": "Ooki Protocol", "OOKS": "Onooks", "OOM": "OomerBot", "OOOO": "oooo", @@ -12389,16 +12917,18 @@ "OOT": "Utrum", "OOW": "OPP Open WiFi", "OP": "Optimism", - "OPA": "Option Panda Platform", + "OPA": "OptionPanda", "OPAD": "OpenPad AI", "OPAI": "Optopia AI", "OPAIG": "OvalPixel", + "OPAL": "Opal", "OPC": "OP Coin", "OPCA": "OP_CAT(BIP-420)", "OPCAT": "OPCAT", "OPCATVIP": "OP_CAT", "OPCT": "Opacity", - "OPEN": "OpenLedger", + "OPEN": "Open Custody Protocol", + "OPEN37456": "OpenLedger", "OPENAI": "OpenAI ERC", "OPENCHAT": "OpenChat", "OPENCUSTODY": "Open Custody Protocol", @@ -12436,10 +12966,11 @@ "OPSV1": "Octopus Protocol v1", "OPSV2": "Octopus Protocol v2", "OPT": "Opus", + "OPT2": "Optimus OPT2", "OPTA": "Opta Global", "OPTC": "Open Predict Token", "OPTCM": "Optimus", - "OPTI": "Optimus AI", + "OPTI": "OptiToken", "OPTIG": "Catgirl Optimus", "OPTIM": "Optimus X", "OPTIMOUSE": "Optimouse", @@ -12458,12 +12989,12 @@ "ORACLER": "Oracler", "ORACOLXOR": "Oracolxor", "ORACUL": "Oracul Ai", - "ORAI": "Oraichain Token", + "ORAI": "Oraichain", "ORAIX": "OraiDEX", "ORANGE": "Annoying Orange", "ORAO": "ORAO Network", "ORARE": "OneRare", - "ORB": "KlayCity ORB", + "ORB": "OrbCity", "ORBD": "OrbitEdge", "ORBI": "Orbs", "ORBIS": "Orbis", @@ -12480,7 +13011,7 @@ "ORCLX": "Oracle xStock", "ORD": "ordinex", "ORDER": "Orderly Network", - "ORDI": "Ordinals ", + "ORDI": "Ordinals", "ORDI2": "ORDI 2.0", "ORDIFI": "OrdinalsFi", "ORDIN": "ORDINAL HODL MEME", @@ -12503,15 +13034,17 @@ "ORME": "Ormeus Coin", "ORMO": "Ormolus", "ORN": "Orion Protocol", + "ORNE": "Orne", "ORNG": "Juice Town", "ORNJ": "Orange", - "ORO": "Operon Origins", + "ORO": "ORO", "OROC": "Orocrypt", "OROCOIN": "OroCoin", "OROP": "ORO", "OROX": "Cointorox", - "ORS": "ORS Group", - "ORT": "Okratech Token", + "ORPO": "ORPO", + "ORS": "Origin Sport", + "ORT": "Omni Real Estate Token", "ORV": "Orvium", "ORYX": "OryxCoin", "OS": "Ethereans", @@ -12525,7 +13058,7 @@ "OSETH": "StakeWise Staked ETH", "OSF": "One Solution", "OSH": "OSHI", - "OSHI": "Oshi Token", + "OSHI": "OSHI", "OSIS": "OSIS", "OSK": "OSK", "OSKDAO": "OSK DAO", @@ -12545,6 +13078,7 @@ "OTK": "Octokn", "OTN": "Open Trading Network", "OTO": "OTOCASH", + "OTRUMP": "Trump Official", "OTSEA": "OTSea", "OTT": "Coost", "OTTERHOME": "OtterHome", @@ -12553,6 +13087,7 @@ "OUCHI": "OUCHI", "OUD": "OUD", "OUR": "Our Pay", + "OURO": "Ouroboros", "OUSD": "Origin Dollar", "OUSDC": "Orbit Bridge Klaytn USDC", "OUSE": "OUSE Token", @@ -12566,30 +13101,31 @@ "OVERLORD": "Overlord", "OVL": "Overlay", "OVN": "Overnight", - "OVO": "OVO", + "OVO": "Ovato", "OVPP": "OpenVPP", - "OVR": "Ovr", + "OVR": "OVR", "OWB": "OWB", - "OWC": "Oduwa", + "OWC": "Oduwacoin", "OWD": "Owlstand", "OWL": "Owlto", "OWLTOKEN": "OWL Token", - "OWN": "OTHERWORLD", + "OWN": "OWNDATA", "OWNDATA": "OWNDATA", "OWNLY": "Ownly", "OWO": "SoMon", "OWOCOIN": "Owo", - "OX": "Open Exchange Token", + "OX": "OrcaX", + "OX26543": "Open Exchange Token", "OXAI": "OxAI.com", - "OXB": "Oxbull Tech", - "OXBT": "OXBT (Ordinals)", + "OXB": "Oxbull.tech", + "OXBT": "OXBT", "OXD": "0xDAO", "OXEN": "Oxen", "OXM": "OXM Protocol", "OXN": "0xNumber", "OXO": "OXO Network", - "OXS": "0xS", - "OXT": "Orchid Protocol", + "OXS": "Oxbull Solana", + "OXT": "Orchid", "OXY": "Oxygen", "OXY2": "Cryptoxygen", "OXYC": "Oxycoin", @@ -12600,22 +13136,23 @@ "OZMPC": "Ozempic", "OZNI": "Ni Token", "OZO": "Ozone Chain", - "OZONE": "Ozone metaverse", + "OZONE": "Ozonechain", "OZONEC": "Ozonechain", "OZP": "OZAPHYRE", "P": "PoP Planet", "P1": "PEPE ONE", "P202": "Project 202", "P2P": "Sentinel", - "P2PS": "P2P Solutions Foundation", + "P2PS": "P2P Solutions foundation", "P2PV1": "Sentinel", "P33L": "THE P33L", "P3D": "3DPass", "P404": "Potion 404", + "P4D": "PoSH4D", "PAAL": "PAAL AI", "PAALV1": "PAAL AI v1", "PABLO": "PABLO DEFI", - "PAC": "PacMoon", + "PAC": "PAC Protocol", "PACE": "3space Art", "PACK": "HashPack", "PACM": "Pacman Blastoff", @@ -12623,13 +13160,13 @@ "PACO": "Paco", "PACOCA": "Pacoca", "PACP": "PAC Protocol", - "PACT": "impactMarket", + "PACT": "PACT community token", "PACTTOKEN": "PACT community token", "PACTV1": "impactMarket v1", - "PAD": "NearPad", + "PAD": "SmartPad", "PAF": "Pacific", "PAGE": "Page", - "PAI": "ParallelAI", + "PAI": "Project Pai", "PAID": "PAID Network", "PAIDV1": "PAID Network v1", "PAIN": "PAIN", @@ -12646,6 +13183,7 @@ "PALG": "PalGold", "PALLA": "Pallapay", "PALM": "PaLM AI", + "PALM28567": "PaLM AI", "PALMECO": "Palm Economy", "PALMO": "ORCIB", "PALMP": "PalmPay", @@ -12708,9 +13246,9 @@ "PARRY": "Parry Parrot", "PART": "Particl", "PARTI": "PARTI Token", - "PARTY": "Party", + "PARTY": "MONEY PARTY", "PAS": "Passive Coin", - "PASC": "Pascal Coin", + "PASC": "Pascal", "PASG": "Passage", "PASL": "Pascal Lite", "PASS": "Blockpass", @@ -12722,16 +13260,18 @@ "PATRIOT": "Patriot", "PATTON": "Patton", "PAUL": "Elephant Penguin", + "PAVAX": "Ripae AVAX", "PAVEON": "Global X US Infrastructure Development ETF (Ondo Tokenized)", "PAVIA": "Pavia", "PAVO": "Pavocoin", - "PAW": "PAWSWAP", + "PAW": "PAW", "PAWPAW": "PawPaw", "PAWS": "PAWS", "PAWSE": "PAWSE", "PAWSTA": "dogeatingpasta", "PAWSTARS": "PawStars", "PAWTH": "Pawthereum", + "PAX": "Pax Dollar", "PAXE": "Paxe", "PAXEX": "PAXEX", "PAXG": "PAX Gold", @@ -12767,14 +13307,15 @@ "PBTC35A": "pBTC35A", "PBTCV1": "pTokens BTC v1", "PBUX": "Playbux", - "PBX": "Probinex", + "PBX": "Paribus", "PBXV1": "Probinex v1", "PC": "Promotion Coin", "PCC": "PCORE", "PCCM": "Poseidon Chain", - "PCD": " Phecda", + "PCD": "Phecda", "PCE": "PEACE COIN", "PCH": "Pichi", + "PCHF": "peachfolio", "PCHS": "Peaches.Finance", "PCI": "PayProtocol Paycoin", "PCKB": "pCKB (via Godwoken Bridge from CKB)", @@ -12809,7 +13350,7 @@ "PDX": "PDX Coin", "PE": "Pe", "PEA": "Pea Farm", - "PEACH": "Based Peaches", + "PEACH": "Peach Inu", "PEACHY": "Peachy", "PEAGUY": "The Pea Guy by Virtuals", "PEAK": "PEAKDEFI", @@ -12817,15 +13358,16 @@ "PEANIE": "Peanie", "PEANU": "PEANUT INU", "PEANUT": "Peanut", - "PEAQ": "peaq", - "PEAR": "Pear Swap", - "PEARL": "Pearl Finance", + "PEAQ": "Peaq", + "PEAR": "PearZap", + "PEARL": "Pearl", "PEAS": "Peapods Finance", - "PEBBLE": "Etherrock #72", + "PEBBLE": "Etherrock#72", "PEBIRD": "PEPE BIRD", "PEC": "PeaceCoin", "PECH": "PEPE CASH", "PECL": "PECland", + "PECO": "Amun Polygon Ecosystem Index", "PED": "PEDRO", "PEDRO": "Pedro The Raccoon", "PEE": "peecoin", @@ -12852,7 +13394,7 @@ "PEKC": "Peacock Coin", "PEKINU": "PEKI INU", "PEKO": "Pepe Neko", - "PEL": "Propel Token", + "PEL": "Propel", "PELF": "PELFORT", "PELL": "PELL Network Token", "PEM": "Pembrock", @@ -12875,6 +13417,7 @@ "PENR": "Penrose Finance", "PENTA": "Penta", "PENTAG": "Pentagon", + "PEO": "PepeCEO", "PEON": "Peon", "PEOPLE": "ConstitutionDAO", "PEOPLEFB": "PEOPLE", @@ -12888,8 +13431,9 @@ "PEPE2": "Pepe 2.0", "PEPE2024": "Olympic Pepe 2024", "PEPE20V1": "Pepe 2.0 v1", + "PEPE24478": "Pepe", "PEPEA": "Pepeandybrettlandwolf", - "PEPEAI": "Pepe Analytics", + "PEPEAI": "PepeAI", "PEPEARMY": "PEPEARMY", "PEPEB": "PEPEBOMB", "PEPEBNB": "Pepe The Frog", @@ -12904,6 +13448,7 @@ "PEPECHAIN": "PEPE Chain", "PEPECO": "PEPE COIN BSC", "PEPECOIN": "PepeCoin", + "PEPECOLA": "PepeCola", "PEPED": "PepeDAO Coin", "PEPEDAO": "PEPE DAO", "PEPEDERP": "PepeDerp", @@ -12928,8 +13473,9 @@ "PEPENODE": "PEPENODE", "PEPEOFSOL": "Pepe of Solana", "PEPEPI": "PEPEPi", - "PEPER": "Baby Pepe", + "PEPER": "Peper Token", "PEPERA": "PEPERA", + "PEPES": "McPepe's", "PEPESDOG": "Pepes Dog", "PEPESOL": "PEPE SOL", "PEPESOLCTO": "Pepe (pepesolcto.vip)", @@ -12941,6 +13487,7 @@ "PEPEWIFHAT": "Pepewifhat", "PEPEWO": "PEPE World", "PEPEX": "pepeX", + "PEPEXL": "PepeXL", "PEPEYE2": "PEPEYE 2.0", "PEPEZILLA": "PEPEZilla", "PEPI": "PEPI", @@ -12949,7 +13496,7 @@ "PEPLO": "Peplo Escobar", "PEPO": "Peepo", "PEPOC": "Pepoclown", - "PEPPA": "PEPPA", + "PEPPA": "Peppa", "PEPPER": "Pepper Token", "PEPS": "PEPS Coin", "PEPU": "Pepe Unchained", @@ -12962,11 +13509,11 @@ "PERC": "Perion", "PERCY": "Percy Verence", "PERI": "PERI Finance", - "PERKSCOIN": "PerksCoin ", + "PERKSCOIN": "PerksCoin", "PERL": "PERL.eco", "PERMIAN": "Permian", "PERP": "Perpetual Protocol", - "PERRY": "Perry The BNB", + "PERRY": "Swaperry", "PERU": "PeruCoin", "PERX": "PeerEx Network", "PESA": "Credible", @@ -12980,17 +13527,17 @@ "PETERTODD": "Peter Todd", "PETF": "PEPE ETF", "PETG": "Pet Games", - "PETH": "pETH", + "PETH": "PumpETH", "PETL": "Petlife", "PETN": "Pylon Eco Token", "PETO": "Petoverse", "PETOSHI": "Petoshi", - "PETS": "PolkaPets", + "PETS": "MicroPets", "PETT": "Pett Network", "PETUNIA": "Petunia", "PEUSD": "peg-eUSD", "PEW": "pepe in a memes world", - "PEX": "Pexcoin", + "PEX": "PosEx", "PF": "Purple Frog", "PFEON": "Pfizer (Ondo Tokenized)", "PFEX": "Pfizer xStock", @@ -13009,20 +13556,21 @@ "PGF7T": "PGF500", "PGL": "Prospectors", "PGN": "Paragon", - "PGOLD": " Polkagold", + "PGOLD": "Polkagold", "PGPT": "PrivateAI", "PGROK": "Papa Grok", + "PGS": "Pegasus PoW", "PGT": "Polyient Games Governance Token", "PGTS": "Puregold token", "PGU": "Polyient Games Unity", - "PGX": "Procter & Gamble xStock", + "PGX": "Pegaxy", "PHA": "Phala Network", "PHAE": "Phaeton", "PHALA": "Phalanx", "PHAME": "PHAME", "PHAR": "Pharaoh", "PHAUNTEM": "Phauntem", - "PHB": "Phoenix Global [v2]", + "PHB": "Phoenix", "PHBD": "Polygon HBD", "PHCR": "PhotoChromic", "PHEN": "Phenx", @@ -13039,26 +13587,28 @@ "PHNX": "PhoenixDAO", "PHO": "Photon", "PHOENIX": "Phoenix Finance", - "PHONON": "Phonon DAO ", + "PHONON": "Phonon DAO", "PHOON": "Typhoon Cash", "PHORE": "Phore", - "PHR": "Phreak", + "PHR": "Phore", "PHRYG": "PHRYGES", "PHRYGE": "PHRYGES", "PHRYGES": "The Phryges", "PHRZ": "Pharaohs", "PHS": "PhilosophersStone", - "PHT": "Photon Token", + "PHT": "ParadiseHotel NFT", "PHTC": "Photochain", "PHTR": "Phuture", "PHUN": "PHUNWARE", "PHV": "PATHHIVE", + "PHX": "Phoenix Global [old]", "PHY": "DePHY", - "PI": "Pi Network", + "PI": "Plian", + "PI35697": "Pi", "PIA": "Olympia AI", "PIAI": "Pi Network AI", "PIAS": "PIAS", - "PIB": "Pibble", + "PIB": "PIBBLE", "PICA": "Picasso", "PICAARTMONEY": "PicaArtMoney", "PICKL": "PICKLE", @@ -13069,8 +13619,8 @@ "PIDOGE": "Pi Network Doge", "PIE": "Persistent Information Exchange", "PIERRE": "sacré bleu", - "PIEVERSE": "Pieverse Token", - "PIF": "Pepe Wif Hat", + "PIEVERSE": "Pieverse", + "PIF": "Play It Forward DAO", "PIG": "Pig Finance", "PIGC": "Pigcoin", "PIGE": "Pige", @@ -13084,7 +13634,7 @@ "PIGS": "Elon Vitalik Pigs", "PIIN": "piin (Ordinals)", "PIK": "Pika Protocol", - "PIKA": "Pikaboss", + "PIKA": "Pika", "PIKACHU": "Pikachu Inu", "PIKACRYPTO": "Pika", "PIKAM": "Pikamoon", @@ -13095,22 +13645,24 @@ "PILLAR": "PillarFi", "PILOT": "Unipilot", "PIM": "PIM", - "PIN": "PinLink", + "PIN": "Public Index Network", "PINCHAIN": "Pin", "PINCHI": "Da Pinchi", "PINE": "Pine", + "PINETWORK": "PI", "PINETWORKDEFI": "Pi Network DeFi", "PINEYE": "PinEye", - "PING": "Ping", + "PING": "Sonar", "PINGO": "PinGo", "PINGPONG": "PINGPONG Token", - "PINK": "PINK - The Panther", + "PINK": "Pinkcoin", "PINKCOIN": "PinkCoin", "PINKSALE": "PinkSale", "PINKX": "PantherCoin", "PINMO": "Pinmo", "PINO": "Pinocchu", "PINS": "PINs Network Token", + "PINU": "Piccolo Inu", "PINU100X": "Pi INU 100x", "PIO": "Pioneershares", "PIP": "Pip", @@ -13120,10 +13672,11 @@ "PIPL": "PiplCoin", "PIPO": "Pipo", "PIPONHL": "PiP", - "PIPPIN": "pippin", + "PIPPIN": "pippin USD Price", "PIPPKIN": "Pippkin The Horse", "PIPT": "Power Index Pool Token", - "PIRATE": "Pirate Nation", + "PIRATE": "PirateCash", + "PIRATE31704": "Pirate Nation", "PIRATECASH": "PirateCash", "PIRATECASHV1": "PirateCash v1", "PIRATECASHV2": "PirateCash v2 (PirateCash Telegram bot)", @@ -13132,7 +13685,7 @@ "PIRI": "Pirichain", "PIRL": "Pirl", "PIS": "Polkainsure Finance", - "PIST": "Pist Trust", + "PIST": "PIST TRUST", "PIT": "Pitbull", "PITCH": "PITCH", "PITCHFINANCE": "Pitch Finance Token", @@ -13140,13 +13693,14 @@ "PIUU": "PIXIU", "PIVN": "PIVN", "PIVOTTOKEN": "Pivot Token", - "PIVX": "Private Instant Verified Transaction", + "PIVX": "PIVX", "PIX": "PixelSwap", "PIXEL": "Pixels", + "PIXEL29335": "Pixels", "PIXELV": "PixelVerse", "PIXFI": "Pixelverse", "PIXL": "PIXL", - "PIZA": "PIZA", + "PIZA": "HalfPizza", "PIZPEPE": "Pepe Pizzeria", "PIZZA": "Pizza", "PIZZACOIN": "PizzaCoin", @@ -13156,13 +13710,14 @@ "PKB": "ParkByte", "PKC": "Pikciochain", "PKD": "PetKingdom", - "PKF": "PolkaFoundry", + "PKF": "Red Kite", "PKG": "PKG Token", "PKIN": "PUMPKIN", "PKM": "Pockemy", "PKN": "Poken", - "PKOIN": "Pocketcoin", + "PKOIN": "Pkoin", "PKT": "PKT", + "PKTK": "Peak Token", "PLA": "PlayDapp", "PLAAS": "PLAAS FARMERS TOKEN", "PLAC": "PLANET", @@ -13181,9 +13736,10 @@ "PLATC": "PlatinCoin", "PLATINUM": "Platinum", "PLATO": "Plato Game", - "PLAY": "Play", + "PLAY": "HEROcoin", "PLAYC": "PlayChip", "PLAYCOIN": "PlayCoin", + "PLAYDAPP": "PlayDapp", "PLAYFUN": "PLAYFUN", "PLAYKEY": "Playkey", "PLAYSOLANA": "Play Solana", @@ -13202,7 +13758,7 @@ "PLENTY": "Plenty DeFi", "PLEO": "Empleos", "PLERF": "Plerf", - "PLEX": "PLEX", + "PLEX": "MinePlex", "PLEXCOIN": "PlexCoin", "PLF": "PlayFuel", "PLG": "Pledgecamp", @@ -13210,11 +13766,12 @@ "PLI": "Plugin", "PLIAN": "Plian", "PLINK": "Chainlink (Polygon Portal)", + "PLKR": "Polker", "PLM": "Plasmonics", "PLMC": "Polimec", "PLMS": "Polemos", "PLMT": "Pallium", - "PLNC": "PLNCoin", + "PLNC": "PLNcoin", "PLNX": "Planumex", "PLOT": "PlotX", "PLPA": "Palapa", @@ -13227,7 +13784,7 @@ "PLSPAD": "PulsePad", "PLSRDNT": "Plutus RDNT", "PLSX": "PulseX", - "PLT": "Poollotto.finance", + "PLT": "Poollotto Finance", "PLTC": "PlatonCoin", "PLTRON": "Palantir Technologies (Ondo Tokenized)", "PLTRX": "Palantir xStock", @@ -13242,22 +13799,23 @@ "PLURA": "PluraCoin", "PLUS1": "PlusOneCoin", "PLUTUS": "PlutusDAO", - "PLX": "Planet Labs xStock", + "PLX": "PolyDEX", "PLXY": "Plxyer", - "PLY": "Aurigami", + "PLY": "PlayNity", "PLYR": "PLYR L1", "PLZ": "PLUNZ", "PM": "PumpMeme", "PMA": "PumaPay", + "PMATIC": "Ripae pMATIC", "PMD": "Pandemic Multiverse", "PME": "DogePome", - "PMEER": "Qitmeer", - "PMG": "Pomerium Ecosystem Token", + "PMEER": "Pmeer", + "PMG": "PMG Coin", "PMGT": "Perth Mint Gold Token", "PMKR": "Maker (Polygon Portal)", "PMM": "Perpetual Motion Machine", "PMNT": "Paymon", - "PMON": "Polkamon", + "PMON": "Polychain Monsters", "PMOON": "Pookimoon", "PMPY": "Prometheum Prodigy", "PMR": "Pomerium Utility Token", @@ -13271,23 +13829,25 @@ "PNDC": "Pond Coin", "PNDN": "Pandana", "PNDO": "Pondo", - "PNDR": "Pandora Finance", + "PNDR": "Pandora Protocol", "PNFT": "Pawn My NFT", "PNG": "Pangolin", "PNGDA": "Pengda Yellow Panda", "PNGN": "SpacePenguin", "PNIC": "Phoenic", "PNK": "Kleros", - "PNL": "True PNL", + "PNL": "TruePNL", "PNODE": "Pinknode", - "PNT": "pNetwork Token", + "PNP": "Penpie", + "PNT": "Penta", + "PNT5794": "pNetwork", "PNUT": "Peanut the Squirrel", "PNUTDOGE": "PNUT DOGE", "PNUTRUMP": "Peanut Trump", "PNUTS": "Pnuts for squirrel", "PNX": "PhantomX", "PNY": "Peony Coin", - "POA": "Poa Network", + "POA": "POA Network", "POAI": "Port AI", "POC": "POC Blockchain", "POCAT": "Polite Cat", @@ -13300,7 +13860,7 @@ "PODFAST": "PodFast", "PODIUM": "Smart League", "PODO": "Power Of Deep Ocean", - "POE": "Portal Network", + "POE": "Po.et", "POET": "Po.et", "POFU": "POFU", "POG": "PolygonumOnline", @@ -13314,22 +13874,25 @@ "POKEMO": "Pokemon", "POKEMON": "Pokemon", "POKER": "PokerCoin", - "POKERFI": "PokerFi", + "POKERFI": "PokerFI.Finance", "POKKY": "Pokky Cat", + "POKMON": "Pokmon", "POKO": "POKOMON", "POKT": "Pocket Network", - "POL": "Polygon Ecosystem Token", + "POL": "Polygon (ex-MATIC)", + "POL28321": "POL (ex-MATIC)", "POLA": "Polaris Share", "POLAO": "Pola On Base", - "POLAR": "Polaris", - "POLC": "Polka City", + "POLAR": "Polar", + "POLC": "Polkacity", "POLI": "Polinate", - "POLIS": "Star Atlas DAO", + "POLIS": "Polis", + "POLIS11213": "Star Atlas DAO", "POLISPLAY": "PolisPay", "POLK": "Polkamarkets", "POLKER": "Polker", "POLL": "Pollchain", - "POLLEN": "Beraborrow", + "POLLEN": "Pollen", "POLLUK": "Jasse Polluk", "POLLUX": "Pollux Coin", "POLLY": "Polly Penguin", @@ -13338,7 +13901,7 @@ "POLS": "Polkastarter", "POLVEN": "Polka Ventures", "POLX": "Polylastic", - "POLY": "Polymath Network", + "POLY": "Polymath", "POLYCUB": "PolyCub", "POLYDOGE": "PolyDoge", "POLYN": "Polynetica", @@ -13356,14 +13919,15 @@ "PONKEI": "Chinese Ponkei the Original", "PONTEM": "Pontem Liquidswap", "PONYO": "Ponyo Impact", - "PONZI": "Ponzi", + "PONZI": "PonziCoin", "PONZIO": "Ponzio The Cat", "PONZU": "Ponzu Inu", "POO": "POOMOON", "POOC": "Poo Chi", "POOCOIN": "PooCoin", - "POODL": "Poodl", + "POODL": "Poodl Token", "POODOGE": "Poo Doge", + "POOF": "Poof.cash", "POOH": "POOH", "POOKU": "Pooku", "POOL": "PoolTogether", @@ -13372,10 +13936,12 @@ "POOLZ": "Poolz Finance", "POOP": "Poopsicle", "POOPC": "Poopcoin", + "POOPE": "Poope", "POOWEL": "Joram Poowel", - "POP": "Zypher Network", + "POP": "POP Network Token", "POPC": "PopChest", - "POPCAT": "Popcat", + "POPCAT": "Popcat (SOL)", + "POPCAT28782": "Popcat (SOL)", "POPCHAIN": "POPCHAIN", "POPCO": "Popcorn", "POPCOIN": "Popcoin", @@ -13395,24 +13961,26 @@ "POR": "Portugal National Team Fan Token", "PORA": "PORA AI", "PORK": "PepeFork", + "PORK29220": "PepeFork", "PORKE": "PONKE FORK", "PORKINU": "PepeFork INU", "PORNROCKET": "PornRocket", - "PORT": "Port Finance", + "PORT": "PackagePortal", "PORT3": "Port3 Network", "PORT3V2": "Port3 Network v2", "PORTAL": "Portal", + "PORTAL29555": "Portal Gaming", "PORTALS": "Portals", "PORTALTOKEN": "Portal", - "PORTO": "FC Porto", + "PORTO": "FC Porto Fan Token", "PORTU": "Portuma", "PORTX": "ChainPort", "POS": "PoSToken", "POSEX": "PosEx", - "POSI": "Position Token", + "POSI": "Position Exchange", "POSQ": "Poseidon Quark", "POSS": "Posschain", - "POST": "InterPlanetary Search Engine", + "POST": "PostCoin", "POSTC": "PostCoin", "POSW": "PoSW Coin", "POT": "PotCoin", @@ -13427,19 +13995,20 @@ "POUW": "Pouwifhat", "POW": "PowBlocks", "POWELL": "Jerome Powell", - "POWER": "Power", + "POWER": "UniPower", "POWERLOOM": "Powerloom Token", "POWERMARKET": "POWER MARKET", - "POWR": "Power Ledger", + "POWR": "Powerledger", "POWSCHE": "Powsche", - "POX": "Monkey Pox", + "POX": "Pollux Coin", "POZO": "Pozo Coin", - "PP": "ProducePay Chain", + "PP": "Phoenix Protocol Dao", "PPAD": "PlayPad", + "PPAI": "ThePepe.AI", "PPALPHA": "Phoenix Protocol", "PPAY": "Plasma Finance", "PPBLZ": "Pepemon Pepeballs", - "PPC": "PeerCoin", + "PPC": "Peercoin", "PPCOIN": "Project Plutus", "PPFT": "Papparico Finance", "PPI": "Primpy", @@ -13448,18 +14017,20 @@ "PPLTON": "abrdn Physical Platinum Shares ETF (Ondo Tokenized)", "PPM": "Punk Panda Messenger", "PPN": "Puppies Network", + "PPOLL": "PancakePoll", "PPOVR": "POVR", "PPP": "PayPie", "PPR": "Papyrus", "PPS": "PopulStay", - "PPT": "Pop Token", + "PPT": "Populous", "PPX": "Prophex", "PPY": "Peerplays", "PQT": "Prediqt", "PRA": "ProChain", "PRAI": "Privasea AI", - "PRARE": "Polkarare", + "PRARE": "POLKARARE", "PRB": "Paribu Net", + "PRBLY": "Probably Nothing", "PRC": "ProsperCoin", "PRCH": "Power Cash", "PRCL": "Parcl", @@ -13483,7 +14054,7 @@ "PRESSX": "PressX", "PRFT": "Proof Suite Token", "PRG": "Paragon", - "PRI": "PRIVATEUM INITIATIVE", + "PRI": "PRIVATEUM GLOBAL", "PRIA": "PRIA", "PRICELESS": "Priceless", "PRICK": "Pickle Rick", @@ -13491,22 +14062,25 @@ "PRIMAL": "PRIMAL", "PRIMATE": "Primate", "PRIME": "Echelon Prime", + "PRIME23711": "Echelon Prime", "PRIMECHAIN": "PrimeChain", "PRIMECOIN": "PrimeCoin", "PRIMEETH": "Prime Staked ETH", "PRIMEX": "Primex Finance", + "PRIMO": "Primo DAO", "PRIN": "Print The Pepe", "PRINT": "Printer.Finance", "PRINTERIUM": "Printerium", "PRINTS": "FingerprintsDAO", "PRISM": "Prism", "PRISMA": "Prisma Finance", + "PRIV": "PRiVCY", "PRIVIX": "Privix", "PRIX": "Privatix", - "PRL": "Perle", + "PRL": "The Parallel", "PRM": "PrismChain", "PRMX": "PREMA", - "PRNT": "Prime Numbers", + "PRNT": "Prime Numbers Labs Ecosystem", "PRO": "Propy", "PROB": "ProBit Token", "PROC": "ProCurrency", @@ -13518,16 +14092,19 @@ "PROJECTARENA": "Arena", "PROJECTPAI": "Project Pai", "PROLIFIC": "Prolific Game Studio", - "PROM": "Prometeus", - "PROMPT": "Wayfinder", + "PROM": "Prom", + "PROME": "Prometheus Trading", + "PROMPT": "Wayfinder USD Price", + "PRON": "Rise Of Nebula", "PROOF": "PROVER", "PROP": "Propeller", "PROPC": "Propchain", "PROPEL": "PayRue (Propel)", "PROPHET": "PROPHET", - "PROPS": "Propbase", + "PROPS": "Props Token", "PROPSPROJECT": "Props", "PROS": "Prosper", + "PROS8255": "Prosper", "PROSP": "Prospective", "PROT": "PROT", "PROTEO": "Proteo DeFi", @@ -13551,19 +14128,20 @@ "PRV": "PrivacySwap", "PRVC": "PrivaCoin", "PRVS": "Previse", - "PRX": "Parex", + "PRX": "ProxyNode", "PRXY": "Proxy", "PRXYV1": "Proxy v1", - "PRY": "PRIMARY", + "PRY": "Prophecy", "PRZS": "Perezoso", "PS1": "POLYSPORTS", "PSB": "Planet Sandbox", "PSC": "PSC Token", "PSD": "Poseidon", + "PSDN": "H2O", "PSEUD": "PseudoCash", "PSF": "Prime Shipping Foundation", "PSG": "Paris Saint-Germain Fan Token", - "PSI": "Trident", + "PSI": "Passive Income", "PSICOIN": "PSIcoin", "PSILOC": "Psilocybin", "PSK": "Pool of Stake", @@ -13581,6 +14159,7 @@ "PSUSHI": "Sushi (Polygon Portal)", "PSWAP": "Polkaswap", "PSY": "PsyOptions", + "PSYCHO": "Psycho", "PSYOP": "PSYOP", "PSYOPANIME": "PsyopAnime", "PT": "Phemex", @@ -13588,7 +14167,7 @@ "PTAS": "La Peseta", "PTB": "Portal to Bitcoin", "PTC": "Particle Trade", - "PTD": "Pilot", + "PTD": "Peseta Digital", "PTERIA": "Pteria", "PTF": "PowerTrade Fuel", "PTGC": "The Grays Currency", @@ -13603,14 +14182,16 @@ "PTR": "Petro", "PTRUMP": "Pepe Trump", "PTS": "Petals", - "PTT": "Pink Taxi Token", + "PTT": "POTENT", "PTU": "Pintu Token", "PTX": "PlatinX", "PUBLIC": "PublicAI", + "PUBLX": "PUBLC", "PUCA": "Puss Cat", "PUCCA": "PUCCA", + "PUF": "PUF", "PUFETH": "pufETH", - "PUFF": "Puff The Dragon", + "PUFF": "Puff", "PUFFCOIN": "Puff", "PUFFER": "Puffer", "PUFFIN": "Puffin Global", @@ -13628,6 +14209,7 @@ "PUMBAA": "Pumbaa", "PUMLX": "PUMLx", "PUMP": "Pump.fun", + "PUMP36507": "Pump.fun", "PUMPAI": "PumpAI", "PUMPB": "Pump", "PUMPBTC": "pumpBTC", @@ -13642,11 +14224,12 @@ "PUNCHI": "Punchimals", "PUNCHWORD": "PUNCHWORD (punchword.com)", "PUNDIAI": "Pundi AI", - "PUNDIX": "Pundi X", + "PUNDIX": "Pundi X (New)", "PUNDU": "Pundu", "PUNGU": "PUNGU", "PUNI": "Uniswap Protocol Token (Polygon Portal)", "PUNK": "PunkCity", + "PUNK30112": "PunkCity", "PUNKAI": "PunkAI", "PUNKV": "Punk Vault (NFTX)", "PUP": "PUP", @@ -13667,17 +14250,19 @@ "PURPE": "Purple Pepe", "PURPLEBTC": "Purple Bitcoin", "PURR": "Purr", + "PURR34332": "Purr USD Price", "PURRC": "Purrcoin", "PURSE": "Pundi X PURSE", "PUS": "Pussy Cat", "PUSD": "PegsUSD", "PUSDC": "USD Coin (Polygon Portal)", - "PUSH": "Ethereum Push Notification Service", + "PUSH": "Push Protocol", "PUSHI": "Pushi", "PUSS": "PussFi", "PUSSY": "Pussy Financial", "PUSSYINBIO": "Pussy In Bio", - "PUT": "PutinCoin", + "PUSUKE": "Pusuke Inu", + "PUT": "PUTinCoin", "PUTIN": "Putin Meme", "PUUSH": "puush da button", "PUX": "pukkamex", @@ -13686,8 +14271,8 @@ "PVP": "Pvpfun", "PVPCHAIN": "PVPChain", "PVPGAME": "PvP", - "PVT": "Punkvism Token", - "PVU": "Plant vs Undead Token", + "PVT": "Pivot Token", + "PVU": "Plant Vs Undead", "PWAR": "PolkaWar", "PWC": "PixelWorldCoin", "PWEASE": "Pwease", @@ -13696,17 +14281,19 @@ "PWOG": "Purple Fwog", "PWON": "Personal Wager", "PWR": "MaxxChain", - "PWRC": "PWR Coin", + "PWRC": "PWRCASH", "PWT": "PANDAINU", "PX": "Not Pixel", "PXB": "PixelBit", - "PXC": "PhoenixCoin", + "PXC": "Phoenixcoin", "PXCOIN": "PXcoin", "PXG": "PlayGame", - "PXI": "Prime-X1", + "PXI": "Prime-XI", "PXL": "PIXEL", + "PXLC": "Pixl Coin", "PXP": "PointPay", "PXT": "Pixer Eternity", + "PY": "PayPal USD", "PYBOBO": "Capybobo", "PYC": "PayCoin", "PYE": "CreamPYE", @@ -13716,10 +14303,11 @@ "PYM": "Playermon", "PYME": "PymeDAO", "PYN": "Paynetic", + "PYO": "Pyrrho", "PYP": "PayPro", "PYPLON": "PayPal (Ondo Tokenized)", "PYQ": "PolyQuity", - "PYR": "Vulcan Forged", + "PYR": "Vulcan Forged PYR", "PYRAM": "Pyram Token", "PYRAMID": "Pyramid", "PYRK": "Pyrk", @@ -13728,9 +14316,9 @@ "PYT": "Payther", "PYTH": "Pyth Network", "PYTHIA": "Pythia", - "PYUSD": "PayPal USD", + "PYUSD": "PayPal", "PZETH": "pzETH", - "PZM": "Prizm", + "PZM": "PRIZM", "PZP": "PlayZap", "PZT": "Pizon", "Q": "Quack AI", @@ -13743,13 +14331,14 @@ "QANX": "QANplatform", "QANXV2": "QANplatform v2", "QARK": "QANplatform", - "QASH": "Quoine Liquid", + "QASH": "QASH", + "QATARGROW": "QatarGrow", "QAU": "Quantum", "QBAO": "Qbao", "QBC": "Quebecoin", "QBIT": "Project Quantum", "QBK": "QuBuck Coin", - "QBT": "Cubits", + "QBT": "Qbao", "QBU": "Quannabu", "QBX": "qiibee foundation", "QBZ": "QUEENBEE", @@ -13767,7 +14356,7 @@ "QFI": "QFinance", "QGOLD": "Quorium", "QGOV": "Q Protocol", - "QI": "BENQI", + "QI": "QiSwap", "QIE": "QI Blockchain", "QINGWA": "ShangXin QingWa", "QISWAP": "QiSwap", @@ -13776,7 +14365,7 @@ "QKITTY": "QueenKitty", "QKNTL": "Quick Intel", "QLC": "Kepple [OLD]", - "QLINDO": "QLINDO", + "QLINDO": "Qlindo", "QLIX": "QLix", "QLK": "Quantlink", "QMALL": "QMALL TOKEN", @@ -13786,6 +14375,7 @@ "QNTU": "Quanta", "QNX": "QueenDex Coin", "QOBI": "Qobit", + "QODEX": "Qoda Finance", "QOM": "Shiba Predator", "QONE": "QONE", "QOOB": "QOOBER", @@ -13796,9 +14386,10 @@ "QQQ": "Poseidon Network", "QQQF": "Standard Crypto Fund", "QQQON": "Invesco QQQ (Ondo Tokenized)", - "QQQX": "Nasdaq xStock", + "QQQX": "Nasdaq tokenized ETF (xStock)", "QR": "Qrolli", - "QRK": "QuarkCoin", + "QRDO": "Qredo", + "QRK": "Quark", "QRL": "Quantum Resistant Ledger", "QRO": "Querio", "QRP": "Cryptics", @@ -13815,31 +14406,33 @@ "QTCC": "Quick Transfer coin", "QTCON": "Quiztok", "QTDAO": "Quantum DAO", - "QTF": "Quantfury", + "QTF": "Quantfury Token", "QTK": "QuantCheck", "QTL": "Quatloo", "QTLX": "Quantlytica", "QTO": "Quanto", "QTOK": "QToken", - "QTUM": "QTUM", + "QTUM": "Qtum", "QTZ": "Quartz", "QU3": "QU3ai", "QU3V1": "QU3ai v1", "QUA": "Quantum Tech", "QUAC": "QUACK", - "QUACK": "Rich Quack", + "QUACK": "RichQUACK.com", + "QUACKS": "STAR QUACK", + "QUAD": "Quadency", "QUADRANS": "QuadransToken", "QUAI": "Quai Network", "QUAIN": "QUAIN", "QUAM": "Quam Network", - "QUAN": "Quant AI", + "QUAN": "Quantis Network", "QUANT": "Quant Finance", "QUARASHI": "Quarashi Network", - "QUARTZ": "Sandclock", + "QUARTZ": "QUARTZ", "QUASA": "Quasacoin", "QUASAR": "Quasar", "QUB": "Qubism", - "QUBE": "Qube", + "QUBE": "Qube Crypto Space", "QUBIC": "Qubic", "QUBITICA": "Qubitica", "QUBTON": "Quantum Computing (Ondo Tokenized)", @@ -13848,6 +14441,7 @@ "QUE": "Queen Of Memes", "QUEEN": "Queen of Engrand", "QUICK": "Quickswap", + "QUICKI": "Quick Intel", "QUICKOLD": "Quickswap", "QUIDD": "Quidd", "QUIL": "Wrapped QUIL", @@ -13870,7 +14464,8 @@ "QWLA": "Qawalla", "QWT": "QoWatt", "QXC": "QuantumXC", - "R1": "Recast1", + "R": "R", + "R1": "Recast1 Coin", "R2": "R2", "R2R": "CitiOs", "R34P": "R34P", @@ -13885,15 +14480,17 @@ "RABET": "Rabet", "RABI": "Rabi", "RAC": "RAcoin", - "RACA": "Radio Caca", + "RACA": "RACA", "RACEFI": "RaceFi", + "RACEX": "RaceX", "RACING": "Racing Club Fan Token", "RAD": "Radworks", - "RADAR": "DappRadar", + "RADAR": "Radar", "RADI": "RadicalCoin", "RADIO": "RadioShack", "RADR": "RADR", "RADX": "Radx AI", + "RAE": "Receive Access Ecosystem", "RAFF": "Ton Raffles", "RAFFLES": "Degen Raffles", "RAFL": "RAFL", @@ -13901,14 +14498,14 @@ "RAGDOLL": "Ragdoll", "RAGE": "Rage Fan", "RAGET": "RAGE", - "RAI": "Reploy", + "RAI": "Rai Reflex Index", "RAID": "Raid Token", "RAIDER": "Crypto Raiders", "RAIF": "RAI Finance", "RAIIN": "Raiin", "RAIL": "Railgun", "RAILS": "Rails Token", - "RAIN": "Rain", + "RAIN": "Rainmaker Games", "RAINBOW": "Rainbow Token", "RAINBOWTOKEN": "Rainbow Token", "RAINC": "RainCheck", @@ -13930,23 +14527,26 @@ "RAMEN": "RamenSwap", "RAMON": "Ramon", "RAMP": "RAMP", - "RANKER": "RankerDao", + "RANKER": "RankerDAO", "RAP": "Philosoraptor", "RAPDOGE": "RapDoge", "RAPTOR": "Jesus-Raptor", "RAR": "Rare Pepe", - "RARE": "SuperRare", + "RARE": "Unique One", + "RARE1": "SuperRare", "RARI": "Rarible", "RASTA": "ZionLabs Token", "RAT": "RatCoin", "RATECOIN": "Ratecoin", "RATING": "DPRating", - "RATIO": "Ratio Governance Token", + "RATIO": "Ratio Finance", "RATO": "Rato The Rat", "RATOTHERAT": "Rato The Rat", "RATS": "Rats", "RATWIF": "RatWifHat", - "RAVE": "RaveDAO", + "RAV": "Ravelin Finance", + "RAVE": "Ravendex", + "RAVE38967": "RaveDAO USD Price", "RAVELOUS": "Ravelous", "RAVEN": "Raven Protocol", "RAVENCOINC": "Ravencoin Classic", @@ -13967,19 +14567,20 @@ "RBIF": "Robo Inu Finance", "RBIS": "ArbiSmart", "RBIT": "ReturnBit", + "RBK": "REBorn", "RBLS": "Rebel Bots", "RBLZ": "RebelSatoshi", "RBN": "Ribbon Finance", "RBNB": "StaFi Staked BNB", "RBNT": "Redbelly Network", - "RBP": "Rare Ball Potion", + "RBP": "Pikaster", "RBR": "Ribbit Rewards", "RBRETT": "ROARING BRETT", - "RBT": "RebootWorld", - "RBTC": "Smart Bitcoin", + "RBT": "Rimbit", + "RBTC": "Rootstock Smart Bitcoin", "RBUNNY": "Rocket Bunny", - "RBW": "Crypto Unicorns Rainbow", - "RBX": "RabbitX", + "RBW": "Rainbow Token", + "RBX": "RBX", "RBXDEFI": "RBX", "RBXS": "RBXSamurai", "RBY": "RubyCoin", @@ -13990,11 +14591,11 @@ "RCCC": "RCCC", "RCG": "Recharge", "RCGE": "RCGE", - "RCH": "Rich", + "RCH": "MyRichFarm", "RCHV": "Archivas", "RCKT": "RocketSwap", "RCM": "READ2N", - "RCN": "Ripio", + "RCN": "Ripio Credit Network", "RCOIN": "ArCoin", "RCOINEU": "RCoin", "RCT": "RealChain", @@ -14002,7 +14603,7 @@ "RD": "Round Dollar", "RDAC": "Redacted Coin", "RDC": "Ordocoin", - "RDD": "Reddcoin", + "RDD": "ReddCoin", "RDDT": "Reddit", "RDEX": "Orders.Exchange", "RDF": "ReadFi", @@ -14053,12 +14654,13 @@ "RECON": "RECON", "RECORD": "Music Protocol", "RECT": "ReflectionAI", - "RED": "RedStone", + "RED": "RED", "REDC": "RedCab", "REDCO": "Redcoin", "REDDIT": "Reddit", "REDFEG": "RedFEG", "REDFLOKI": "Red Floki", + "REDFLOKICEO": "Red Floki CEO", "REDI": "REDi", "REDLANG": "RED", "REDLC": "Redlight Chain", @@ -14067,14 +14669,17 @@ "REDNOTE": "RedNote Xiaohongshu", "REDO": "Resistance Dog", "REDP": "Red Ponzi Gud", + "REDPANDA": "Redpanda Earth", "REDPEPE": "Red Pepe", "REDTH": "Red The Mal", "REDTOKEN": "RED TOKEN", + "REDUX": "ReduX", "REDX": "REDX", "REDZILLA": "REDZILLA COIN", "REE": "ReeCoin", "REEE": "REEE", "REEF": "Reef", + "REELFI": "ReelFi", "REELT": "Reel Token", "REF": "Ref Finance", "REFI": "Realfinance Network", @@ -14095,31 +14700,34 @@ "REHA": "Resistance Hamster", "REHAB": "NFT Rehab", "REI": "REI Network", + "REI19819": "REI Network", + "REI35536": "REI NETWORK", "REIGN": "Reign of Terror", "REINDEER": "Reindeer", "REK": "Rekt", "REKT": "Rekt", + "REKT34434": "Rekt USD Price", "REKTV1": "REKT", "REKTV2": "REKT 2.0", "REKTV3": "REKT v3 (rekt.game)", - "REL": "Reliance", + "REL": "Release Project", "RELAY": "Relay Token", "RELI": "Relite Finance", "RELIGN": "RELIGN", "RELOADED": "Doge Reloaded", "RELVT": "Relevant", - "REM": "REMME", + "REM": "Remme", "REMCO": "Remco", "REME": "REME-Coin", - "REMILIA": " Remilia", + "REMILIA": "Remilia", "REMIT": "BlockRemit", "REMMETA": "Real Estate Metaverse", "REMUS": "REMUS", - "REN": "REN", + "REN": "Ren", "RENA": "Warena", "RENBTC": "renBTC", "RENC": "RENC", - "RENDER": "Render Network", + "RENDER": "Render", "RENDOGE": "renDOGE", "RENEC": "RENEC", "RENQ": "Renq Finance", @@ -14127,6 +14735,7 @@ "RENT": "Rent AI", "RENTA": "Renta Network", "RENTBE": "Rentberry", + "RENZEC": "renZEC", "REP": "Augur", "REPE": "Resistance Pepe", "REPO": "Repo Coin", @@ -14135,7 +14744,7 @@ "REPUBLICAN": "Republican", "REPUX": "Repux", "REPV1": "Reputation", - "REQ": "Request Network", + "REQ": "Request", "RES": "Resistance", "RESCUE": "Rescue", "RESOLV": "Resolv", @@ -14148,13 +14757,14 @@ "RETARDIA": "RETARDIA", "RETARDIO": "RETARDIO", "RETH": "Rocket Pool ETH", - "RETH2": "rETH2", + "RETH2": "StakeWise", "RETIK": "Retik Finance", "RETIRE": "The Last Play", "RETIRETOKEN": "Retire Token", + "RETRO": "Retromoon", "RETSA": "Retsa Coin", "REU": "REUCOIN", - "REUNI": "Reunit Wallet", + "REUNI": "Reunit wallet", "REUR": "Royal Euro", "REUSDC": "Relend USDC", "REV": "Revain", @@ -14162,7 +14772,7 @@ "REVA": "Revault Network", "REVAL": "RevaLink Wallet Token", "REVE": "Revenu", - "REVO": "Revomon", + "REVO": "Revomon (OLD)", "REVOAI": "revoAI", "REVOL": "Revolution", "REVOLAND": "Revoland Governance Token", @@ -14176,7 +14786,7 @@ "REXBT": "rexbt by VIRTUALS", "REXHAT": "rexwifhat", "REZ": "Renzo", - "RF": "Raido Financial", + "RF": "ReactorFusion", "RFC": "Royal Finance Coin", "RFCTR": "Reflector.Finance", "RFD": "RefundCoin", @@ -14185,11 +14795,11 @@ "RFI": "reflect.finance", "RFKJ": "Independence Token", "RFL": "RAFL", - "RFOX": "RedFOX Labs", + "RFOX": "RFOX", "RFR": "Refereum", "RFRM": "Reform DAO", "RFT": "Rangers Fan Token", - "RFUEL": "Rio DeFi", + "RFUEL": "RioDeFi", "RFX": "Reflex", "RGAME": "RGAMES", "RGC": "RG Coin", @@ -14206,12 +14816,13 @@ "RHP": "Rhypton Club", "RHUB": "ROLLHUB", "RHYPURR": "rHYPURR", + "RHYTHM": "Rhythm", "RIA": "aRIA Currency", "RIB": "Ribus", "RIBB": "Ribbit", "RIBBIT": "Ribbit", - "RIC": "Riecoin", - "RICE": "RICE AI", + "RIC": "Meta Ricaro", + "RICE": "DAOSquare", "RICECOIN": "RiceCoin", "RICEFARM": "RiceFarm", "RICH": "Ostrich", @@ -14222,10 +14833,10 @@ "RICHR": "RichRabbit", "RICK": "Infinite Ricks", "RICKMORTY": "Rick And Morty", - "RIDE": "Holoride", + "RIDE": "holoride", "RIDECHAIN": "Ride Chain Coin", "RIDEMY": "Ride My Car", - "RIF": "RIF Token", + "RIF": "Rootstock Infrastructure Framework", "RIF3": "MetaTariffv3", "RIFA": "Rifampicin", "RIFI": "Rikkei Finance", @@ -14233,6 +14844,7 @@ "RIFTS": "Rifts Finance", "RIGEL": "Rigel Finance", "RIK": "RIKEZA", + "RIKEN": "Poriverse", "RIL": "Rilcoin", "RIM": "MetaRim", "RIMBIT": "Rimbit", @@ -14256,7 +14868,7 @@ "RIPT": "RiptideCoin", "RIPTO": "RiptoBuX", "RIS": "Riser", - "RISE": "Rise NASA", + "RISE": "Rise", "RISECOIN": "Rise coin", "RISEP": "Rise Protocol", "RISEVISION": "Rise", @@ -14286,16 +14898,18 @@ "RKR": "REAKTOR", "RKT": "Rock Token", "RLB": "Rollbit Coin", - "RLC": "iExec", + "RLC": "iExec RLC", "RLM": "MarbleVerse", + "RLOKI": "Floki Rocket", "RLOOP": "rLoop", "RLP": "Resolv RLP", "RLS": "Rayls", "RLT": "Runner Land", - "RLTM": "RealityToken", - "RLUSD": "Ripple USD", + "RLTM": "Reality Metaverse", + "RLUSD": "Ripple", "RLX": "Relex", "RLY": "Rally", + "RMARS": "RushMars", "RMATIC": "StaFi Staked MATIC", "RMBCASH": "RMBCASH", "RMC": "Russian Mining Coin", @@ -14303,16 +14917,17 @@ "RMK": "KIM YONG EN", "RMOB": "RewardMob", "RMPL": "RMPL", - "RMRK": "RMRK.app", + "RMRK": "RMRK", "RMS": "Resumeo Shares", "RMT": "SureRemit", "RMV": "Reality Metaverse", "RNAPEPE": "RNA PEPE", "RNB": "Rentible", - "RNBW": "Rainbow", + "RNBW": "HaloDAO", "RNC": "ReturnCoin", - "RND": "The RandomDAO", - "RNDR": "Render Token", + "RND": "random", + "RNDM": "Random", + "RNDR": "Render", "RNDX": "Round X", "RNEAR": "Near (Rainbow Bridge)", "RNGR": "Ranger", @@ -14335,22 +14950,25 @@ "ROBOHERO": "RoboHero", "ROBOTA": "TAXI", "ROBOTAXI": "ROBOTAXI", - "ROC": "Rasputin Online Coin", + "ROC": "Roxe Cash", "ROCCO": "Just A Rock", - "ROCK": "Zenrock", + "ROCK": "Bedrock", "ROCK2": "Ice Rock Mining", "ROCKET": "Team Rocket", "ROCKETCOIN": "RocketCoin", "ROCKETFI": "RocketFi", - "ROCKI": "Rocki", + "ROCKI": "ROCKI", "ROCKY": "Rocky", "ROCKYCOIN": "ROCKY", - "ROCO": "ROCO FINANCE", + "ROCO": "ROIyal Coin", "RODAI": "ROD.AI", "RODEO": "Rodeo Finance", "ROE": "Rover Coin", + "ROFI": "HeroFi (ROFI)", "ROG": "ROGin AI", + "ROGE": "Rogue Doge", "ROGER": "ROGER", + "ROGUE": "Rogue Coin", "ROI": "ROIcoin", "ROK": "Rockchain", "ROKM": "Rocket Ma", @@ -14360,6 +14978,7 @@ "ROLS": "RollerSwap", "ROM": "ROMCOIN", "ROME": "Rome", + "RON14101": "Ronin", "RONALDINHO": "Ronaldinho Soccer Coin", "RONCOIN": "RON", "ROND": "ROND", @@ -14367,35 +14986,38 @@ "RONNIE": "Ronnie", "ROO": "Lucky Roo", "ROOBEE": "ROOBEE", - "ROOK": "KeeperDAO", + "ROOK": "Rook", "ROOM": "OptionRoom", "ROON": "Raccoon", "ROOST": "Roost Coin", "ROOSTV1": "Roost Coin v1", - "ROOT": "The Root Network", + "ROOT": "Rootkit Finance", + "ROOT28479": "The Root Network", "ROOTCOIN": "RootCoin", "ROOTS": "RootProject", "ROP": "Redemption Of Pets", - "ROPE": "Rope Token", + "ROPE": "Rope", "ROPELOL": "Rope", "ROPIRITO": "Ropirito", "ROS": "ROS Coin", "ROSA": "Rosa Inu", "ROSCOE": "Roscoe", - "ROSE": "Oasis Labs", + "ROSE": "Oasis Network", "ROSEC": "Rosecoin", "ROSEW": "RoseWifHat", - "ROSN": "Roseon Finance", + "ROSN": "Roseon", "ROSS": "Ross Ulbricht", "ROSX": "Roseon", "ROT": "Rotten", + "ROTTO": "Rottolabs (old)", "ROTTY": "ROTTYCOIN", "ROU": "ROUTINE COIN", "ROUGE": "Rouge Studio", + "ROUL": "ArbiRoul Casino Chip", "ROUND": "RoundCoin", "ROUP": "Roup (Ordinals)", "ROUSH": "Roush Fenway Racing Fan Token", - "ROUTE": "Router Protocol", + "ROUTE": "Router Protocol (Old)", "ROUTEV1": "Router Protocol v1", "ROUTINE": "Morning Routine", "ROVI": "ROVI", @@ -14405,21 +15027,21 @@ "ROX": "Robotina", "ROXY": "ROXY FROG", "ROY": "Crypto Royale", - "ROYA": "Royale", + "ROYA": "Royale Finance", "ROYAL": "RoyalCoin", "RPB": "Republia", "RPC": "RonPaulCoin", "RPD": "Rapids", "RPEPEc": "RoaringPepe", - "RPG": "Rangers Protocol", + "RPG": "Revolve Games", "RPGV1": "Rangers Protocol v1", "RPILL": "Red Pill", "RPK": "RepubliK", - "RPL": "RocketPool", + "RPL": "Rocket Pool", "RPLAY": "Replay", "RPM": "Render Payment", "RPR": "The Reaper", - "RPS": "Rps League", + "RPS": "RPS LEAGUE", "RPT": "Rug Proof", "RPTR": "Raptor Finance", "RPUT": "Robin8 Profile Utility Token", @@ -14431,6 +15053,7 @@ "RRT": "Recovery Right Tokens", "RS": "ReadySwap", "RSC": "ResearchCoin", + "RSC27054": "ResearchCoin USD Price", "RSETH": "Kelp DAO Restaked ETH", "RSF": "Royal Sting", "RSG": "RSG TOKEN", @@ -14461,7 +15084,7 @@ "RTM": "Raptoreum", "RTP": "Return to Player", "RTR": "Restore The Republic", - "RTT": "Restore Truth Token", + "RTT": "RebelTraderToken", "RTX": "RateX", "RU": "RIFI United", "RUBB": "Rubber Ducky Cult", @@ -14471,21 +15094,22 @@ "RUBIX": "Rubix", "RUBMEME": "Reverse Unit Bias", "RUBX": "eToro Russian Ruble", - "RUBY": "RubyToken", + "RUBY": "Ruby Play Network", "RUBYEX": "Ruby.Exchange", "RUC": "Rush", "RUFF": "Ruff", - "RUG": "RUGMAN", + "RUG": "R U Generous", "RUGA": "RUGAME", "RUGMONEY": "Rug", "RUGPROOF": "Launchpad", "RUGPULL": "Captain Rug Pull", "RUGZ": "pulltherug.finance", "RUJI": "Rujira", + "RULE": "Rule", "RULER": "Ruler Protocol", "RUM": "RUM Pirates of The Arrland Token", - "RUN": "Speedrun", - "RUNE": "Thorchain", + "RUN": "RunNode", + "RUNE": "THORChain", "RUNESX": "RUNES·X·BITCOIN", "RUNEVM": "RUNEVM", "RUNI": "Runesterminal", @@ -14498,6 +15122,7 @@ "RURI": "Ruri - Truth Terminal's Crush", "RUSD": "Royal Dollar", "RUSH": "RUSH COIN", + "RUSHAI": "AlphaRush AI", "RUSHCMC": "RUSHCMC", "RUSSELL": "Russell", "RUSSIACOIN": "Russiacoin", @@ -14505,8 +15130,8 @@ "RUSTBITS": "Rustbits", "RUTH": "RUTH", "RUUF": "RuufCoin", - "RUX": "Gacrux NFT", - "RVC": "Revenue Coin", + "RUX": "RunBlox", + "RVC": "Ravencoin Classic", "RVF": "RocketX exchange", "RVFV1": "RocketX exchange v1", "RVFV2": "RocketX exchange v2", @@ -14519,11 +15144,13 @@ "RVO": "AhrvoDEEX", "RVP": "Revolution Populi", "RVR": "Revolution VR", + "RVRS": "Reverse Climate Change", "RVST": "Revest Finance", "RVT": "Rivetz", "RVV": "REVIVE", "RVX": "Rivex", "RWA": "Allo", + "RWA33611": "RWA Inc.", "RWAECO": "RWA Ecosystem", "RWAI": "RWA Inc.", "RWAS": "RWA Finance", @@ -14544,7 +15171,7 @@ "RYCN": "RoyalCoin 2.0", "RYD": "RYderOSHI", "RYIU": "RYI Unity", - "RYO": "RYO Coin", + "RYO": "Ryo Currency", "RYOCURRENCY": "Ryo", "RYOMA": "Ryoma", "RYOSHI": "Ryoshis Vision", @@ -14559,23 +15186,27 @@ "S": "Sonic Labs", "S2K": "Sports 2K75", "S315": "SWAP315", + "S32684": "Sonic", "S4F": "S4FE", "S8C": "S88 Coin", "SA": "Superalgos", "SAAD": "Saad Boi", "SAAS": "SaaSGo", - "SABAI": "Sabai Protocol", + "SABAI": "Sabai Ecoverse", "SABER": "Saber", "SABLE": "Sable Finance", "SABR": "SABR Coin", "SAC1": "Sable Coin", "SACKS": "SackFurie", + "SACT": "srnArt Gallery", "SAD": "SadCat", - "SAF": "Safinus", - "SAFE": "Safe", + "SAF": "Safcoin", + "SAFE": "SafeCoin", + "SAFE21585": "Safe USD Price", "SAFEBTC": "SafeBTC", "SAFEBULL": "SafeBull", "SAFECOIN": "SafeCoin", + "SAFEEARTH": "SafeEarth", "SAFEGROK": "SafeGrok", "SAFEHAMSTERS": "SafeHamsters", "SAFELIGHT": "SafeLight", @@ -14593,15 +15224,19 @@ "SAFET": "SafemoonTon", "SAFEX": "SafeExchangeCoin", "SAFLE": "Safle", + "SAFO": "SafeOne Chain", + "SAFTI": "SafuTitano", "SAFTP": "Simple Agreement for Future Tokens", + "SAFU": "StaySAFU", "SAFUU": "SAFUU", "SAGA": "Saga", + "SAGA30372": "Saga-USD", "SAGACOIN": "SagaCoin", "SAGE": "Ceremonies AI", "SAHA": "Sahara AI Coin", "SAHARA": "Sahara AI", - "SAI": "Sharpe AI", - "SAIL": "SAIL", + "SAI": "Simpsons AI", + "SAIL": "SolanaSail", "SAITA": "SaitaChain", "SAITABIT": "SaitaBit", "SAITAMA": "Saitama Inu", @@ -14611,38 +15246,42 @@ "SAIV1": "SAI", "SAIY": "Saiyan PEPE", "SAK": "SharkCoin", + "SAK3": "Sake", "SAKAI": "Sakai Vault", "SAKATA": "Sakata Inu", "SAKE": "SakeToken", "SAKURACOIN": "Sakuracoin", - "SAL": "Salvium", + "SAL": "Salmonation", "SALD": "Salad", - "SALE": "DxSale Network", + "SALE": "DxSale.Network", "SALL": "Sallar", "SALLY": "SALAMANDER", "SALMAN": "Mohameme Bit Salman", "SALMON": "Salmon", "SALPAY": "SalPay", - "SALT": "Salt Lending", + "SALT": "SALT", + "SALTY": "Salty Coin", "SALUTE": "Salute", - "SAM": "Samsunspor Fan Token", + "SAM": "Samurai", "SAMA": "Moonsama", "SAMMY": "Samoyed", "SAMO": "Samoyedcoin", "SAMS": "Samsara.Build", - "SAN": "San Chan", + "SAMU": "Samusky", + "SAN": "Santiment Network Token", "SANA": "Storage Area Network Anywhere", "SANCHO": "Sancho", "SAND": "The Sandbox", "SANDG": "Save and Gain", - "SANDWICH": " Sandwich Network", + "SANDWICH": "Sandwich Network", "SANDY": "Sandy", "SANI": "Sanin Inu", "SANIN": "Sanin", + "SANINU": "Santa Inu", "SANJI": "Sanji Inu", "SANSFOREST": "FOREST", "SANSHU": "Sanshu Inu", - "SANTA": "SANTA CHRISTMAS INU", + "SANTA": "Santa Coin", "SANTAGROK": "Santa Grok", "SANTAHAT": "SANTA HAT", "SANTI": "Santiment", @@ -14650,7 +15289,7 @@ "SAO": "Sator", "SAP": "SwapAll", "SAPE": "SolanaApe", - "SAPIEN": "Sapien", + "SAPIEN": "Sapien USD Price", "SAPP": "Sapphire", "SAPPC": "SappChat", "SAR": "Saren", @@ -14676,6 +15315,7 @@ "SATOX": "Satoxcoin", "SATOZ": "Satozhi", "SATS": "SATS (Ordinals)", + "SATS28194": "SATS (Ordinals) USD Price", "SATSALL": "ALL BEST ICO SATOSHI", "SATT": "SaTT", "SATX": "SATX", @@ -14687,14 +15327,14 @@ "SAUDISHIB": "Saudi Shiba Inu", "SAUNA": "SaunaFinance Token", "SAV": "Save America", - "SAV3": "SAV3", + "SAV3": "Sav3Token", "SAVAX": "BENQI Liquid Staked AVAX", "SAVEOCEAN": "Save The Ocean", - "SAVG": "SAVAGE", + "SAVG": "Savage", "SAVM": "SatoshiVM", "SAVVA": "SAVVA", "SAY": "SAY Coin", - "SB": "DragonSB", + "SB": "Snowbank", "SBA": "simplyBrand", "SBABE": "SNOOPYBABE", "SBAE": "Salt Bae For The People", @@ -14702,6 +15342,7 @@ "SBC": "StableCoin", "SBCC": "Smart Block Chain City", "SBCH": "Smart Bitcoin Cash", + "SBD": "Steem Dollars", "SBE": "Sombe", "SBEFE": "BEFE", "SBET": "Sports Bet", @@ -14709,8 +15350,10 @@ "SBGO": "Bingo Share", "SBIO": "Vector Space Biosciences, Inc.", "SBNB": "Binance Coin (SpookySwap)", + "SBNK": "Solbank Token", + "SBONK": "SHIBONK", "SBOX": "SUIBOXER", - "SBR": "STRATEGIC BITCOIN RESERVE", + "SBR": "Saber", "SBRT": "SaveBritney", "SBSC": "Subscriptio", "SBT": "SOLBIT", @@ -14719,19 +15362,20 @@ "SC": "Siacoin", "SC20": "Shine Chain", "SCA": "Scallop", - "SCALE": "Scalia Infrastructure", + "SCA29679": "Scallop", + "SCALE": "Scaleton", "SCALR": "Scalr", "SCAM": "Scam Coin", "SCAMP": "ScamPump", "SCANS": "0xScans", "SCAP": "SafeCapital", "SCAPE": "Etherscape", - "SCAR": "Velhalla", + "SCAR": "ScarQuest", "SCARAB": "Scarab Finance", "SCARCITY": "SCARCITY", "SCASH": "SpaceCash", "SCAT": "Sad Cat Token", - "SCC": "StockChain Coin", + "SCC": "SiaCashCoin", "SCCOON": "Southern Copper (Ondo Tokenized)", "SCCP": "S.C. Corinthians Fan Token", "SCDS": "Shrine Cloud Storage Network", @@ -14743,6 +15387,8 @@ "SCHRO": "Schrodinger", "SCHRODI": "Schrödi", "SCIA": "Stem Cell", + "SCIE": "Scientia", + "SCIFI": "SCIFI Index", "SCIHUB": "sci-hub", "SCIVIVE": "sciVive", "SCIX": "Scientix", @@ -14754,10 +15400,12 @@ "SCN": "Swiscoin", "SCNR": "Swapscanner", "SCNSOL": "Socean Staked Sol", - "SCO": "SCOPE", + "SCO": "Score Token", "SCOIN": "ShinCoin", "SCONE": "Sportcash One", - "SCOOBY": "Scooby coin", + "SCONEX": "Sportcash One", + "SCOOBY": "SCOOBY", + "SCOP": "Scopuly", "SCOR": "Scor", "SCORE": "Scorecoin", "SCOT": "Scotcoin", @@ -14765,26 +15413,27 @@ "SCOTTY": "Scotty Beam", "SCP": "ScPrime", "SCPT": "Script Network", + "SCR26998": "Scroll", "SCRAP": "Scrap", "SCRAPPY": "Scrappy", "SCRAT": "Scrat", "SCRATCH": "Scratch", "SCREAM": "Scream", "SCRIBE": "Scribe Network", - "SCRIV": "SCRIV", - "SCRL": "Scroll", + "SCRIV": "SCRIV NETWORK", + "SCRL": "Wizarre Scroll", "SCRM": "Scorum", "SCROLL": "Scroll Network", "SCROLLY": "Scrolly the map", - "SCROOGE": "Scrooge", + "SCROOGE": "SCROOGE", "SCRPT": "ScryptCoin", "SCRT": "Secret", "SCRVUSD": "Savings crvUSD", "SCRYPTA": "Scrypta", "SCRYPTTOKEN": "ScryptToken", - "SCS": "Solcasino Token", + "SCS": "SpeedCash", "SCSX": "Secure Cash", - "SCT": "SuperCells", + "SCT": "Safechaintoken", "SCTK": "SharesChain", "SCTRL": "SOLCONTROL", "SCUBA": "Scuba Dog", @@ -14793,25 +15442,29 @@ "SDA": "SDChain", "SDAI": "Savings Dai", "SDAO": "SingularityDAO", - "SDC": "ShadowCash", + "SDAOG": "SyncDAO Governance", + "SDC": "SOMDEJ", "SDCRV": "Stake DAO CRV", "SDEUSD": "Staked deUSD", "SDEX": "SmarDex", - "SDL": "Saddle Finance", + "SDF": "ShadowFi", + "SDL": "Saddle", "SDM": "Shieldeum", "SDME": "SDME", "SDN": "Shiden Network", "SDO": "TheSolanDAO", "SDOG": "Small Doge", - "SDOGE": "SpaceXDoge", + "SDOGE": "SolDoge", "SDOPE": "SHIBADOGEPEPE", "SDP": "SydPakCoin", "SDR": "SedraCoin", "SDRN": "Senderon", "SDS": "Alchemint Standards", - "SDT": "TerraSDT", + "SDT": "Terra SDT", "SDUSD": "SDUSD", "SDX": "SwapDEX", + "SEA": "SEA", + "SEACHAIN": "SeaChain", "SEAGULL": "SEAGULL SAM", "SEAIO": "Second Exchange Alliance", "SEAL": "Seal", @@ -14821,17 +15474,17 @@ "SEAMLESS": "SeamlessSwap", "SEAN": "Starfish Finance", "SEAS": "Seasons", - "SEAT": "Seamans Token", + "SEAT": "SeatlabNFT", "SEATLABNFT": "SeatlabNFT", "SEBA": "Seba", "SEC": "SecureCryptoPayments", "SECO": "Serum Ecosystem Token", "SECOND": "MetaDOS", "SECRT": "SecretCoin", - "SECT": "SECTBOT", + "SECT": "Sector Finance", "SECTO": "Sector Finance", "SEDA": "SEDA Protocol", - "SEED": "SEED", + "SEED": "SeedCoin", "SEEDS": "SeedShares", "SEEDV": "Seed Venture", "SEEDX": "SEEDx", @@ -14840,6 +15493,7 @@ "SEEN": "SEEN", "SEER": "SEER", "SEFA": "Mesefa", + "SEFI": "Secret Finance", "SEG": "Solar Energy", "SEI": "Sei", "SEILOR": "Kryptonite", @@ -14859,7 +15513,8 @@ "SENA": "Ethena Staked ENA", "SENATE": "SENATE", "SENC": "Sentinel Chain", - "SEND": "Suilend", + "SEND": "Social Send", + "SEND34611": "Suilend USD Price", "SENDCOIN": "Sendcoin", "SENDOR": "Sendor", "SENK": "Senk", @@ -14871,6 +15526,7 @@ "SENSOV1": "SENSO v1", "SENSUS": "Sensus", "SENT": "Sentient", + "SENT38868": "Sentient", "SENTAI": "SentAI", "SENTI": "Sentinel Bot Ai", "SENTIS": "Sentism AI Token", @@ -14884,7 +15540,7 @@ "SER": "Secretum", "SERAPH": "Seraph", "SERG": "Seiren Games Network", - "SERO": "Super Zero", + "SERO": "Super Zero Protocol", "SERP": "Shibarium Perpetuals", "SERSH": "Serenity Shield", "SERV": "OpenServ", @@ -14893,28 +15549,29 @@ "SESE": "Simpson Pepe", "SESH": "Session Token", "SESSIA": "SESSIA", - "SETH": "sETH", + "SETH": "sETH2", "SETH2": "sETH2", "SETHER": "Sether", "SETHH": "Staked ETH Harbour", "SETS": "Sensitrust", - "SEUR": "Synth sEUR", + "SEUR": "sEUR", "SEW": "simpson in a memes world", - "SEX": "SEX Odyssey", + "SEX": "Solidex", "SEXY": "EthXY", "SEXYP": "SEXY PEPE", "SFAGRO": "SFAGRO", "SFARM": "SolFarm", "SFC": "Solarflarecoin", "SFCP": "SF Capital", - "SFD": "SafeDeal", + "SFD": "SAFE DEAL", "SFEX": "SafeLaunch", "SFF": "Sunflower Farm", "SFG": "S.Finance", - "SFI": "Saffron.finance", + "SFI": "saffron.finance", + "SFIL": "Filecoin Standard Full Hashrate Token", "SFIN": "Songbird Finance", "SFIT": "Sense4FIT", - "SFL": "Sunflower Land", + "SFL": "Shiftal", "SFLOKI": "SuiFloki-Inu", "SFLR": "Sceptre Staked FLR", "SFM": "SafeMoon V2", @@ -14936,7 +15593,8 @@ "SFY": "Stakefy", "SG": "SocialGood", "SGA": "Saga", - "SGB": "Songbird", + "SGB": "SubGame", + "SGB12186": "Songbird", "SGDX": "eToro Singapore Dollar", "SGE": "Society of Galactic Exploration", "SGI": "SmartGolfToken", @@ -14949,11 +15607,13 @@ "SGR": "Schrodinger", "SGROK": "Super Grok", "SGT": "SharedStake Governance Token", + "SH": "StakHolders", "SHA": "Safe Haven", - "SHACK": "Shackleford", + "SHACK": "Shack Token", "SHACOIN": "Shacoin", "SHAD": "Shadowswap Finance", "SHADE": "ShadeCoin", + "SHADOWCATS": "Shadowcats", "SHAK": "Shakita Inu", "SHAKE": "Spaceswap SHAKE", "SHAMAN": "Shaman King Inu", @@ -14962,12 +15622,14 @@ "SHAPE": "Shape", "SHAR": "Shark Cat", "SHARBI": "SHARBI", - "SHARDS": "WorldShards", + "SHARD": "Shard", + "SHARDS": "SolChicks Shards", "SHARE": "Seigniorage Shares", "SHARECHAIN": "ShareChain", "SHARES": "shares.finance", "SHAREV1": "Seigniorage Shares v1", "SHARK": "Sharky", + "SHARK30666": "Sharky", "SHARKI": "Sharki", "SHARKS": "Sharks", "SHARKYSH": "Sharky Sharkx", @@ -14984,16 +15646,18 @@ "SHEB": "SHEBOSHIS", "SHEEESH": "Secret Gem", "SHEESH": "Sheesh it is bussin bussin", - "SHEESHA": "Sheesha Finance", + "SHEESHA": "Sheesha Finance [ERC20]", "SHEGEN": "Aiwithdaddyissues", "SHEI": "SheikhSolana", "SHELL": "MyShell", + "SHELL35710": "MyShell", "SHELLTOKEN": "Shell Token", "SHEN": "Shen", "SHEPE": "Shiba V Pepe", "SHERA": "Shera Tokens", "SHEZMU": "Shezmu", "SHFL": "Shuffle", + "SHFLCN": "ShibFalcon", "SHFT": "Shyft Network", "SHG": "Shib Generating", "SHI": "Shirtum", @@ -15020,6 +15684,7 @@ "SHIBAZILLA": "ShibaZilla2.0", "SHIBCAT": "SHIBCAT", "SHIBCEO": "ShibCEO", + "SHIBDAO": "Shibarium DAO", "SHIBDOGE": "ShibaDoge", "SHIBEINU": "Shibe Inu", "SHIBELON": "ShibElon", @@ -15032,18 +15697,21 @@ "SHIBL": "ShibLa", "SHIBLITE": "Shiba Lite", "SHIBMERICAN": "Shibmerican", + "SHIBN": "Shibnaut", "SHIBO": "ShiBonk", "SHIBON": "SHIB ON SOLANA", + "SHIBOT": "SHIBOT", "SHIBS": "Shibsol", "SHIBTC": "Shibabitcoin", "SHIBU": "SHIBU INU", + "SHIBX": "SHIBAVAX", "SHICO": "ShibaCorgi", "SHIDO": "Shido", - "SHIELD": "Crypto Shield", + "SHIELD": "Shield Protocol", "SHIELDNET": "Shield Network", "SHIFT": "Shift", "SHIH": "Shih Tzu", - "SHIK": "Shikoku", + "SHIK": "SHIKOKU", "SHIKOKU": "Mikawa Inu", "SHIL": "Shila Inu", "SHILL": "SHILL Token", @@ -15059,6 +15727,7 @@ "SHIR": "SHIRO", "SHIRO": "Shiro Neko", "SHIROSOL": "Shiro Neko (shirosol.online)", + "SHIRYO-INU": "Shiryo", "SHIRYOINU": "Shiryo-Inu", "SHISA": "SHISA", "SHISHA": "Shisha Coin", @@ -15080,33 +15749,37 @@ "SHOKI": "Shoki", "SHON": "ShonToken", "SHONG": "Shong Inu", + "SHOO": "NFTshootout", "SHOOK": "SHOOK", "SHOOT": "Mars Battle", "SHOOTER": "Top Down Survival Shooter", - "SHOP": "Shoppi Coin", + "SHOP": "Shopping.io", "SHOPN": "ShopNEXT", - "SHOPX": "Splyt", + "SHOPX": "SHOPX", "SHORK": "shork", "SHORT": "Bermuda Shorts", "SHORTY": "ShortyCoin", "SHOW": "ShowCoin", - "SHPING": "Shping Coin", + "SHPING": "SHPING", "SHR": "ShareToken", "SHRA": "Shrapnel", "SHRAP": "Shrapnel", + "SHRAP28363": "Shrapnel", "SHRED": "ShredN", "SHREK": "Shrek", "SHRI": "Shrimp Paste", "SHRIMP": "SHRIMP", "SHROO": "Shroomates", - "SHROOM": "Shroom.Finance", + "SHROOM": "Niftyx Protocol", "SHROOMFOX": "Magic Shroom", + "SHROOMS": "SHROOMS AI", "SHRUB": "Shrub", "SHRUBIUS": "Shrubius Maximus", "SHRX": "Sherex", "SHS": "SHEESH", "SHU": "Shutter", "SHUB": "SimpleHub", + "SHUEY": "Shuey Rhon Inu", "SHUFFLE": "SHUFFLE!", "SHVR": "Shivers", "SHX": "Stronghold Token", @@ -15117,7 +15790,7 @@ "SI": "Siren", "SI14": "Si14", "SIACLASSIC": "SiaClassic", - "SIB": "SibCoin", + "SIB": "SIBCoin", "SIBA": "SibaInu", "SIC": "Swisscoin", "SID": "Sid", @@ -15125,17 +15798,19 @@ "SIDELINED": "Sidelined?", "SIDELINER": "Sideliner Coin", "SIDESHIFT": "SideShift Token", - "SIDUS": "Sidus", + "SIDUS": "SIDUS", + "SIENNA": "Sienna", "SIERRA": "Sierracoin", "SIF": "Solana Index Fund", "SIFT": "Smart Investment Fund Token", - "SIFU": "SIFU", + "SIFU": "Sifu Vision", "SIG": "Signal", "SIGHT": "Empire of Sight", + "SIGIL": "Sigil Finance", "SIGM": "Sigma", "SIGMA": "SIGMA", - "SIGN": "Sign", - "SIGNA": "Signa", + "SIGN": "Signature Chain", + "SIGNA": "Signum", "SIGNAT": "SignatureChain", "SIGNMETA": "Sign Token", "SIGT": "Signatum", @@ -15144,7 +15819,7 @@ "SIKA": "SikaSwap", "SIL": "SIL Finance Token V2", "SILENTIS": "Silentis", - "SILK": "SilkCoin", + "SILK": "SILK", "SILKR": "SilkRoadCoin", "SILKT": "SilkChain", "SILL": "Silly Duck", @@ -15163,12 +15838,13 @@ "SIMBA": "SIMBA The Sloth", "SIMMI": "Simmi Token", "SIMON": "Simon the Gator", - "SIMP": "SO-COL", + "SIMP": "SIMP Token", "SIMPLE": "SimpleChain", + "SIMPLI": "Simpli Finance", "SIMPS": "Simpson MAGA", "SIMPSO": "Simpson Neiro", "SIMPSON": "Homer", - "SIMPSON6900": "Simpson6900 ", + "SIMPSON6900": "Simpson6900", "SIMPSONAI": "Simpson AI Agent", "SIMPSONF": "Simpson FUKU", "SIMPSONP": "Simpson Predictions", @@ -15185,14 +15861,14 @@ "SINSO": "SINSO", "SINX": "SINX Token", "SIO": "SAINO", - "SION": "FC Sion", + "SION": "FC Sion Fan Token", "SIP": "Space SIP", "SIPHER": "Sipher", "SIPHON": "Siphon Life Spell", "SIR": "Sir", - "SIREN": "siren", - "SIRIUS": "first reply", - "SIS": "Symbiosis Finance", + "SIREN": "Siren", + "SIRIUS": "FIRST", + "SIS": "Symbiosis", "SISA": "Strategic Investments in Significant Areas", "SISC": "Shirushi Coin", "SISHI": "Sishi Finance", @@ -15200,13 +15876,14 @@ "SIUU": "SIUUU", "SIUUU": "Crustieno Renaldo", "SIV": "Sivasspor Token", - "SIX": "SIX Network", + "SIX": "SIX", "SIXP": "Sixpack Miner", "SIXPACK": "SIXPACK", "SIXSI": "SIX SIGMA", "SIZ": "Sizlux", "SIZE": "SIZE", "SJCX": "StorjCoin", + "SK": "SideKick Token", "SKAI": "Skillful AI", "SKAIN": "SKAINET", "SKATE": "Skate", @@ -15214,40 +15891,44 @@ "SKBDI": "Skibidi Toilet", "SKC": "Skeincoin", "SKCS": "Staked KCS", - "SKEB": "Skeb", + "SKEB": "Skeb Coin", "SKET": "Sketch coin", - "SKEY": "SmartKey", + "SKEY": "Skey Network", "SKG888": "Safu & Kek Gigafundz 888", "SKI": "Ski Mask Dog", + "SKI31173": "Ski Mask Dog", "SKIBIDI": "Skibidi Toilet", "SKICAT": "SKI MASK CAT", "SKID": "Success Kid", "SKILL": "CryptoBlades", "SKILLC": "Skillchain", - "SKIN": "Skincoin", + "SKIN": "SkinCoin", "SKING": "Solo King", "SKINS": "Coins & Skins", "SKINUT": "Skimask Pnut", "SKIPUP": "SKI MASK PUP", "SKITTEN": "Ski Mask Kitten", - "SKL": "SKALE Network", + "SKL": "SKALE", "SKLAY": "sKLAY", "SKM": "Skrumble Network", + "SKMT": "Soakmont", "SKO": "Sugar Kingdom Odyssey", "SKOP": "Skulls of Pepe Token", "SKPEPE": "Sheikh Pepe", "SKR": "Seeker", + "SKR39377": "Solana Mobile Seeker", "SKRB": "Sakura Bloom", "SKRIMP": "Skrimples", "SKRP": "Skraps", - "SKRT": "Skrilla Token", + "SKRT": "Sekuritance", "SKRY": "Sakaryaspor Token", "SKT": "Sukhavati Network", "SKU": "Sakura", - "SKULL": "Pirate Blocks", - "SKUY": "Token Sekuya", + "SKULL": "Skull Order", + "SKUY": "SEKUYA", "SKX": "SKPANAX", - "SKY": "Sky", + "SKY": "Skycoin", + "SKY33038": "Sky", "SKYA": "Sekuya Multiverse", "SKYAI": "SKYAI", "SKYCOIN": "Skycoin", @@ -15279,21 +15960,22 @@ "SLICE": "Tranche Finance", "SLICEC": "SLICE", "SLIM": "Solanium", - "SLIME": "Snail Trail", + "SLIME": "SquishiVerse", "SLING": "Sling Coin", "SLINK": "Soft Link", "SLIPPY": "SLIPPY", "SLISBNB": "Lista Staked BNB", "SLISBNBX": "slisBNBx", + "SLIZ": "SolidLizard", "SLK": "SLK", - "SLM": "SlimCoin", + "SLM": "Solomon Defi", "SLN": "Smart Layer Network", "SLND": "Solend", "SLNV2": "SLNV2", "SLOKI": "Super Floki", "SLOP": "Slop", "SLORK": "SLORK", - "SLOT": "Alphaslot", + "SLOT": "Snowtomb LOT", "SLOTH": "Sloth", "SLOTHA": "Slothana", "SLP": "Smooth Love Potion", @@ -15310,12 +15992,12 @@ "SLVN": "SLVNToken", "SLVON": "iShares Silver Trust (Ondo Tokenized)", "SLVX": "eToro Silver", - "SLX": "SLIMEX", + "SLX": "Solex Finance", "SMA": "Soma Network", "SMAC": "Social Media Coin", "SMAK": "Smartlink", "SMARS": "SafeMars", - "SMART": "Smart game", + "SMART": "SmartCash", "SMARTB": "Smart Coin", "SMARTCASH": "SmartCash", "SMARTCREDIT": "SmartCredit Token", @@ -15330,8 +16012,8 @@ "SMARTUP": "Smartup", "SMAT": "Smathium", "SMB": "SMB Token", - "SMBR": "Sombra", - "SMBSWAP": "SimbCoin Swap", + "SMBR": "Sombra Network", + "SMBSWAP": "Simbcoin Swap", "SMC": "SmartCoin", "SMCION": "Super Micro Computer (Ondo Tokenized)", "SMCW": "Space Misfits", @@ -15359,7 +16041,7 @@ "SMOKE": "Smoke", "SMOL": "Smolcoin", "SMOLE": "smolecoin", - "SMON": "StarMon", + "SMON": "Starmon Metaverse", "SMOON": "SaylorMoon", "SMPF": "SMP Finance", "SMPL": "SMPL Foundation", @@ -15368,7 +16050,7 @@ "SMRT": "SmartMoney", "SMRTR": "SmarterCoin", "SMSR": "Samsara Coin", - "SMT": "Swarm Markets", + "SMT": "SmartMesh", "SMTF": "SmartFi", "SMTY": "Smoothy", "SMU": "SafeMoneyUP", @@ -15385,6 +16067,7 @@ "SNACK": "Crypto Snack", "SNAI": "SwarmNode.ai", "SNAIL": "SnailBrook", + "SNAILS": "Snail Race", "SNAKE": "snake", "SNAKEAI": "Snake-ai", "SNAKEMOON": "Snakemoon", @@ -15402,10 +16085,11 @@ "SNDKON": "SanDisk (Ondo Tokenized)", "SNE": "StrongNode", "SNEED": "Sneed", - "SNEK": "Snek", + "SNEK": "SoliSnek", + "SNEK25264": "Snek", "SNEKE": "Snek on Ethereum", "SNET": "Snetwork", - "SNFT": "Spanish National Team Fan Token", + "SNFT": "Spain National Fan Token", "SNFTS": "Seedify NFT Space", "SNG": "SINERGIA", "SNGLS": "SingularDTV", @@ -15418,7 +16102,7 @@ "SNITCH": "Randall", "SNK": "Snook", "SNL": "Sport and Leisure", - "SNM": "SONM", + "SNM": "SONM (BEP-20)", "SNMT": "Satoshi Nakamoto Token", "SNN": "SeChain", "SNO": "Snow Leopard", @@ -15430,23 +16114,25 @@ "SNORK": "Snork", "SNORT": "SNORT", "SNOV": "Snovio", - "SNOW": "Snowswap", + "SNOW": "SnowSwap", "SNOWBALL": "Simpson Cat", "SNOWMANTASTIC": "Snowmantastic", + "SNP": "Synapse Network", "SNPAD": "SNP adverse", "SNPC": "SnapCoin", "SNPS": "Snaps", "SNPT": "SNPIT TOKEN", "SNRG": "Synergy", "SNRK": "Snark Launch", - "SNS": "Solana Name Service", + "SNS": "Synesis One", + "SNS36468": "Solana Name Service USD Price", "SNST": "Smooth Network Solutions Token", "SNSY": "Sensay", - "SNT": "Status Network Token", - "SNTR": "Silent Notary", + "SNT": "Status", + "SNTR": "Sentre Protocol", "SNTVT": "Sentivate", "SNX": "Synthetix", - "SNY": "Synthetify ", + "SNY": "Synthetify", "SO": "Shiny Ore", "SOAI": "SOAI", "SOAK": "Soak Token", @@ -15457,14 +16143,15 @@ "SOBER": "Solabrador", "SOBTC": "Wrapped Bitcoin (Sollet)", "SOBULL": "SoBULL", - "SOC": "All Sports Coin", + "SOC": "All Sports", "SOCA": "Socaverse", "SOCC": "SocialCoin", "SOCCER": "SoccerInu", "SOCIAL": "Phavercoin", + "SOCIALAI": "Social AI", "SOCIALLT": "Social Lending Network", "SOCIALSEND": "Social Send", - "SOCKS": "Alpaca Socks", + "SOCKS": "Unisocks", "SOCOLA": "SOCOLA INU", "SODA": "SODA Coin", "SODAL": "Sodality Coin", @@ -15484,10 +16171,13 @@ "SOILCOIN": "SoilCoin", "SOJ": "Sojourn Coin", "SOK": "shoki", - "SOKU": "Soku Swap", + "SOKU": "SokuSwap", "SOL": "Solana", + "SOL1": "SOL RUNE - Rune.Game", "SOL10": "SOLANA MEME TOKEN", - "SOLA": "Sola", + "SOLA": "SOLA Token", + "SOLAB": "Solabrador", + "SOLACE": "SOLACE", "SOLAI": "Solana AI BNB", "SOLALA": "Solala", "SOLAMA": "Solama", @@ -15498,7 +16188,7 @@ "SOLANAS": "Solana Swap", "SOLANATREASURY": "Solana Treasury Machine", "SOLAPE": "SolAPE Token", - "SOLAR": "Solar", + "SOLAR": "Solarbeam", "SOLARA": "Solara", "SOLARDAO": "Solar DAO", "SOLARE": "Solareum", @@ -15512,13 +16202,14 @@ "SOLBO": "SolBoss", "SOLBOX": "SolBox", "SOLBULL": "SOLBULL", - "SOLC": "SolCard", + "SOLC": "Solcubator", "SOLCASH": "SOLCash", "SOLCAT": "CatSolHat", "SOLCATMEME": "SOLCAT", "SOLCEX": "SolCex", "SOLCHAT": "Solchat", "SOLCHICKSSHARDS": "SolChicks Shards", + "SOLD": "Solanax", "SOLE": "SoleCoin", "SOLER": "Solerium", "SOLETF": "SOL ETF", @@ -15526,11 +16217,13 @@ "SOLEY": "Soley", "SOLFI": "SoliDefi", "SOLFUN": "SolFun", + "SOLGE": "Solge", "SOLGOAT": "SOLGOAT", "SOLGUN": "Solgun", + "SOLI": "Solana Ecosystem Index", "SOLIB": "Solitaire Blossom", "SOLIC": "Solice", - "SOLID": "Solidified", + "SOLID": "Solidly", "SOLIDSEX": "SOLIDsex: Tokenized veSOLID", "SOLINK": "Wrapped Chainlink (Sollet)", "SOLITO": "SOLITO", @@ -15559,14 +16252,14 @@ "SOLTR": "SolTrump", "SOLV": "Solv Protocol", "SOLVBTC": "Solv Protocol SolvBTC", - "SOLVBTCBBN": "Solv Protocol SolvBTC.BBN", + "SOLVBTCBBN": "SolvBTC.BBN", "SOLVBTCCORE": "Solv Protocol SolvBTC.CORE", "SOLVBTCENA": "SolvBTC Ethena", "SOLVBTCJUP": "SolvBTC Jupiter", "SOLVE": "SOLVE", "SOLVEX": "SOLVEX", "SOLWIF": "Solwif", - "SOLX": "Solaxy", + "SOLX": "Soldex", "SOLXD": "Solxdex", "SOLY": "Solamander", "SOLYMPICS": "Solympics", @@ -15580,7 +16273,7 @@ "SOMPS": "SompsOnKas", "SON": "Simone", "SONAR": "SonarWatch", - "SONG": "Song Coin", + "SONG": "SongCoin", "SONGOKU": "SONGOKU", "SONIC": "Sonic SVM", "SONICO": "Sonic", @@ -15589,28 +16282,31 @@ "SONNE": "Sonne Finance", "SONOF": "Son of Solana", "SONOR": "SonorusToken", - "SOON": "SOON Token", + "SOON": "SoonVerse", + "SOON36542": "SOON USD Price", "SOONAVERSE": "Soonaverse", "SOONCOIN": "SoonCoin", "SOONTOKEN": "SOON", "SOOTCASE": "I like my sootcase", "SOP": "SoPay", - "SOPH": "Sophon", + "SOPH": "SophiaVerse", "SOPHIA": "SophiaVerse", "SOPHON": "Sophon (Atomicals)", "SOR": "Sorcery", "SORA": "Sora Validator Token", + "SORA29434": "Sora", "SORACEO": "SORA CEO", "SORADOGE": "Sora Doge", "SORAETH": "SORA", "SORAI": "Sora AI", "SORAPORN": "Sora Porn", + "SOS": "OpenDAO", "SOSO": "SoSoValue", "SOT": "Soccer Crypto", "SOTA": "SOTA Finance", "SOUL": "Phantasma", "SOULO": "SouloCoin", - "SOULS": "Unfettered Ecosystem", + "SOULS": "The Unfettered", "SOULSA": "Soulsaver", "SOUND": "Sound Coin", "SOURCE": "ReSource Protocol", @@ -15627,14 +16323,16 @@ "SP8DE": "Sp8de", "SPA": "Sperax", "SPAC": "SPACE DOGE", - "SPACE": "Spacecoin", + "SPACE": "Space Finance", "SPACECOIN": "SpaceCoin", "SPACED": "SPACE DRAGON", "SPACEHAMSTER": "Space Hamster", "SPACELENS": "Spacelens", "SPACEM": "Spacem Token", "SPACEPI": "SpacePi", + "SPACES": "AstroSpaces.io", "SPAD": "SolPad", + "SPADE": "PolygonFarm Finance", "SPAI": "Starship AI", "SPAIN": "SpainCoin", "SPANK": "SpankChain", @@ -15642,13 +16340,13 @@ "SPARKLET": "Upland", "SPARKO": "Sparko", "SPARKSPAY": "SparksPay", - "SPARTA": "Spartan Protocol Token", + "SPARTA": "Spartan Protocol", "SPARTACATS": "SpartaCats", "SPARTAD": "SpartaDex", "SPAT": "Meta Spatial", "SPAVAX": "Avalanche (Synapse Protocol)", - "SPAY": "SpaceY 2025", - "SPC": "SpaceChain ERC20", + "SPAY": "SpaceY", + "SPC": "SpaceChain", "SPC.QRC": "SpaceChain (QRC-20)", "SPCIE": "Specie", "SPCT": "Spectra Chain", @@ -15657,11 +16355,12 @@ "SPDR": "SpiderDAO", "SPDX": "Speedex", "SPE": "SavePlanetEarth", - "SPEC": "SpecCoin", + "SPEC": "Spectral", + "SPEC32925": "Spectral", "SPECT": "Spectral", "SPECTRE": "SPECTRE AI", "SPEE": "SpeedCash", - "SPEED": "IShowSpeed", + "SPEED": "Speed Star SPEED", "SPEEDCOIN": "Speed Coin", "SPEEDY": "Speedy", "SPELL": "Spell Token", @@ -15673,15 +16372,15 @@ "SPERG": "Bloomsperg Terminal", "SPEX": "StepEx", "SPF": "SportyCo", - "SPFC": "São Paulo FC Fan Token", + "SPFC": "Sao Paulo FC Fan Token", "SPG": "Space Crypto", "SPGBB": "SPGBB", "SPH": "Spheroid Universe", - "SPHERE": "Sphere Finance", - "SPHR": "Sphere Coin", + "SPHERE": "Cronosphere", + "SPHR": "Sphere", "SPHRI": "Spherium", "SPHTX": "SophiaTX", - "SPHYNX": "Sphynx Token", + "SPHYNX": "Sphynx BSC", "SPHYNXV1": "Sphynx Token v1", "SPHYNXV2": "Sphynx Token v2", "SPHYNXV3": "Sphynx Token v3", @@ -15704,7 +16403,8 @@ "SPIRIT": "SpiritSwap", "SPITT": "Hawk Ttuuaahh", "SPIZ": "SPACE-iZ", - "SPK": "Spark", + "SPK": "SparksPay", + "SPK36569": "Spark", "SPKI": "SPIKE INU", "SPKL": "SpokLottery", "SPKTR": "Ghost Coin", @@ -15713,7 +16413,7 @@ "SPLA": "SmartPlay", "SPLD": "Splendor", "SPM": "Supreme", - "SPN": "Sapien Network", + "SPN": "SPORTZCHAIN", "SPND": "Spindle", "SPO": "Spores Network", "SPOK": "Spock", @@ -15724,9 +16424,9 @@ "SPONGEBOB": "Spongebob Squarepants", "SPOODY": "Spoody Man", "SPOOF": "Spoofify", - "SPOOL": "Spool DAO Token", + "SPOOL": "Spool DAO", "SPORE": "Spore", - "SPORT": "SportsCoin", + "SPORT": "SPORT", "SPORTBET": "SBET", "SPORTFUN": "Sport.fun", "SPORTS": "ZenSports", @@ -15736,6 +16436,7 @@ "SPOTCOIN": "Spotcoin", "SPOTS": "Spots", "SPOX": "Sports Future Exchange Token", + "SPR": "SpreadCoin", "SPRING": "Spring", "SPRITZMOON": "SpritzMoon Crypto Token", "SPRKL": "Sparkle Loyalty", @@ -15745,8 +16446,8 @@ "SPRTS": "Sprouts", "SPRTZ": "SpritzCoin", "SPRX": "Sprint Coin", - "SPS": "Splinterlands", - "SPT": "SPECTRUM", + "SPS": "Splintershards", + "SPT": "Spectrum", "SPUME": "Spume", "SPUNK": "PUNK", "SPURD": "Spurdo Spärde", @@ -15754,18 +16455,21 @@ "SPURS": "Tottenham Hotspur Fan Token", "SPWN": "Bitspawn", "SPX": "SPX6900", + "SPX28081": "SPX6900", "SPX6969": "SPX 6969", "SPXC": "SpaceXCoin", "SPY": "Smarty Pay", "SPYON": "SPDR S&P 500 ETF (Ondo Tokenized)", "SPYRO": "SPYRO", - "SPYX": "SP500 xStock", + "SPYX": "SP500 tokenized ETF (xStock)", "SQ3": "Squad3", "SQAT": "Syndiqate", "SQD": "SQD", + "SQF": "SquadFund", "SQG": "Squid Token", "SQGROW": "SquidGrow", "SQL": "Squall Coin", + "SQM": "Squid Moon", "SQQQON": "ProShares UltraPro Short QQQ (Ondo Tokenized)", "SQR": "Magic Square", "SQRL": "Squirrel Swap", @@ -15779,7 +16483,7 @@ "SQUEEZER": "Squeezer", "SQUIBONK": "SQUIBONK", "SQUID": "Squid Game", - "SQUID2": "Squid Game 2.0", + "SQUID2": "SquidDao", "SQUIDGROW": "SquidGrow", "SQUIDGROWV1": "SquidGrow v1", "SQUIDV1": "Squid Game v1", @@ -15789,7 +16493,7 @@ "SQUIRT": "SQUIRTLE", "SQUOGE": "DogeSquatch", "SR30": "SatsRush", - "SRBP": "Super Rare Ball Potion", + "SRBP": "Pikaster", "SRC": "SecureCoin", "SRCH": "SolSrch", "SRCOIN": "SRCoin", @@ -15801,12 +16505,13 @@ "SRLTY": "SaitaRealty", "SRLY": "Rally (Solana)", "SRM": "Serum", - "SRN": "SirinLabs", + "SRN": "SIRIN LABS Token", "SRNT": "Serenity", - "SRP": "Starpunk", + "SROCKET": "Stable One Rocket", + "SRP": "Starpad", "SRT": "Smart Reward Token", "SRWD": "ShibRWD", - "SRX": "StorX", + "SRX": "StorX Network", "SS": "Sharder", "SS20": "Shell Trade", "SSB": "SatoshiStreetBets", @@ -15818,6 +16523,7 @@ "SSEV2": "Soroosh Smart Ecosystem", "SSG": "SOMESING", "SSGT": "Safeswap", + "SSGTX": "SafeSwap", "SSGV1": "SOMESING", "SSH": "StreamSpace", "SSHIB": "Solana Shib", @@ -15825,11 +16531,13 @@ "SSLX": "StarSlax", "SSNC": "SatoshiSync", "SSOL": "Solayer SOL", + "SSP": "Smartshare", "SSR": "SOL Strategic Reserve", - "SSS": "Sparkle Token", + "SSS": "Simple Software Solutions", "SSSSS": "Snake wif Hat", "SST": "SIMBA Storage Token", "SSTC": "SunShotCoin", + "SSTX": "Silver Stonks", "SSTZ": "SSTZ", "SSU": "Sunny Side up", "SSUI": "Spring Staked SUI", @@ -15838,29 +16546,31 @@ "SSVV1": "Blox", "SSWP": "Suiswap", "SSX": "Solana Stock Index", - "ST": "Skippy Token", - "STA": "STOA Network", + "ST": "Sacred Tails", + "STA": "STATERA", "STAB": "STABLE ASSET", - "STABLE": "Stable", + "STABLE": "Stablecoin", + "STABLE38892": "Stable", "STABLZ": "Stablz", "STABUL": "Stabull Finance", "STAC": "STAC", - "STACK": "StackOS", - "STACKS": " STACKS PAY", + "STACK": "Stacker Ventures", + "STACKS": "STACKS PAY", "STACS": "STACS Token", "STAFIRETH": "StaFi Staked ETH", "STAGE": "Stage", "STAI": "StereoAI", - "STAK": "Jigstack", - "STAKE": "xDai Chain", + "STAK": "STRAKS", + "STAKE": "STAKE", + "STAKECUBECOIN": "StakeCubeCoin", "STAKEDETH": "StakeHound Staked Ether", "STAKERDAOWXTZ": "Wrapped Tezos", "STALIN": "StalinCoin", "STAMP": "SafePost", "STAN": "Stank Memes", - "STANDARD": "Stakeborg DAO", + "STANDARD": "Construct", "STAPT": "Ditto Staked Aptos", - "STAR": "Starpower Network Token", + "STAR": "Starbase", "STAR10": "Ronaldinho Coin", "STARAMBA": "Staramba", "STARBASE": "Starbase", @@ -15868,19 +16578,20 @@ "STARDOGE": "StarDOGE", "STARGATEAI": "Stargate AI Agent", "STARHEROES": "StarHeroes", - "STARL": "StarLink", + "STARL": "Starlink", "STARLAUNCH": "StarLaunch", "STARLY": "Starly", "STARP": "Star Pacific Coin", "STARRI": "starri", - "STARS": "Stargaze", + "STARS": "Mogul Productions", + "STARS16842": "Stargaze", "STARSH": "StarShip Token", "STARSHARKS": "StarSharks", "STARSHI": "Starship", "STARSHIP": "STARSHIP", "STARSHIPDOGE": "Starship Doge", "STARSHIPONSOL": "Starship", - "START": "StartCoin", + "START": "Startcoin", "STARTA": "Starta", "STARTER": "Starter.xyz", "STARTUP": "Startup", @@ -15889,25 +16600,28 @@ "STASH": "STASH INU", "STASHV1": "BitStash", "STAT": "STAT", - "STATE": "New World Order", + "STATE": "ParaState", "STATER": "Stater", "STATERA": "Statera", + "STATIK": "Statik", "STATOK": "STA", "STATOKEN": "STA", "STATOM": "Stride Staked ATOM", "STATS": "Stats", "STAU": "STAU", - "STAX": "Staxcoin", + "STAX": "StableXSwap", "STAY": "NFsTay", "STB": "stabble", - "STBL": "STBL Governance Token", + "STBL": "STBL", "STBOT": "SolTradingBot", "STBTC": "Lorenzo stBTC", "STBU": "Stobox Token", - "STC": "Satoshi Island", + "STBZ": "Stabilize", + "STC": "Student Coin", "STCN": "Stakecoin", "STD": "STEED", "STDYDX": "Stride Staked DYDX", + "STE": "Stretch To Earn", "STEAK": "SteakHut Finance", "STEAKUSDC": "Steakhouse USDC Morpho Vault", "STEALTH": "StealthPad", @@ -15926,23 +16640,27 @@ "STEPR": "Step", "STEPS": "Steps", "STERLINGCOIN": "SterlingCoin", - "STETH": "Staked Ether", + "STETH": "Lido Staked ETH", "STEVMOS": "Stride Staked EVMOS", "STEWIE": "Stewie Coin", "STEX": "STEX", - "STF": "Structure Finance", + "STF": "Structure finance", "STFLOW": "Increment Staked FLOW", "STFX": "STFX", "STG": "Stargate Finance", + "STG18934": "Stargate Finance", "STHR": "Stakerush", "STHYPE": "Staked HYPE", "STI": "Seek Tiger", "STIC": "StickMan", + "STICK": "Stick Man", "STICKMAN": "stickman", "STIK": "Staika", + "STILT": "Stilton", "STIMA": "STIMA", "STING": "Sting", "STINJ": "Stride Staked INJ", + "STINK": "Drunk Skunks DC", "STIPS": "Stips", "STITCH": "Stitch", "STIX": "STIX", @@ -15951,19 +16669,21 @@ "STKAAVE": "Staked Aave", "STKATOM": "pSTAKE Staked ATOM", "STKBNB": "pSTAKE Staked BNB", - "STKC": "Streakk Chain", + "STKC": "STICKY COIN", "STKD": "Stkd SCRT", + "STKE": "AlgoStake", "STKHUAHUA": "pSTAKE Staked HUAHUA", "STKK": "Streakk", "STKSTARS": "pSTAKE Staked STARS", "STKXPRT": "pSTAKE Staked XPRT", "STLE": "Saint Ligne", - "STMAN": "Stickman Battleground", + "STMAN": "STMAN | Stickman's Battleground NFT Game", "STMATIC": "Lido Staked Matic", "STMX": "StormX", "STMXV1": "Storm", "STMXV2": "StormX v1", - "STND": "Standard Protocol", + "STN": "STING", + "STND": "Standard", "STNEAR": "Staked NEAR", "STNK": "Stonks", "STO": "StakeStone", @@ -15972,38 +16692,40 @@ "STOG": "Stooges", "STOGE": "Stoner Doge Finance", "STOIC": "stoicDAO", - "STON": "STON", + "STON": "Mainston", "STONEDE": "Stone DeFi", "STONETOKEN": "Stone Token", "STONK": "STONK", - "STONKS": "STONKS", + "STONKS": "Stonks DAO", "STOP": "LETSTOP", + "STOPELON": "Stopelon", "STOR": "Self Storage Coin", "STORE": "Bit Store", "STOREFUN": "FUN", "STOREP": "Storepay", "STORJ": "Storj", - "STORM": "STORM", + "STORM": "Storm Token", "STORY": "Story", "STOS": "Stratos", "STOSMO": "Stride Staked OSMO", "STOX": "Stox", "STP": "StashPay", "STPL": "Stream Protocol", - "STPT": "STP Network", + "STPT": "STP", "STQ": "Storiqa Token", - "STR": "Sourceless", + "STR": "Staker", "STRA": "STRAY", "STRAKS": "Straks", - "STRAT": "Strategic Hub for Innovation in Blockchain", + "STRAT": "Stratis", "STRAWBE": "Strawberry In Bloom", "STRAX": "Stratis", "STRAY": "Stray Dog", "STRAYDOG": "Stray Dog", "STRD": "Stride", "STRDY": "Sturdy", - "STREAM": "Streamflow", + "STREAM": "Streamit Coin", "STREAMER": "StreamerCoin", + "STREAMERINU": "Streamer Inu", "STREAMIT": "STREAMIT COIN", "STREETH": "STREETH", "STRI": "Strite", @@ -16011,7 +16733,8 @@ "STRIKETOKEN": "Strike", "STRIP": "Stripto", "STRK": "Starknet", - "STRM": "StreamCoin", + "STRK22691": "Starknet", + "STRM": "Instrumental Finance", "STRNGR": "Stronger", "STRONG": "Strong", "STRONGSOL": "Stronghold Staked SOL", @@ -16028,7 +16751,7 @@ "STSR": "SatelStar", "STSTARS": "Stride Staked Stars", "STSW": "Stackswap", - "STT": "Statter Network ", + "STT": "Statter Network", "STTAO": "Tensorplex Staked TAO", "STTIA": "Stride Staked TIA", "STTON": "bemo staked TON", @@ -16041,19 +16764,20 @@ "STUMEE": "Stride Staked UMEE", "STUPID": "StupidCoin", "STUSDT": "Staked USDT", - "STV": "Sativa Coin", + "STV": "Sint-Truidense Voetbalvereniging Fan Token", "STWEMIX": "Staked WEMIX", "STX": "Stacks", + "STX4847": "Stacks", "STXON": "Seagate (Ondo Tokenized)", "STYL": "Stylike Governance", "STYLE": "Style", "STZ": "99Starz", "STZEN": "StakedZEN", "STZETA": "ZetaEarn", - "STZU": "Shihtzu Exchange Token", + "STZU": "Shihtzu Exchange", "SU": "Smol Su", "SUAI": "SuiAI", - "SUB": "Subsocial", + "SUB": "Substratum", "SUBA": "Yotsuba", "SUBAWU": "Subawu Token", "SUBF": "Super Best Friends", @@ -16066,6 +16790,7 @@ "SUGAR": "Sugar Exchange", "SUGARB": "SugarBlock", "SUI": "Sui", + "SUI20947": "Sui", "SUIA": "SUIA", "SUIAGENT": "aiSUI", "SUIAI": "SUI Agents", @@ -16081,7 +16806,8 @@ "SUISHIB": "SuiShiba", "SUITE": "Suite", "SUKI": "SUKI", - "SUKU": "SUKU", + "SUKO": "Retsuko", + "SUKU": "Suku", "SULFERC": "SULFERC", "SUM": "SumSwap", "SUMI": "SUMI", @@ -16090,10 +16816,10 @@ "SUMMITTHE": "SUMMIT", "SUMO": "Sumokoin", "SUMR": "SummerToken", - "SUN": "Sun Token", + "SUN": "Sun (New)", "SUNC": "Sunrise", "SUNCAT": "Suncat", - "SUNDAE": "Sundae the Dog", + "SUNDAE": "SundaeSwap", "SUNDOG": "SUNDOG", "SUNEX": "The Sun Exchange", "SUNGOAT": "SUNGOAT", @@ -16112,11 +16838,13 @@ "SUNTRON": "TRON MASCOT", "SUNV1": "Sun Token v1", "SUNWUKONG": "SunWukong", - "SUP": "Superp", + "SUP": "SUP", "SUP8EME": "SUP8EME Token", "SUPCOIN": "Supcoin", - "SUPE": "Supe Infinity", + "SUPE": "SUPE", "SUPER": "SuperVerse", + "SUPER-BITCOIN": "SuperCoin", + "SUPER8290": "SuperVerse", "SUPERBID": "SuperBid", "SUPERBONDS": "SuperBonds Token", "SUPERBONK": "SUPER BONK", @@ -16125,6 +16853,7 @@ "SUPERCYCLE": "Crypto SuperCycle", "SUPERDAPP": "SuperDapp", "SUPERF": "SUPER FLOKI", + "SUPERFARM": "Superfarm", "SUPERFL": "Superfluid", "SUPERGROK": "SuperGrok", "SUPEROETHB": "Super OETH", @@ -16135,8 +16864,8 @@ "SUPRA": "Supra", "SUPREMEFINANCE": "Hype", "SUR": "Suretly", - "SURE": "inSure", - "SURF": "Surf.Finance", + "SURE": "inSure DeFi", + "SURF": "SURF Finance", "SURGE": "Surge", "SURV": "Survival Game Online", "SURVIVING": "Surviving Soldiers", @@ -16146,11 +16875,11 @@ "SUSDS": "Savings USDS", "SUSDT": "SkyTrade Pro", "SUSDX": "Staked USDX", - "SUSHI": "Sushi", + "SUSHI": "SushiSwap", "SUSX": "Savings USX", "SUT": "SuperTrust", "SUTEKU": "Suteku", - "SUTER": "Suterusu", + "SUTER": "suterusu", "SUWI": "suwi", "SUZUME": "Shita-kiri Suzume", "SVD": "savedroid", @@ -16161,7 +16890,7 @@ "SVPN": "Shadow Node", "SVS": "GivingToServices SVS", "SVSA": "SavannaSurvival", - "SVT": "Solvent", + "SVT": "Space Vikings", "SVTS": "Syncvault", "SVX": "Savix", "SVY": "Savvy", @@ -16170,18 +16899,18 @@ "SWAG": "SWAG Finance", "SWAGGY": "swaggy", "SWAGT": "Swag Token", - "SWAI": "Safe Water AI", + "SWAI": "SchwiftAI", "SWAMP": "Swampy", "SWAN": "Swan Chain", "SWANSOL": "Black Swan", - "SWAP": "Trustswap", + "SWAP": "TrustSwap", "SWAPP": "SWAPP Protocol", - "SWAPZ": "SWAPZ.app", + "SWAPZ": "Swapz", "SWARM": "SwarmCoin", "SWARMS": "Swarms", "SWASH": "Swash", "SWAST": "Swasticoin", - "SWAY": "Sway Social", + "SWAY": "Sway Protocol", "SWBTC": "Swell Restaked BTC", "SWC": "Scanetchain Token", "SWCH": "SwissCheese", @@ -16193,24 +16922,27 @@ "SWELL": "Swell Network", "SWETH": "swETH", "SWFL": "Swapfolio", - "SWFTC": "SWFTCoin", + "SWFTC": "SwftCoin", "SWG": "Swirge", "SWGT": "SmartWorld Global", "SWH": "simbawifhat", + "SWHAL": "SafeWhale Games", + "SWI": "Swinca", "SWIF": "SUNwifHat", - "SWIFT": "BitSwift", + "SWIFT": "SwiftCash", "SWIFTIES": "Taylor Swift", "SWIM": "SWIM - Spread Wisdom", "SWIN": "SwinCoin", - "SWING": "SwingCoin", + "SWING": "Swing", "SWINGBY": "Swingby", + "SWIPE": "Swipe Bot", "SWIPES": "BNDR", "SWIRL": "Swirl Social", "SWIRLX": "SwirlToken", "SWIS": "Swiss Cash Coin", "SWISE": "StakeWise", "SWITCH": "Switch", - "SWM": "Swarm Fund", + "SWM": "Swarm", "SWO": "SwordMagicToken", "SWOL": "Snowy Owl", "SWOLE": "Swole Doge", @@ -16224,9 +16956,10 @@ "SWPX": "SwapX", "SWRV": "Swerve", "SWRX": "SwissRx Coin", - "SWT": "Swarm City Token", + "SWS": "SwiftSwap", + "SWT": "Swarm City", "SWTCH": "Switchboard", - "SWTH": "Carbon", + "SWTH": "Carbon Protocol", "SWTS": "SWEETS", "SWU": "Smart World Union", "SWY": "Swype", @@ -16236,15 +16969,15 @@ "SXCH": "SolarX", "SXDT": "SPECTRE Dividend Token", "SXM": "saxumdao", - "SXP": "SXP", + "SXP": "Solar", "SXS": "Sphere", "SXT": "Space and Time", "SXUT": "SPECTRE Utility Token", - "SYA": "SaveYourAssets", + "SYA": "SYA x Flooz", "SYBC": "SYB Coin", "SYBL": "Sybulls", "SYBTC": "sBTC", - "SYC": "SynchroCoin", + "SYC": "YCLUB", "SYK": "Stryke", "SYL": "XSL Labs", "SYLO": "Sylo", @@ -16252,8 +16985,9 @@ "SYM": "SymVerse", "SYMM": "Symmio", "SYMP": "Sympson AI", - "SYN": "Synapse", - "SYNC": "Syncus", + "SYN": "SynLev", + "SYNAPTICAI": "Synaptic AI", + "SYNC": "SYNC Network", "SYNCC": "SyncCoin", "SYNCG": "SyncGPT", "SYNCN": "Sync Network", @@ -16265,38 +16999,42 @@ "SYNLEV": "SynLev", "SYNO": "Synonym Finance", "SYNR": "MOBLAND", - "SYNT": "Synthetix Network", + "SYNT": "Synternet", "SYNTE": "Synternet", - "SYNTH": "SYNTHR", + "SYNTH": "Synthswap", "SYNTHSWAP": "Synthswap", "SYNX": "Syndicate", "SYPOOL": "Sypool", "SYRAX": "Syrax AI", - "SYRUP": "Syrup", + "SYRUP": "Maple Finance", "SYRUPUSDC": "SyrupUSDC", "SYRUPUSDT": "Syrup USDT", "SYS": "Syscoin", + "SZC": "ShopZcoin", "SZCB": "Zugacoin", "SZN": "BNB SZN", - "T": "Threshold Network Token", + "T": "Threshold", "T1": "Trump Mobile", "T23": "T23", "T99": "Tethereum", "TA": "Trusta.AI", - "TAAS": "Token as a Service", - "TAB": "MollyCoin", - "TABOO": "Taboo Token", + "TAAS": "TaaS", + "TAB": "TABANK", + "TABOO": "TABOO TOKEN", "TAC": "TAC", + "TAC37338": "TAC Protocol", "TACC": "TACC", "TACHYON": "Tachyon Protocol", + "TACO": "Taco Finance", "TAD": "Tadpole", "TADA": "Ta-da", "TADDY": "DADDY TRUMP", "TADPOLEF": "Tadpole Finance", "TAF": "TAF", - "TAG": "Tagger", + "TAG": "TagCoin", "TAGR": "Think And Get Rich Coin", - "TAI": "TARS Protocol", + "TAI": "TAI", + "TAI20605": "TARS AI", "TAIKO": "Taiko", "TAIKULA": "TAIKULA COIN", "TAIL": "Tail", @@ -16306,7 +17044,7 @@ "TAKE": "TAKE", "TAKEAMERICAB": "Take America Back", "TAKER": "Taker", - "TAKI": "Taki", + "TAKI": "TAKI", "TAKO": "Tako", "TALA": "Baby Tala", "TALAHON": "Talahon", @@ -16322,18 +17060,20 @@ "TANG": "Tangent", "TANGO": "keyTango", "TANGYUAN": "TangYuan", - "TANK": "AgentTank", + "TANK": "CryptoTanks", + "TANKS": "Tanks For Playing", "TANPIN": "Tanpin", "TANSSI": "TANSSI", "TANUKI": "Tanuki", "TANUPAD": "Tanuki Launchpad", "TAO": "Bittensor", + "TAO22974": "Bittensor", "TAOBOT": "tao.bot", - "TAOCAT": "TAOCat by Virtuals", + "TAOCAT": "TAOCat by Virtuals & Masa", "TAONU": "TAO INU", "TAOP": "TaoPad", "TAOTOOLS": "TAOTools", - "TAP": "TAP FANTASY", + "TAP": "Tapioca DAO", "TAPC": "Tap Coin", "TAPCOIN": "TAP FANTASY", "TAPP": "TAPP", @@ -16347,6 +17087,7 @@ "TARDI": "Tardi", "TARGETCOIN": "TargetCoin", "TARI": "Tari World", + "TARO": "Taroverse", "TAROT": "Tarot", "TAROTV1": "Tarot v1", "TARP": "Totally A Rug Pull", @@ -16360,7 +17101,7 @@ "TATES": "Tate Stop", "TATETOKENETH": "Tate", "TATSU": "Taτsu", - "TAU": "Lamden Tau", + "TAU": "Lamden", "TAUC": "Taurus Coin", "TAUD": "TrueAUD", "TAUM": "Orbitau Taureum", @@ -16372,17 +17113,18 @@ "TAXLESSTRUMP": "MAGA TAXLESS", "TAXP": "Taxpad", "TBAC": "BlockAura", + "TBAKE": "Bakery Tools", "TBANK": "TaoBank", "TBAR": "Titanium BAR", "TBB": "Trade Butler Bot", - "TBC": "Ten Best Coins", + "TBC": "TeraBlock", "TBCC": "TBCC", "TBCI": "tbci", "TBCX": "TrashBurn", "TBD": "THE BIG DEBATE", "TBE": "TrustBase", "TBEER": "TRON BEER", - "TBFT": "Türkiye Basketbol Federasyon Token", + "TBFT": "Turkish Basketball Federation Fan Token", "TBILL": "OpenEden T-Bills", "TBILLV1": "OpenEden T-Bills v1", "TBIS": "TBIS token", @@ -16397,8 +17139,9 @@ "TBULL": "Tron Bull", "TBX": "Tokenbox", "TBY": "TOBY", + "TC": "TTcoin", "TCANDY": "TripCandy", - "TCAP": "Total Crypto Market Cap", + "TCAP": "Total Crypto Market Cap Token", "TCAPY": "TonCapy", "TCASH": "Trump Cash", "TCAT": "The Currency Analytics", @@ -16406,7 +17149,7 @@ "TCG": "Today's Crypto", "TCG2": "TCG Coin 2.0", "TCGC": "TCG Verse", - "TCH": "Thorecash", + "TCH": "Thore Cash", "TCHAIN": "Tchain", "TCHB": "Teachers Blockchain", "TCHTRX": "ThoreCashTRX", @@ -16414,7 +17157,7 @@ "TCNX": "Tercet Network", "TCO": "ThinkCoin", "TCOM": "TCOM", - "TCP": "The Crypto Prophecies", + "TCP": "Token CashPay", "TCR": "Tracer DAO", "TCS": "Timechain Swap Token", "TCT": "TokenClub", @@ -16440,16 +17183,16 @@ "TEARS": "Liberals Tears", "TEC": "TeCoin", "TECAR": "Tesla Cars", - "TECH": "TechCoin", + "TECH": "Cryptomeda", "TECHX": "WisdomTree Technology & Innovation 100 Digital Fund", "TECK": "Technet", "TECRA": "TecraCoin", - "TED": "Tezos Domains", + "TED": "TED BNB", "TEDBNB": "TED", - "TEDDY": "Teddy Doge v2", + "TEDDY": "Teddy Cash", "TEDDYV1": "Teddy Doge", "TEE": "Guarantee", - "TEER": "Integritee", + "TEER": "Integritee Network", "TEITEI": "TeiTei", "TEK": "TekCoin", "TEL": "Telcoin", @@ -16466,7 +17209,7 @@ "TEMM": "TEM MARKET", "TEMP": "Tempus", "TEMPLE": "TempleDAO", - "TEN": "TEN", + "TEN": "Tokenomy", "TENCENTAI": "Tencent AI", "TEND": "Tendies", "TENDIE": "TendieSwap", @@ -16481,10 +17224,11 @@ "TEQ": "Teq Network", "TER": "TerraNovaCoin", "TERA": "TERA", - "TERA2": "Terareum", + "TERA2": "Terareum(v2)", "TERADYNE": "Teradyne", "TERAV1": "Terareum v1", "TERAWATT": "Terawatt", + "TERC": "TronEuropeRewardCoin", "TERM": "Terminal of Simpson", "TERMINAL": "Book Terminal of Truths", "TERMINUS": "Terminus", @@ -16507,6 +17251,7 @@ "TETH": "Treehouse ETH", "TETHYS": "Tethys", "TETRA": "Tetra", + "TETRIS": "Tetris", "TETSUO": "Tetsuo Coin", "TETU": "TETU", "TEVA": "Tevaera", @@ -16514,29 +17259,33 @@ "TEW": "Trump in a memes world", "TEX": "Terrax", "TF47": "Trump Force 47", - "TFBX": "Truefeedback Token", + "TFBX": "TrueFeedBack", "TFC": "The Freedom Coin", - "TFI": "TrustFi Network Token", + "TFF": "Tutti Frutti", + "TFI": "TrustFi Network", "TFL": "True Flip Lottery", "TFLOW": "TradeFlow", "TFNY": "TFNY", "TFS": "TFS Token", - "TFT": "The Famous Token", + "TFT": "Tron Flight Ticket", "TFUEL": "Theta Fuel", "TGAME": "TrueGame", "TGC": "TG.Casino", "TGCC": "TheGCCcoin", + "TGDAO": "Launchpad TG DAO 3.0", "TGPT": "Trading GPT", "TGRAM": "TG20 TGram", "TGRASS": "Top Grass Club", - "TGT": "Tokyo Games Token", + "TGT": "THORWallet DEX", "TGW": "The Green World", "TH": "Team Heretics Fan Token", "THALES": "Thales", "THAPT": "Thala APT", "THAVAGE": "Mike Tython", - "THC": "The Hempcoin", + "THC": "HempCoin", "THD": "Trump Harris Debate", + "THE": "THENA", + "THE23335": "THENA", "THE369": "The 369 code", "THE9": "THE9", "THEAICOIN": "AI", @@ -16576,7 +17325,7 @@ "THEX": "Thore Exchange", "THG": "Thetan Arena", "THIK": "ThikDik", - "THING": "Nothing", + "THING": "Nothing Token", "THINGSOP": "ThingsOperatingSystem", "THINK": "THINK Token", "THINKWAREAI": "ThinkwareAI", @@ -16585,7 +17334,7 @@ "THL": "Thala", "THN": "Throne", "THNX": "ThankYou", - "THO": "Athero", + "THO": "Thorus", "THOL": "AngelBlock", "THOLA": "Tholana", "THOR": "THORSwap", @@ -16593,7 +17342,7 @@ "THP": "TurboHigh Performance", "THQ": "Theoriq Token", "THR": "Thorecoin", - "THREE": "Three Protocol Token ", + "THREE": "Three Protocol Token", "THRT": "ThriveToken", "THRUST": "Thruster", "THRY": "THEORY", @@ -16608,6 +17357,7 @@ "TIANHE": "Tianhe", "TIBBIR": "Ribbita", "TIC": "TrueInvestmentCoin", + "TICK": "Microtick", "TICO": "Tico", "TIDAL": "Tidal Finance", "TIDDIES": "TIDDIES", @@ -16616,7 +17366,7 @@ "TIEDAN": "TieDan", "TIF": "This Is Fine", "TIFI": "TiFi Token", - "TIG": "Tigereum", + "TIG": "Tigris", "TIGER": "TIGER", "TIGERC": "TigerCash", "TIGERCOIN": "TigerCoin", @@ -16631,7 +17381,7 @@ "TIKTOK": "Tiktok", "TIKTOKEN": "TikToken", "TILECOIN": "TileCoin", - "TIM": "TIMTIM GAMES", + "TIM": "Tourism Industry Metaverse", "TIME": "Chrono.tech", "TIMEFUN": "timefun", "TIMELESS": "Timeless", @@ -16646,23 +17396,25 @@ "TINU": "Telegram Inu", "TINY": "TinyBits", "TIOX": "TIOx", - "TIP": "Tip", + "TIP": "TECHNOLOGY INNOVATION PROJECT", "TIPC": "Tipcoin", "TIPINU": "Tip Inu", + "TIPO": "TIPO Token", "TIPS": "FedoraCoin", "TIPSX": "WisdomTree TIPS Digital Fund", "TIPSY": "TipsyCoin", "TIT": "TITANIUM", "TITA": "Titan Hunters", - "TITAN": "SATOSHI•RUNE•TITAN (Runes)", + "TITAN": "TitanSwap", "TITANCOIN": "Titan Coin", "TITANO": "Titano", "TITANSWAP": "TitanSwap", "TITANX": "TitanX", "TITC": "TitCoin", "TITCOIN": "titcoin", - "TITI": "TiTi Protocol", + "TITI": "Titi Financial", "TITN": "Titan", + "TITR": "Titter", "TITS": "We Love Tits", "TITTIECOIN": "TittieCoin", "TITTY": "TamaKitty", @@ -16678,9 +17430,9 @@ "TKINU": "Tsuki Inu", "TKMK": "TOKAMAK", "TKMN": "Tokemon", - "TKN": "Token Name Service", + "TKN": "Monolith", "TKNT": "TKN Token", - "TKO": "Tokocrypto", + "TKO": "Toko Token", "TKP": "TOKPIE", "TKR": "CryptoInsight", "TKS": "Tokes", @@ -16688,20 +17440,22 @@ "TKT": "Crypto Tickets", "TKX": "Tokenize Xchange", "TKY": "THEKEY Token", - "TLC": "Trillioner", + "TLC": "TLChain", "TLF": "Tradeleaf", "TLM": "Alien Worlds", "TLN": "Trustlines Network", "TLOS": "Telos", "TLP": "TulipCoin", + "TLSD": "TLSD Coin", "TLTON": "iShares 20+ Year Treasury Bond ETF (Ondo Tokenized)", "TLW": "TILWIKI", "TMAGA": "THE MAGA MOVEMENT", "TMAI": "Token Metrics AI", "TMANIA": "Trump Mania", + "TMC": "Trading Membership Community", "TME": "Timereum", "TMED": "MDsquare", - "TMFT": "Turkish Motorcycle Federation", + "TMFT": "Türkiye Motosiklet Federasyonu Fan Token", "TMG": "T-mac DAO", "TMN": "TranslateMe", "TMNG": "TMN Global", @@ -16717,24 +17471,27 @@ "TMX": "TMX", "TN": "TurtleNetwork", "TNB": "Time New Bank", - "TNC": "TNC Coin", + "TNC": "Trinity Network Credit", "TNDC": "TendaCoin", + "TNDF": "Tender.fi", "TNGBL": "Tangible", "TNS": "Transcodium", "TNSR": "Tensor", - "TNT": "Tierion", + "TNT": "Talent", + "TNX": "Tonex", "TOA": "TOA Coin", "TOAD": "TOAD", "TOADCOIN": "TOAD", "TOB": "Tom On Base", "TOBI": "MOTO DOG", - "TOBY": "toby", + "TOBY": "Toby", "TOC": "TouchCon", + "TOD": "Trava Capital", "TODAY": "TodayCoin", "TODD": "TURBO TODD", "TOG": "Token of Games", "TOILET": "Toilet Dust", - "TOK": "Tokai", + "TOK": "TOKOK", "TOKA": "Tonka Finance", "TOKABU": "Tokabu", "TOKAMAK": "Tokamak Network", @@ -16742,12 +17499,13 @@ "TOKC": "Tokyo Coin", "TOKE": "Tokemak", "TOKEN": "TokenFi", + "TOKEN28299": "TokenFi", "TOKENOMY": "Tokenomy", "TOKENPLACE": "Tokenplace", "TOKENSTARS": "TokenStars", "TOKERO": "TOKERO LevelUP Token", "TOKKI": "CRYPTOKKI", - "TOKO": "ToKoin", + "TOKO": "Tokoin", "TOKU": "TokugawaCoin", "TOKUD": "Tokuda", "TOL": "Tolar", @@ -16760,11 +17518,13 @@ "TOMAN": "IRR", "TOMB": "Tomb", "TOMC": "TOM CAT", - "TOMI": "tomiNet", - "TOMO": "Tomo Cat", + "TOMI": "tomi", + "TOMI23246": "tomi", + "TOMO": "TomoChain", "TOMOE": "TomoChain ERC20", "TOMS": "TomTomCoin", "TON": "Toncoin", + "TON11419": "Toncoin", "TONALD": "Tonald Trump", "TONE": "TE-FOOD", "TONI": "Daytona Finance", @@ -16775,17 +17535,18 @@ "TONS": "TONSniper", "TONST": "Ton Stars", "TONT": "TONKIT", - "TONTOKEN": "TONToken", + "TONTOKEN": "TON Token", "TONUP": "TonUP", "TONXX": "TON xStock", "TONY": "TONY THE DUCK", "TOOB": "Toobcoin", "TOOBIGTORIG": "Too Big To Rig", "TOOKER": "tooker kurlson", - "TOOLS": "TOOLS", + "TOOLS": "BSC TOOLS", "TOON": "Pontoon", "TOONF": "Toon Finance", "TOOTHLESS": "Toothless", + "TOP": "TOP", "TOPBIDDER": "TopBidder", "TOPC": "Topchain", "TOPCA": "TOP CAT", @@ -16794,9 +17555,10 @@ "TOPGP": "TOP G PEPE", "TOPI": "Topi Meme", "TOPIA": "Hytopia", + "TOPMT": "TopManager", "TOPN": "TOP Network", - "TOR": "TOR", - "TORA": "Tensora", + "TOR": "Torex", + "TORA": "Tora Inu", "TORAN": "TORA NEKO", "TORCH": "Hercules Token", "TORE": "Toreus Finance", @@ -16807,20 +17569,22 @@ "TORO": "Toro Inoue", "TOROSOL": "Toro", "TORSY": "TORSY", - "TOS": "Cryptos", + "TOS": "ThingsOperatingSystem", "TOSA": "TosaInu BSC", "TOSC": "T.OS", "TOSDIS": "TosDis", "TOSHE": "Toshe", - "TOSHI": "Toshi", + "TOSHI": "Toshimon", + "TOSHI27750": "Toshi", "TOSHKIN": "Toshkin Coin", "TOT": "TotCoin", "TOTAKE": "Totakeke", "TOTAKEKE": "Dark Cheems", - "TOTEM": "DragonMaster", + "TOTEM": "Totem Finance", "TOTHEMOON": "To The Moon", - "TOTM": "Totem", + "TOTM": "TotemFi", "TOTO": "TOTO", + "TOTO18411": "Tiamonds", "TOTT": "TOTT", "TOUCANPROTOCOL": "Toucan Protocol: Base Carbon Tonne", "TOUCHFAN": "TouchFan", @@ -16829,7 +17593,7 @@ "TOURI": "Tourist Token", "TOURISTS": "TOURIST SHIBA INU", "TOWELI": "Towelie", - "TOWER": "Tower", + "TOWER": "TOWER", "TOWN": "Town Star", "TOWNS": "Towns", "TOX": "INTOverse", @@ -16837,13 +17601,13 @@ "TOYBOX": "Memefi Toybox 404", "TOZ": "Tozex", "TP": "Token Swap", - "TPAD": "TrustPad", + "TPAD": "Trustpad", "TPAY": "TokenPay", "TPC": "Techpay", "TPCASH": "TPCash", "TPG": "Troll Payment", "TPRO": "TPRO Network", - "TPT": "Token Pocket", + "TPT": "TokenPocket", "TPTU": "Trading and Payment Token", "TPU": "TensorSpace", "TPV": "TravGoPV", @@ -16860,7 +17624,7 @@ "TRACEABILITY": "Traceability Chain", "TRACKEDBIO": "TrackedBio", "TRACN": "trac (Ordinals)", - "TRADE": "Polytrade", + "TRADE": "Unitrade", "TRADEBOT": "TradeBot", "TRADECHAIN": "Trade Chain", "TRADETIDE": "Trade Tide Token", @@ -16878,13 +17642,14 @@ "TRANSFER": "TransferCoin", "TRASH": "TrashCoin", "TRAT": "Tratok", - "TRAVA": "Trava Finance", + "TRAVA": "TRAVA.FINANCE", + "TRAVEL": "Travel Care", "TRAXIA": "Traxia Membership Token", "TRAXX": "Traxx", "TRB": "Tellor", "TRBT": "Tribute", "TRBV1": "Tellor Tributes v1", - "TRC": "Terrace", + "TRC": "Terracoin", "TRCB": "TRCB Chain", "TRCL": "Treecle", "TRCR": "Tracer", @@ -16895,7 +17660,7 @@ "TRDT": "Trident", "TRDX": "Trendix", "TREA": "Treat", - "TREAT": "Shiba Inu Treat", + "TREAT": "Treat DAO [old]", "TREB": "Treble", "TRECENTO": "Trecento Blockchain Capital", "TREE": "Treehouse", @@ -16904,6 +17669,8 @@ "TREEOFALPHA": "Tree", "TREMP": "Doland Tremp", "TRENCHER": "Trencher", + "TRENDAI": "TrendAI", + "TRENDX": "Trend X", "TRESTLE": "TRESTLE", "TRET": "Tourist Review", "TRG": "The Rug Game", @@ -16911,33 +17678,35 @@ "TRHUB": "Tradehub", "TRI": "Triangles Coin", "TRIA": "TRIA", - "TRIAS": "Trias", + "TRIAS": "Trias Token (new)", "TRIBE": "Tribe", "TRIBETOKEN": "TribeToken", - "TRIBEX": "Tribe Token", - "TRIBL": "Tribal Token", + "TRIBEX": "TRIBE", + "TRIBL": "Tribal Finance", "TRICK": "TrickyCoin", "TRICKLE": "Trickle", "TRIG": "Trigger", "TRINI": "Trinity Network Credit", - "TRIO": "TRIO", + "TRIO": "Tripio", "TRIPAD": "TripAdvisor, Inc.", "TRIPIO": "Tripio", "TRIPPKI": "Trippki", "TRISIG": "TRI SIGMA", + "TRISM": "Trism", "TRITON": "Triton", "TRIVI": "TriviAgent by Virtuals", "TRIVIA": "Trivians", "TRIX": "TriumphX", - "TRK": "TruckCoin", + "TRK": "Torekko", "TRKX": "Trakx", - "TRL": "Triall", + "TRL": "Trillion", "TRMX": "TourismX Token", + "TRND": "Trendering", "TRNDZ": "Trendsy", "TRNGUY": "Tron Guy Project", "TROG": "Trog", "TROGE": "Troge", - "TROLL": "TROLL", + "TROLL": "Trollcoin", "TROLLC": "Trollcoin", "TROLLGE": "TROLLGE", "TROLLHEIM": "Trollheim", @@ -16954,19 +17723,21 @@ "TROP": "Interop", "TROPPY": "TROPPY", "TROSS": "Trossard", - "TROVE": "TROVE", - "TROY": "Troy", + "TROVE": "TroveDAO", + "TROY": "TROY", "TRP": "Tronipay", "TRR": "Terran Coin", "TRSCT": "Transactra Finance", - "TRST": "TrustCoin", + "TRST": "WeTrust", "TRT": "TRUST AI", "TRTL": "TurtleCoin", "TRTT": "Trittium", "TRU": "TrueFi", + "TRU7725": "TrueFi", "TRUAPT": "TruFin Staked APT", + "TRUBGR": "TruBadger", "TRUCE": "WORLD PEACE PROJECT", - "TRUE": "True Chain", + "TRUE": "TrueChain", "TRUEBIT": "Truebit Protocol", "TRUEZEUSCOIN": "Zeus", "TRUF": "Truflation", @@ -16974,10 +17745,13 @@ "TRUM": "TrumpBucks", "TRUMAGA": "TrumpMAGA", "TRUMATIC": "TruFin Staked MATIC", - "TRUMP": "OFFICIAL TRUMP", + "TRUMP": "MAGA Trump", + "TRUMP-OFFICIAL": "OFFICIAL TRUMP", "TRUMP2": "Trump2024", "TRUMP2024": "Donald Trump", + "TRUMP27872": "MAGA", "TRUMP3": "Trump MP3", + "TRUMP35336": "OFFICIAL TRUMP USD Price", "TRUMP47": "47th President of the United States", "TRUMPA": "TRUMP AI", "TRUMPAI": "Trump Maga AI", @@ -17044,9 +17818,11 @@ "TRXWIN": "TronWin", "TRYB": "BiLira", "TRYC": "TRYC", + "TRYF": "Try.Finance", "TRYHARDS": "TryHards", "TRYX": "eToro Turkish Lira", "TSA": "Teaswap Art", + "TSANGNYON": "TSANGNYON HERUKA", "TSC": "TrusterCoin", "TSCT": "Transient", "TSD": "True Seigniorage Dollar", @@ -17057,7 +17833,7 @@ "TSHP": "12Ships", "TSL": "Energo", "TSLAON": "Tesla (Ondo Tokenized)", - "TSLAX": "Tesla xStock", + "TSLAX": "Tesla tokenized stock (xStock)", "TSLT": "Tamkin", "TSMON": "Taiwan Semiconductor Manufacturing (Ondo Tokenized)", "TSN": "Tsunami Exchange Token", @@ -17065,8 +17841,9 @@ "TSOTCHKE": "tsotchke", "TSR": "Tesra", "TST": "Test", + "TST35647": "Test", "TSTAI": "Test AI", - "TSTON": "Tonstakers TON", + "TSTON": "Tonstakers", "TSTS": "Test", "TSUBASAUT": "TSUBASA Utility Token", "TSUGT": "Captain Tsubasa", @@ -17075,7 +17852,7 @@ "TSX": "TradeStars", "TT": "ThunderCore", "TTAJ": "TTAJ", - "TTC": "TonTycoon", + "TTC": "Tao Te Ching", "TTF": "TurboTrix Finance", "TTK": "The Three Kingdoms", "TTM": "Tradetomato", @@ -17101,20 +17878,23 @@ "TUNACOIN": "TUNACOIN", "TUNE": "Bitune", "TUNETRADEX": "TuneTrade", - "TUP": "Tenup", + "TUP": "TenUp", "TUPE": "Turtle Pepe", "TUR": "Turron", + "TURAI": "Turismo AI", "TURB": "TurboX", "TURBO": "Turbo", "TURBOB": "Turbo Browser", "TURBOS": "Turbos Finance", "TURBOW": "Turbo Wallet", "TURT": "TurtSat", + "TURT28826": "TurtSat", "TURTLE": "Turtle", + "TURTLE38671": "Turtle USD Price", "TUS": "Treasure Under Sea", - "TUSD": "True USD", + "TUSD": "TrueUSD", "TUSDV1": "True USD v1", - "TUT": "Tutorial", + "TUT": "Tutellus", "TUTC": "TUTUT COIN", "TUTELLUS": "Tutellus", "TUTTER": "Tutter", @@ -17122,7 +17902,7 @@ "TUX": "Tux The Penguin", "TUZKI": "Tuzki", "TUZLA": "Tuzlaspor Token", - "TVK": "Terra Virtua Kolect", + "TVK": "Virtua", "TVNT": "TravelNote", "TVRS": "TiraVerse", "TVS": "TVS", @@ -17130,7 +17910,7 @@ "TWC": "Twilight", "TWD": "Terra World Token", "TWEE": "TWEEBAA", - "TWEETY": "Tweety", + "TWEETY": "Tweety Coin", "TWELVE": "TWELVE ZODIAC", "TWEP": "The Web3 Project", "TWIF": "Tomwifhat", @@ -17141,18 +17921,20 @@ "TWLV": "Twelve Coin", "TWOCAT": "TwoTalkingCats", "TWOGE": "Twoge Inu", + "TWOPAW": "Two Paws", "TWP": "TrumpWifPanda", "TWT": "Trust Wallet Token", "TWURTLE": "twurtle the turtle", - "TX": "tx", + "TX": "TransferCoin", "TX20": "Trex20", - "TXA": "TXA", + "TXA": "Project TXA", "TXAG": "tSILVER", "TXAGV1": "AurusSILVER", "TXAI": "TrumpX Ai", "TXAU": "tGOLD", "TXBIT": "Txbit Token", "TXC": "TEXITcoin", + "TXC32744": "TEXITcoin", "TXG": "TRUSTxGAMING", "TXL": "Autobahn Network", "TXT": "Taxa Token", @@ -17164,7 +17946,7 @@ "TYKE": "Tyke The Elephant", "TYLER": "Tyler", "TYOGHOUL": "TYO GHOUL", - "TYPE": "TypeAI", + "TYPE": "Typerium", "TYPEL": "TypeIt", "TYPERIUM": "Typerium", "TYPUS": "Typus", @@ -17176,7 +17958,7 @@ "TZKI": "Tsuzuki Inu", "TZPEPE": "Tezos Pepe", "TZU": "Sun Tzu", - "U": "United Stables", + "U": "Unidef", "U2U": "U2U Network", "U8D": "Universal Dollar", "UA1": "UA1", @@ -17184,23 +17966,26 @@ "UAHG": "UAHg", "UAI": "UnifAI", "UAT": "UltrAlpha", + "UAXIE": "Unicly Mystic Axies Collection", "UB": "Unibase", "UBA": "Unbox.Art", "UBC": "Universal Basic Compute", "UBCOIN": "Ubcoin", "UBDN": "UBD Network", + "UBE": "Ubeswap", "UBEX": "Ubex", "UBI": "Universal Basic Income", "UBIQ": "Ubiqoin", "UBIT": "UBIT", "UBITTOKEN": "UBit Token", "UBQ": "Ubiq", - "UBT": "UniBright", - "UBTC": "UnitedBitcoin", + "UBSN": "Silent Notary", + "UBT": "Unibright", + "UBTC": "United Bitcoin", "UBU": "UBU", - "UBX": "UBIX Network", + "UBX": "UBIX.Network", "UBXN": "UpBots Token", - "UBXS": "UBXS", + "UBXS": "UBXS Token", "UBXT": "UpBots", "UC": "YouLive Coin", "UCA": "UCA Coin", @@ -17208,27 +17993,29 @@ "UCAP": "Unicap.finance", "UCASH": "U.CASH", "UCCOIN": "UC Coin", + "UCF": "UC Finance", "UCG": "Universe Crystal Gene", - "UCH": "UChain", + "UCH": "Universidad de Chile Fan Token", "UCJL": "Utility Cjournal", "UCM": "UCROWDME", "UCN": "UCHAIN", "UCO": "Uniris", "UCOIN": "UCOIN", - "UCON": "YouCoin Metaverse", + "UCON": "YouCoin Metaverse (old)", "UCORE": "UnityCore Protocol", "UCR": "Ultra Clear", "UCT": "UnitedCrowd", "UCX": "UCX", "UDAO": "UDAO", - "UDO": "Unido", + "UDO": "Unido EP", + "UDOKI": "Unicly Doki Doki Collection", "UDOO": "Hyprr", "UDS": "Undeads Games", "UDT": "Unlock Protocol", "UE": "UE Coin", "UEC": "United Emirates Coin", "UECON": "Uranium Energy (Ondo Tokenized)", - "UEDC": "United Emirate Decentralized Coin", + "UEDC": "UNITED EMIRATE DECENTRALIZED COIN.", "UENC": "UniversalEnergyChain", "UET": "Useless Ethereum Token", "UETL": "Useless Eth Token Lite", @@ -17236,14 +18023,14 @@ "UFC": "Union Fair Coin", "UFD": "Unicorn Fart Dust", "UFFYI": "Unlimited FiscusFYI", - "UFI": "PureFi", - "UFO": "UFO Gaming", + "UFI": "PureFi Protocol", + "UFO": "Uniform Fiscal Object", "UFOC": "Unknown Fair Object", "UFOCOIN": "Uniform Fiscal Object", "UFOP": "UFOPepe", "UFR": "Upfiring", - "UFT": "UniLend Finance", - "UGAS": "Ultrain", + "UFT": "UniLend", + "UGAS": "UGAS", "UGC": "ugChain", "UGO": "UGO", "UGOLD": "UGOLD Inc.", @@ -17259,25 +18046,28 @@ "UKG": "UnikoinGold", "UKRAINEDAO": "UkraineDAO Flag NFT", "ULD": "Unlighted", - "ULT": "Ultiledger", + "ULG": "Ultragate", + "ULT": "Ultra", "ULTC": "Umbrella", "ULTGG": "UltimoGG", "ULTI": "Ultiverse", + "ULTI31504": "Ultiverse", "ULTIMA": "Ultima", "ULTIMATEBOT": "Ultimate Tipbot", "ULTR": "ULTRA MAGA", - "ULTRA": "Ultra", + "ULTRA": "UltraSafe Token", "ULTRAP": "ULTRA Prisma Finance", + "ULTRON": "Ultron Vault", "ULX": "ULTRON", - "UM": "UncleMine", + "UM": "Continuum World", "UMA": "UMA", "UMAD": "MADworld", - "UMAMI": "Umami", + "UMAMI": "Umami Finance", "UMB": "Umbrella Network", "UMBR": "Umbria Network", - "UMBRA": "Umbra", + "UMBRA": "Umbra USD Price", "UMC": "Umbrella Coin", - "UMI": "Universal Money Instrument", + "UMI": "UMI", "UMID": "Umi Digital", "UMJA": "Umoja", "UMK": "UMKA", @@ -17289,7 +18079,7 @@ "UMY": "KaraStar UMY", "UNA": "Unagi Token", "UNAT": "Unattanium", - "UNB": "Unbound Finance", + "UNB": "Unbound", "UNBNK": "Unbanked", "UNBREAKABLE": "UnbreakableCoin", "UNC": "UnCoin", @@ -17297,11 +18087,11 @@ "UNCL": "UNCL", "UNCN": "Unseen", "UNCOMMONGOODS": "UNCOMMON•GOODS", - "UNCX": "UniCrypt", + "UNCX": "UNCX Network", "UND": "United Network Distribution", "UNDB": "unibot.cash", "UNDE": "Undead Finance", - "UNDEAD": "Undead Blocks", + "UNDEAD": "Undead Finance", "UNDG": "UniDexGas", "UNDX": "UNODEX", "UNF": "Unfed Coin", @@ -17309,9 +18099,11 @@ "UNFK": "UNFK", "UNGON": "US Natural Gas Fund (Ondo Tokenized)", "UNHX": "UnitedHealth xStock", - "UNI": "Uniswap Protocol Token", + "UNI": "Uniswap", + "UNI7083": "Uniswap", "UNIART": "UNIART", - "UNIBOT": "Unibot", + "UNIBOT": "UniBot", + "UNIBOT27009": "UniBot", "UNIBOTV1": "Unibot v1", "UNIBTC": "uniBTC", "UNIC": "Unicly", @@ -17325,6 +18117,7 @@ "UNIE": "Uniswap Protocol Token (Avalanche Bridge)", "UNIETH": "Universal ETH", "UNIFI": "Unifi", + "UNIFI-PROTOCOL": "UNIFI DeFi", "UNIFY": "Unify", "UNIL": "UniLayer", "UNIM": "Unicorn Milk", @@ -17332,7 +18125,7 @@ "UNION": "Union", "UNIPOWER": "UniPower", "UNIPT": "Universal Protocol Token", - "UNIQ": "Uniqredit", + "UNIQ": "Uniqly", "UNIQUE": "Unique One", "UNIR": "UniRouter", "UNISD": "unified Stable Dollar", @@ -17353,27 +18146,28 @@ "UNITS": "GameUnits", "UNITY": "SuperNET", "UNIVRS": "Universe", - "UNIX": "UniX", + "UNIX": "UniX Gaming", "UNIXCOIN": "UNIX", "UNLEASH": "UnleashClub", "UNM": "UNIUM", "UNMD": "Utility Nexusmind", "UNN": "UNION Protocol Governance Token", - "UNO": "UnoRe", + "UNO": "Unobtanium", "UNOB": "Unobtanium", "UNP": "UNIPOLY", "UNPON": "Union Pacific Corporation (Ondo Tokenized)", - "UNQ": "UNQ", + "UNQ": "Unique Network", "UNQT": "Unique Utility Token", "UNR": "Unirealchain", "UNRC": "UniversalRoyalCoin", "UNS": "UNS TOKEN", "UNSHETH": "unshETH Ether", - "UNT": "UnityWallet Token", + "UNT": "Unity Network", + "UNV": "Unvest", "UNW": "UniWorld", "UOP": "Utopia Genesis Foundation", - "UOS": "UOS", - "UP": "Superform", + "UOS": "Ultra", + "UP": "UpToken", "UPC": "UPCX", "UPCG": "Upcomings", "UPCO2": "Universal Carbon", @@ -17400,13 +18194,14 @@ "URALS": "Urals Coin", "URANUS": "Uranus", "URAON": "Global X Uranium ETF (Ondo Tokenized)", + "URD": "UrDEX Finance", "URFA": "Urfaspor Token", "URMOM": "urmom", "URO": "Urolithin A", "UROCOIN": "UroCoin", "URQA": "UREEQA", "URS": "URUS", - "URUS": "Urus Token", + "URUS": "Aurox", "URX": "URANIUMX", "US": "Talus Token", "USA": "Based USA", @@ -17419,15 +18214,16 @@ "USCC": "USC", "USCOIN": "USCoin", "USCR": "United States Crypto Reserve", - "USD0": "Usual", + "USD0": "Usual USD", "USD1": "World Liberty Financial USD", + "USD136148": "World Liberty Financial USD USD Price", "USD3": "Web 3 Dollar", "USDA": "USDA", "USDACC": "USDA", "USDAI": "USDai", "USDAP": "Bond Appetite USD", "USDAVALON": "USDa", - "USDB": "Blynex USD", + "USDB": "USDB", "USDBC": "Bridged USDC", "USDBLAST": "USDB Blast", "USDC": "USD Coin", @@ -17445,11 +18241,13 @@ "USDDD": "USDDD", "USDDV1": "USDD v1", "USDE": "Ethena USDe", + "USDE29470": "Ethena USDe", "USDEBT": "USDEBT", "USDEX": "eToro US Dollar", - "USDF": "Falcon USD", + "USDF": "FolgoryUSD", "USDFL": "USDFreeLiquidity", "USDG": "Global Dollar", + "USDG33793": "Global Dollar", "USDGLOBI": "Globiance USD Stablecoin", "USDGO": "USDGO", "USDGV1": "USDG v1", @@ -17462,9 +18260,9 @@ "USDK": "USDK", "USDKG": "USDKG", "USDL": "Lift Dollar", - "USDM": "USDM", + "USDM": "USD mars", "USDMA": "USD mars", - "USDN": "Ultimate Synthetic Delta Neutral", + "USDN": "Neutrino USD", "USDNEUTRAL": "Neutral AI", "USDO": "USD Open Dollar", "USDON": "U.S. Dollar Tokenized Currency (Ondo)", @@ -17472,12 +18270,13 @@ "USDPLUS": "Overnight.fi USD+", "USDQ": "Quantoz USDQ", "USDQSTABLE": "USDQ", - "USDR": "StablR USD", - "USDS": "Sky Dollar", + "USDR": "Wrapped USDR", + "USDS": "Stably Classic", + "USDS33039": "USDS", "USDSB": "USDSB", "USDSTABLY": "StableUSD", "USDSUI": "USDsui", - "USDT": "Tether", + "USDT": "Tether USDt", "USDT0": "USDT0", "USDT1": "USDT1", "USDTB": "USDtb", @@ -17487,18 +18286,21 @@ "USDU": "Upper Dollar", "USDUC": "Unstable Coin", "USDV": "Verified USD", + "USDV28443": "Verified", "USDW": "USD DWIN", "USDWON": "Won Chang", - "USDX": "USDX Stablecoin", + "USDX": "USDX [Lighthouse]", "USDXL": "Last USD", "USDY": "Ondo US Dollar Yield", "USDZ": "Zedxion USDZ", "USE": "Usechain Token", "USEDCAR": "A Gently Used 2001 Honda", "USELESS": "USELESS COIN", + "USELESS36828": "Useless Coin USD Price", "USETH": "USETH", + "USF": "Unslashed Finance", "USG": "USGold", - "USH": "unshETHing_Token", + "USH": "Hedge USD", "USHARK": "uShark", "USHI": "Ushi", "USHIBA": "American Shiba", @@ -17507,12 +18309,12 @@ "USNBT": "NuBits", "USNOTA": "NOTA", "USOR": "U.S Oil", - "USP": "USP Token", + "USP": "USP", "USPEPE": "American pepe", "USPLUS": "Fluent Finance", "USR": "Resolv USR", "USSD": "Autonomous Secure Dollar", - "UST": "Wrapped UST Token", + "UST": "TerraClassicUSD", "USTB": "Superstate Short Duration U.S. Government Securities Fund", "USTBL": "Spiko US T-Bills Money Market Fund", "USTC": "TerraClassicUSD", @@ -17524,7 +18326,7 @@ "USUALX": "USUALx", "USUD": "USUD", "USV": "Universal Store of Value", - "USX": "USX", + "USX": "Unified Society Quantum", "USXQ": "USX Quantum", "USYC": "Hashnote USYC", "UT": "Ulord", @@ -17537,7 +18339,7 @@ "UTHX": "Utherverse", "UTI": "Unicorn Technology International", "UTIL": "Utility Coin", - "UTK": "Utrust", + "UTK": "xMoney", "UTKV1": "Utrust", "UTMDOGE": "UltramanDoge", "UTNP": "Universa", @@ -17551,14 +18353,17 @@ "UTYAB": "Utya Black", "UUC": "USA Unity Coin", "UUSD": "Unity USD", + "UUSD22700": "Utopia", "UUU": "U Network", "UVT": "UvToken", "UW3S": "Utility Web3Shot", - "UWU": "Unlimited Wealth Utility", + "UWL": "UniWhales", + "UWU": "UwU Lend", "UWUCOIN": "uwu", "UWULEND": "UwU Lend", - "UX": "Umee", - "UXLINK": "UXLINK", + "UX": "UX Chain", + "UXD": "UXD Stablecoin", + "UXLINK": "UXLINK USD Price", "UXLINKV1": "UXLINK v1", "UXOS": "UXOS", "UXP": "UXD Protocol", @@ -17567,7 +18372,7 @@ "VAAVE": "Venus AAVE", "VAB": "Vabble", "VADA": "Venus Cardano", - "VADER": "VaderAI", + "VADER": "Vader Protocol", "VADERPROTOCOL": "Vader Protocol", "VAI": "Vai", "VAIN": "Vainguard by Virtuals", @@ -17575,6 +18380,7 @@ "VAIOTV1": "VAIOT v1", "VAIX": "Vectorspace AI X", "VAL": "Validity", + "VAL7876": "SORA Validator Token", "VALAN": "Valannium", "VALAS": "Valas Finance", "VALENTINE": "Valentine", @@ -17589,7 +18395,7 @@ "VAM": "Vitalum", "VAMPIRE": "Vampire Inu", "VAN": "Vanspor Token", - "VANA": "Vana", + "VANA": "VANA", "VANCAT": "Vancat", "VANCE": "JD Vance", "VANCEMEME": "Vance Meme", @@ -17600,14 +18406,15 @@ "VANY": "Vanywhere", "VAPE": "VAPE", "VAPOR": "Hypervapor", - "VARA": "Vara Network", + "VARA": "Équilibre", + "VARA28067": "Vara Network", "VARIUS": "Varius", "VARK": "Aardvark", "VATAN": "Vatan Token", "VATO": "vanitis", "VATR": "Vatra INU", "VATRENI": "Croatian FF Fan Token", - "VAULT": "Vault Tech", + "VAULT": "VAULT", "VAULTCOIN": "VaultCoin", "VBCH": "Venus BCH", "VBETH": "Venus BETH", @@ -17630,7 +18437,7 @@ "VCI": "VinciToken", "VCK": "28VCK", "VCNT": "ViciCoin", - "VCORE": "VCORE", + "VCORE": "IMVU", "VCT": "VCHAT Token", "VCX": "VaultCraft", "VDA": "Verida", @@ -17645,33 +18452,34 @@ "VDX": "Vodi X", "VDZ": "Voidz", "VEC": "VECTOR", - "VEC2": "VectorCoin 2.0", + "VEC2": "VectorAI", "VECT": "Vectorium", "VECTOR": "VectorChat.ai", - "VEE": "Vee Token", - "VEED": "VEED", + "VEE": "BLOCKv", + "VEED": "VIMworld", "VEEN": "LIVEEN", "VEETOKEN": "Vee Token", "VEG": "BitVegan", "VEGA": "Vega Protocol", - "VEGAS": "Vegas", + "VEGAS": "Vegasino", "VEGASI": "Vegas Inu Token", "VEGASINO": "Vegasino", "VEGE": "Vege Token", - "VEIL": "DarkVeil", + "VEIL": "Veil", "VEILPROJECT": "VEIL", "VEKTOR": "VEKTOR", - "VELA": "Vela Token", + "VELA": "Vela Exchange", "VELAAI": "velaai", "VELAR": "Velar", "VELO": "Velo", + "VELO20435": "Velodrome Finance", "VELOD": "Velodrome Finance", "VELODV1": "Velodrome v1", "VELOX": "Velox", "VELOXPROJECT": "Velox", "VELT": "VELTRIXA", "VELVET": "Velvet", - "VEMP": "vEmpire DDAO", + "VEMP": "VEMP", "VEN": "VeChain Old", "VENA": "Vena Network", "VENKO": "VENKO", @@ -17690,9 +18498,9 @@ "VERIC": "VeriCoin", "VERIFY": "Verify", "VERO": "VEROPAD", - "VERSA": "Versa Token", + "VERSA": "VersaGames", "VERSACE": "VERSACE", - "VERSE": "Verse World", + "VERSE": "Shibaverse", "VERSEBIT": "Verse", "VERT": "VERT", "VERTAI": "Vertical AI", @@ -17707,10 +18515,11 @@ "VETME": "VetMe", "VETTER": "Vetter Token", "VEUR": "VNX Euro", + "VEUSD": "VeUSD", "VEX": "Vexanium", "VEXT": "Veloce", "VFIL": "Venus Filecoin", - "VFOX": "VFOX", + "VFOX": "RFOX Finance", "VFSON": "VinFast Auto (Ondo Tokenized)", "VFT": "Value Finance", "VFX": "ViFoxCoin", @@ -17718,15 +18527,16 @@ "VFYV1": "Verify Token", "VG": "Viu Ganhou", "VGBP": "VNX British Pound", - "VGO": "Vagabond", + "VGO": "Virgo", "VGX": "Voyager Token", "VGXV1": "Voyager v1", "VHC": "Vault Hill City", "VI": "Vid", - "VIA": "Octavia AI", + "VIA": "Viacoin", + "VIA29488": "Octavia", "VIAC": "ViaCoin", "VIB": "Viberate", - "VIBE": "VIBEHub", + "VIBE": "VIBE", "VIBEA": "Vibe AI", "VIBLO": "VIBLO", "VIC": "Viction", @@ -17737,17 +18547,17 @@ "VICS": "RoboF", "VICT": "Victory Impact Coin", "VICTORIUM": "Victorium", - "VID": "VideoCoin", + "VID": "Vivid Labs", "VIDA": "Vidiachange", "VIDEO": "Videocoin by Drakula", - "VIDT": "VIDT Datalink", + "VIDT": "VIDT DAO", "VIDTV1": "VIDT Datalink", "VIDY": "Vidy", "VIDYA": "Vidya", "VIDYX": "VidyX", "VIDZ": "PureVidz", "VIEW": "Viewly", - "VIG": "TheVig", + "VIG": "VIG", "VIGI": "Vigi", "VIK": "VIKTAMA", "VIKITA": "VIKITA", @@ -17755,10 +18565,12 @@ "VILADY": "Vitalik Milady", "VIM": "VicMove", "VIN": "VulgarTycoon", + "VINA": "VICUNA", "VINCHAIN": "VinChain", "VINCI": "VINCI", "VINE": "Vine Coin", - "VINU": "Vita Inu", + "VINU": "Viral Inu", + "VINU15270": "Vita Inu", "VIOR": "ViorCoin", "VIP": "VIP Tokens", "VIPER": "Viper Protocol", @@ -17766,19 +18578,19 @@ "VIRAL": "Viral Coin", "VIRES": "Vires Finance", "VIRTU": "VIRTUCLOUD", - "VIRTUAL": "Virtual Protocol", + "VIRTUAL": "Virtuals Protocol", "VIRTUALMINING": "VirtualMining Coin", "VIRTUM": "VIRTUMATE", "VIS": "Vigorus", "VISAON": "Visa (Ondo Tokenized)", "VISIO": "Visio", - "VISION": "VisionGame", + "VISION": "APY Vision", "VISIONCITY": "Vision City", "VISR": "Visor", "VIST": "VISTA", "VISTA": "Ethervista", "VISTADOG": "VISTADOG", - "VIT": "Vision Industry Token", + "VIT": "Team Vitality Fan Token", "VITA": "VitaDAO", "VITAE": "Vitae", "VITAFAST": "Molecules of Korolchuk IP-NFT", @@ -17790,6 +18602,7 @@ "VITASTEM": "VitaStem", "VITE": "VITE", "VITEX": "ViteX Coin", + "VITO": "Very Special Dragon", "VITRA": "Vitra Studios", "VITY": "Vitteey", "VIU": "Viuly", @@ -17826,8 +18639,9 @@ "VMS": "Vehicle Mining System", "VMT": "Vemate", "VNDC": "VNDC", - "VNDT": "Vendit ", + "VNDT": "Vendit", "VNES": "Vanesse", + "VNLA": "Vanilla Network", "VNLNK": "VINLINK", "VNM": "Venom", "VNN": "VINU Network", @@ -17845,6 +18659,7 @@ "VOCO": "Provoco", "VODCAT": "VODKA CAT", "VODKA": "Vodka Token", + "VOICE": "Voice Token", "VOID": "Nothing", "VOIP": "Voip Finance", "VOISE": "Voise", @@ -17853,8 +18668,8 @@ "VOLLAR": "Vollar", "VOLM": "VOLM", "VOLR": "Volare Network", - "VOLT": "Volt Inu", - "VOLTA": "Volta Club", + "VOLT": "Bitvolt", + "VOLTA": "Volta", "VOLTOLD": "Volt Inu (Old)", "VOLTV1": "Volt Inu v1", "VOLTV2": "Volt Inu v2", @@ -17880,12 +18695,12 @@ "VPAY": "VPay by Virtuals", "VPK": "Vulture Peak", "VPND": "VaporNodes", - "VPP": "Virtue Poker Points", + "VPP": "Virtue Poker", "VPR": "VaporWallet", "VPRC": "VapersCoin", "VPS": "VPS AI", "VPT": "Veritas Protocol", - "VR": "Victoria", + "VR": "Victoria VR", "VR1": "VR1", "VRA": "Verasity", "VRAV1": "Verasity v1", @@ -17901,42 +18716,45 @@ "VROOM": "TurboPepe", "VRP": "Prosense.tv", "VRS": "Veros", - "VRSC": "Verus Coin", + "VRSC": "VerusCoin", "VRSE": "CronosVerse", "VRSW": "VirtuSwap", "VRT": "Venus Reward Token", "VRTX": "Vertex Protocol", "VRTXON": "Vertex Pharmaceuticals (Ondo Tokenized)", "VRTY": "Verity", - "VRX": "Verox", - "VS": "veSync", + "VRX": "VEROX", + "VS": "ValleySwap", "VSC": "Vyvo Coin", "VSD": "Value Set Dollar", "VSG": "Vitalik Smart Gas", "VSHARE": "V3S Share", - "VSL": "vSlice", + "VSL": "Vetter Skylabs", "VSN": "Vision", + "VSN37322": "Vision", "VSO": "Verso", "VSOL": "VSolidus", - "VSP": "Vesper Finance", + "VSP": "Vesper", + "VST": "Voice Street", "VSTA": "Vesta Finance", "VSTON": "Vistra (Ondo Tokenized)", "VSTR": "Vestra DAO", "VSUI": "Volo Staked SUI", "VSX": "Versus-X", + "VSY": "v.systems", "VSYNC": "Vsync", "VSYS": "V Systems", "VT": "Virtual Tourist", "VTC": "Vertcoin", "VTCN": "Versatize Coin", "VTG": "Victory Gem", - "VTHO": "VeChainThor", + "VTHO": "VeThor Token", "VTIX": "Vanguard xStock", "VTL": "Vertical", "VTM": "Victorieum", "VTN": "Voltroon", "VTOS": "VTOS", - "VTRA": " E.C. Vitoria Fan Token", + "VTRA": "E.C. Vitoria Fan Token", "VTRAD": "VTRADING", "VTRO": "Vitruveo DEX", "VTRUMP": "Vote Trump", @@ -17945,7 +18763,7 @@ "VTS": "Veritise", "VTU": "Virtu", "VTUSD": "Venus TUSD", - "VTX": "Vortex DeFi", + "VTX": "Vortex Defi", "VTY": "Victoriouscoin", "VU": "Vu", "VUC": "Virta Unique Coin", @@ -17989,7 +18807,7 @@ "WA7A5": "Wrapped A7A5", "WAAC": "Wrapped AyeAyeCoin", "WAB": "WABnetwork", - "WABI": "WABI", + "WABI": "Wabi", "WABU": "Warrenbuffett", "WACME": "Wrapped Accumulate", "WACO": "Waste Digital Coin", @@ -18003,15 +18821,16 @@ "WAGIE": "Wagie", "WAGIEBOT": "Wagie Bot", "WAGM": "WAGMI", - "WAGMI": "Wagmi Coin", - "WAGMIGAMES": "WAGMI Game", + "WAGMI": "Euphoria", + "WAGMIGAMES": "WAGMI Games", "WAGMIT": "Wagmi", "WAGON": "Wagon Network", - "WAI": "WORLD3", + "WAI": "Wanaka Farm WAIRERE Token", "WAIF": "Waifu Token", "WAIFU": "Waifu", "WAIT": "Hourglass", - "WAL": "WAL Token", + "WAL": "The Wasted Lands", + "WAL36119": "Walrus", "WALE": "Waletoken", "WALK": "Walk Token", "WALL": "Du Rove's Wall", @@ -18019,22 +18838,23 @@ "WALLI": "WALLi", "WALLY": "Wally Bot", "WALTER": "walter", - "WALV": "Alvey Chain", - "WAM": "Wam", + "WALV": "Wrapped Alvey Chain", + "WAM": "WAM", "WAMPL": "Wrapped Ampleforth", "WAN": "Wanchain", "WANA": "Wanaka Farm", "WANAKA": "Wanaka Farm WAIRERE Token", "WANATHA": "Wrapped ANATHA", "WAND": "WandX", + "WANETH": "wanETH", "WANK": "Wojak The Wanker", "WANKO": "WANKO•MANKO•RUNES", "WANNA": "Wanna Bot", "WANUSDT": "wanUSDT", "WAP": "Wet Ass Pussy", - "WAR": "WAR", + "WAR": "Warrior Token", "WARD": "Warden", - "WARP": "WarpCoin", + "WARP": "Warp Finance", "WARPED": "Warped Games", "WARPIE": "Warpie", "WARS": "MetaWars", @@ -18043,14 +18863,15 @@ "WASABI": "WasabiX", "WASD": "WASD Studios", "WASH": "WashingtonCoin", + "WASP": "WanSwap", "WASSIE": "WASSIE", "WASTED": "WastedLands", "WASTR": "Wrapped Astar", "WAT": "WATCoin", "WAT0X63": "Wat", "WATC": "WATCoin", - "WATCH": "Yieldwatch", - "WATER": "Waterfall", + "WATCH": "yieldwatch", + "WATER": "Emit Water Element", "WATERCOIN": "WATER", "WATLAS": "Wrapped Star Atlas (Portal Bridge)", "WATT": "WATTTON", @@ -18061,14 +18882,14 @@ "WAWA": "Wawa Cat", "WAXE": "WAXE", "WAXL": "Wrapped Axelar", - "WAXP": "Worldwide Asset eXchange", + "WAXP": "WAX", "WAXS": "Axie Infinity Shards (Wormhole)", "WAY": "WayCoin", "WAYGU": "WAYGU CASH", "WAZ": "MikeAI", "WBAI": "Wrapped Balance AI", "WBAN": "Wrapped Banano", - "WBB": "Wild Beast Coin", + "WBB": "Wild Beast Block", "WBBC": "Wibcoin", "WBC": "WorldBrain Coin", "WBCH": "Wrapped Bitcoin Cash", @@ -18086,7 +18907,7 @@ "WBONK": "BONK (Portal Bridge)", "WBRLY": "Wrapped BRLY", "WBS": "Websea", - "WBT": "WhiteBIT Token", + "WBT": "WhiteBIT Coin", "WBTC": "Wrapped Bitcoin", "WBTCWXG": "WBTC-WXG", "WBULL": "BNB Wallstreet Bull", @@ -18099,6 +18920,7 @@ "WCDONALDS": "WC Donalds", "WCELL": "Wrapped CellMates", "WCELO": "Wrapped Celo", + "WCFG": "Wrapped Centrifuge", "WCFGV1": "Wrapped Centrifuge", "WCFX": "Wrapped Conflux", "WCG": "World Crypto Gold", @@ -18111,20 +18933,23 @@ "WCSOV": "Wrapped CrownSterling", "WCT": "WalletConnect", "WCT1WCT1": "Wrapped Car Token 1", + "WCT33152": "WalletConnect Token USD Price", "WCTH": "Wrapped CTH Token", "WCUSD": "Wrapped Celo Dollar", "WDAI": "Dai (Wormhole)", - "WDC": "WorldCoin", + "WDC": "WorldCoin WDC", "WDCON": "Western Digital (Ondo Tokenized)", + "WDF": "Wallet Defi", "WDOG": "Winterdog", "WDOGE": "Wrapped Dogecoin", "WDOT": "WDOT", "WDR": "Wider Coin", "WDX": "WeiDex", - "WE": "WeBuy", + "WE": "Wanda Exchange", "WEALTH": "WealthCoin", "WEAPON": "MEGAWEAPON", "WEAR": "MetaWear", + "WEAVE": "Weave", "WEAVE6": "Weave6", "WEB": "Webcoin", "WEB3": "WEB3 Inu", @@ -18148,15 +18973,18 @@ "WEGI": "Wegie", "WEGL": "White Eagle", "WEGLD": "Wrapped EGLD", + "WEGRO": "WeGro", "WEHMND": "Wrapped eHMND", "WEHODL": "HODL", + "WEI": "WEI", "WEIRD": "Weird Coin", "WEIRDO": "Weirdo", "WEL": "Welsh Corgi", "WELA": "Wrapped Elastos", "WELD": "Weld", "WELF": "welf", - "WELL": "Moonwell", + "WELL": "WELL", + "WELL20734": "Moonwell", "WELL3": "WELL3", "WELLTOKEN": "Well", "WELLV1": "Moonwell v1", @@ -18167,6 +18995,7 @@ "WEMIX": "WEMIX", "WEMIXUSD": "WEMIX", "WEN": "Wen", + "WEN29175": "Wen", "WEND": "Wellnode", "WENIS": "WenisCoin", "WENL": "Wen Lambo Financial", @@ -18183,7 +19012,7 @@ "WETHV1": "WETH v1", "WETHW": "Wrapped EthereumPoW", "WEVE": "veDAO", - "WEVER": "Wrapped Ever", + "WEVER": "Wrapped Everscale", "WEVERV1": "Wrapped Ever v1", "WEVMOS": "Wrapped Evmos", "WEWE": "WEWE", @@ -18191,10 +19020,12 @@ "WEXO": "Wexo", "WEXPOLY": "WaultSwap Polygon", "WFAI": "WaifuAI", + "WFAIR": "WFAIR", "WFBTC": "Wrapped Fantom Bitcoin", "WFDP": "WFDP", "WFI": "WeFi", "WFIL": "Wrapped Filecoin", + "WFIO": "Wrapped FIO Protocol", "WFLAMA": "WIFLAMA", "WFLOW": "Wrapped Flow", "WFLR": "Wrapped Flare", @@ -18209,6 +19040,7 @@ "WGHOST": "Wrapped GhostbyMcAfee", "WGL": "Wiggly Finance", "WGLMR": "Wrapped Moonbeam", + "WGMI": "WGMI", "WGO": "WavesGO", "WGP": "W Green Pay", "WGR": "Wagerr", @@ -18217,14 +19049,16 @@ "WHA": "WHALES DOGE", "WHAL": "WHALEBERT", "WHALE": "WHALE", + "WHALE1": "White Whale", "WHALES": "Whales Market", + "WHALES29282": "Whales Market", "WHAT": "What the Duck", "WHATSONPIC": "WhatsOnPic", "WHBAR": "Wrapped HBAR", "WHC": "Whales Club", "WHCHZ": "Chiliz (Portal Bridge)", "WHEAT": "Wheat Token", - "WHEE": "WHEE (Ordinals)", + "WHEE": "WHEE", "WHEEL": "Wheelers", "WHEN": "WhenHub", "WHEX": "Whale Exploder", @@ -18233,7 +19067,7 @@ "WHIRL": "Whirl Finance", "WHISK": "Whiskers", "WHISKEY": "WHISKEY", - "WHITE": "WhiteRock", + "WHITE": "Whiteheart", "WHITEHEART": "Whiteheart", "WHITEPEPE": "The White Pepe", "WHITEWHALE": "The White Whale", @@ -18262,7 +19096,7 @@ "WIFE": "Wifejak", "WIFEAR": "TRUMP WIF EAR", "WIFEDOGE": "Wifedoge", - "WIFI": "WiFi Map", + "WIFI": "Wifi Coin", "WIFICOIN": "Wifi Coin", "WIFS": "dogwifscarf", "WIFSA": "dogwifsaudihat", @@ -18275,11 +19109,12 @@ "WILD": "Wilder World", "WILDC": "Wild Crypto", "WILDCOIN": "WILDCOIN", - "WIN": "WINk", + "WIN": "WINkLink", "WINB": "WINBIT CASINO", - "WINE": "WineCoin", + "WINE": "Wine Shares", "WING": "Wing Finance", - "WINGS": "Wings DAO", + "WING7048": "Wing Finance", + "WINGS": "Wings", "WINK": "Wink", "WINN": "Winnerz", "WINNIE": "Winnie the Poodle", @@ -18292,6 +19127,7 @@ "WINX": "WinX.io", "WIOTA": "wIOTA", "WIOTX": "Wrapped IoTeX", + "WIPE": "Wipe My ASS", "WIRE": "717ai by Virtuals", "WIRTUAL": "Wirtual", "WIS": "Experty Wisdom Token", @@ -18301,8 +19137,9 @@ "WISP": "Whisper", "WISTA": "Wistaverse", "WIT": "Witnet", - "WITCH": "Witch", + "WITCH": "Witch Token", "WITCOIN": "Witcoin", + "WIVA": "WIVA by WiV Technology", "WIWI": "Wiggly Willy", "WIX": "Wixlar", "WIZA": "Wizardia", @@ -18314,12 +19151,16 @@ "WKAS": "Wrapped Kaspa", "WKAVA": "Wrapped Kava", "WKC": "Wiki Cat", + "WKCS": "Wrapped KuCoin Token", "WKD": "Wakanda Inu", "WKEYDAO": "WebKey DAO", + "WKLAY": "Wrapped Klaytn", "WLAI": "Weblume AI", "WLD": "Worldcoin", + "WLEO": "Wrapped LEO", "WLF": "Wolfs Group", "WLFI": "World Liberty Financial", + "WLFI33251": "World Liberty Financial", "WLFIAI": "World Liberty Financial", "WLFICLUB": "World Liberty Financial (wlfi.club)", "WLFIMOON": "World Liberty Financial", @@ -18328,14 +19169,15 @@ "WLFISITE": "World Liberty Financial", "WLFISPACE": "World Liberty Financial", "WLFIWLFI": "World Liberty Financial", - "WLITI": "wLITI", + "WLITI": "Liti Capital", "WLK": "Wolk", "WLKN": "Walken", "WLO": "WOLLO", + "WLRS": "Walrus", "WLSC": "WESTLAND SMART CITY", "WLTH": "Common Wealth", "WLUNA": "Wrapped LUNA Token", - "WLUNC": "Wrapped LUNA Classic", + "WLUNC": "Wrapped LUNA Classic USD Price", "WLXT": "Wallex Token", "WM": "WrappedM by M^0", "WMATIC": "Wrapped Matic", @@ -18353,13 +19195,13 @@ "WMNT": "Wrapped Mantle", "WMON": "Waste Management (Ondo Tokenized)", "WMOXY": "Moxy", - "WMT": "World Mobile Token v1", + "WMT": "World Mobile Token", "WMTON": "Walmart (Ondo Tokenized)", "WMTX": "World Mobile Token", "WMW": "WoopMoney", "WMX": "Wombex Finance", "WMXWOM": "Wombex WOM", - "WNCG": "Wrapped NCG", + "WNCG": "Nine Chronicles", "WND": "WonderHero", "WNDGAME": "Wizards And Dragons", "WNDR": "Wonderman Nation", @@ -18373,17 +19215,18 @@ "WNRG": "Wrapped-Energi", "WNRZ": "WinPlay", "WNT": "Wicrypt", + "WNTR": "Weentar", "WNXM": "Wrapped NXM", "WNYC": "Wrapped NewYorkCoin", "WNZ": "Winerz", "WOA": "Wrapped Origin Axie", - "WOD": "World of Dypians", + "WOD": "World of Defish", "WOETH": "Wrapped Origin Ether", "WOFM": "World of Masters", "WOID": "WORLD ID", "WOJ": "Wojak Finance", "WOJA": "Wojak", - "WOJAK": "wojak", + "WOJAK": "Wojak", "WOJAK2": "Wojak 2.0 Coin", "WOJAKC": "Wojak Coin", "WOJAKIO": "Wojak", @@ -18391,16 +19234,17 @@ "WOKIE": "Wokie Plumpkin by Virtuals", "WOKT": "Wrapped OKT", "WOL": "World of Legends", - "WOLF": "Landwolf 0x67", + "WOLF": "moonwolf.io", "WOLFILAND": "Wolfiland", "WOLFOF": "Wolf of Wall Street", "WOLFP": "Wolfpack Coin", "WOLFY": "WOLFY", "WOLT": "Wolt", "WOLVERINU": "WOLVERINU", - "WOM": "WOM", + "WOM": "WOM Protocol", + "WOM19623": "Wombat Exchange", "WOMB": "Wombat Exchange", - "WOMBAT": "Wombat", + "WOMBAT": "Wombat Web 3 Gaming Platform", "WOME": "WAR OF MEME", "WOMEN": "WomenCoin", "WOMI": "Wrapped ECOMI", @@ -18409,7 +19253,8 @@ "WONE": "Wrapped Harmony", "WOO": "WOO Network", "WOOD": "Mindfolk Wood", - "WOOF": "WoofWork.io", + "WOOF": "WOOF", + "WOOF35194": "WOOF", "WOOFY": "Woofy", "WOOL": "Wolf Game Wool", "WOOLLY": "Miniature Woolly Mammoth", @@ -18437,7 +19282,7 @@ "WOS": "Wolf Of Solana", "WOT": "World Of Trump", "WOULD": "would", - "WOW": "WOWswap", + "WOW": "Wownero", "WOWS": "Wolves of Wall Street", "WOZX": "Efforce", "WPAY": "WPAY", @@ -18451,7 +19296,7 @@ "WPOR": "Wrapped Portugal National Team", "WPP": "Green Energy Token", "WPR": "WePower", - "WQT": "Work Quest", + "WQT": "WorkQuest Token", "WR": "White Rat", "WRC": "Worldcore", "WREACT": "Wrapped REACT", @@ -18460,25 +19305,27 @@ "WRLD": "NFT Worlds", "WRONG": "The Wrong Token", "WROSE": "Wrapped Rose", - "WRT": "WRT Token", + "WRT": "WingRiders Governance Token", "WRTCOIN": "WRTcoin", "WRX": "WazirX", "WRZ": "Weriz", "WS": "Wrapped Sonic", + "WSAFU": "Wallet SAFU", "WSB": "WallStreetBets DApp", "WSBABY": "Wall Street Baby", "WSBC": "WSB Coin", "WSBS": "Wall Street Bets Solana", - "WSCRT": "Secret ERC20", + "WSCRT": "Secret (ERC20)", "WSDM": "Wisdomise AI", "WSDOGE": "Doge of Woof Street", "WSG": "Wall Street Games", "WSGV1": "Wall Street Games v1", "WSH": "White Yorkshire", + "WSHEC": "Wrapped Staked HEC", "WSHIB": "Wrapped Shiba Inu (Wormhole)", "WSHIBA": "wShiba", "WSI": "WeSendit", - "WSIENNA": "Sienna ERC20", + "WSIENNA": "Sienna (ERC20)", "WSM": "Wall Street Memes", "WSOL": "Wrapped Solana", "WSPP": "Wolf Safe Poor People", @@ -18489,7 +19336,9 @@ "WSTR": "Wrapped Star", "WSTUSDT": "wstUSDT", "WSTUSR": "Resolv wstUSR", + "WSWAP": "Wallet Swap", "WSX": "WeAreSatoshi", + "WSYS": "Wrapped Syscoin", "WT": "WeToken", "WTAO": "Wrapped TAO", "WTC": "Waltonchain", @@ -18503,9 +19352,12 @@ "WTKV1": "WadzPay Token v1", "WTL": "Welltrado", "WTLGX": "WisdomTree Long Term Treasury Digital Fund", + "WTLOS": "Wrapped Telos", "WTN": "Wateenswap", "WTON": "Wrapped TON Crystal", "WTR": "Deepwaters", + "WTRTL": "Wrapped TurtleCoin", + "WTRX": "Wrapped TRON", "WTSIX": "WisdomTree Short-Duration Income Digital Fund", "WTSTX": "WisdomTree 7-10 Year Treasury Digital Fund", "WTSYX": "WisdomTree Short-Term Treasury Digital Fund", @@ -18520,7 +19372,7 @@ "WUM": "Unicorn Meat", "WUSD": "Worldwide USD", "WUST": "Wrapped UST Token", - "WVG0": "Wrapped Virgin Gen-0 CryptoKittties", + "WVG0": "Wrapped Virgin Gen-0 CryptoKitties", "WVTRS": "Vitreus", "WW3": "WW3", "WWAN": "Wrapped WAN", @@ -18540,7 +19392,7 @@ "WXM": "WeatherXM", "WXPL": "Wrapped XPL", "WXRP": "Wrapped XRP", - "WXT": "WXT", + "WXT": "Wirex Token", "WYAC": "Woman Yelling At Cat", "WYDE": "WYDE: End Hunger", "WYN": "Wynn", @@ -18554,24 +19406,26 @@ "WZM": "Woozoo Music", "WZNN": "Wrapped Zenon (Zenon Bridge)", "WZNNV1": "Wrapped Zenon (Zenon Bridge) v1", - "WZRD": "Bitcoin Wizards", - "X": "X Empire", + "WZRD": "Wizardia", + "X": "GIBX Swap", + "X1": "MetaX", "X2": "X2Coin", "X2Y2": "X2Y2", "X314": "X314", "X314V1": "X314 v1", "X33": "Shadow Liquid Staking Token", - "X42": "X42 Protocol", + "X42": "x42 Protocol", "X7": "X7", "X7C": "X7 Coin", "X7DAO": "X7DAO", "X7R": "X7R", - "X8X": "X8Currency", + "X8X": "X8X Token", "XACT": "XactToken", "XAEAXII": "XAEA-Xii Token", "XAGX": "Silver Token", "XAH": "Xahau", "XAI": "Xai", + "XAI28933": "Xai", "XAIGAME": "xAI Game Studio", "XALGO": "Wrapped ALGO", "XALPHA": "XAlpha AI", @@ -18595,7 +19449,7 @@ "XB": "XBANKING", "XBASE": "ETERBASE", "XBB": "BrickBlock", - "XBC": "BitcoinPlus", + "XBC": "Bitcoin Plus", "XBE": "XBE Token", "XBG": "XBorg Token", "XBI": "Bitcoin Incognito", @@ -18606,9 +19460,9 @@ "XBO": "XBO", "XBOND": "Bitacium", "XBOT": "SocialXbotCoin", - "XBP": "Black Pearl Coin", + "XBP": "BlitzPick", "XBS": "Bitstake", - "XBT": "Xbit", + "XBT": "XBIT", "XBTC": "XenBitcoin", "XBTC21": "Bitcoin 21", "XBTS": "Beats", @@ -18632,38 +19486,39 @@ "XCHAT": "XChat", "XCHATSOL": "XChat", "XCHF": "CryptoFranc", - "XCHNG": "Chainge Finance", + "XCHNG": "Chainge", "XCI": "Cannabis Industry Coin", "XCL": "Xcellar", "XCLR": "ClearCoin", - "XCM": "CoinMetro", - "XCN": "Onyxcoin", + "XCM": "Coinmetro Token", + "XCN": "Cryptonite", + "XCN18679": "Onyxcoin", "XCO": "XCoin", "XCOM": "X.COM", "XCONSOL": "X-Consoles", - "XCP": "CounterParty", + "XCP": "Counterparty", "XCPO": "Copico", "XCR": "Crypti", - "XCRE": "Creatio", + "XCRE": "Cresio", "XCREDI": "xCREDI", "XCRX": "xCRX", - "XCT": "C-Bits", + "XCT": "Citadel.one", "XCUR": "Curate", "XCV": "XCarnival", "XCX": "Xeleb AI", "XCXT": "CoinonatX", "XD": "Data Transaction Token", - "XDAG": "Dagger", - "XDAI": "XDAI", + "XDAG": "XDAG", + "XDAI": "xDAI", "XDAO": "XDAO", "XDATA": "Streamr XDATA", "XDB": "DigitalBits", "XDC": "XDC Network", "XDCE": "XinFin Coin", "XDEF2": "Xdef Finance", - "XDEFI": "XDEFI", + "XDEFI": "XDEFI Wallet", "XDEN": "Xiden", - "XDG": "Decentral Games Governance", + "XDG": "Decentral Games Governance (xDG)", "XDN": "DigitalNote", "XDNA": "XDNA", "XDOG": "XDOG", @@ -18671,33 +19526,36 @@ "XDOT": "DotBased", "XDP": "DogeParty", "XDQ": "Dirac Coin", + "XDSHARE": "ToxicDeer Share", "XEC": "eCash", "XED": "Exeedme", "XEDO": "XedoAI", "XEL": "XELIS", "XELCOIN": "Xel", - "XELS": "XELS Coin", + "XELS": "XELS", "XEM": "NEM", "XEN": "XEN Crypto", + "XEND": "Xend Finance", "XENDV1": "Xend Finance", "XENDV2": "Xend Finance", "XENIX": "XenixCoin", "XENO": "Xeno", "XENOVERSE": "Xenoverse", "XEP": "Electra Protocol", + "XEQ": "Equilibria", "XERA": "XERA", "XERO": "XERO", "XERS": "X Project", "XES": "Proxeus", "XET": "Xfinite Entertainment Token", - "XETA": "Xana", + "XETA": "Xeta Reality", "XETH": "Xplosive Ethereum", - "XFC": "Football Coin", - "XFI": "CrossFi", + "XFC": "Footballcoin (XFC)", + "XFI": "Xfinance", "XFINANCE": "Xfinance", "XFIT": "Xfit", "XFLOKI": "XFLOKI", - "XFT": "Offshift", + "XFT": "Offshift (old)", "XFTV1": "Offshift v1", "XFUEL": "XFUEL", "XFUND": "xFund", @@ -18707,7 +19565,7 @@ "XGC": "Xiglute Coin", "XGD": "X Gold", "XGEM": "Exchange Genesis Ethlas Medium", - "XGLI": "Glitter Finance", + "XGLI": "XGLI DAO Protocol", "XGN": "0xGen", "XGOLD": "XGOLD COIN", "XGOX": "Go!", @@ -18722,13 +19580,14 @@ "XHT": "HollaEx", "XHUNT": "CryptoHunter World", "XHV": "Haven Protocol", - "XI": "Xi", + "XI": "Xi Token", "XIASI": "Xiasi Inu", "XID": "Sphre AIR", - "XIDO": "Xido Finance", + "XIDO": "XIDO FINANCE", "XIDR": "XIDR", - "XIL": "Xillion", + "XIL": "Project X", "XIN": "Mixin", + "XIN2349": "Mixin USD Price", "XING": "Xing Xing", "XINGXING": "星星", "XINU": "XINU", @@ -18736,7 +19595,7 @@ "XION": "XION", "XIOS": "Xios", "XIOT": "Xiotri", - "XIV": "Project Inverse", + "XIV": "Planet Inverse", "XJEWEL": "xJEWEL", "XJO": "JouleCoin", "XKI": "Ki", @@ -18749,6 +19608,7 @@ "XLIST": "XList", "XLM": "Stellar", "XLN": "LunaOne", + "XLON": "Xenlon Mars", "XLQ": "Alqo", "XLR": "Solaris", "XLS": "Elis", @@ -18769,12 +19629,12 @@ "XMP": "Mapt.Coin", "XMR": "Monero", "XMRG": "Monero Gold", - "XMS": "Megastake", + "XMS": "Mars Ecosystem Token", "XMT": "MetalSwap", "XMV": "MoneroV", "XMW": "Morphware", "XMX": "XMax", - "XMY": "MyriadCoin", + "XMY": "Myriad", "XNA": "Neurai", "XNAP": "SNAPX", "XNB": "Xeonbit", @@ -18785,7 +19645,7 @@ "XNK": "Ink Protocol", "XNL": "Chronicle", "XNN": "Xenon", - "XNO": "Xeno Token", + "XNO": "Nano", "XNODE": "XNODE", "XNP": "ExenPay Token", "XNPCS": "NPCS AI", @@ -18798,13 +19658,13 @@ "XODEX": "Xodex", "XOLO": "Xoloitzcuintli", "XOMX": "Exxon Mobil xStock", - "XOR": "Sora", + "XOR": "SORA", "XOT": "Okuru", "XOV": "XOVBank", "XOX": "XOX Labs", "XOXNO": "XOXNO", "XOXO": "XO Protocol", - "XP": "Xphere", + "XP": "PolkaFantasy", "XPA": "XPA", "XPARTY": "X Party", "XPASS": "XPASS Token", @@ -18812,7 +19672,7 @@ "XPAY": "Wallet Pay", "XPB": "Pebble Coin", "XPC": "eXPerience Chain", - "XPD": "PetroDollar", + "XPD": "Palladium Spot Token", "XPE": "Xpense", "XPED": "Xpedition", "XPET": "XPET token", @@ -18823,15 +19683,16 @@ "XPL": "Plasma", "XPLA": "XPLA", "XPLL": "ParallelChain", - "XPM": "XPMarket Token", + "XPM": "Primecoin", + "XPM20261": "XPMarket", "XPN": "PANTHEON X", "XPND": "Time Raiders", - "XPNET": "XP Network", + "XPNET": "XP NETWORK", "XPO": "Opair", "XPOKE": "PokeChain", - "XPR": "Proton", + "XPR": "XPR Network", "XPRESS": "CryptoXpress", - "XPRO": "ProCoin", + "XPRO": "XPROJECT", "XPROT": "X Protocol", "XPRT": "Persistence", "XPS": "PoisonIvyCoin", @@ -18846,7 +19707,7 @@ "XQR": "Qredit", "XQUOK": "XQUOK", "XR": "Xraders", - "XRA": "Xriba", + "XRA": "Ratecoin", "XRAI": "X-Ratio A", "XRAY": "Ray Network", "XRC": "xRhodium", @@ -18871,7 +19732,8 @@ "XRPH": "XRP Healthcare", "XRPHEDGE": "1X Short XRP Token", "XRS": "Xrius", - "XRT": "Robonomics Network", + "XRT": "Robonomics.network", + "XRU": "Thorstarter", "XRUN": "XRun", "XRUNE": "Thorstarter", "XSAUCE": "xSAUCE", @@ -18884,13 +19746,13 @@ "XSI": "Stability Shares", "XSLR": "NovaXSolar", "XSN": "StakeNet", - "XSP": "XSwap", + "XSP": "XSwap Protocol", "XSPA": "XSPA", "XSPC": "SpectreSecurityCoin", "XSPEC": "Spectre", "XSPECTAR": "xSPECTAR", "XSPT": "PoolStamp", - "XSR": "Xensor", + "XSR": "Sucrecoin", "XST": "StealthCoin", "XSTAR": "StarCurve", "XSTC": "Safe Trade Coin", @@ -18899,15 +19761,15 @@ "XSWAP": "XSwap", "XT": "XT.com Token", "XT3": "Xt3ch", - "XTAG": "xHashtag", + "XTAG": "xHashtag DAO", "XTAL": "XTAL", "XTC": "Xitcoin", "XTECH": "X-TECH", "XTER": "Xterio", "XTK": "xToken", - "XTM": "TORUM", + "XTM": "Torum", "XTMV1": "TORUM v1", - "XTN": "Neutrino Index Token", + "XTN": "Neutrino Index", "XTO": "Tao", "XTP": "Tap", "XTR": "Xtreme", @@ -18915,9 +19777,11 @@ "XTRACK": "Xtrack AI", "XTREME": "ExtremeCoin", "XTREMEV": "Xtremeverse", + "XTRI": "Tribar", "XTRM": "XTRM COIN", "XTRUMP": "X TRUMP", "XTT": "XSwap Treasure", + "XTT-B20": "XTblock", "XTTA": "XTTA", "XTTB20": "XTblock", "XTUSD": "XT Stablecoin XTUSD", @@ -18933,7 +19797,7 @@ "XUSD": "StraitsX XUSD", "XUV": "XUV Coin", "XV": "XV", - "XVC": "Vcash", + "XVC": "Xverse", "XVE": "The Vegan Initiative", "XVG": "Verge", "XVM": "Volt", @@ -18951,9 +19815,9 @@ "XXX": "XXXCoin", "XY": "XY Finance", "XYM": "Symbol", - "XYO": "XY Oracle", + "XYO": "XYO", "XYRO": "XYRO", - "XYZ": "Universe.XYZ", + "XYZ": "Universe XYZ", "XZK": "Mystiko Network", "Y24": "Yield 24", "Y2K": "Y2K", @@ -18969,20 +19833,22 @@ "YAKS": "YakDAO", "YAKU": "Yaku", "YALA": "Yala Token", - "YAM": "YAM", + "YAM": "YAM V1", "YAMA": "YAMA Inu", "YAMV1": "YAM v1", - "YAMV2": "YAM v2", + "YAMV2": "YAM V2", "YAOYAO": "Yaoyao's Cat", "YAP": "Yap Stone", "YAPSTER": "YAPSTER", + "YARA": "Yieldara", + "YARD": "Solyard Finance", "YARL": "Yarloo", "YAW": "Yawww", "YAWN": "YAWN", "YAXIS": "yAxis", "YAY": "YAY Games", "YAYCOIN": "YAYcoin", - "YB": "Yield Basis", + "YB": "YieldBasis USD Price", "YBC": "YbCoin", "YBDBD": "YBDBD", "YBNB": "Yellow BNB 4", @@ -19004,23 +19870,25 @@ "YEECO": "Yeeco", "YEED": "Yggdrash", "YEEHAW": "YEEHAW", - "YEET": "Yeet", + "YEET": "YEET DAO", "YEETI": "YEETI 液体", "YEFI": "YeFi", - "YEL": "Yel.Finance", + "YEL": "YEL.Finance", "YELLOWWHALE": "The Yellow Whale", "YELP": "Yelpro", "YEON": "Yeon", "YEPE": "Yellow Pepe", - "YES": "YES Money", + "YES": "YES Coin", "YESCOIN": "YesCoin", "YESP": "Yesports", "YESTOKEN": "Yes Token", "YESTOKENV1": "Yes Token v1", "YESW": "Yes World", "YETI": "Yeti Finance", + "YETIC": "YetiCoin", "YETIUSD": "YUSD Stablecoin", "YETU": "Yetucoin", + "YF-DAI": "YFDAI.FINANCE", "YFARM": "YFARM Token", "YFBETA": "yfBeta", "YFBT": "Yearn Finance Bit", @@ -19032,8 +19900,9 @@ "YFI": "yearn.finance", "YFIE": "yearn.finance (Avalanche Bridge)", "YFIEXCHANGE": "YFIEXCHANGE.FINANCE", - "YFII": "DFI.money", - "YFIII": "Dify.Finance", + "YFIH2": "H2Finance", + "YFII": "DFI.Money", + "YFIII": "DiFy.Finance", "YFIVE": "YFIVE FINANCE", "YFL": "YF Link", "YFO": "YFIONE", @@ -19041,7 +19910,7 @@ "YFSX": "YFSX", "YFTE": "YFTether", "YFV": "YFValue", - "YFX": "Your Futures Exchange", + "YFX": "Your Future Exchange", "YGG": "Yield Guild Games", "YIDO": "Yidocy Plus", "YIELD": "Yield Protocol", @@ -19053,7 +19922,7 @@ "YINBI": "Yinbi", "YLAY": "Yelay", "YLC": "YoloCash", - "YLD": "YIELD App", + "YLD": "Yield", "YLDY": "Yieldly", "YMC": "YamahaCoin", "YMS": "Yeni Malatyaspor Token", @@ -19062,14 +19931,17 @@ "YNG": "Young", "YO": "Yobit Token", "YOBASE": "All Your Base", - "YOC": "YoCoin", - "YOCO": "YocoinYOCO", + "YOC": "Yocoin", + "YOCO": "YoCoin", "YOD": "Year of the Dragon", "YODA": "YODA", "YODE": "YodeSwap", "YOEX": "YO EXCHANGE", + "YOGI": "Yogi", + "YOK": "YOKcoin", "YOLO": "YoloNolo", "YOM": "YOM", + "YON": "YES||NO", "YONNY": "YONNY", "YOOSHI": "YooShi", "YOP": "Yield Optimization Platform & Protocol", @@ -19079,14 +19951,14 @@ "YOTD": "Year of the Dragon", "YOTO": "yotoshi", "YOTSUBA": "Yotsuba Koiwai", - "YOU": "YOU Chain", + "YOU": "YOU COIN", "YOUC": "yOUcash", "YOUNES": "YOUNES", "YOURAI": "YOUR AI", "YOURMOM": "YOUR MOM DOG", "YOUSIM": "YouSim", "YOVI": "YobitVirtualCoin", - "YOYOW": "Yoyow", + "YOYOW": "YOYOW", "YOZI": "YoZi Protocol", "YPC": "YoungParrot", "YPIE": "PieDAO Yearn Ecosystem Pie", @@ -19114,10 +19986,10 @@ "YUM": "Yumerium", "YUMMI": "Yummi Universe", "YUMMY": "Yummy", - "YUP": "Crowdholding", + "YUP": "Yup", "YURI": "YURI", "YURU": "YURU COIN", - "YUSD": "YieldFi yToken", + "YUSD": "YUSD Stablecoin", "YUSE": "Yuse Token", "YUSRA": "YUSRA", "YUSUF": "Yusuf Dikec Meme", @@ -19129,8 +20001,9 @@ "YYE": "YYE Energy", "YYFI": "YYFI.Protocol", "YYOLO": "yYOLO", - "YZY": "YZY", + "YZY": "YZY MONEY", "Z3": "Z-Cubed", + "Z7": "Z7DAO", "ZAAR": "THE•ORDZAAR•RUNES", "ZABAKU": "Zabaku Inu", "ZACK": "Zack Morris", @@ -19143,7 +20016,7 @@ "ZAMZAM": "ZAMZAM", "ZANO": "Zano", "ZAO": "zkTAO", - "ZAP": "ZAP", + "ZAP": "Zap", "ZAPI": "Zapicorn", "ZAPO": "Zapo AI", "ZAPTOKEN": "Zap", @@ -19157,10 +20030,11 @@ "ZAZU": "Zazu", "ZAZZLES": "Zazzles", "ZB": "ZB", - "ZBC": "Zebec Protocol", + "ZBC": "Zebec", "ZBCN": "Zebec Network", "ZBIT": "zbit", - "ZBT": "ZEROBASE", + "ZBT": "ZB Token", + "ZBT38427": "ZEROBASE USD Price", "ZBU": "Zeebu", "ZBUV1": "ZEEBU v1", "ZCC": "ZCC Coin", @@ -19170,7 +20044,7 @@ "ZCHF": "Frankencoin", "ZCHN": "Zichain", "ZCL": "ZClassic", - "ZCN": "Züs", + "ZCN": "Zus", "ZCO": "Zebi Coin", "ZCON": "Zcon Protocol", "ZCOR": "Zrocor", @@ -19183,21 +20057,21 @@ "ZDEX": "Zeedex", "ZDR": "Zloadr", "ZEBU": "ZEBU", - "ZEC": "ZCash", + "ZEC": "Zcash", "ZECD": "ZCashDarkCoin", "ZED": "ZED Token", "ZEDCOIN": "ZedCoin", "ZEDD": "ZedDex", "ZEDTOKEN": "Zed Token", "ZEDX": "ZEDX Сoin", - "ZEDXION": "Zedxion", + "ZEDXION": "ZEDXION", "ZEDXIONV1": "Zedxion v1", "ZEE": "ZeroSwap", "ZEEP": "ZEEPR", "ZEFI": "ZCore Finance", "ZEFU": "Zenfuse", "ZEIT": "ZeitCoin", - "ZEL": "Zelcash", + "ZEL": "ZelCash", "ZELIX": "ZELIX", "ZEN": "Horizen", "ZENAD": "Zenad", @@ -19207,7 +20081,7 @@ "ZENF": "Zenland", "ZENI": "Zennies", "ZENIQ": "Zeniq Coin", - "ZENITH": "Zenith Chain", + "ZENITH": "Zenith Coin", "ZENIX": "ZENIX", "ZENPROTOCOL": "Zen Protocol", "ZENQ": "Zenqira", @@ -19216,52 +20090,59 @@ "ZEON": "Zeon Network", "ZEP": "Zeppelin Dao", "ZEPH": "Zephyr Protocol", + "ZEPH28492": "Zephyr Protocol", "ZER": "Zero", "ZERA": "ZERA", - "ZERC": "zkRace Coin", + "ZERC": "zkRace USD", "ZEREBRO": "Zerebro", - "ZERO": "ZeroLend", + "ZERO": "Zero Exchange", + "ZERO31076": "ZeroLend", "ZEROB": "ZeroBank", "ZEROEX": "0.exchange", "ZES": "Zetos", "ZESH": "Zesh", "ZEST": "ZestCoin", - "ZET": "ZetaCoin", + "ZET": "Zetacoin", "ZET2": "Zeta2Coin", "ZETA": "ZetaChain", + "ZETA21259": "ZetaChain", "ZETH": "Zethan", "ZETO": "ZeTo", "ZETRIX": "Zetrix", "ZEUM": "Colizeum", "ZEUS": "Zeus Network", + "ZEUS30391": "Zeus Network", "ZEUSPEPES": "Zeus", "ZEX": "Zeta", "ZEXI": "ZEXICON", "ZEXX": "ZEXXCOIN", "ZEXY": "ZEXY", - "ZF": "zkSwap Finance ", + "ZF": "zkSwap Finance", "ZFI": "Zyfi", "ZFL": "Zuflo Coin", "ZFLOKI": "zkFloki", "ZFM": "ZFMCOIN", "ZGC": "Z Generation Coin", - "ZGD": "ZambesiGold", + "ZGD": "Zambesigold", "ZGEM": "GemSwap", "ZHC": "ZHC : Zero Hour Cash", "ZHOA": "Chengpang Zhoa", "ZHOUKING": "ZhouKing", "ZIBU": "Zibu", - "ZIG": "Zignaly", + "ZIG": "Zigcoin", "ZIGAP": "ZIGAP", "ZIK": "Ziktalk", "ZIKC": "Zik coin", "ZIL": "Zilliqa", "ZILBERCOIN": "Zilbercoin", + "ZILLA": "Zilla Finance", "ZILLIONXO": "ZILLION AAKAR XO", "ZILPEPE": "ZilPepe", "ZINC": "ZINC", "ZINU": "Zombie Inu", - "ZIP": "Zipper", + "ZION": "ZION", + "ZIOT": "ziot Coin", + "ZIP": "ZipSwap", "ZIPPYSOL": "Zippy Staked SOL", "ZIPT": "Zippie", "ZIRVE": "Zirve Coin", @@ -19273,11 +20154,13 @@ "ZJLT": "ZJLT Distributed Factoring Network", "ZJOE": "zJOE", "ZK": "zkSync", + "ZK29779": "Polyhedra Network", "ZKAI": "ZKCrypt AI", "ZKARCH": "zkArchive", "ZKB": "ZKBase", "ZKBOB": "BOB", "ZKC": "ZK Coin", + "ZKC38371": "Boundless", "ZKCRO": "Cronos zkEVM CRO", "ZKDOGE": "zkDoge", "ZKDX": "ZKDX", @@ -19297,10 +20180,10 @@ "ZKLAB": "zkSync Labs", "ZKLK": "ZkLock", "ZKML": "zKML", - "ZKP": "zkPass", + "ZKP": "Panther Protocol", "ZKPAD": "zkLaunchpad", "ZKPEPE": "ZKPEPEs", - "ZKS": "ZKSpace", + "ZKS": "ZKBase", "ZKSHIB": "zkShib", "ZKSP": "zkSwap", "ZKT": "zkTube", @@ -19311,14 +20194,14 @@ "ZLA": "Zilla", "ZLDA": "ZELDA 2.0", "ZLDAV1": "ZELDA v1", - "ZLK": "Zenlink Network", + "ZLK": "Zenlink", "ZLOT": "zLOT Finance", - "ZLP": "ZilPay Wallet", + "ZLP": "Zuplo", "ZLQ": "ZLiteQubit", "ZLW": "Zelwin", "ZMBE": "RugZombie", "ZMN": "ZMINE", - "ZMT": "Zipmex Token", + "ZMT": "Zipmex", "ZND": "ZND Token", "ZNE": "ZoneCoin", "ZNN": "Zenon", @@ -19332,19 +20215,20 @@ "ZOE": "Zoe Cash", "ZOI": "Zoin", "ZON": "Zon Token", - "ZONE": "Zone", + "ZONE": "GridZone.io", "ZONO": "Zono Swap", "ZONX": "METAZONX", - "ZOO": "ZooKeeper", + "ZOO": "ZooCoin", "ZOOA": "Zoopia", "ZOOC": "ZOO Crypto World", - "ZOOM": "ZoomCoin", - "ZOOMER": "Zoomer Coin", + "ZOOM": "ZoomSwap", + "ZOOMER": "ZOOMER", "ZOON": "CryptoZoon", "ZOOSTORY": "ZOO", "ZOOT": "Zoo Token", "ZOOTOPIA": "Zootopia", - "ZORA": "Zora", + "ZORA": "Zoracles", + "ZORA35931": "ZORA", "ZORACLES": "Zoracles", "ZORKSEES": "Zorksees", "ZORO": "Zoro Inu", @@ -19361,17 +20245,18 @@ "ZPTC": "Zeptacoin", "ZRC": "Zircuit", "ZRCOIN": "ZrCoin", - "ZRO": "LayerZero", + "ZRO": "Carb0n.fi", + "ZRO26997": "LayerZero", "ZRPY": "Zerpaay", "ZRS": "Zaros", - "ZRX": "0x", + "ZRX": "0x Protocol", "ZSC": "Zeusshield", "ZSD": "Zephyr Protocol Stable Dollar", "ZSE": "ZSEcoin", "ZSH": "Ziesha", "ZSWAP": "ZygoSwap", "ZT": "ZBG Token", - "ZTC": "Zenchain", + "ZTC": "Zent Cash", "ZTG": "Zeitgeist", "ZTK": "Zefi", "ZTX": "ZTX", @@ -19386,7 +20271,7 @@ "ZUNUSD": "Zunami USD", "ZUR": "Zurcoin", "ZURR": "ZURRENCY", - "ZUSD": "ZUSD", + "ZUSD": "Zytara dollar", "ZUSHI": "ZUSHI", "ZUT": "Zero Utility Token", "ZUZALU": "Zuzalu Inu", @@ -19397,7 +20282,7 @@ "ZXC": "Oxcert", "ZXT": "Zcrypt", "ZYB": "Zyberswap", - "ZYD": "ZayedCoin", + "ZYD": "Zayedcoin", "ZYGO": "Zygo the frog", "ZYN": "Zynecoin", "ZYNC": "ZynCoin", @@ -19406,17 +20291,21 @@ "ZYR": "Zyrri", "ZYRO": "Zyro", "ZYTARA": "Zytara dollar", + "ZYX": "ZYX", "ZZ": "ZigZag", "ZZC": "ZudgeZury", - "ZZZ": "ZZZ", + "ZZZ": "GoSleep", "ZZZV1": "zzz.finance", + "aEth": "ankrETH", "anyeth1": "anyeth1", "eFIC": "FIC Network", "ePRX": "eProxy", + "eXRD": "E-RADIX", "gOHM": "Governance OHM", "redBUX": "redBUX", "sOHM": "Staked Olympus", "vXDEFI": "vXDEFI", + "vXVS": "Venus XVS", "wsOHM": "Wrapped Staked Olympus", "修仙": "修仙", "分红狗头": "分红狗头", diff --git a/apps/api/src/dtos/date-range-filter.dto.ts b/apps/api/src/dtos/date-range-filter.dto.ts new file mode 100644 index 000000000..bf440ac7c --- /dev/null +++ b/apps/api/src/dtos/date-range-filter.dto.ts @@ -0,0 +1,16 @@ +import { DATE_RANGES, DEFAULT_DATE_RANGE } from '@ghostfolio/common/config'; +import { DateRange } from '@ghostfolio/common/types'; + +import { Matches } from 'class-validator'; + +import { FilterDto } from './filter.dto'; + +// A named date range or a calendar year like '2024', '2023', '2022', etc. +export const DATE_RANGE_PATTERN = new RegExp( + `^(${DATE_RANGES.join('|')}|\\d{4})$` +); + +export class DateRangeFilterDto extends FilterDto { + @Matches(DATE_RANGE_PATTERN) + range?: DateRange = DEFAULT_DATE_RANGE; +} diff --git a/apps/api/src/dtos/filter.dto.ts b/apps/api/src/dtos/filter.dto.ts new file mode 100644 index 000000000..cb26d582b --- /dev/null +++ b/apps/api/src/dtos/filter.dto.ts @@ -0,0 +1,23 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class FilterDto { + @IsOptional() + @IsString() + accounts?: string; + + @IsOptional() + @IsString() + assetClasses?: string; + + @IsOptional() + @IsString() + dataSource?: string; + + @IsOptional() + @IsString() + symbol?: string; + + @IsOptional() + @IsString() + tags?: string; +} diff --git a/apps/api/src/events/asset-profile-changed.listener.ts b/apps/api/src/events/asset-profile-changed.listener.ts index cc70edad6..15c19ff0a 100644 --- a/apps/api/src/events/asset-profile-changed.listener.ts +++ b/apps/api/src/events/asset-profile-changed.listener.ts @@ -5,16 +5,18 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { Injectable, Logger } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; -import { DataSource } from '@prisma/client'; import ms from 'ms'; import { AssetProfileChangedEvent } from './asset-profile-changed.event'; @Injectable() export class AssetProfileChangedListener { + private readonly logger = new Logger(AssetProfileChangedListener.name); + private static readonly DEBOUNCE_DELAY = ms('5 seconds'); private debounceTimers = new Map(); @@ -62,15 +64,8 @@ export class AssetProfileChangedListener { currency, dataSource, symbol - }: { - currency: string; - dataSource: DataSource; - symbol: string; - }) { - Logger.log( - `Asset profile of ${symbol} (${dataSource}) has changed`, - 'AssetProfileChangedListener' - ); + }: { currency: string } & AssetProfileIdentifier) { + this.logger.log(`Asset profile of ${symbol} (${dataSource}) has changed`); if ( this.configurationService.get( @@ -84,10 +79,7 @@ export class AssetProfileChangedListener { const existingCurrencies = this.exchangeRateDataService.getCurrencies(); if (!existingCurrencies.includes(currency)) { - Logger.log( - `New currency ${currency} has been detected`, - 'AssetProfileChangedListener' - ); + this.logger.log(`New currency ${currency} has been detected`); await this.exchangeRateDataService.initialize(); } diff --git a/apps/api/src/events/events.module.ts b/apps/api/src/events/events.module.ts index df943a3c9..dabc3edb7 100644 --- a/apps/api/src/events/events.module.ts +++ b/apps/api/src/events/events.module.ts @@ -1,9 +1,12 @@ import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; +import { UserModule } from '@ghostfolio/api/app/user/user.module'; +import { ApiModule } from '@ghostfolio/api/services/api/api.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 { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; +import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { Module } from '@nestjs/common'; @@ -13,11 +16,14 @@ import { PortfolioChangedListener } from './portfolio-changed.listener'; @Module({ imports: [ ActivitiesModule, + ApiModule, ConfigurationModule, DataGatheringQueueModule, DataProviderModule, ExchangeRateDataModule, - RedisCacheModule + PortfolioSnapshotQueueModule, + RedisCacheModule, + UserModule ], providers: [AssetProfileChangedListener, PortfolioChangedListener] }) diff --git a/apps/api/src/events/portfolio-changed.listener.ts b/apps/api/src/events/portfolio-changed.listener.ts index f8e2a9229..026711b93 100644 --- a/apps/api/src/events/portfolio-changed.listener.ts +++ b/apps/api/src/events/portfolio-changed.listener.ts @@ -1,4 +1,12 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { ApiService } from '@ghostfolio/api/services/api/api.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { + PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW, + PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, + PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS +} from '@ghostfolio/common/config'; import { Injectable, Logger } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; @@ -8,11 +16,18 @@ import { PortfolioChangedEvent } from './portfolio-changed.event'; @Injectable() export class PortfolioChangedListener { + private readonly logger = new Logger(PortfolioChangedListener.name); + private static readonly DEBOUNCE_DELAY = ms('5 seconds'); private debounceTimers = new Map(); - public constructor(private readonly redisCacheService: RedisCacheService) {} + public constructor( + private readonly apiService: ApiService, + private readonly portfolioSnapshotService: PortfolioSnapshotService, + private readonly redisCacheService: RedisCacheService, + private readonly userService: UserService + ) {} @OnEvent(PortfolioChangedEvent.getName()) handlePortfolioChangedEvent(event: PortfolioChangedEvent) { @@ -35,11 +50,46 @@ export class PortfolioChangedListener { } private async processPortfolioChanged({ userId }: { userId: string }) { - Logger.log( - `Portfolio of user '${userId}' has changed`, - 'PortfolioChangedListener' - ); + this.logger.log(`Portfolio of user '${userId}' has changed`); + + try { + await this.redisCacheService.removePortfolioSnapshotsByUserId({ userId }); + + const user = await this.userService.user({ id: userId }); - await this.redisCacheService.removePortfolioSnapshotsByUserId({ userId }); + if (!user) { + return; + } + + const userSettings = user.settings.settings; + + const filters = this.apiService.buildFiltersFromUserSettings({ + userSettings + }); + + // Recompute in the background to avoid a cold start on the next request + await this.portfolioSnapshotService.addJobToQueue({ + data: { + filters, + userId, + calculationType: userSettings.performanceCalculationType, + userCurrency: userSettings.baseCurrency + }, + name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, + opts: { + ...PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS, + jobId: this.redisCacheService.getPortfolioSnapshotKey({ + filters, + userId + }), + priority: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW + } + }); + } catch (error) { + this.logger.error( + `Portfolio snapshot of user '${userId}' could not be recomputed`, + error + ); + } } } diff --git a/apps/api/src/filters/portfolio-snapshot-computation-exception.filter.ts b/apps/api/src/filters/portfolio-snapshot-computation-exception.filter.ts new file mode 100644 index 000000000..05471c3f6 --- /dev/null +++ b/apps/api/src/filters/portfolio-snapshot-computation-exception.filter.ts @@ -0,0 +1,26 @@ +import { PortfolioSnapshotComputationError } from '@ghostfolio/api/app/portfolio/errors/portfolio-snapshot-computation.error'; + +import { ArgumentsHost, Catch, ExceptionFilter, Logger } from '@nestjs/common'; +import { Response } from 'express'; +import { getReasonPhrase, StatusCodes } from 'http-status-codes'; + +@Catch(PortfolioSnapshotComputationError) +export class PortfolioSnapshotComputationExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger( + PortfolioSnapshotComputationExceptionFilter.name + ); + + public catch( + exception: PortfolioSnapshotComputationError, + host: ArgumentsHost + ) { + this.logger.error(exception.message); + + const response = host.switchToHttp().getResponse(); + + response.status(StatusCodes.SERVICE_UNAVAILABLE).json({ + message: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE), + statusCode: StatusCodes.SERVICE_UNAVAILABLE + }); + } +} diff --git a/apps/api/src/guards/custom-throttler.guard.ts b/apps/api/src/guards/custom-throttler.guard.ts new file mode 100644 index 000000000..00a2ba087 --- /dev/null +++ b/apps/api/src/guards/custom-throttler.guard.ts @@ -0,0 +1,23 @@ +import { ExecutionContext, Injectable, Logger } from '@nestjs/common'; +import { ThrottlerException, ThrottlerGuard } from '@nestjs/throttler'; + +@Injectable() +export class CustomThrottlerGuard extends ThrottlerGuard { + private readonly logger = new Logger(CustomThrottlerGuard.name); + + public override async canActivate( + context: ExecutionContext + ): Promise { + try { + return await super.canActivate(context); + } catch (error) { + if (error instanceof ThrottlerException) { + throw error; + } + + this.logger.error(error); + + return true; + } + } +} diff --git a/apps/api/src/helper/account.helper.ts b/apps/api/src/helper/account.helper.ts new file mode 100644 index 000000000..da0762ba5 --- /dev/null +++ b/apps/api/src/helper/account.helper.ts @@ -0,0 +1,28 @@ +import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; + +import { Prisma } from '@prisma/client'; +import { endOfToday, isAfter } from 'date-fns'; + +export const WHERE_ACCOUNT_NOT_EXCLUDED: Prisma.AccountWhereInput = { + tags: { + none: { + tagId: TAG_ID_EXCLUDE_FROM_ANALYSIS + } + } +}; + +export function getWhereAccountBalanceNotInFuture(): Prisma.AccountBalanceWhereInput { + return { + date: { lte: endOfToday() } + }; +} + +export function isAccountBalanceInFuture({ + date, + endOfTodayDate = endOfToday() +}: { + date: Date; + endOfTodayDate?: Date; +}) { + return isAfter(date, endOfTodayDate); +} diff --git a/apps/api/src/helper/country.helper.ts b/apps/api/src/helper/country.helper.ts new file mode 100644 index 000000000..1d9f8f99a --- /dev/null +++ b/apps/api/src/helper/country.helper.ts @@ -0,0 +1,21 @@ +import { countries } from 'countries-list'; + +export function getCountryCodeByName({ + aliases = {}, + name +}: { + aliases?: Record; + name: string; +}): string { + if (aliases[name]) { + return aliases[name]; + } + + for (const [code, country] of Object.entries(countries)) { + if (country.name === name) { + return code; + } + } + + return undefined; +} diff --git a/apps/api/src/helper/data-source.helper.ts b/apps/api/src/helper/data-source.helper.ts new file mode 100644 index 000000000..f3ed75229 --- /dev/null +++ b/apps/api/src/helper/data-source.helper.ts @@ -0,0 +1,67 @@ +import { DataSource } from '@prisma/client'; +import { createHash } from 'node:crypto'; + +const encodedDataSourceByDataSource = new Map( + Object.values(DataSource).map((dataSource) => { + return [dataSource, hashDataSource(dataSource)]; + }) +); + +const dataSourceByEncodedDataSource = new Map( + [...encodedDataSourceByDataSource].map(([dataSource, encodedDataSource]) => { + return [encodedDataSource, dataSource]; + }) +); + +/** + * @deprecated Backward compatibility to support importing data that was + * exported using the previous data source encoding + */ +const dataSourceByDeprecatedEncodedDataSource = new Map( + Object.values(DataSource).map((dataSource) => { + return [deprecatedHashDataSource(dataSource), dataSource]; + }) +); + +/** + * @deprecated Backward compatibility (see above) + */ +function deprecatedHashDataSource(dataSource: DataSource) { + return Buffer.from(dataSource, 'utf-8').toString('hex'); +} + +function hashDataSource(dataSource: DataSource) { + return createHash('sha256').update(dataSource).digest('hex').slice(0, 8); +} + +export function decodeDataSource(encodedDataSource: string) { + if (!encodedDataSource) { + return undefined; + } + + return ( + dataSourceByEncodedDataSource.get(encodedDataSource) ?? + dataSourceByDeprecatedEncodedDataSource.get(encodedDataSource) ?? + encodedDataSource + ); +} + +export function encodeDataSource(dataSource: DataSource) { + if (!dataSource) { + return undefined; + } + + return encodedDataSourceByDataSource.get(dataSource); +} + +export function getMaskedGhostfolioDataSource({ + dataSource, + ghostfolioDataSources +}: { + dataSource: DataSource; + ghostfolioDataSources: string[]; +}) { + return ghostfolioDataSources.includes(dataSource) + ? DataSource.GHOSTFOLIO + : dataSource; +} diff --git a/apps/api/src/helper/redis.helper.ts b/apps/api/src/helper/redis.helper.ts new file mode 100644 index 000000000..81fe905eb --- /dev/null +++ b/apps/api/src/helper/redis.helper.ts @@ -0,0 +1,22 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; + +export function getRedisConnectionOptions( + configurationService: ConfigurationService +) { + return { + db: configurationService.get('REDIS_DB'), + host: configurationService.get('REDIS_HOST'), + password: configurationService.get('REDIS_PASSWORD'), + port: configurationService.get('REDIS_PORT') + }; +} + +export function getRedisConnectionUrl( + configurationService: ConfigurationService +): string { + const { db, host, password, port } = + getRedisConnectionOptions(configurationService); + const encodedPassword = encodeURIComponent(password); + + return `redis://${encodedPassword ? `:${encodedPassword}` : ''}@${host}:${port}/${db}`; +} diff --git a/apps/api/src/helper/sector.helper.ts b/apps/api/src/helper/sector.helper.ts new file mode 100644 index 000000000..e0face386 --- /dev/null +++ b/apps/api/src/helper/sector.helper.ts @@ -0,0 +1,28 @@ +import { SECTORS } from '@ghostfolio/common/config'; +import { SectorName } from '@ghostfolio/common/types'; + +import { Logger } from '@nestjs/common'; + +export function getSectorName({ + aliases = {}, + name +}: { + aliases?: Record; + name: string; +}): SectorName { + if (aliases[name]) { + return aliases[name]; + } + + if ((SECTORS as readonly string[]).includes(name)) { + return name as SectorName; + } + + if (name) { + const logger = new Logger('getSectorName'); + + logger.warn(`Could not map the sector "${name}" to the ontology`); + } + + return 'Other'; +} diff --git a/apps/api/src/interceptors/performance-logging/performance-logging.service.ts b/apps/api/src/interceptors/performance-logging/performance-logging.service.ts index 1b1faf8e0..b2ea03c37 100644 --- a/apps/api/src/interceptors/performance-logging/performance-logging.service.ts +++ b/apps/api/src/interceptors/performance-logging/performance-logging.service.ts @@ -2,6 +2,8 @@ import { Injectable, Logger } from '@nestjs/common'; @Injectable() export class PerformanceLoggingService { + private readonly logger = new Logger(); + public logPerformance({ className, methodName, @@ -13,7 +15,7 @@ export class PerformanceLoggingService { }) { const endTime = performance.now(); - Logger.debug( + this.logger.debug( `Completed execution of ${methodName}() in ${((endTime - startTime) / 1000).toFixed(3)} seconds`, className ); diff --git a/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts b/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts index 60b994cac..6a9596298 100644 --- a/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts +++ b/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts @@ -38,7 +38,7 @@ export class RedactValuesInResponseInterceptor implements NestInterceptor< if ( hasReadRestrictedAccessPermission({ impersonationId, - user + accesses: user?.accessesGet }) || isRestrictedView(user) ) { diff --git a/apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts b/apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts index 17c5ebe57..4ab0794ec 100644 --- a/apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts +++ b/apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts @@ -1,5 +1,5 @@ +import { decodeDataSource } from '@ghostfolio/api/helper/data-source.helper'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { decodeDataSource } from '@ghostfolio/common/helper'; import { CallHandler, diff --git a/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts b/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts index 57643f76c..81aabf4ae 100644 --- a/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts +++ b/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts @@ -1,6 +1,10 @@ +import { + encodeDataSource, + getMaskedGhostfolioDataSource +} from '@ghostfolio/api/helper/data-source.helper'; import { redactPaths } from '@ghostfolio/api/helper/object.helper'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { encodeDataSource } from '@ghostfolio/common/helper'; +import { hasRole } from '@ghostfolio/common/permissions'; import { CallHandler, @@ -44,26 +48,40 @@ export class TransformDataSourceInResponseInterceptor< next: CallHandler ): Observable { const isExportMode = context.getClass().name === 'ExportController'; + const { user } = context.switchToHttp().getRequest(); return next.handle().pipe( map((data: any) => { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { - const valueMap = this.encodedDataSourceMap; + const valueMap = hasRole(user, 'ADMIN') + ? {} + : { ...this.encodedDataSourceMap }; if (isExportMode) { - for (const dataSource of this.configurationService.get( + const ghostfolioDataSources = this.configurationService.get( 'DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER' - )) { - valueMap[dataSource] = 'GHOSTFOLIO'; + ) as DataSource[]; + + for (const dataSource of ghostfolioDataSources) { + valueMap[dataSource] = getMaskedGhostfolioDataSource({ + dataSource, + ghostfolioDataSources + }); } } + if (Object.keys(valueMap).length === 0) { + return data; + } + data = redactPaths({ valueMap, object: data, paths: [ + '["filters.dataSource"]', + 'activities[*].assetProfile.dataSource', 'activities[*].dataSource', - 'activities[*].SymbolProfile.dataSource', + 'assetProfile.dataSource', 'benchmarks[*].dataSource', 'errors[*].dataSource', 'fearAndGreedIndex.CRYPTOCURRENCIES.dataSource', @@ -71,7 +89,7 @@ export class TransformDataSourceInResponseInterceptor< 'holdings[*].assetProfile.dataSource', 'holdings[*].dataSource', 'items[*].dataSource', - 'SymbolProfile.dataSource', + 'settings["filters.dataSource"]', 'watchlist[*].dataSource' ] }); diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index f08a09a83..b30a20323 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,3 +1,5 @@ +import { languageRedirectMiddleware } from '@ghostfolio/api/middlewares/language-redirect.middleware'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { BULL_BOARD_ROUTE, DEFAULT_HOST, @@ -18,11 +20,26 @@ import type { NestExpressApplication } from '@nestjs/platform-express'; import cookieParser from 'cookie-parser'; import { NextFunction, Request, Response } from 'express'; import helmet from 'helmet'; +import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici'; import { AppModule } from './app/app.module'; import { environment } from './environments/environment'; +const logger = new Logger('Bootstrap'); +const processWarningLogger = new Logger('ProcessWarning'); + +process.on('warning', ({ name, stack }) => { + if (name === 'MaxListenersExceededWarning') { + // Log the stack trace of MaxListenersExceededWarning occurrences to identify + // the event emitter and the call site which registers the listeners + processWarningLogger.warn(stack); + } +}); + async function bootstrap() { + // Respect HTTP_PROXY / HTTPS_PROXY / NO_PROXY for outbound HTTP requests + setGlobalDispatcher(new EnvHttpProxyAgent()); + const configApp = await NestFactory.create(AppModule); const configService = configApp.get(ConfigService); let customLogLevels: LogLevel[]; @@ -33,6 +50,8 @@ async function bootstrap() { ) as LogLevel[]; } catch {} + await configApp.close(); + const app = await NestFactory.create(AppModule, { logger: customLogLevels ?? @@ -91,6 +110,25 @@ async function bootstrap() { }); } + app.use(languageRedirectMiddleware); + + const configurationService = app.get(ConfigurationService); + + const trustProxy = configurationService.get('TRUST_PROXY'); + + if (trustProxy) { + app.set('trust proxy', trustProxy); + } + + if ( + configurationService.get('ENABLE_FEATURE_RATE_LIMITING') && + trustProxy === '' + ) { + logger.warn( + 'Rate limiting is enabled, but TRUST_PROXY is not set. If the Ghostfolio application runs behind a reverse proxy, the rate limits are shared across all clients.' + ); + } + const HOST = configService.get('HOST') || DEFAULT_HOST; const PORT = configService.get('PORT') || DEFAULT_PORT; @@ -110,20 +148,20 @@ async function bootstrap() { address = `${host}:${addressObject.port}`; } - Logger.log(`Listening at http://${address}`); - Logger.log(''); + logger.log(`Listening at http://${address}`); + logger.log(''); }); } function logLogo() { - Logger.log(' ________ __ ____ ___'); - Logger.log(' / ____/ /_ ____ _____/ /_/ __/___ / (_)___'); - Logger.log(' / / __/ __ \\/ __ \\/ ___/ __/ /_/ __ \\/ / / __ \\'); - Logger.log('/ /_/ / / / / /_/ (__ ) /_/ __/ /_/ / / / /_/ /'); - Logger.log( + logger.log(' ________ __ ____ ___'); + logger.log(' / ____/ /_ ____ _____/ /_/ __/___ / (_)___'); + logger.log(' / / __/ __ \\/ __ \\/ ___/ __/ /_/ __ \\/ / / __ \\'); + logger.log('/ /_/ / / / / /_/ (__ ) /_/ __/ /_/ / / / /_/ /'); + logger.log( `\\____/_/ /_/\\____/____/\\__/_/ \\____/_/_/\\____/ ${environment.version}` ); - Logger.log(''); + logger.log(''); } bootstrap(); diff --git a/apps/api/src/middlewares/html-template.middleware.ts b/apps/api/src/middlewares/html-template.middleware.ts index 2b8820e81..928a9f22c 100644 --- a/apps/api/src/middlewares/html-template.middleware.ts +++ b/apps/api/src/middlewares/html-template.middleware.ts @@ -92,34 +92,50 @@ const locales = { @Injectable() export class HtmlTemplateMiddleware implements NestMiddleware { + private readonly logger = new Logger(HtmlTemplateMiddleware.name); + private indexHtmlMap: { [languageCode: string]: string } = {}; public constructor(private readonly i18nService: I18nService) { - try { - this.indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce( - (map, languageCode) => ({ - ...map, - [languageCode]: readFileSync( - join(__dirname, '..', 'client', languageCode, 'index.html'), - 'utf8' - ) - }), - {} - ); - } catch (error) { - Logger.error( - 'Failed to initialize index HTML map', - error, - 'HTMLTemplateMiddleware' - ); + if (!environment.production) { + return; } + + this.indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce((map, languageCode) => { + const indexHtmlPath = join( + __dirname, + '..', + 'client', + languageCode, + 'index.html' + ); + + try { + // Restore the interpolation token which the template replaces with a + // static fallback title to avoid showing an unresolved template + // literal when served without interpolation (e.g. by the service worker) + map[languageCode] = readFileSync(indexHtmlPath, 'utf8').replace( + /.*?<\/title>/, + '<title>${title}' + ); + } catch { + this.logger.warn( + `Skipping language '${languageCode}': ${indexHtmlPath} not found` + ); + } + + return map; + }, {}); } public use(request: Request, response: Response, next: NextFunction) { const path = request.originalUrl.replace(/\/$/, ''); let languageCode = path.substr(1, 2); - if (!SUPPORTED_LANGUAGE_CODES.includes(languageCode)) { + if ( + !(SUPPORTED_LANGUAGE_CODES as readonly string[]).includes(languageCode) || + !this.indexHtmlMap[languageCode] + ) { languageCode = DEFAULT_LANGUAGE_CODE; } diff --git a/apps/api/src/middlewares/language-redirect.middleware.ts b/apps/api/src/middlewares/language-redirect.middleware.ts new file mode 100644 index 000000000..5b6fac6c4 --- /dev/null +++ b/apps/api/src/middlewares/language-redirect.middleware.ts @@ -0,0 +1,37 @@ +import { environment } from '@ghostfolio/api/environments/environment'; +import { + DEFAULT_LANGUAGE_CODE, + SUPPORTED_LANGUAGE_CODES +} from '@ghostfolio/common/config'; + +import { NextFunction, Request, Response } from 'express'; +import { StatusCodes } from 'http-status-codes'; + +export function languageRedirectMiddleware( + request: Request, + response: Response, + next: NextFunction +) { + if ( + !environment.production || + request.path !== '/' || + !['GET', 'HEAD'].includes(request.method) + ) { + return next(); + } + + let languageCode = DEFAULT_LANGUAGE_CODE; + + try { + const code = request.headers['accept-language'].split(',')[0].split('-')[0]; + + if ((SUPPORTED_LANGUAGE_CODES as readonly string[]).includes(code)) { + languageCode = code; + } + } catch {} + + return response.redirect( + StatusCodes.MOVED_PERMANENTLY, + `/${languageCode}${request.url.slice(1)}` + ); +} diff --git a/apps/api/src/models/rule.ts b/apps/api/src/models/rule.ts index 66cda1d78..d47ea30f2 100644 --- a/apps/api/src/models/rule.ts +++ b/apps/api/src/models/rule.ts @@ -59,7 +59,7 @@ export abstract class Rule implements RuleInterface { new Big(currentValue.quantity) .mul(currentValue.marketPrice ?? 0) .toNumber(), - currentValue.assetProfile.currency, + currentValue.assetProfile.currency ?? baseCurrency, baseCurrency ), 0 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 400a2506f..1b967c5db 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 @@ -1,6 +1,7 @@ import { Rule } from '@ghostfolio/api/models/rule'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { DEFAULT_CURRENCY, DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { PortfolioDetails, RuleSettings, @@ -13,7 +14,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule { private accounts: PortfolioDetails['accounts']; public constructor( - protected exchangeRateDataService: ExchangeRateDataService, + exchangeRateDataService: ExchangeRateDataService, private i18nService: I18nService, languageCode: string, accounts: PortfolioDetails['accounts'] @@ -50,7 +51,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule { }; } - let maxAccount: (typeof accounts)[0]; + let maxAccount: (typeof accounts)[0] | undefined; let totalInvestment = 0; for (const account of Object.values(accounts)) { @@ -67,7 +68,8 @@ export class AccountClusterRiskCurrentInvestment extends Rule { } } - const maxInvestmentRatio = maxAccount?.investment / totalInvestment || 0; + const maxInvestmentRatio = + (maxAccount?.investment ?? 0) / totalInvestment || 0; if (maxInvestmentRatio > ruleSettings.thresholdMax) { return { @@ -75,7 +77,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule { id: 'rule.accountClusterRiskCurrentInvestment.false', languageCode: this.getLanguageCode(), placeholders: { - maxAccountName: maxAccount.name, + maxAccountName: maxAccount?.name ?? '', maxInvestmentRatio: (maxInvestmentRatio * 100).toPrecision(3), thresholdMax: ruleSettings.thresholdMax * 100 } @@ -89,7 +91,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule { id: 'rule.accountClusterRiskCurrentInvestment.true', languageCode: this.getLanguageCode(), placeholders: { - maxAccountName: maxAccount.name, + maxAccountName: maxAccount?.name ?? '', maxInvestmentRatio: (maxInvestmentRatio * 100).toPrecision(3), thresholdMax: ruleSettings.thresholdMax * 100 } @@ -118,8 +120,8 @@ export class AccountClusterRiskCurrentInvestment extends Rule { } public getSettings({ - baseCurrency, - locale, + baseCurrency = DEFAULT_CURRENCY, + locale = DEFAULT_LOCALE, xRayRules }: UserSettings): Settings { return { 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 e4ee99064..9a6224ac2 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 @@ -1,6 +1,7 @@ import { Rule } from '@ghostfolio/api/models/rule'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { PortfolioDetails, RuleSettings, @@ -11,7 +12,7 @@ export class AccountClusterRiskSingleAccount extends Rule { private accounts: PortfolioDetails['accounts']; public constructor( - protected exchangeRateDataService: ExchangeRateDataService, + exchangeRateDataService: ExchangeRateDataService, private i18nService: I18nService, languageCode: string, accounts: PortfolioDetails['accounts'] @@ -68,7 +69,10 @@ export class AccountClusterRiskSingleAccount extends Rule { }); } - public getSettings({ locale, xRayRules }: UserSettings): RuleSettings { + public getSettings({ + locale = DEFAULT_LOCALE, + xRayRules + }: UserSettings): RuleSettings { return { locale, isActive: xRayRules?.[this.getKey()]?.isActive ?? true diff --git a/apps/api/src/models/rules/asset-class-cluster-risk/equity.ts b/apps/api/src/models/rules/asset-class-cluster-risk/equity.ts index 12303fd92..0e054f184 100644 --- a/apps/api/src/models/rules/asset-class-cluster-risk/equity.ts +++ b/apps/api/src/models/rules/asset-class-cluster-risk/equity.ts @@ -1,6 +1,7 @@ import { Rule } from '@ghostfolio/api/models/rule'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { DEFAULT_CURRENCY, DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { PortfolioPosition, RuleSettings, @@ -11,7 +12,7 @@ export class AssetClassClusterRiskEquity extends Rule { private holdings: PortfolioPosition[]; public constructor( - protected exchangeRateDataService: ExchangeRateDataService, + exchangeRateDataService: ExchangeRateDataService, private i18nService: I18nService, languageCode: string, holdings: PortfolioPosition[] @@ -107,8 +108,8 @@ export class AssetClassClusterRiskEquity extends Rule { } public getSettings({ - baseCurrency, - locale, + baseCurrency = DEFAULT_CURRENCY, + locale = DEFAULT_LOCALE, xRayRules }: UserSettings): Settings { return { diff --git a/apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts b/apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts index fd7c00f11..94f359809 100644 --- a/apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts +++ b/apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts @@ -1,6 +1,7 @@ import { Rule } from '@ghostfolio/api/models/rule'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { DEFAULT_CURRENCY, DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { PortfolioPosition, RuleSettings, @@ -11,7 +12,7 @@ export class AssetClassClusterRiskFixedIncome extends Rule { private holdings: PortfolioPosition[]; public constructor( - protected exchangeRateDataService: ExchangeRateDataService, + exchangeRateDataService: ExchangeRateDataService, private i18nService: I18nService, languageCode: string, holdings: PortfolioPosition[] @@ -107,8 +108,8 @@ export class AssetClassClusterRiskFixedIncome extends Rule { } public getSettings({ - baseCurrency, - locale, + baseCurrency = DEFAULT_CURRENCY, + locale = DEFAULT_LOCALE, xRayRules }: UserSettings): Settings { return { 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 6890fecd6..588c50ca1 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,6 +1,7 @@ import { Rule } from '@ghostfolio/api/models/rule'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { DEFAULT_CURRENCY, DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { PortfolioPosition, RuleSettings, @@ -11,7 +12,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule { private holdings: PortfolioPosition[]; public constructor( - protected exchangeRateDataService: ExchangeRateDataService, + exchangeRateDataService: ExchangeRateDataService, private i18nService: I18nService, holdings: PortfolioPosition[], languageCode: string @@ -95,8 +96,8 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule { } public getSettings({ - baseCurrency, - locale, + baseCurrency = DEFAULT_CURRENCY, + locale = DEFAULT_LOCALE, xRayRules }: UserSettings): Settings { return { diff --git a/apps/api/src/models/rules/economic-market-cluster-risk/developed-markets.ts b/apps/api/src/models/rules/economic-market-cluster-risk/developed-markets.ts index 70f09f58c..c3b8e618c 100644 --- a/apps/api/src/models/rules/economic-market-cluster-risk/developed-markets.ts +++ b/apps/api/src/models/rules/economic-market-cluster-risk/developed-markets.ts @@ -1,6 +1,7 @@ import { Rule } from '@ghostfolio/api/models/rule'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { DEFAULT_CURRENCY, DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { RuleSettings, UserSettings } from '@ghostfolio/common/interfaces'; export class EconomicMarketClusterRiskDevelopedMarkets extends Rule { @@ -8,7 +9,7 @@ export class EconomicMarketClusterRiskDevelopedMarkets extends Rule { private developedMarketsValueInBaseCurrency: number; public constructor( - protected exchangeRateDataService: ExchangeRateDataService, + exchangeRateDataService: ExchangeRateDataService, private i18nService: I18nService, currentValueInBaseCurrency: number, developedMarketsValueInBaseCurrency: number, @@ -97,8 +98,8 @@ export class EconomicMarketClusterRiskDevelopedMarkets extends Rule { } public getSettings({ - baseCurrency, - locale, + baseCurrency = DEFAULT_CURRENCY, + locale = DEFAULT_LOCALE, xRayRules }: UserSettings): Settings { return { diff --git a/apps/api/src/models/rules/economic-market-cluster-risk/emerging-markets.ts b/apps/api/src/models/rules/economic-market-cluster-risk/emerging-markets.ts index 120c3f6a2..ad91bc554 100644 --- a/apps/api/src/models/rules/economic-market-cluster-risk/emerging-markets.ts +++ b/apps/api/src/models/rules/economic-market-cluster-risk/emerging-markets.ts @@ -1,6 +1,7 @@ import { Rule } from '@ghostfolio/api/models/rule'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { DEFAULT_CURRENCY, DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { RuleSettings, UserSettings } from '@ghostfolio/common/interfaces'; export class EconomicMarketClusterRiskEmergingMarkets extends Rule { @@ -8,7 +9,7 @@ export class EconomicMarketClusterRiskEmergingMarkets extends Rule { private emergingMarketsValueInBaseCurrency: number; public constructor( - protected exchangeRateDataService: ExchangeRateDataService, + exchangeRateDataService: ExchangeRateDataService, private i18nService: I18nService, currentValueInBaseCurrency: number, emergingMarketsValueInBaseCurrency: number, @@ -97,8 +98,8 @@ export class EconomicMarketClusterRiskEmergingMarkets extends Rule { } public getSettings({ - baseCurrency, - locale, + baseCurrency = DEFAULT_CURRENCY, + locale = DEFAULT_LOCALE, xRayRules }: UserSettings): Settings { return { 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 fcbd99d54..1efd224ef 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 @@ -1,13 +1,14 @@ import { Rule } from '@ghostfolio/api/models/rule'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { DEFAULT_CURRENCY, DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { RuleSettings, UserSettings } from '@ghostfolio/common/interfaces'; export class EmergencyFundSetup extends Rule { private emergencyFund: number; public constructor( - protected exchangeRateDataService: ExchangeRateDataService, + exchangeRateDataService: ExchangeRateDataService, private i18nService: I18nService, languageCode: string, emergencyFund: number @@ -52,8 +53,8 @@ export class EmergencyFundSetup extends Rule { } public getSettings({ - baseCurrency, - locale, + baseCurrency = DEFAULT_CURRENCY, + locale = DEFAULT_LOCALE, xRayRules }: UserSettings): Settings { return { diff --git a/apps/api/src/models/rules/fees/fee-ratio-total-investment-volume.ts b/apps/api/src/models/rules/fees/fee-ratio-total-investment-volume.ts index 23f9076e8..1786c2f8e 100644 --- a/apps/api/src/models/rules/fees/fee-ratio-total-investment-volume.ts +++ b/apps/api/src/models/rules/fees/fee-ratio-total-investment-volume.ts @@ -1,6 +1,7 @@ import { Rule } from '@ghostfolio/api/models/rule'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { DEFAULT_CURRENCY, DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { RuleSettings, UserSettings } from '@ghostfolio/common/interfaces'; export class FeeRatioTotalInvestmentVolume extends Rule { @@ -8,7 +9,7 @@ export class FeeRatioTotalInvestmentVolume extends Rule { private totalInvestmentVolumeInBaseCurrency: number; public constructor( - protected exchangeRateDataService: ExchangeRateDataService, + exchangeRateDataService: ExchangeRateDataService, private i18nService: I18nService, languageCode: string, totalInvestmentVolumeInBaseCurrency: number, @@ -76,8 +77,8 @@ export class FeeRatioTotalInvestmentVolume extends Rule { } public getSettings({ - baseCurrency, - locale, + baseCurrency = DEFAULT_CURRENCY, + locale = DEFAULT_LOCALE, xRayRules }: UserSettings): Settings { return { diff --git a/apps/api/src/models/rules/liquidity/buying-power.ts b/apps/api/src/models/rules/liquidity/buying-power.ts index 7e8b96143..4017516cf 100644 --- a/apps/api/src/models/rules/liquidity/buying-power.ts +++ b/apps/api/src/models/rules/liquidity/buying-power.ts @@ -1,13 +1,14 @@ import { Rule } from '@ghostfolio/api/models/rule'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { DEFAULT_CURRENCY, DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { RuleSettings, UserSettings } from '@ghostfolio/common/interfaces'; export class BuyingPower extends Rule { private buyingPower: number; public constructor( - protected exchangeRateDataService: ExchangeRateDataService, + exchangeRateDataService: ExchangeRateDataService, private i18nService: I18nService, buyingPower: number, languageCode: string @@ -83,8 +84,8 @@ export class BuyingPower extends Rule { } public getSettings({ - baseCurrency, - locale, + baseCurrency = DEFAULT_CURRENCY, + locale = DEFAULT_LOCALE, xRayRules }: UserSettings): Settings { return { diff --git a/apps/api/src/models/rules/regional-market-cluster-risk/asia-pacific.ts b/apps/api/src/models/rules/regional-market-cluster-risk/asia-pacific.ts index 4723389b0..b244641ae 100644 --- a/apps/api/src/models/rules/regional-market-cluster-risk/asia-pacific.ts +++ b/apps/api/src/models/rules/regional-market-cluster-risk/asia-pacific.ts @@ -1,6 +1,7 @@ import { Rule } from '@ghostfolio/api/models/rule'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { DEFAULT_CURRENCY, DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { UserSettings } from '@ghostfolio/common/interfaces'; import { Settings } from './interfaces/rule-settings.interface'; @@ -10,7 +11,7 @@ export class RegionalMarketClusterRiskAsiaPacific extends Rule { private currentValueInBaseCurrency: number; public constructor( - protected exchangeRateDataService: ExchangeRateDataService, + exchangeRateDataService: ExchangeRateDataService, private i18nService: I18nService, languageCode: string, currentValueInBaseCurrency: number, @@ -91,8 +92,8 @@ export class RegionalMarketClusterRiskAsiaPacific extends Rule { } public getSettings({ - baseCurrency, - locale, + baseCurrency = DEFAULT_CURRENCY, + locale = DEFAULT_LOCALE, xRayRules }: UserSettings): Settings { return { diff --git a/apps/api/src/models/rules/regional-market-cluster-risk/emerging-markets.ts b/apps/api/src/models/rules/regional-market-cluster-risk/emerging-markets.ts index d4695406a..eb7ed7815 100644 --- a/apps/api/src/models/rules/regional-market-cluster-risk/emerging-markets.ts +++ b/apps/api/src/models/rules/regional-market-cluster-risk/emerging-markets.ts @@ -1,6 +1,7 @@ import { Rule } from '@ghostfolio/api/models/rule'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { DEFAULT_CURRENCY, DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { UserSettings } from '@ghostfolio/common/interfaces'; import { Settings } from './interfaces/rule-settings.interface'; @@ -10,7 +11,7 @@ export class RegionalMarketClusterRiskEmergingMarkets extends Rule { private emergingMarketsValueInBaseCurrency: number; public constructor( - protected exchangeRateDataService: ExchangeRateDataService, + exchangeRateDataService: ExchangeRateDataService, private i18nService: I18nService, languageCode: string, currentValueInBaseCurrency: number, @@ -93,8 +94,8 @@ export class RegionalMarketClusterRiskEmergingMarkets extends Rule { } public getSettings({ - baseCurrency, - locale, + baseCurrency = DEFAULT_CURRENCY, + locale = DEFAULT_LOCALE, xRayRules }: UserSettings): Settings { return { diff --git a/apps/api/src/models/rules/regional-market-cluster-risk/europe.ts b/apps/api/src/models/rules/regional-market-cluster-risk/europe.ts index c5cb4d134..24e0825a3 100644 --- a/apps/api/src/models/rules/regional-market-cluster-risk/europe.ts +++ b/apps/api/src/models/rules/regional-market-cluster-risk/europe.ts @@ -1,6 +1,7 @@ import { Rule } from '@ghostfolio/api/models/rule'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { DEFAULT_CURRENCY, DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { UserSettings } from '@ghostfolio/common/interfaces'; import { Settings } from './interfaces/rule-settings.interface'; @@ -10,7 +11,7 @@ export class RegionalMarketClusterRiskEurope extends Rule { private europeValueInBaseCurrency: number; public constructor( - protected exchangeRateDataService: ExchangeRateDataService, + exchangeRateDataService: ExchangeRateDataService, private i18nService: I18nService, languageCode: string, currentValueInBaseCurrency: number, @@ -91,8 +92,8 @@ export class RegionalMarketClusterRiskEurope extends Rule { } public getSettings({ - baseCurrency, - locale, + baseCurrency = DEFAULT_CURRENCY, + locale = DEFAULT_LOCALE, xRayRules }: UserSettings): Settings { return { diff --git a/apps/api/src/models/rules/regional-market-cluster-risk/japan.ts b/apps/api/src/models/rules/regional-market-cluster-risk/japan.ts index fc9ab92ee..301cebf23 100644 --- a/apps/api/src/models/rules/regional-market-cluster-risk/japan.ts +++ b/apps/api/src/models/rules/regional-market-cluster-risk/japan.ts @@ -1,6 +1,7 @@ import { Rule } from '@ghostfolio/api/models/rule'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { DEFAULT_CURRENCY, DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { UserSettings } from '@ghostfolio/common/interfaces'; import { Settings } from './interfaces/rule-settings.interface'; @@ -10,7 +11,7 @@ export class RegionalMarketClusterRiskJapan extends Rule { private japanValueInBaseCurrency: number; public constructor( - protected exchangeRateDataService: ExchangeRateDataService, + exchangeRateDataService: ExchangeRateDataService, private i18nService: I18nService, languageCode: string, currentValueInBaseCurrency: number, @@ -91,8 +92,8 @@ export class RegionalMarketClusterRiskJapan extends Rule { } public getSettings({ - baseCurrency, - locale, + baseCurrency = DEFAULT_CURRENCY, + locale = DEFAULT_LOCALE, xRayRules }: UserSettings): Settings { return { diff --git a/apps/api/src/models/rules/regional-market-cluster-risk/north-america.ts b/apps/api/src/models/rules/regional-market-cluster-risk/north-america.ts index 8bd3fb0cf..eda446f9a 100644 --- a/apps/api/src/models/rules/regional-market-cluster-risk/north-america.ts +++ b/apps/api/src/models/rules/regional-market-cluster-risk/north-america.ts @@ -1,6 +1,7 @@ import { Rule } from '@ghostfolio/api/models/rule'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { DEFAULT_CURRENCY, DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { UserSettings } from '@ghostfolio/common/interfaces'; import { Settings } from './interfaces/rule-settings.interface'; @@ -10,7 +11,7 @@ export class RegionalMarketClusterRiskNorthAmerica extends Rule { private northAmericaValueInBaseCurrency: number; public constructor( - protected exchangeRateDataService: ExchangeRateDataService, + exchangeRateDataService: ExchangeRateDataService, private i18nService: I18nService, languageCode: string, currentValueInBaseCurrency: number, @@ -91,8 +92,8 @@ export class RegionalMarketClusterRiskNorthAmerica extends Rule { } public getSettings({ - baseCurrency, - locale, + baseCurrency = DEFAULT_CURRENCY, + locale = DEFAULT_LOCALE, xRayRules }: UserSettings): Settings { return { diff --git a/apps/api/src/services/api/api.service.ts b/apps/api/src/services/api/api.service.ts index 052119246..11074870e 100644 --- a/apps/api/src/services/api/api.service.ts +++ b/apps/api/src/services/api/api.service.ts @@ -1,4 +1,4 @@ -import { Filter } from '@ghostfolio/common/interfaces'; +import { Filter, UserSettings } from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; @@ -89,4 +89,18 @@ export class ApiService { return filters; } + + public buildFiltersFromUserSettings({ + userSettings + }: { + userSettings: UserSettings; + }): Filter[] { + return this.buildFiltersFromQueryParams({ + filterByAccounts: userSettings?.['filters.accounts']?.[0], + filterByAssetClasses: userSettings?.['filters.assetClasses']?.[0], + filterByDataSource: userSettings?.['filters.dataSource'], + filterBySymbol: userSettings?.['filters.symbol'], + filterByTags: userSettings?.['filters.tags']?.[0] + }); + } } diff --git a/apps/api/src/services/asset-profile-split/asset-profile-split.module.ts b/apps/api/src/services/asset-profile-split/asset-profile-split.module.ts new file mode 100644 index 000000000..1219a2f98 --- /dev/null +++ b/apps/api/src/services/asset-profile-split/asset-profile-split.module.ts @@ -0,0 +1,12 @@ +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; + +import { Module } from '@nestjs/common'; + +import { AssetProfileSplitService } from './asset-profile-split.service'; + +@Module({ + exports: [AssetProfileSplitService], + imports: [PrismaModule], + providers: [AssetProfileSplitService] +}) +export class AssetProfileSplitModule {} diff --git a/apps/api/src/services/asset-profile-split/asset-profile-split.service.ts b/apps/api/src/services/asset-profile-split/asset-profile-split.service.ts new file mode 100644 index 000000000..ec842caaf --- /dev/null +++ b/apps/api/src/services/asset-profile-split/asset-profile-split.service.ts @@ -0,0 +1,87 @@ +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { resetHours } from '@ghostfolio/common/helper'; +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; + +import { Injectable } from '@nestjs/common'; +import { AssetProfileSplit } from '@prisma/client'; + +@Injectable() +export class AssetProfileSplitService { + public constructor(private readonly prismaService: PrismaService) {} + + /** + * Deletes the split with the given id of an asset profile and returns + * whether it existed + */ + public async deleteById({ + id, + symbolProfileId + }: { + id: string; + symbolProfileId: string; + }) { + const { count } = await this.prismaService.assetProfileSplit.deleteMany({ + where: { + id, + symbolProfileId + } + }); + + return count > 0; + } + + /** + * Returns the splits of the given asset profile in ascending order by date + */ + public async getSplits({ + dataSource, + symbol + }: AssetProfileIdentifier): Promise { + return this.prismaService.assetProfileSplit.findMany({ + orderBy: [ + { + date: 'asc' + } + ], + where: { + symbolProfile: { + dataSource, + symbol + } + } + }); + } + + public async upsert({ + date, + denominator, + numerator, + symbolProfileId + }: { + date: Date; + denominator: number; + numerator: number; + symbolProfileId: string; + }): Promise { + const dateOfSplit = resetHours(date); + + return this.prismaService.assetProfileSplit.upsert({ + create: { + denominator, + numerator, + symbolProfileId, + date: dateOfSplit + }, + update: { + denominator, + numerator + }, + where: { + symbolProfileId_date: { + symbolProfileId, + date: dateOfSplit + } + } + }); + } +} diff --git a/apps/api/src/services/benchmark/benchmark.service.spec.ts b/apps/api/src/services/benchmark/benchmark.service.spec.ts index 833dbcdfc..0e7119b04 100644 --- a/apps/api/src/services/benchmark/benchmark.service.spec.ts +++ b/apps/api/src/services/benchmark/benchmark.service.spec.ts @@ -12,4 +12,16 @@ describe('BenchmarkService', () => { expect(benchmarkService.calculateChangeInPercentage(2, 2)).toEqual(0); expect(benchmarkService.calculateChangeInPercentage(2, 1)).toEqual(-0.5); }); + + it('getMarketCondition', async () => { + expect(benchmarkService.getMarketCondition(0)).toEqual('ALL_TIME_HIGH'); + expect(benchmarkService.getMarketCondition(-5.90736454893e-9)).toEqual( + 'ALL_TIME_HIGH' + ); + expect(benchmarkService.getMarketCondition(-0.1)).toEqual('NEUTRAL_MARKET'); + expect(benchmarkService.getMarketCondition(-0.19996)).toEqual( + 'BEAR_MARKET' + ); + expect(benchmarkService.getMarketCondition(-0.2)).toEqual('BEAR_MARKET'); + }); }); diff --git a/apps/api/src/services/benchmark/benchmark.service.ts b/apps/api/src/services/benchmark/benchmark.service.ts index 4b1d9a65f..993e0f0aa 100644 --- a/apps/api/src/services/benchmark/benchmark.service.ts +++ b/apps/api/src/services/benchmark/benchmark.service.ts @@ -8,26 +8,30 @@ import { CACHE_TTL_INFINITE, PROPERTY_BENCHMARKS } from '@ghostfolio/common/config'; -import { calculateBenchmarkTrend } from '@ghostfolio/common/helper'; +import { + calculateBenchmarkTrend, + getAssetProfileIdentifier +} from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, Benchmark, BenchmarkProperty, 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 { addHours, isAfter, subDays } from 'date-fns'; -import { uniqBy } from 'lodash'; +import { addHours, isPast, subDays } from 'date-fns'; +import { round, uniqBy } from 'lodash'; import ms from 'ms'; import { BenchmarkValue } from './interfaces/benchmark-value.interface'; @Injectable() export class BenchmarkService { + private readonly logger = new Logger(BenchmarkService.name); + private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS'; public constructor( @@ -87,9 +91,9 @@ export class BenchmarkService { const { benchmarks, expiration }: BenchmarkValue = JSON.parse(cachedBenchmarkValue); - Logger.debug('Fetched benchmarks from cache', 'BenchmarkService'); + this.logger.debug('Fetched benchmarks from cache'); - if (isAfter(new Date(), new Date(expiration))) { + if (isPast(new Date(expiration))) { this.calculateAndCacheBenchmarks({ enableSharing }); @@ -141,7 +145,7 @@ export class BenchmarkService { public async addBenchmark({ dataSource, symbol - }: AssetProfileIdentifier): Promise> { + }: AssetProfileIdentifier): Promise | undefined> { const assetProfile = await this.prismaService.symbolProfile.findFirst({ where: { dataSource, @@ -155,7 +159,8 @@ export class BenchmarkService { let benchmarks = (await this.propertyService.getByKey( - PROPERTY_BENCHMARKS + PROPERTY_BENCHMARKS, + { skipCache: true } )) ?? []; benchmarks.push({ symbolProfileId: assetProfile.id }); @@ -178,7 +183,7 @@ export class BenchmarkService { public async deleteBenchmark({ dataSource, symbol - }: AssetProfileIdentifier): Promise> { + }: AssetProfileIdentifier): Promise | null> { const assetProfile = await this.prismaService.symbolProfile.findFirst({ where: { dataSource, @@ -192,7 +197,8 @@ export class BenchmarkService { let benchmarks = (await this.propertyService.getByKey( - PROPERTY_BENCHMARKS + PROPERTY_BENCHMARKS, + { skipCache: true } )) ?? []; benchmarks = benchmarks.filter(({ symbolProfileId }) => { @@ -215,9 +221,11 @@ export class BenchmarkService { public getMarketCondition( aPerformanceInPercent: number ): Benchmark['marketCondition'] { - if (aPerformanceInPercent >= 0) { + const performanceInPercent = round(aPerformanceInPercent, 4); + + if (performanceInPercent >= 0) { return 'ALL_TIME_HIGH'; - } else if (aPerformanceInPercent <= -0.2) { + } else if (performanceInPercent <= -0.2) { return 'BEAR_MARKET'; } else { return 'NEUTRAL_MARKET'; @@ -227,18 +235,18 @@ export class BenchmarkService { private async calculateAndCacheBenchmarks({ enableSharing = false }): Promise { - Logger.debug('Calculate benchmarks', 'BenchmarkService'); + this.logger.debug('Calculate benchmarks'); const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({ enableSharing }); - const promisesAllTimeHighs: Promise<{ date: Date; marketPrice: number }>[] = - []; - const promisesBenchmarkTrends: Promise<{ - trend50d: BenchmarkTrend; - trend200d: BenchmarkTrend; - }>[] = []; + const promisesAllTimeHighs: ReturnType< + typeof this.marketDataService.getMax + >[] = []; + const promisesBenchmarkTrends: ReturnType< + typeof this.getBenchmarkTrends + >[] = []; const quotes = await this.dataProviderService.getQuotes({ items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => { @@ -264,8 +272,9 @@ export class BenchmarkService { let storeInCache = true; const benchmarks = allTimeHighs.map((allTimeHigh, index) => { + const { dataSource, symbol } = benchmarkAssetProfiles[index]; const { marketPrice } = - quotes[benchmarkAssetProfiles[index].symbol] ?? {}; + quotes[getAssetProfileIdentifier({ dataSource, symbol })] ?? {}; let performancePercentFromAllTimeHigh = 0; diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index b19508d3e..3c152d5df 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -6,15 +6,38 @@ import { DEFAULT_PORT, DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY, DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY, + DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY, DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY, DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT } from '@ghostfolio/common/config'; import { Injectable } from '@nestjs/common'; import { DataSource } from '@prisma/client'; -import { bool, cleanEnv, host, json, num, port, str, url } from 'envalid'; +import { + bool, + cleanEnv, + host, + json, + makeValidator, + num, + port, + str, + url +} from 'envalid'; import ms from 'ms'; +const trustProxy = makeValidator((input) => { + if (/^\d+$/.test(input)) { + return Number(input); + } else if (input === 'false') { + return false; + } else if (input === 'true') { + return true; + } + + return input; +}); + @Injectable() export class ConfigurationService { private readonly environmentConfiguration: Environment; @@ -30,10 +53,12 @@ export class ConfigurationService { API_KEY_FINANCIAL_MODELING_PREP: str({ default: '' }), API_KEY_OPEN_FIGI: str({ default: '' }), API_KEY_RAPID_API: str({ default: '' }), - BULL_BOARD_IS_READ_ONLY: bool({ default: true }), CACHE_QUOTES_TTL: num({ default: ms('1 minute') }), CACHE_TTL: num({ default: CACHE_TTL_NO_CACHE }), DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }), + DATA_SOURCE_FEAR_AND_GREED_INDEX_STOCKS: str({ + default: DataSource.MANUAL + }), DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }), DATA_SOURCES: json({ default: [DataSource.COINGECKO, DataSource.MANUAL, DataSource.YAHOO] @@ -44,8 +69,10 @@ export class ConfigurationService { ENABLE_FEATURE_AUTH_GOOGLE: bool({ default: false }), ENABLE_FEATURE_AUTH_OIDC: bool({ default: false }), ENABLE_FEATURE_AUTH_TOKEN: bool({ default: true }), + ENABLE_FEATURE_CRON: bool({ default: true }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }), + ENABLE_FEATURE_RATE_LIMITING: bool({ default: false }), ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }), ENABLE_FEATURE_STATISTICS: bool({ default: false }), ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }), @@ -89,9 +116,15 @@ export class ConfigurationService { PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY: num({ default: DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY }), + PROCESSOR_GATHER_STATISTICS_CONCURRENCY: num({ + default: DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY + }), PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY: num({ default: DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY }), + PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_REMOVE_ON_FAIL: bool({ + default: true + }), PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT: num({ default: DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT }), @@ -104,6 +137,7 @@ export class ConfigurationService { default: environment.rootUrl }), STRIPE_SECRET_KEY: str({ default: '' }), + TRUST_PROXY: trustProxy({ default: '' }), TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }), TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }), TWITTER_API_KEY: str({ default: 'dummyApiKey' }), diff --git a/apps/api/src/services/cron/cron.module.ts b/apps/api/src/services/cron/cron.module.ts index bcc9d3360..bc14990c1 100644 --- a/apps/api/src/services/cron/cron.module.ts +++ b/apps/api/src/services/cron/cron.module.ts @@ -1,12 +1,17 @@ import { UserModule } from '@ghostfolio/api/app/user/user.module'; +import { UserService } from '@ghostfolio/api/app/user/user.service'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; -import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; +import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { StatisticsGatheringQueueModule } from '@ghostfolio/api/services/queues/statistics-gathering/statistics-gathering.module'; +import { StatisticsGatheringService } from '@ghostfolio/api/services/queues/statistics-gathering/statistics-gathering.service'; import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module'; +import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service'; -import { Module } from '@nestjs/common'; +import { Logger, Module } from '@nestjs/common'; import { CronService } from './cron.service'; @@ -14,12 +19,46 @@ import { CronService } from './cron.service'; imports: [ ConfigurationModule, DataGatheringQueueModule, - ExchangeRateDataModule, PropertyModule, StatisticsGatheringQueueModule, TwitterBotModule, UserModule ], - providers: [CronService] + providers: [ + { + inject: [ + ConfigurationService, + DataGatheringService, + PropertyService, + StatisticsGatheringService, + TwitterBotService, + UserService + ], + provide: CronService, + useFactory: ( + configurationService: ConfigurationService, + dataGatheringService: DataGatheringService, + propertyService: PropertyService, + statisticsGatheringService: StatisticsGatheringService, + twitterBotService: TwitterBotService, + userService: UserService + ) => { + if (!configurationService.get('ENABLE_FEATURE_CRON')) { + Logger.log('Scheduled cron jobs are disabled', 'CronService'); + + return null; + } + + return new CronService( + configurationService, + dataGatheringService, + propertyService, + statisticsGatheringService, + twitterBotService, + userService + ); + } + } + ] }) export class CronModule {} diff --git a/apps/api/src/services/cron/cron.service.ts b/apps/api/src/services/cron/cron.service.ts index e680f0063..b3e60ae59 100644 --- a/apps/api/src/services/cron/cron.service.ts +++ b/apps/api/src/services/cron/cron.service.ts @@ -1,6 +1,5 @@ import { UserService } from '@ghostfolio/api/app/user/user.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { StatisticsGatheringService } from '@ghostfolio/api/services/queues/statistics-gathering/statistics-gathering.service'; @@ -24,7 +23,6 @@ export class CronService { public constructor( private readonly configurationService: ConfigurationService, private readonly dataGatheringService: DataGatheringService, - private readonly exchangeRateDataService: ExchangeRateDataService, private readonly propertyService: PropertyService, private readonly statisticsGatheringService: StatisticsGatheringService, private readonly twitterBotService: TwitterBotService, @@ -41,15 +39,11 @@ export class CronService { @Cron(CronService.EVERY_HOUR_AT_RANDOM_MINUTE) public async runEveryHourAtRandomMinute() { if (await this.isDataGatheringEnabled()) { - await this.dataGatheringService.gather7Days(); + await this.dataGatheringService.gatherHourlyMarketData(); + await this.dataGatheringService.gatherRecentMarketData(); } } - @Cron(CronExpression.EVERY_12_HOURS) - public async runEveryTwelveHours() { - await this.exchangeRateDataService.loadCurrencies(); - } - @Cron(CronExpression.EVERY_DAY_AT_5PM) public async runEveryDayAtFivePm() { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { 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 40b45a115..799f1280b 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 @@ -70,7 +70,7 @@ export class AlphaVantageService symbol, to }: GetHistoricalParams): Promise<{ - [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + [date: string]: DataProviderHistoricalResponse; }> { try { const historicalData: { @@ -83,11 +83,9 @@ export class AlphaVantageService ); const response: { - [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + [date: string]: DataProviderHistoricalResponse; } = {}; - response[symbol] = {}; - for (const [key, timeSeries] of Object.entries( historicalData['Time Series (Digital Currency Daily)'] ).sort()) { @@ -95,7 +93,7 @@ export class AlphaVantageService isAfter(from, parse(key, DATE_FORMAT, new Date())) && isBefore(to, parse(key, DATE_FORMAT, new Date())) ) { - response[symbol][key] = { + response[key] = { marketPrice: parseFloat(timeSeries['4a. close (USD)']) }; } 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 d5ed69d06..96bc00561 100644 --- a/apps/api/src/services/data-provider/coingecko/coingecko.service.ts +++ b/apps/api/src/services/data-provider/coingecko/coingecko.service.ts @@ -7,6 +7,7 @@ import { GetQuotesParams, GetSearchParams } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { @@ -28,11 +29,14 @@ import { format, fromUnixTime, getUnixTime } from 'date-fns'; @Injectable() export class CoinGeckoService implements DataProviderInterface, OnModuleInit { + private readonly logger = new Logger(CoinGeckoService.name); + private apiUrl: string; private headers: HeadersInit = {}; public constructor( - private readonly configurationService: ConfigurationService + private readonly configurationService: ConfigurationService, + private readonly fetchService: FetchService ) {} public onModuleInit() { @@ -67,12 +71,14 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit { }; try { - const { name } = await fetch(`${this.apiUrl}/coins/${symbol}`, { - headers: this.headers, - signal: AbortSignal.timeout( - this.configurationService.get('REQUEST_TIMEOUT') - ) - }).then((res) => res.json()); + const { name } = await this.fetchService + .fetch(`${this.apiUrl}/coins/${symbol}`, { + headers: this.headers, + signal: AbortSignal.timeout( + this.configurationService.get('REQUEST_TIMEOUT') + ) + }) + .then((res) => res.json()); response.name = name; } catch (error) { @@ -84,7 +90,7 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit { ).toFixed(3)} seconds`; } - Logger.error(message, 'CoinGeckoService'); + this.logger.error(message); } return response; @@ -109,7 +115,7 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit { symbol, to }: GetHistoricalParams): Promise<{ - [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + [date: string]: DataProviderHistoricalResponse; }> { try { const queryParams = new URLSearchParams({ @@ -118,13 +124,15 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit { vs_currency: DEFAULT_CURRENCY.toLowerCase() }); - const { error, prices, status } = await fetch( - `${this.apiUrl}/coins/${symbol}/market_chart/range?${queryParams.toString()}`, - { - headers: this.headers, - signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + const { error, prices, status } = await this.fetchService + .fetch( + `${this.apiUrl}/coins/${symbol}/market_chart/range?${queryParams.toString()}`, + { + headers: this.headers, + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then((res) => res.json()); if (error?.status) { throw new Error(error.status.error_message); @@ -135,13 +143,11 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit { } const result: { - [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; - } = { - [symbol]: {} - }; + [date: string]: DataProviderHistoricalResponse; + } = {}; for (const [timestamp, marketPrice] of prices) { - result[symbol][format(fromUnixTime(timestamp / 1000), DATE_FORMAT)] = { + result[format(fromUnixTime(timestamp / 1000), DATE_FORMAT)] = { marketPrice }; } @@ -181,13 +187,12 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit { vs_currencies: DEFAULT_CURRENCY.toLowerCase() }); - const quotes = await fetch( - `${this.apiUrl}/simple/price?${queryParams.toString()}`, - { + const quotes = await this.fetchService + .fetch(`${this.apiUrl}/simple/price?${queryParams.toString()}`, { headers: this.headers, signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + }) + .then((res) => res.json()); for (const symbol in quotes) { response[symbol] = { @@ -209,7 +214,7 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit { ).toFixed(3)} seconds`; } - Logger.error(message, 'CoinGeckoService'); + this.logger.error(message); } return response; @@ -230,13 +235,12 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit { query }); - const { coins } = await fetch( - `${this.apiUrl}/search?${queryParams.toString()}`, - { + const { coins } = await this.fetchService + .fetch(`${this.apiUrl}/search?${queryParams.toString()}`, { headers: this.headers, signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + }) + .then((res) => res.json()); items = coins.map(({ id: symbol, name }) => { return { @@ -258,7 +262,7 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit { ).toFixed(3)} seconds`; } - Logger.error(message, 'CoinGeckoService'); + this.logger.error(message); } return { items }; diff --git a/apps/api/src/services/data-provider/data-enhancer/data-enhancer.module.ts b/apps/api/src/services/data-provider/data-enhancer/data-enhancer.module.ts index cadf8cf1d..ecad9a673 100644 --- a/apps/api/src/services/data-provider/data-enhancer/data-enhancer.module.ts +++ b/apps/api/src/services/data-provider/data-enhancer/data-enhancer.module.ts @@ -3,6 +3,7 @@ import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cr import { OpenFigiDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/openfigi/openfigi.service'; import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/trackinsight/trackinsight.service'; import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service'; +import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module'; import { Module } from '@nestjs/common'; @@ -16,7 +17,7 @@ import { DataEnhancerService } from './data-enhancer.service'; YahooFinanceDataEnhancerService, 'DataEnhancers' ], - imports: [ConfigurationModule, CryptocurrencyModule], + imports: [ConfigurationModule, CryptocurrencyModule, FetchModule], providers: [ DataEnhancerService, OpenFigiDataEnhancerService, diff --git a/apps/api/src/services/data-provider/data-enhancer/openfigi/openfigi.service.ts b/apps/api/src/services/data-provider/data-enhancer/openfigi/openfigi.service.ts index bb9d0606c..1f5bb74b4 100644 --- a/apps/api/src/services/data-provider/data-enhancer/openfigi/openfigi.service.ts +++ b/apps/api/src/services/data-provider/data-enhancer/openfigi/openfigi.service.ts @@ -1,5 +1,6 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; +import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { parseSymbol } from '@ghostfolio/common/helper'; import { Injectable } from '@nestjs/common'; @@ -10,7 +11,8 @@ export class OpenFigiDataEnhancerService implements DataEnhancerInterface { private static baseUrl = 'https://api.openfigi.com'; public constructor( - private readonly configurationService: ConfigurationService + private readonly configurationService: ConfigurationService, + private readonly fetchService: FetchService ) {} public async enhance({ @@ -42,9 +44,8 @@ export class OpenFigiDataEnhancerService implements DataEnhancerInterface { this.configurationService.get('API_KEY_OPEN_FIGI'); } - const mappings = (await fetch( - `${OpenFigiDataEnhancerService.baseUrl}/v3/mapping`, - { + const mappings = (await this.fetchService + .fetch(`${OpenFigiDataEnhancerService.baseUrl}/v3/mapping`, { body: JSON.stringify([ { exchCode: exchange, idType: 'TICKER', idValue: ticker } ]), @@ -54,8 +55,8 @@ export class OpenFigiDataEnhancerService implements DataEnhancerInterface { }, method: 'POST', signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json())) as any[]; + }) + .then((res) => res.json())) as any[]; if (mappings?.length === 1 && mappings[0].data?.length === 1) { const { compositeFIGI, figi, shareClassFIGI } = mappings[0].data[0]; 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 1e297b93b..29e4e5129 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,29 +1,44 @@ +import { getCountryCodeByName } from '@ghostfolio/api/helper/country.helper'; +import { getSectorName } from '@ghostfolio/api/helper/sector.helper'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; +import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { Holding } from '@ghostfolio/common/interfaces'; import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; +import { SectorName } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; import { SymbolProfile } from '@prisma/client'; -import { countries } from 'countries-list'; @Injectable() export class TrackinsightDataEnhancerService implements DataEnhancerInterface { - private static baseUrl = 'https://www.trackinsight.com/data-api'; + private static baseUrl = 'https://www.trackinsight.com'; + private static countriesMapping = { - 'Russian Federation': 'Russia' + 'Republic of Korea': 'KR', + 'Russian Federation': 'RU', + Turkey: 'TR', + USA: 'US', + 'Virgin Islands, British': 'VG' }; + private static holdingsWeightTreshold = 0.85; - private static sectorsMapping = { + + private static sectorsMapping: Record = { 'Consumer Discretionary': 'Consumer Cyclical', - 'Consumer Defensive': 'Consumer Staples', + 'Consumer Staples': 'Consumer Defensive', + Financials: 'Financial Services', 'Health Care': 'Healthcare', - 'Information Technology': 'Technology' + 'Information Technology': 'Technology', + Materials: 'Basic Materials' }; + private readonly logger = new Logger(TrackinsightDataEnhancerService.name); + public constructor( - private readonly configurationService: ConfigurationService + private readonly configurationService: ConfigurationService, + private readonly fetchService: FetchService ) {} public async enhance({ @@ -35,12 +50,10 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { response: Partial; symbol: string; }): Promise> { - if ( - !( - response.assetClass === 'EQUITY' && - ['ETF', 'MUTUALFUND'].includes(response.assetSubClass) - ) - ) { + if (!( + response.assetClass === 'EQUITY' && + ['ETF', 'MUTUALFUND'].includes(response.assetSubClass) + )) { return response; } @@ -60,12 +73,13 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { return response; } - const profile = await fetch( - `${TrackinsightDataEnhancerService.baseUrl}/funds/${trackinsightSymbol}.json`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ) + const profile = await this.fetchService + .fetch( + `${TrackinsightDataEnhancerService.baseUrl}/data-api/funds/${trackinsightSymbol}.json`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) .then((res) => res.json()) .catch(() => { return {}; @@ -83,12 +97,13 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { response.isin = isin; } - const holdings = await fetch( - `${TrackinsightDataEnhancerService.baseUrl}/holdings/${trackinsightSymbol}.json`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ) + const holdings = await this.fetchService + .fetch( + `${TrackinsightDataEnhancerService.baseUrl}/data-api/holdings/${trackinsightSymbol}.json`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) .then((res) => res.json()) .catch(() => { return {}; @@ -110,21 +125,11 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { for (const [name, value] of Object.entries( holdings?.countries ?? {} )) { - let countryCode: string; - - for (const [code, country] of Object.entries(countries)) { - if ( - country.name === name || - country.name === - TrackinsightDataEnhancerService.countriesMapping[name] - ) { - countryCode = code; - break; - } - } - response.countries.push({ - code: countryCode, + code: getCountryCodeByName({ + name, + aliases: TrackinsightDataEnhancerService.countriesMapping + }), weight: value.weight }); } @@ -158,7 +163,10 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { holdings?.sectors ?? {} )) { response.sectors.push({ - name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name, + name: getSectorName({ + name, + aliases: TrackinsightDataEnhancerService.sectorsMapping + }), weight: value.weight }); } @@ -182,12 +190,13 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { requestTimeout: number; symbol: string; }) { - return fetch( - `https://www.trackinsight.com/search-api/search_v2/${symbol}/_/ticker/default/0/3`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ) + return this.fetchService + .fetch( + `${TrackinsightDataEnhancerService.baseUrl}/search-api/search_v2/${symbol}/_/ticker/default/0/3`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) .then((res) => res.json()) .then((jsonRes) => { if ( @@ -203,9 +212,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { return undefined; }) .catch(({ message }) => { - Logger.error( - `Failed to search Trackinsight symbol for ${symbol} (${message})`, - 'TrackinsightDataEnhancerService' + this.logger.warn( + `Could not search Trackinsight symbol for "${symbol}": ${message}` ); return undefined; 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 72136dc04..749f10c12 100644 --- a/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts +++ b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts @@ -1,12 +1,13 @@ +import { getSectorName } from '@ghostfolio/api/helper/sector.helper'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { AssetProfileDelistedError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-delisted.error'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { DEFAULT_CURRENCY, - REPLACE_NAME_PARTS, - UNKNOWN_KEY + REPLACE_NAME_PARTS } from '@ghostfolio/common/config'; -import { isCurrency } from '@ghostfolio/common/helper'; +import { isCurrencySymbol } from '@ghostfolio/common/helper'; +import { SectorName } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; import { @@ -23,6 +24,22 @@ import type { Price } from 'yahoo-finance2/esm/src/modules/quoteSummary-iface'; @Injectable() export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { + private static sectorsMapping: Record = { + basic_materials: 'Basic Materials', + communication_services: 'Communication Services', + consumer_cyclical: 'Consumer Cyclical', + consumer_defensive: 'Consumer Defensive', + energy: 'Energy', + financial_services: 'Financial Services', + healthcare: 'Healthcare', + industrials: 'Industrials', + realestate: 'Real Estate', + technology: 'Technology', + utilities: 'Utilities' + }; + + private readonly logger = new Logger(YahooFinanceDataEnhancerService.name); + private readonly yahooFinance = new YahooFinance({ suppressNotices: ['yahooSurvey'] }); @@ -56,31 +73,21 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { * DOGEUSD -> DOGE-USD */ public convertToYahooFinanceSymbol(aSymbol: string) { - if ( - aSymbol.includes(DEFAULT_CURRENCY) && - aSymbol.length > DEFAULT_CURRENCY.length + if (isCurrencySymbol(aSymbol)) { + return `${aSymbol}=X`; + } else if ( + this.cryptocurrencyService.isCryptocurrency( + aSymbol.replace(new RegExp(`-${DEFAULT_CURRENCY}$`), DEFAULT_CURRENCY) + ) ) { - if ( - isCurrency( - aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length) - ) && - isCurrency(aSymbol.substring(aSymbol.length - DEFAULT_CURRENCY.length)) - ) { - return `${aSymbol}=X`; - } else if ( - this.cryptocurrencyService.isCryptocurrency( - aSymbol.replace(new RegExp(`-${DEFAULT_CURRENCY}$`), DEFAULT_CURRENCY) - ) - ) { - // Add a dash before the last three characters - // BTCUSD -> BTC-USD - // DOGEUSD -> DOGE-USD - // SOL1USD -> SOL1-USD - return aSymbol.replace( - new RegExp(`-?${DEFAULT_CURRENCY}$`), - `-${DEFAULT_CURRENCY}` - ); - } + // Add a dash before the last three characters + // BTCUSD -> BTC-USD + // DOGEUSD -> DOGE-USD + // SOL1USD -> SOL1-USD + return aSymbol.replace( + new RegExp(`-?${DEFAULT_CURRENCY}$`), + `-${DEFAULT_CURRENCY}` + ); } return aSymbol; @@ -123,7 +130,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { response.url = url; } } catch (error) { - Logger.error(error, 'YahooFinanceDataEnhancerService'); + this.logger.error(error); } return response; @@ -193,13 +200,13 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { response.assetClass = assetClass; response.assetSubClass = assetSubClass; - response.currency = assetProfile.price.currency; + response.currency = assetProfile.price?.currency; response.dataSource = this.getName(); response.name = this.formatName({ - longName: assetProfile.price.longName, - quoteType: assetProfile.price.quoteType, - shortName: assetProfile.price.shortName, - symbol: assetProfile.price.symbol + longName: assetProfile.price?.longName, + quoteType: assetProfile.price?.quoteType, + shortName: assetProfile.price?.shortName, + symbol: assetProfile.price?.symbol }); response.symbol = this.convertFromYahooFinanceSymbol( assetProfile.price.symbol @@ -218,16 +225,21 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { }; }) ?? []; - response.sectors = ( - assetProfile.topHoldings?.sectorWeightings ?? [] - ).flatMap((sectorWeighting) => { - return Object.entries(sectorWeighting).map(([sector, weight]) => { - return { - name: this.parseSector(sector), - weight: weight as number - }; + response.sectors = (assetProfile.topHoldings?.sectorWeightings ?? []) + .flatMap((sectorWeighting) => { + return Object.entries(sectorWeighting).map(([sector, weight]) => { + return { + name: getSectorName({ + aliases: YahooFinanceDataEnhancerService.sectorsMapping, + name: sector + }), + weight: weight as number + }; + }); + }) + .filter(({ weight }) => { + return weight > 0; }); - }); } else if ( assetSubClass === 'STOCK' && assetProfile.summaryProfile?.country @@ -264,7 +276,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { `No data found, ${aSymbol} (${this.getName()}) may be delisted` ); } else { - Logger.error(error, 'YahooFinanceService'); + this.logger.error(error); } } @@ -327,46 +339,4 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { return { assetClass, assetSubClass }; } - - private parseSector(aString: string) { - let sector = UNKNOWN_KEY; - - switch (aString) { - case 'basic_materials': - sector = 'Basic Materials'; - break; - case 'communication_services': - sector = 'Communication Services'; - break; - case 'consumer_cyclical': - sector = 'Consumer Cyclical'; - break; - case 'consumer_defensive': - sector = 'Consumer Staples'; - break; - case 'energy': - sector = 'Energy'; - break; - case 'financial_services': - sector = 'Financial Services'; - break; - case 'healthcare': - sector = 'Healthcare'; - break; - case 'industrials': - sector = 'Industrials'; - break; - case 'realestate': - sector = 'Real Estate'; - break; - case 'technology': - sector = 'Technology'; - break; - case 'utilities': - sector = 'Utilities'; - break; - } - - return sector; - } } diff --git a/apps/api/src/services/data-provider/data-provider.module.ts b/apps/api/src/services/data-provider/data-provider.module.ts index 71b54f01e..2c6e9fce1 100644 --- a/apps/api/src/services/data-provider/data-provider.module.ts +++ b/apps/api/src/services/data-provider/data-provider.module.ts @@ -10,6 +10,7 @@ import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/goog import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service'; import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; +import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; @@ -26,6 +27,7 @@ import { DataProviderService } from './data-provider.service'; ConfigurationModule, CryptocurrencyModule, DataEnhancerModule, + FetchModule, MarketDataModule, PrismaModule, PropertyModule, 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 5f0a6928a..c5de3e4e5 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -1,5 +1,6 @@ import { ImportDataDto } from '@ghostfolio/api/app/import/import-data.dto'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { getMaskedGhostfolioDataSource } from '@ghostfolio/api/helper/data-source.helper'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; @@ -8,6 +9,7 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv import { DEFAULT_CURRENCY, DERIVED_CURRENCIES, + NON_INVESTMENT_ACTIVITY_TYPES, PROPERTY_API_KEY_GHOSTFOLIO, PROPERTY_DATA_SOURCE_MAPPING } from '@ghostfolio/common/config'; @@ -19,14 +21,16 @@ import { getCurrencyFromSymbol, getStartOfUtcDate, isCurrency, - isDerivedCurrency + isDerivedCurrency, + isValidSearchQuery } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, DataProviderHistoricalResponse, DataProviderResponse, LookupItem, - LookupResponse + LookupResponse, + MarketDataOfMarketsResponse } from '@ghostfolio/common/interfaces'; import type { Granularity, UserWithSettings } from '@ghostfolio/common/types'; @@ -41,7 +45,9 @@ import { AssetProfileInvalidError } from './errors/asset-profile-invalid.error'; @Injectable() export class DataProviderService implements OnModuleInit { - private dataProviderMapping: { [dataProviderName: string]: string }; + private readonly logger = new Logger(DataProviderService.name); + + private dataProviderMapping: { [dataProviderName: string]: string } = {}; public constructor( private readonly configurationService: ConfigurationService, @@ -75,26 +81,27 @@ export class DataProviderService implements OnModuleInit { useCache: false }); - if (quotes[symbol]?.marketPrice > 0) { + if ( + quotes[getAssetProfileIdentifier({ dataSource, symbol })]?.marketPrice > 0 + ) { return true; } return false; } - // TODO: Change symbol in response to assetProfileIdentifier public async getAssetProfiles(items: AssetProfileIdentifier[]): Promise<{ - [symbol: string]: Partial; + [assetProfileIdentifier: string]: Partial; }> { const response: { - [symbol: string]: Partial; + [assetProfileIdentifier: string]: Partial; } = {}; const itemsGroupedByDataSource = groupBy(items, ({ dataSource }) => { return dataSource; }); - const promises = []; + const promises: Promise[] = []; for (const [dataSource, assetProfileIdentifiers] of Object.entries( itemsGroupedByDataSource @@ -113,7 +120,12 @@ export class DataProviderService implements OnModuleInit { promises.push( promise.then((assetProfile) => { if (isCurrency(assetProfile?.currency)) { - response[symbol] = assetProfile; + response[ + getAssetProfileIdentifier({ + symbol, + dataSource: DataSource[dataSource] + }) + ] = { ...assetProfile, symbol }; } }) ); @@ -129,7 +141,7 @@ export class DataProviderService implements OnModuleInit { ); } } catch (error) { - Logger.error(error, 'DataProviderService'); + this.logger.error(error); throw error; } @@ -168,6 +180,12 @@ export class DataProviderService implements OnModuleInit { ]; } + public getDataSourceForFearAndGreedIndexStocks(): DataSource { + return DataSource[ + this.configurationService.get('DATA_SOURCE_FEAR_AND_GREED_INDEX_STOCKS') + ]; + } + public getDataSourceForImport(): DataSource { return DataSource[this.configurationService.get('DATA_SOURCE_IMPORT')]; } @@ -179,11 +197,7 @@ export class DataProviderService implements OnModuleInit { return DataSource[dataSource]; }); - const ghostfolioApiKey = await this.propertyService.getByKey( - PROPERTY_API_KEY_GHOSTFOLIO - ); - - if (ghostfolioApiKey) { + if (await this.isDataProviderGhostfolioConfigured()) { dataSources.push('GHOSTFOLIO'); } @@ -213,6 +227,9 @@ export class DataProviderService implements OnModuleInit { } = {}; const dataSources = await this.getDataSources(); + const ghostfolioDataSources = this.configurationService.get( + 'DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER' + ); for (const [ index, @@ -220,6 +237,10 @@ export class DataProviderService implements OnModuleInit { ] of activitiesDto.entries()) { const activityPath = maxActivitiesToImport === 1 ? 'activity' : `activities.${index}`; + const maskedDataSource = getMaskedGhostfolioDataSource({ + dataSource, + ghostfolioDataSources + }); if (!dataSources.includes(dataSource)) { throw new Error( @@ -229,13 +250,13 @@ export class DataProviderService implements OnModuleInit { if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - user.subscription.type === SubscriptionType.Basic + user.subscription?.type === SubscriptionType.Basic ) { const dataProvider = this.getDataProvider(DataSource[dataSource]); if (dataProvider.getDataProviderInfo().isPremium) { throw new Error( - `${activityPath}.dataSource ("${dataSource}") is not valid` + `${activityPath}.dataSource ("${maskedDataSource}") requires Ghostfolio Premium` ); } } @@ -248,7 +269,7 @@ export class DataProviderService implements OnModuleInit { if (!assetProfiles[assetProfileIdentifier]) { if ( (dataSource === DataSource.MANUAL && type === 'BUY') || - ['FEE', 'INTEREST', 'LIABILITY'].includes(type) + NON_INVESTMENT_ACTIVITY_TYPES.includes(type) ) { const assetProfileInImport = assetProfilesWithMarketDataDto?.find( (assetProfile) => { @@ -279,7 +300,7 @@ export class DataProviderService implements OnModuleInit { symbol } ]) - )?.[symbol]; + )?.[assetProfileIdentifier]; } catch {} if (!assetProfile?.name) { @@ -298,7 +319,7 @@ export class DataProviderService implements OnModuleInit { if (!assetProfile?.name) { throw new Error( - `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` + `${activityPath}.symbol ("${symbol}") is not valid for the specified data source ("${maskedDataSource}")` ); } @@ -316,12 +337,10 @@ export class DataProviderService implements OnModuleInit { symbol, to }: { - dataSource: DataSource; from: Date; granularity: Granularity; - symbol: string; to: Date; - }) { + } & AssetProfileIdentifier) { return this.getDataProvider(DataSource[dataSource]).getDividends({ from, granularity, @@ -331,17 +350,20 @@ export class DataProviderService implements OnModuleInit { }); } - // TODO: Change symbol in response to assetProfileIdentifier public async getHistorical( aItems: AssetProfileIdentifier[], aGranularity: Granularity = 'month', from: Date, to: Date ): Promise<{ - [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + [assetProfileIdentifier: string]: { + [date: string]: DataProviderHistoricalResponse; + }; }> { let response: { - [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + [assetProfileIdentifier: string]: { + [date: string]: DataProviderHistoricalResponse; + }; } = {}; if (isEmpty(aItems) || !isValid(from) || !isValid(to)) { @@ -381,23 +403,30 @@ export class DataProviderService implements OnModuleInit { ORDER BY date;`; response = marketDataByGranularity.reduce((r, marketData) => { - const { date, marketPrice, symbol } = marketData; + const { dataSource, date, marketPrice, symbol } = marketData; - r[symbol] = { - ...(r[symbol] || {}), - [format(new Date(date), DATE_FORMAT)]: { marketPrice } + const assetProfileIdentifier = getAssetProfileIdentifier({ + dataSource, + symbol + }); + + if (!r[assetProfileIdentifier]) { + r[assetProfileIdentifier] = {}; + } + + r[assetProfileIdentifier][format(new Date(date), DATE_FORMAT)] = { + marketPrice }; return r; }, {}); } catch (error) { - Logger.error(error, 'DataProviderService'); + this.logger.error(error); } finally { return response; } } - // TODO: Change symbol in response to assetProfileIdentifier public async getHistoricalRaw({ assetProfileIdentifiers, from, @@ -407,7 +436,9 @@ export class DataProviderService implements OnModuleInit { from: Date; to: Date; }): Promise<{ - [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + [assetProfileIdentifier: string]: { + [date: string]: DataProviderHistoricalResponse; + }; }> { for (const { currency, rootCurrency } of DERIVED_CURRENCIES) { if ( @@ -440,11 +471,14 @@ export class DataProviderService implements OnModuleInit { ); const result: { - [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + [assetProfileIdentifier: string]: { + [date: string]: DataProviderHistoricalResponse; + }; } = {}; const promises: Promise<{ data: { [date: string]: DataProviderHistoricalResponse }; + dataSource: DataSource; symbol: string; }>[] = []; for (const { dataSource, symbol } of assetProfileIdentifiers) { @@ -462,6 +496,7 @@ export class DataProviderService implements OnModuleInit { promises.push( Promise.resolve({ data, + dataSource, symbol }) ); @@ -475,7 +510,7 @@ export class DataProviderService implements OnModuleInit { requestTimeout: ms('30 seconds') }) .then((data) => { - return { symbol, data: data?.[symbol] }; + return { data, dataSource, symbol }; }) ); } @@ -485,25 +520,29 @@ export class DataProviderService implements OnModuleInit { try { const allData = await Promise.all(promises); - for (const { data, symbol } of allData) { + for (const { data, dataSource, symbol } of allData) { const currency = DERIVED_CURRENCIES.find(({ rootCurrency }) => { return `${DEFAULT_CURRENCY}${rootCurrency}` === symbol; }); if (currency) { // Add derived currency - result[`${DEFAULT_CURRENCY}${currency.currency}`] = - this.transformHistoricalData({ - allData, - currency: `${DEFAULT_CURRENCY}${currency.rootCurrency}`, - factor: currency.factor - }); + result[ + getAssetProfileIdentifier({ + dataSource, + symbol: `${DEFAULT_CURRENCY}${currency.currency}` + }) + ] = this.transformHistoricalData({ + allData, + currency: `${DEFAULT_CURRENCY}${currency.rootCurrency}`, + factor: currency.factor + }); } - result[symbol] = data; + result[getAssetProfileIdentifier({ dataSource, symbol })] = data; } } catch (error) { - Logger.error(error, 'DataProviderService'); + this.logger.error(error); throw error; } @@ -511,7 +550,22 @@ export class DataProviderService implements OnModuleInit { return result; } - // TODO: Change symbol in response to assetProfileIdentifier + public async getMarketDataOfMarkets({ + includeHistoricalData + }: { + includeHistoricalData: number; + }): Promise { + const dataProvider = this.getDataProvider(DataSource.GHOSTFOLIO); + + if (!dataProvider.getMarketDataOfMarkets) { + throw new Error( + `The data provider (${DataSource.GHOSTFOLIO}) does not support the market data of markets` + ); + } + + return dataProvider.getMarketDataOfMarkets({ includeHistoricalData }); + } + public async getQuotes({ items, requestTimeout, @@ -523,10 +577,12 @@ export class DataProviderService implements OnModuleInit { useCache?: boolean; user?: UserWithSettings; }): Promise<{ - [symbol: string]: DataProviderResponse; + [assetProfileIdentifier: string]: DataProviderResponse; }> { const response: { - [symbol: string]: DataProviderResponse; + [assetProfileIdentifier: string]: DataProviderResponse & { + symbol: string; + }; } = {}; const startTimeTotal = performance.now(); @@ -535,11 +591,17 @@ export class DataProviderService implements OnModuleInit { return symbol === `${DEFAULT_CURRENCY}USX`; }) ) { - response[`${DEFAULT_CURRENCY}USX`] = { + response[ + getAssetProfileIdentifier({ + dataSource: this.getDataSourceForExchangeRates(), + symbol: `${DEFAULT_CURRENCY}USX` + }) + ] = { currency: 'USX', dataSource: this.getDataSourceForExchangeRates(), marketPrice: 100, - marketState: 'open' + marketState: 'open', + symbol: `${DEFAULT_CURRENCY}USX` }; } @@ -554,8 +616,13 @@ export class DataProviderService implements OnModuleInit { if (quoteString) { try { - const cachedDataProviderResponse = JSON.parse(quoteString); - response[symbol] = cachedDataProviderResponse; + const cachedDataProviderResponse = JSON.parse( + quoteString + ) as DataProviderResponse; + response[getAssetProfileIdentifier({ dataSource, symbol })] = { + ...cachedDataProviderResponse, + symbol + }; continue; } catch {} } @@ -567,13 +634,12 @@ export class DataProviderService implements OnModuleInit { const numberOfItemsInCache = Object.keys(response)?.length; if (numberOfItemsInCache) { - Logger.debug( + this.logger.debug( `Fetched ${numberOfItemsInCache} quote${ numberOfItemsInCache > 1 ? 's' : '' } from cache in ${((performance.now() - startTimeTotal) / 1000).toFixed( 3 - )} seconds`, - 'DataProviderService' + )} seconds` ); } @@ -596,7 +662,7 @@ export class DataProviderService implements OnModuleInit { } else if ( dataProvider.getDataProviderInfo().isPremium && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - user?.subscription.type === SubscriptionType.Basic + user?.subscription?.type === SubscriptionType.Basic ) { // Skip symbols of Premium data providers for users without subscription return false; @@ -625,7 +691,11 @@ export class DataProviderService implements OnModuleInit { ); const promise = Promise.resolve( - dataProvider.getQuotes({ requestTimeout, symbols: symbolsChunk }) + dataProvider.getQuotes({ + requestTimeout, + useCache, + symbols: symbolsChunk + }) ); promises.push( @@ -644,14 +714,19 @@ export class DataProviderService implements OnModuleInit { continue; } - response[symbol] = dataProviderResponse; + response[ + getAssetProfileIdentifier({ + symbol, + dataSource: DataSource[dataSource] + }) + ] = { ...dataProviderResponse, symbol }; this.redisCacheService.set( this.redisCacheService.getQuoteKey({ symbol, dataSource: DataSource[dataSource] }), - JSON.stringify(response[symbol]), + JSON.stringify(dataProviderResponse), this.configurationService.get('CACHE_QUOTES_TTL') ); @@ -661,7 +736,7 @@ export class DataProviderService implements OnModuleInit { rootCurrency } of DERIVED_CURRENCIES) { if (symbol === `${DEFAULT_CURRENCY}${rootCurrency}`) { - response[`${DEFAULT_CURRENCY}${currency}`] = { + const derivedDataProviderResponse: DataProviderResponse = { ...dataProviderResponse, currency, marketPrice: new Big( @@ -672,45 +747,54 @@ export class DataProviderService implements OnModuleInit { marketState: 'open' }; + response[ + getAssetProfileIdentifier({ + dataSource: DataSource[dataSource], + symbol: `${DEFAULT_CURRENCY}${currency}` + }) + ] = { + ...derivedDataProviderResponse, + symbol: `${DEFAULT_CURRENCY}${currency}` + }; + this.redisCacheService.set( this.redisCacheService.getQuoteKey({ dataSource: DataSource[dataSource], symbol: `${DEFAULT_CURRENCY}${currency}` }), - JSON.stringify(response[`${DEFAULT_CURRENCY}${currency}`]), + JSON.stringify(derivedDataProviderResponse), this.configurationService.get('CACHE_QUOTES_TTL') ); } } } - Logger.debug( + this.logger.debug( `Fetched ${symbolsChunk.length} quote${ symbolsChunk.length > 1 ? 's' : '' } from ${dataSource} in ${( (performance.now() - startTimeDataSource) / 1000 - ).toFixed(3)} seconds`, - 'DataProviderService' + ).toFixed(3)} seconds` ); try { await this.marketDataService.updateMany({ - data: Object.keys(response) - .filter((symbol) => { + data: Object.values(response) + .filter(({ marketPrice, marketState }) => { return ( - isNumber(response[symbol].marketPrice) && - response[symbol].marketPrice > 0 && - response[symbol].marketState === 'open' + isNumber(marketPrice) && + marketPrice > 0 && + marketState === 'open' ); }) - .map((symbol) => { + .map((dataProviderResponse) => { return { - symbol, - dataSource: response[symbol].dataSource, + dataSource: dataProviderResponse.dataSource, date: getStartOfUtcDate(new Date()), - marketPrice: response[symbol].marketPrice, - state: 'INTRADAY' + marketPrice: dataProviderResponse.marketPrice, + state: 'INTRADAY', + symbol: dataProviderResponse.symbol }; }) }); @@ -722,19 +806,28 @@ export class DataProviderService implements OnModuleInit { await Promise.all(promises); - Logger.debug('--------------------------------------------------------'); - Logger.debug( + this.logger.debug( + '--------------------------------------------------------' + ); + this.logger.debug( `Fetched ${items.length} quote${items.length > 1 ? 's' : ''} in ${( (performance.now() - startTimeTotal) / 1000 - ).toFixed(3)} seconds`, - 'DataProviderService' + ).toFixed(3)} seconds` + ); + this.logger.debug( + '========================================================' ); - Logger.debug('========================================================'); return response; } + public async isDataProviderGhostfolioConfigured(): Promise { + return !!(await this.propertyService.getByKey( + PROPERTY_API_KEY_GHOSTFOLIO + )); + } + public async search({ includeIndices = false, query, @@ -747,7 +840,9 @@ export class DataProviderService implements OnModuleInit { let lookupItems: LookupItem[] = []; const promises: Promise[] = []; - if (query?.length < 2) { + query = query?.trim(); + + if (!isValidSearchQuery(query)) { return { items: lookupItems }; } @@ -785,7 +880,7 @@ export class DataProviderService implements OnModuleInit { }) .map((lookupItem) => { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { - if (user.subscription.type === SubscriptionType.Premium) { + if (user.subscription?.type === SubscriptionType.Premium) { lookupItem.dataProviderInfo.isPremium = false; } 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 8c718108c..6bc003fc2 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 @@ -7,12 +7,13 @@ import { GetQuotesParams, GetSearchParams } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { DEFAULT_CURRENCY, REPLACE_NAME_PARTS } from '@ghostfolio/common/config'; -import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper'; +import { DATE_FORMAT, isCurrencySymbol } from '@ghostfolio/common/helper'; import { DataProviderHistoricalResponse, DataProviderInfo, @@ -36,11 +37,14 @@ import { isNumber } from 'lodash'; export class EodHistoricalDataService implements DataProviderInterface, OnModuleInit { + private readonly logger = new Logger(EodHistoricalDataService.name); + private apiKey: string; private readonly URL = 'https://eodhistoricaldata.com/api'; public constructor( private readonly configurationService: ConfigurationService, + private readonly fetchService: FetchService, private readonly symbolProfileService: SymbolProfileService ) {} @@ -111,12 +115,11 @@ export class EodHistoricalDataService [date: string]: DataProviderHistoricalResponse; } = {}; - const historicalResult = await fetch( - `${this.URL}/div/${symbol}?${queryParams.toString()}`, - { + const historicalResult = await this.fetchService + .fetch(`${this.URL}/div/${symbol}?${queryParams.toString()}`, { signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + }) + .then((res) => res.json()); for (const { date, value } of historicalResult) { response[date] = { @@ -126,12 +129,11 @@ export class EodHistoricalDataService return response; } catch (error) { - Logger.error( + this.logger.error( `Could not get dividends for ${symbol} (${this.getName()}) from ${format( from, DATE_FORMAT - )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`, - 'EodHistoricalDataService' + )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}` ); return {}; @@ -145,7 +147,7 @@ export class EodHistoricalDataService symbol, to }: GetHistoricalParams): Promise<{ - [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + [date: string]: DataProviderHistoricalResponse; }> { symbol = this.convertToEodSymbol(symbol); @@ -158,30 +160,25 @@ export class EodHistoricalDataService to: format(to, DATE_FORMAT) }); - const response = await fetch( - `${this.URL}/eod/${symbol}?${queryParams.toString()}`, - { + const response = await this.fetchService + .fetch(`${this.URL}/eod/${symbol}?${queryParams.toString()}`, { signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + }) + .then((res) => res.json()); - return response.reduce( - (result, { adjusted_close, date }) => { - if (isNumber(adjusted_close)) { - result[this.convertFromEodSymbol(symbol)][date] = { - marketPrice: adjusted_close - }; - } else { - Logger.error( - `Could not get historical market data for ${symbol} (${this.getName()}) at ${date}`, - 'EodHistoricalDataService' - ); - } + return response.reduce((result, { adjusted_close, date }) => { + if (isNumber(adjusted_close)) { + result[date] = { + marketPrice: adjusted_close + }; + } else { + this.logger.error( + `Could not get historical market data for ${symbol} (${this.getName()}) at ${date}` + ); + } - return result; - }, - { [this.convertFromEodSymbol(symbol)]: {} } - ); + return result; + }, {}); } catch (error) { throw new Error( `Could not get historical market data for ${symbol} (${this.getName()}) from ${format( @@ -223,12 +220,14 @@ export class EodHistoricalDataService s: eodHistoricalDataSymbols.join(',') }); - const realTimeResponse = await fetch( - `${this.URL}/real-time/${eodHistoricalDataSymbols[0]}?${queryParams.toString()}`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + const realTimeResponse = await this.fetchService + .fetch( + `${this.URL}/real-time/${eodHistoricalDataSymbols[0]}?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then((res) => res.json()); const quotes: { close: number; @@ -290,9 +289,8 @@ export class EodHistoricalDataService dataSource: this.getName() }; } else { - Logger.error( - `Could not get quote for ${this.convertFromEodSymbol(code)} (${this.getName()})`, - 'EodHistoricalDataService' + this.logger.error( + `Could not get quote for ${this.convertFromEodSymbol(code)} (${this.getName()})` ); } } @@ -309,7 +307,7 @@ export class EodHistoricalDataService ).toFixed(3)} seconds`; } - Logger.error(message, 'EodHistoricalDataService'); + this.logger.error(message); } return {}; @@ -381,20 +379,11 @@ export class EodHistoricalDataService * Currency: USDCHF -> USDCHF.FOREX */ private convertToEodSymbol(aSymbol: string) { - if ( - aSymbol.startsWith(DEFAULT_CURRENCY) && - aSymbol.length > DEFAULT_CURRENCY.length - ) { - if ( - isCurrency( - aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length) - ) - ) { - let symbol = aSymbol; - symbol = symbol.replace('GBp', 'GBX'); + if (isCurrencySymbol(aSymbol)) { + let symbol = aSymbol; + symbol = symbol.replace('GBp', 'GBX'); - return `${symbol}.FOREX`; - } + return `${symbol}.FOREX`; } return aSymbol; @@ -430,12 +419,11 @@ export class EodHistoricalDataService api_token: this.apiKey }); - const response = await fetch( - `${this.URL}/search/${query}?${queryParams.toString()}`, - { + const response = await this.fetchService + .fetch(`${this.URL}/search/${query}?${queryParams.toString()}`, { signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + }) + .then((res) => res.json()); searchResult = response.map( ({ Code, Currency, Exchange, ISIN: isin, Name: name, Type }) => { @@ -464,7 +452,7 @@ export class EodHistoricalDataService ).toFixed(3)} seconds`; } - Logger.error(message, 'EodHistoricalDataService'); + this.logger.error(message); } return searchResult; 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 d9a43fc50..f7f2e7eb9 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 @@ -1,3 +1,4 @@ +import { getCountryCodeByName } from '@ghostfolio/api/helper/country.helper'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { AssetProfileDelistedError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-delisted.error'; @@ -9,12 +10,17 @@ import { GetQuotesParams, GetSearchParams } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { DEFAULT_CURRENCY, REPLACE_NAME_PARTS } from '@ghostfolio/common/config'; -import { DATE_FORMAT, isCurrency, parseDate } from '@ghostfolio/common/helper'; +import { + DATE_FORMAT, + isCurrencySymbol, + parseDate +} from '@ghostfolio/common/helper'; import { DataProviderHistoricalResponse, DataProviderInfo, @@ -32,7 +38,6 @@ import { SymbolProfile } from '@prisma/client'; import { isISIN } from 'class-validator'; -import { countries } from 'countries-list'; import { addDays, addYears, @@ -42,23 +47,26 @@ import { isSameDay, parseISO } from 'date-fns'; -import { uniqBy } from 'lodash'; +import { isArray, uniqBy } from 'lodash'; @Injectable() export class FinancialModelingPrepService implements DataProviderInterface, OnModuleInit { private static countriesMapping = { - 'Korea (the Republic of)': 'South Korea', - 'Russian Federation': 'Russia', - 'Taiwan (Province of China)': 'Taiwan' + 'Korea (the Republic of)': 'KR', + 'Russian Federation': 'RU', + 'Taiwan (Province of China)': 'TW' }; + private readonly logger = new Logger(FinancialModelingPrepService.name); + private apiKey: string; public constructor( private readonly configurationService: ConfigurationService, private readonly cryptocurrencyService: CryptocurrencyService, + private readonly fetchService: FetchService, private readonly prismaService: PrismaService ) {} @@ -82,9 +90,7 @@ export class FinancialModelingPrepService }; try { - if ( - isCurrency(symbol.substring(0, symbol.length - DEFAULT_CURRENCY.length)) - ) { + if (isCurrencySymbol(symbol)) { response.assetClass = AssetClass.LIQUIDITY; response.assetSubClass = AssetSubClass.CASH; response.currency = symbol.substring( @@ -96,12 +102,20 @@ export class FinancialModelingPrepService apikey: this.apiKey }); - const [quote] = await fetch( - `${this.getUrl({ version: 'stable' })}/quote?${queryParams.toString()}`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + const [quote] = await this.fetchService + .fetch( + `${this.getUrl({ version: 'stable' })}/quote?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then((res) => res.json()); + + if (!quote) { + throw new AssetProfileDelistedError( + `No data found, ${symbol} (${this.getName()}) may be delisted` + ); + } response.assetClass = AssetClass.LIQUIDITY; response.assetSubClass = AssetSubClass.CRYPTOCURRENCY; @@ -115,12 +129,14 @@ export class FinancialModelingPrepService apikey: this.apiKey }); - const [assetProfile] = await fetch( - `${this.getUrl({ version: 'stable' })}/profile?${queryParams.toString()}`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + const [assetProfile] = await this.fetchService + .fetch( + `${this.getUrl({ version: 'stable' })}/profile?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then((res) => res.json()); if (!assetProfile) { throw new AssetProfileDelistedError( @@ -143,43 +159,37 @@ export class FinancialModelingPrepService apikey: this.apiKey }); - const etfCountryWeightings = await fetch( - `${this.getUrl({ version: 'stable' })}/etf/country-weightings?${queryParams.toString()}`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + const etfCountryWeightings = await this.fetchService + .fetch( + `${this.getUrl({ version: 'stable' })}/etf/country-weightings?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then((res) => res.json()); response.countries = etfCountryWeightings .filter(({ country: countryName }) => { return countryName.toLowerCase() !== 'other'; }) .map(({ country: countryName, weightPercentage }) => { - let countryCode: string; - - for (const [code, country] of Object.entries(countries)) { - if ( - country.name === countryName || - country.name === - FinancialModelingPrepService.countriesMapping[countryName] - ) { - countryCode = code; - break; - } - } - return { - code: countryCode, - weight: parseFloat(weightPercentage.slice(0, -1)) / 100 + code: getCountryCodeByName({ + aliases: FinancialModelingPrepService.countriesMapping, + name: countryName + }), + weight: parseFloat(`${weightPercentage}`) / 100 }; }); - const etfHoldings = await fetch( - `${this.getUrl({ version: 'stable' })}/etf/holdings?${queryParams.toString()}`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + const etfHoldings = await this.fetchService + .fetch( + `${this.getUrl({ version: 'stable' })}/etf/holdings?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then((res) => res.json()); const sortedTopHoldings = etfHoldings .sort((a, b) => { @@ -193,23 +203,27 @@ export class FinancialModelingPrepService } ); - const [etfInformation] = await fetch( - `${this.getUrl({ version: 'stable' })}/etf/info?${queryParams.toString()}`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + const [etfInformation] = await this.fetchService + .fetch( + `${this.getUrl({ version: 'stable' })}/etf/info?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then((res) => res.json()); if (etfInformation?.website) { response.url = etfInformation.website; } - const etfSectorWeightings = await fetch( - `${this.getUrl({ version: 'stable' })}/etf/sector-weightings?${queryParams.toString()}`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + const etfSectorWeightings = await this.fetchService + .fetch( + `${this.getUrl({ version: 'stable' })}/etf/sector-weightings?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then((res) => res.json()); response.sectors = etfSectorWeightings.map( ({ sector, weightPercentage }) => { @@ -251,7 +265,11 @@ export class FinancialModelingPrepService ).toFixed(3)} seconds`; } - Logger.error(message, 'FinancialModelingPrepService'); + if (error instanceof AssetProfileDelistedError) { + this.logger.warn(error.message); + } else { + this.logger.error(message); + } } return response; @@ -286,12 +304,14 @@ export class FinancialModelingPrepService [date: string]: DataProviderHistoricalResponse; } = {}; - const dividends = await fetch( - `${this.getUrl({ version: 'stable' })}/dividends?${queryParams.toString()}`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + const dividends = await this.fetchService + .fetch( + `${this.getUrl({ version: 'stable' })}/dividends?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then((res) => res.json()); dividends .filter(({ date }) => { @@ -309,12 +329,11 @@ export class FinancialModelingPrepService return response; } catch (error) { - Logger.error( + this.logger.error( `Could not get dividends for ${symbol} (${this.getName()}) from ${format( from, DATE_FORMAT - )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`, - 'FinancialModelingPrepService' + )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}` ); return {}; @@ -327,14 +346,12 @@ export class FinancialModelingPrepService symbol, to }: GetHistoricalParams): Promise<{ - [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + [date: string]: DataProviderHistoricalResponse; }> { const MAX_YEARS_PER_REQUEST = 5; const result: { - [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; - } = { - [symbol]: {} - }; + [date: string]: DataProviderHistoricalResponse; + } = {}; let currentFrom = from; @@ -354,12 +371,14 @@ export class FinancialModelingPrepService to: format(currentTo, DATE_FORMAT) }); - const historical = await fetch( - `${this.getUrl({ version: 'stable' })}/historical-price-eod/full?${queryParams.toString()}`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + const historical = await this.fetchService + .fetch( + `${this.getUrl({ version: 'stable' })}/historical-price-eod/full?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then((res) => res.json()); for (const { close, date } of historical) { if ( @@ -367,7 +386,7 @@ export class FinancialModelingPrepService isAfter(parseDate(date), currentFrom)) && isBefore(parseDate(date), currentTo) ) { - result[symbol][date] = { + result[date] = { marketPrice: close }; } @@ -422,14 +441,21 @@ export class FinancialModelingPrepService symbolTarget: { in: symbols } } }), - fetch( - `${this.getUrl({ version: 'stable' })}/batch-quote-short?${queryParams.toString()}`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ).then( - (res) => res.json() as unknown as { price: number; symbol: string }[] - ) + this.fetchService + .fetch( + `${this.getUrl({ version: 'stable' })}/batch-quote-short?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then(async (res) => { + const json = (await res.json()) as unknown as { + price: number; + symbol: string; + }[]; + + return isArray(json) ? json : []; + }) ]); for (const { currency, symbolTarget } of assetProfileResolutions) { @@ -462,6 +488,8 @@ export class FinancialModelingPrepService currencyBySymbolMap[symbol] = { currency: assetProfile.currency }; + } else if (this.cryptocurrencyService.isCryptocurrency(symbol)) { + currencyBySymbolMap[symbol] = { currency: DEFAULT_CURRENCY }; } }) ); @@ -470,11 +498,7 @@ export class FinancialModelingPrepService for (const { price, symbol } of quotes) { let marketState: MarketState = 'delayed'; - if ( - isCurrency( - symbol.substring(0, symbol.length - DEFAULT_CURRENCY.length) - ) - ) { + if (isCurrencySymbol(symbol)) { marketState = 'open'; } @@ -497,7 +521,7 @@ export class FinancialModelingPrepService ).toFixed(3)} seconds`; } - Logger.error(message, 'FinancialModelingPrepService'); + this.logger.error(message); } return response; @@ -525,12 +549,14 @@ export class FinancialModelingPrepService isin: query.toUpperCase() }); - const result = await fetch( - `${this.getUrl({ version: 'stable' })}/search-isin?${queryParams.toString()}`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + const result = await this.fetchService + .fetch( + `${this.getUrl({ version: 'stable' })}/search-isin?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then((res) => res.json()); await Promise.all( result.map(({ symbol }) => { @@ -558,18 +584,22 @@ export class FinancialModelingPrepService }); const [nameResults, symbolResults] = await Promise.all([ - fetch( - `${this.getUrl({ version: 'stable' })}/search-name?${queryParams.toString()}`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()), - fetch( - `${this.getUrl({ version: 'stable' })}/search-symbol?${queryParams.toString()}`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()) + this.fetchService + .fetch( + `${this.getUrl({ version: 'stable' })}/search-name?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then((res) => res.json()), + this.fetchService + .fetch( + `${this.getUrl({ version: 'stable' })}/search-symbol?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then((res) => res.json()) ]); const result = uniqBy( @@ -611,7 +641,7 @@ export class FinancialModelingPrepService ).toFixed(3)} seconds`; } - Logger.error(message, 'FinancialModelingPrepService'); + this.logger.error(message); } return { items }; diff --git a/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts b/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts index 2b49e89c2..5b59e9a00 100644 --- a/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts +++ b/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts @@ -5,9 +5,11 @@ import { GetAssetProfileParams, GetDividendsParams, GetHistoricalParams, + GetMarketDataOfMarketsParams, GetQuotesParams, GetSearchParams } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { HEADER_KEY_TOKEN, @@ -22,7 +24,9 @@ import { DividendsResponse, HistoricalResponse, LookupResponse, - QuotesResponse + MarketDataOfMarketsResponse, + QuotesResponse, + SymbolItem } from '@ghostfolio/common/interfaces'; import { Injectable, Logger } from '@nestjs/common'; @@ -32,12 +36,15 @@ import { StatusCodes } from 'http-status-codes'; @Injectable() export class GhostfolioService implements DataProviderInterface { + private readonly logger = new Logger(GhostfolioService.name); + private readonly URL = environment.production ? 'https://ghostfol.io/api' : `${this.configurationService.get('ROOT_URL')}/api`; public constructor( private readonly configurationService: ConfigurationService, + private readonly fetchService: FetchService, private readonly propertyService: PropertyService ) {} @@ -52,7 +59,7 @@ export class GhostfolioService implements DataProviderInterface { let assetProfile: DataProviderGhostfolioAssetProfileResponse; try { - const response = await fetch( + const response = await this.fetchService.fetch( `${this.URL}/v1/data-providers/ghostfolio/asset-profile/${symbol}`, { headers: await this.getRequestHeaders(), @@ -87,7 +94,7 @@ export class GhostfolioService implements DataProviderInterface { 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.'; } - Logger.error(message, 'GhostfolioService'); + this.logger.error(message); } return assetProfile; @@ -122,7 +129,7 @@ export class GhostfolioService implements DataProviderInterface { to: format(to, DATE_FORMAT) }); - const response = await fetch( + const response = await this.fetchService.fetch( `${this.URL}/v2/data-providers/ghostfolio/dividends/${symbol}?${queryParams.toString()}`, { headers: await this.getRequestHeaders(), @@ -152,7 +159,7 @@ export class GhostfolioService implements DataProviderInterface { 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.'; } - Logger.error(message, 'GhostfolioService'); + this.logger.error(message); } return dividends; @@ -165,7 +172,7 @@ export class GhostfolioService implements DataProviderInterface { symbol, to }: GetHistoricalParams): Promise<{ - [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + [date: string]: DataProviderHistoricalResponse; }> { try { const queryParams = new URLSearchParams({ @@ -174,7 +181,7 @@ export class GhostfolioService implements DataProviderInterface { to: format(to, DATE_FORMAT) }); - const response = await fetch( + const response = await this.fetchService.fetch( `${this.URL}/v2/data-providers/ghostfolio/historical/${symbol}?${queryParams.toString()}`, { headers: await this.getRequestHeaders(), @@ -191,9 +198,7 @@ export class GhostfolioService implements DataProviderInterface { const { historicalData } = (await response.json()) as HistoricalResponse; - return { - [symbol]: historicalData - }; + return historicalData; } catch (error) { if (error?.status === StatusCodes.TOO_MANY_REQUESTS) { error.name = 'RequestError'; @@ -209,7 +214,7 @@ export class GhostfolioService implements DataProviderInterface { 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.'; } - Logger.error(error.message, 'GhostfolioService'); + this.logger.error(error.message); throw new Error( `Could not get historical market data for ${symbol} (${this.getName()}) from ${format( @@ -220,6 +225,63 @@ export class GhostfolioService implements DataProviderInterface { } } + public async getMarketDataOfMarkets({ + includeHistoricalData = 0, + requestTimeout = this.configurationService.get('REQUEST_TIMEOUT') + }: GetMarketDataOfMarketsParams): Promise { + let marketDataOfMarkets: MarketDataOfMarketsResponse = { + fearAndGreedIndex: { + CRYPTOCURRENCIES: {} as SymbolItem, + STOCKS: {} as SymbolItem + } + }; + + try { + const queryParams = new URLSearchParams({ + includeHistoricalData: includeHistoricalData.toString() + }); + + const response = await this.fetchService.fetch( + `${this.URL}/v1/data-providers/ghostfolio/markets?${queryParams.toString()}`, + { + headers: await this.getRequestHeaders(), + signal: AbortSignal.timeout(requestTimeout) + } + ); + + if (!response.ok) { + throw new Response(await response.text(), { + status: response.status, + statusText: response.statusText + }); + } + + marketDataOfMarkets = + (await response.json()) as MarketDataOfMarketsResponse; + } catch (error) { + let message = error; + + if (['AbortError', 'TimeoutError'].includes(error?.name)) { + message = `RequestError: The operation to get the market data of markets was aborted because the request to the data provider took more than ${( + requestTimeout / 1000 + ).toFixed(3)} seconds`; + } else if (error?.status === StatusCodes.TOO_MANY_REQUESTS) { + message = 'RequestError: The daily request limit has been exceeded'; + } else if ( + [StatusCodes.FORBIDDEN, StatusCodes.UNAUTHORIZED].includes( + error?.status + ) + ) { + message = + 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.'; + } + + this.logger.error(message); + } + + return marketDataOfMarkets; + } + public getMaxNumberOfSymbolsPerRequest() { return 20; } @@ -245,7 +307,7 @@ export class GhostfolioService implements DataProviderInterface { symbols: symbols.join(',') }); - const response = await fetch( + const response = await this.fetchService.fetch( `${this.URL}/v2/data-providers/ghostfolio/quotes?${queryParams.toString()}`, { headers: await this.getRequestHeaders(), @@ -281,7 +343,7 @@ export class GhostfolioService implements DataProviderInterface { 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.'; } - Logger.error(message, 'GhostfolioService'); + this.logger.error(message); } return quotes; @@ -302,7 +364,7 @@ export class GhostfolioService implements DataProviderInterface { query }); - const response = await fetch( + const response = await this.fetchService.fetch( `${this.URL}/v2/data-providers/ghostfolio/lookup?${queryParams.toString()}`, { headers: await this.getRequestHeaders(), @@ -336,7 +398,7 @@ export class GhostfolioService implements DataProviderInterface { 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.'; } - Logger.error(message, 'GhostfolioService'); + this.logger.error(message); } return searchResult; 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 ba1e5bbe5..75fae673e 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 @@ -24,6 +24,8 @@ import { GoogleSpreadsheet } from 'google-spreadsheet'; @Injectable() export class GoogleSheetsService implements DataProviderInterface { + private readonly logger = new Logger(GoogleSheetsService.name); + public constructor( private readonly configurationService: ConfigurationService, private readonly prismaService: PrismaService, @@ -58,7 +60,7 @@ export class GoogleSheetsService implements DataProviderInterface { symbol, to }: GetHistoricalParams): Promise<{ - [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + [date: string]: DataProviderHistoricalResponse; }> { try { const sheet = await this.getSheet({ @@ -83,9 +85,7 @@ export class GoogleSheetsService implements DataProviderInterface { historicalData[format(date, DATE_FORMAT)] = { marketPrice: close }; }); - return { - [symbol]: historicalData - }; + return historicalData; } catch (error) { throw new Error( `Could not get historical market data for ${symbol} (${this.getName()}) from ${format( @@ -144,7 +144,7 @@ export class GoogleSheetsService implements DataProviderInterface { return response; } catch (error) { - Logger.error(error, 'GoogleSheetsService'); + this.logger.error(error); } return {}; 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 a55c9f328..1e6d2496b 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 @@ -2,7 +2,8 @@ import { DataProviderHistoricalResponse, DataProviderInfo, DataProviderResponse, - LookupResponse + LookupResponse, + MarketDataOfMarketsResponse } from '@ghostfolio/common/interfaces'; import { Granularity } from '@ghostfolio/common/types'; @@ -34,8 +35,13 @@ export interface DataProviderInterface { symbol, to }: GetHistoricalParams): Promise<{ - [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; - }>; // TODO: Return only one symbol + [date: string]: DataProviderHistoricalResponse; + }>; + + getMarketDataOfMarkets?({ + includeHistoricalData, + requestTimeout + }: GetMarketDataOfMarketsParams): Promise; getMaxNumberOfSymbolsPerRequest?(): number; @@ -72,9 +78,15 @@ export interface GetHistoricalParams { to: Date; } +export interface GetMarketDataOfMarketsParams { + includeHistoricalData?: number; + requestTimeout?: number; +} + export interface GetQuotesParams { requestTimeout?: number; symbols: string[]; + useCache?: boolean; } export interface GetSearchParams { 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 51e65e631..a8cfa8b0b 100644 --- a/apps/api/src/services/data-provider/manual/manual.service.ts +++ b/apps/api/src/services/data-provider/manual/manual.service.ts @@ -8,6 +8,7 @@ import { GetQuotesParams, GetSearchParams } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { @@ -30,8 +31,11 @@ import { addDays, format, isBefore } from 'date-fns'; @Injectable() export class ManualService implements DataProviderInterface { + private readonly logger = new Logger(ManualService.name); + public constructor( private readonly configurationService: ConfigurationService, + private readonly fetchService: FetchService, private readonly prismaService: PrismaService, private readonly symbolProfileService: SymbolProfileService ) {} @@ -75,7 +79,7 @@ export class ManualService implements DataProviderInterface { symbol, to }: GetHistoricalParams): Promise<{ - [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + [date: string]: DataProviderHistoricalResponse; }> { try { const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles( @@ -86,14 +90,13 @@ export class ManualService implements DataProviderInterface { if (defaultMarketPrice) { const historical: { - [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; - } = { - [symbol]: {} - }; + [date: string]: DataProviderHistoricalResponse; + } = {}; + let date = from; while (isBefore(date, to)) { - historical[symbol][format(date, DATE_FORMAT)] = { + historical[format(date, DATE_FORMAT)] = { marketPrice: defaultMarketPrice }; @@ -111,10 +114,8 @@ export class ManualService implements DataProviderInterface { }); return { - [symbol]: { - [format(getYesterday(), DATE_FORMAT)]: { - marketPrice: value - } + [format(getYesterday(), DATE_FORMAT)]: { + marketPrice: value } }; } catch (error) { @@ -132,7 +133,8 @@ export class ManualService implements DataProviderInterface { } public async getQuotes({ - symbols + symbols, + useCache = true }: GetQuotesParams): Promise<{ [symbol: string]: DataProviderResponse }> { const response: { [symbol: string]: DataProviderResponse } = {}; @@ -160,33 +162,32 @@ export class ManualService implements DataProviderInterface { } }); - const symbolProfilesWithScraperConfigurationAndInstantMode = - symbolProfiles.filter(({ scraperConfiguration }) => { + const symbolProfilesToScrape = symbolProfiles.filter( + ({ scraperConfiguration }) => { return ( - scraperConfiguration?.mode === 'instant' && + (scraperConfiguration?.mode === 'instant' || !useCache) && scraperConfiguration?.selector && scraperConfiguration?.url ); - }); - - const scraperResultPromises = - symbolProfilesWithScraperConfigurationAndInstantMode.map( - async ({ scraperConfiguration, symbol }) => { - try { - const marketPrice = await this.scrape({ - scraperConfiguration, - symbol - }); - return { marketPrice, symbol }; - } catch (error) { - Logger.error( - `Could not get quote for ${symbol} (${this.getName()}): [${error.name}] ${error.message}`, - 'ManualService' - ); - return { symbol, marketPrice: undefined }; - } + } + ); + + const scraperResultPromises = symbolProfilesToScrape.map( + async ({ scraperConfiguration, symbol }) => { + try { + const marketPrice = await this.scrape({ + scraperConfiguration, + symbol + }); + return { marketPrice, symbol }; + } catch (error) { + this.logger.error( + `Could not get quote for ${symbol} (${this.getName()}): [${error.name}] ${error.message}` + ); + return { symbol, marketPrice: undefined }; } - ); + } + ); // Wait for all scraping requests to complete concurrently const scraperResults = await Promise.all(scraperResultPromises); @@ -214,7 +215,7 @@ export class ManualService implements DataProviderInterface { return response; } catch (error) { - Logger.error(error, 'ManualService'); + this.logger.error(error); } return {}; @@ -292,7 +293,7 @@ export class ManualService implements DataProviderInterface { }): Promise { let locale = scraperConfiguration.locale; - const response = await fetch(scraperConfiguration.url, { + const response = await this.fetchService.fetch(scraperConfiguration.url, { headers: scraperConfiguration.headers as HeadersInit, signal: AbortSignal.timeout( this.configurationService.get('REQUEST_TIMEOUT') 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 d6bc8d0e4..9af22b79a 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 @@ -7,10 +7,8 @@ import { GetQuotesParams, GetSearchParams } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; -import { - ghostfolioFearAndGreedIndexSymbol, - ghostfolioFearAndGreedIndexSymbolStocks -} from '@ghostfolio/common/config'; +import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; +import { ghostfolioFearAndGreedIndexSymbolStocks } from '@ghostfolio/common/config'; import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import { DataProviderHistoricalResponse, @@ -25,8 +23,11 @@ import { format } from 'date-fns'; @Injectable() export class RapidApiService implements DataProviderInterface { + private readonly logger = new Logger(RapidApiService.name); + public constructor( - private readonly configurationService: ConfigurationService + private readonly configurationService: ConfigurationService, + private readonly fetchService: FetchService ) {} public canHandle() { @@ -57,24 +58,19 @@ export class RapidApiService implements DataProviderInterface { symbol, to }: GetHistoricalParams): Promise<{ - [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + [date: string]: DataProviderHistoricalResponse; }> { try { - if ( - [ - ghostfolioFearAndGreedIndexSymbol, - ghostfolioFearAndGreedIndexSymbolStocks - ].includes(symbol) - ) { + if (symbol === ghostfolioFearAndGreedIndexSymbolStocks) { const fgi = await this.getFearAndGreedIndex(); - return { - [symbol]: { + if (fgi) { + return { [format(getYesterday(), DATE_FORMAT)]: { marketPrice: fgi.previousClose.value } - } - }; + }; + } } } catch (error) { throw new Error( @@ -102,25 +98,22 @@ export class RapidApiService implements DataProviderInterface { try { const symbol = symbols[0]; - if ( - [ - ghostfolioFearAndGreedIndexSymbol, - ghostfolioFearAndGreedIndexSymbolStocks - ].includes(symbol) - ) { + if (symbol === ghostfolioFearAndGreedIndexSymbolStocks) { const fgi = await this.getFearAndGreedIndex(); - return { - [symbol]: { - currency: undefined, - dataSource: this.getName(), - marketPrice: fgi.now.value, - marketState: 'open' - } - }; + if (fgi) { + return { + [symbol]: { + currency: undefined, + dataSource: this.getName(), + marketPrice: fgi.now.value, + marketState: 'open' + } + }; + } } } catch (error) { - Logger.error(error, 'RapidApiService'); + this.logger.error(error); } return {}; @@ -142,9 +135,8 @@ export class RapidApiService implements DataProviderInterface { oneYearAgo: { value: number; valueText: string }; }> { try { - const { fgi } = await fetch( - `https://fear-and-greed-index.p.rapidapi.com/v1/fgi`, - { + const { fgi } = await this.fetchService + .fetch(`https://fear-and-greed-index.p.rapidapi.com/v1/fgi`, { headers: { useQueryString: 'true', 'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com', @@ -153,8 +145,8 @@ export class RapidApiService implements DataProviderInterface { signal: AbortSignal.timeout( this.configurationService.get('REQUEST_TIMEOUT') ) - } - ).then((res) => res.json()); + }) + .then((res) => res.json()); return fgi; } catch (error) { @@ -166,7 +158,7 @@ export class RapidApiService implements DataProviderInterface { ).toFixed(3)} seconds`; } - Logger.error(message, 'RapidApiService'); + this.logger.error(message); return undefined; } 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 de8807098..364354f6b 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 @@ -41,6 +41,8 @@ import { SearchQuoteNonYahoo } from 'yahoo-finance2/esm/src/modules/search'; @Injectable() export class YahooFinanceService implements DataProviderInterface { + private readonly logger = new Logger(YahooFinanceService.name); + private readonly yahooFinance = new YahooFinance({ suppressNotices: ['yahooSurvey'] }); @@ -105,12 +107,11 @@ export class YahooFinanceService implements DataProviderInterface { return response; } catch (error) { - Logger.error( + this.logger.error( `Could not get dividends for ${symbol} (${this.getName()}) from ${format( from, DATE_FORMAT - )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`, - 'YahooFinanceService' + )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}` ); return {}; @@ -122,7 +123,7 @@ export class YahooFinanceService implements DataProviderInterface { symbol, to }: GetHistoricalParams): Promise<{ - [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + [date: string]: DataProviderHistoricalResponse; }> { if (isSameDay(from, to)) { to = addDays(to, 1); @@ -143,13 +144,11 @@ export class YahooFinanceService implements DataProviderInterface { ); const response: { - [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + [date: string]: DataProviderHistoricalResponse; } = {}; - response[symbol] = {}; - for (const historicalItem of historicalResult) { - response[symbol][format(historicalItem.date, DATE_FORMAT)] = { + response[format(historicalItem.date, DATE_FORMAT)] = { marketPrice: historicalItem.close }; } @@ -198,12 +197,9 @@ export class YahooFinanceService implements DataProviderInterface { try { quotes = await this.yahooFinance.quote(yahooFinanceSymbols); } catch (error) { - Logger.error(error, 'YahooFinanceService'); + this.logger.error(error); - Logger.warn( - 'Fallback to yahooFinance.quoteSummary()', - 'YahooFinanceService' - ); + this.logger.warn('Fallback to yahooFinance.quoteSummary()'); quotes = await this.getQuotesWithQuoteSummary(yahooFinanceSymbols); } @@ -229,7 +225,7 @@ export class YahooFinanceService implements DataProviderInterface { return response; } catch (error) { - Logger.error(error, 'YahooFinanceService'); + this.logger.error(error); return {}; } @@ -334,7 +330,11 @@ export class YahooFinanceService implements DataProviderInterface { }); } } catch (error) { - Logger.error(error, 'YahooFinanceService'); + if (error?.name === 'BadRequestError') { + this.logger.warn(`Could not search for "${query}": ${error.message}`); + } else { + this.logger.error(error); + } } return { items }; @@ -365,10 +365,7 @@ export class YahooFinanceService implements DataProviderInterface { .filter( (result): result is PromiseFulfilledResult => { if (result.status === 'rejected') { - Logger.error( - `Could not get quote summary: ${result.reason}`, - 'YahooFinanceService' - ); + this.logger.error(`Could not get quote summary: ${result.reason}`); return false; } 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 024bdf4e1..3b48ce292 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 @@ -11,9 +11,11 @@ import { } from '@ghostfolio/common/config'; import { DATE_FORMAT, + getAssetProfileIdentifier, getYesterday, resetHours } from '@ghostfolio/common/helper'; +import { DataProviderHistoricalResponse } from '@ghostfolio/common/interfaces'; import { Injectable, Logger } from '@nestjs/common'; import { @@ -30,6 +32,8 @@ import { ExchangeRatesByCurrency } from './interfaces/exchange-rate-data.interfa @Injectable() export class ExchangeRateDataService { + private readonly logger = new Logger(ExchangeRateDataService.name); + private currencies: string[] = []; private currencyPairs: DataGatheringItem[] = []; private derivedCurrencyFactors: { [currencyPair: string]: number } = {}; @@ -110,9 +114,8 @@ export class ExchangeRateDataService { previousExchangeRate; if (currency === DEFAULT_CURRENCY && isBefore(date, new Date())) { - Logger.error( - `No exchange rate has been found for ${currency}${targetCurrency} at ${dateString}`, - 'ExchangeRateDataService' + this.logger.error( + `No exchange rate has been found for ${currency}${targetCurrency} at ${dateString}` ); } } else { @@ -161,7 +164,7 @@ export class ExchangeRateDataService { } public async loadCurrencies() { - const result = await this.dataProviderService.getHistorical( + const historicalData = await this.dataProviderService.getHistorical( this.currencyPairs, 'day', getYesterday(), @@ -175,11 +178,26 @@ export class ExchangeRateDataService { requestTimeout: ms('30 seconds') }); - for (const symbol of Object.keys(quotes)) { - if (isNumber(quotes[symbol].marketPrice)) { + const result: { + [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + } = {}; + + for (const { dataSource, symbol } of this.currencyPairs) { + const assetProfileIdentifier = getAssetProfileIdentifier({ + dataSource, + symbol + }); + + if (historicalData[assetProfileIdentifier]) { + result[symbol] = historicalData[assetProfileIdentifier]; + } + + const quote = quotes[assetProfileIdentifier]; + + if (isNumber(quote?.marketPrice)) { result[symbol] = { [format(getYesterday(), DATE_FORMAT)]: { - marketPrice: quotes[symbol].marketPrice + marketPrice: quote.marketPrice } }; } @@ -253,9 +271,8 @@ export class ExchangeRateDataService { } // Fallback with error, if currencies are not available - Logger.error( - `No exchange rate has been found for ${aFromCurrency}${aToCurrency}`, - 'ExchangeRateDataService' + this.logger.error( + `No exchange rate has been found for ${aFromCurrency}${aToCurrency}` ); return aValue; @@ -341,12 +358,11 @@ export class ExchangeRateDataService { return factor * aValue; } - Logger.error( + this.logger.error( `No exchange rate has been found for ${aFromCurrency}${aToCurrency} at ${format( aDate, DATE_FORMAT - )}`, - 'ExchangeRateDataService' + )}` ); return undefined; @@ -483,7 +499,7 @@ export class ExchangeRateDataService { errorMessage = `${errorMessage} and ${DEFAULT_CURRENCY}${currencyTo}`; } - Logger.error(`${errorMessage}.`, 'ExchangeRateDataService'); + this.logger.error(`${errorMessage}.`); } } } diff --git a/apps/api/src/services/fetch/fetch.module.ts b/apps/api/src/services/fetch/fetch.module.ts new file mode 100644 index 000000000..16e6f5f5d --- /dev/null +++ b/apps/api/src/services/fetch/fetch.module.ts @@ -0,0 +1,11 @@ +import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; + +import { Module } from '@nestjs/common'; + +@Module({ + exports: [FetchService], + imports: [PropertyModule], + providers: [FetchService] +}) +export class FetchModule {} diff --git a/apps/api/src/services/fetch/fetch.service.ts b/apps/api/src/services/fetch/fetch.service.ts new file mode 100644 index 000000000..1f5320378 --- /dev/null +++ b/apps/api/src/services/fetch/fetch.service.ts @@ -0,0 +1,272 @@ +import { redactPaths } from '@ghostfolio/api/helper/object.helper'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { + PROPERTY_API_KEY_OPENROUTER, + PROPERTY_OPENROUTER_MODEL, + PROPERTY_OPENROUTER_MODEL_WEB_FETCH, + PROPERTY_PROXY_ROUTES, + PROPERTY_WEB_FETCH_ROUTES +} from '@ghostfolio/common/config'; + +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { createOpenRouter } from '@openrouter/ai-sdk-provider'; +import { generateText, jsonSchema, tool } from 'ai'; +import ms from 'ms'; + +import { ProxyRoute } from './interfaces/proxy-route.interface'; +import { WebFetchRoute } from './interfaces/web-fetch-route.interface'; + +@Injectable() +export class FetchService implements OnModuleInit { + private readonly logger = new Logger(FetchService.name); + + private static readonly REDACTED_QUERY_PARAM_NAMES = ['apikey', 'api_token']; + private static readonly WEB_FETCH_TIMEOUT = ms('30 seconds'); + + private proxyRoutes: ProxyRoute[] = []; + private webFetchRoutes: WebFetchRoute[] = []; + + public constructor(private readonly propertyService: PropertyService) {} + + public async onModuleInit() { + this.proxyRoutes = + (await this.propertyService.getByKey( + PROPERTY_PROXY_ROUTES + )) ?? []; + + this.webFetchRoutes = + (await this.propertyService.getByKey( + PROPERTY_WEB_FETCH_ROUTES + )) ?? []; + } + + public async fetch(input: RequestInfo | URL, init?: RequestInit) { + const method = ( + init?.method ?? + (input instanceof Request ? input.method : undefined) ?? + 'GET' + ).toUpperCase(); + + const url = input instanceof Request ? input.url : input.toString(); + const urlRedacted = this.redactUrl(url); + + this.logger.debug(`${method} ${urlRedacted}`); + + if (method === 'GET') { + const webFetchRoute = this.getMatchingWebFetchRoute(url); + + if (webFetchRoute) { + const response = await this.fetchViaWebFetchTool({ + url, + webFetchRoute + }); + + if (response) { + return response; + } + } + } + + const proxiedInput = this.applyProxyRoute(input); + + try { + return await globalThis.fetch(proxiedInput, init); + } catch (error) { + if (error instanceof Error) { + this.logger.error( + `${method} ${urlRedacted} failed: [${error.name}] ${error.message}` + ); + } else { + this.logger.error(`${method} ${urlRedacted} failed: ${String(error)}`); + } + + throw error; + } + } + + private async fetchViaWebFetchTool({ + url, + webFetchRoute + }: { + url: string; + webFetchRoute: WebFetchRoute; + }) { + const [openRouterApiKey, openRouterModel, openRouterModelWebFetch] = + await Promise.all([ + this.propertyService.getByKey(PROPERTY_API_KEY_OPENROUTER), + this.propertyService.getByKey(PROPERTY_OPENROUTER_MODEL), + this.propertyService.getByKey( + PROPERTY_OPENROUTER_MODEL_WEB_FETCH + ) + ]); + + const model = openRouterModelWebFetch || openRouterModel; + + if (!model || !openRouterApiKey) { + return undefined; + } + + try { + const openRouterService = createOpenRouter({ apiKey: openRouterApiKey }); + + const { sources, text } = await generateText({ + model: openRouterService.chat(model), + prompt: [ + 'You have access to a web_fetch tool. You MUST call it to retrieve the URL below, do not answer from prior knowledge.', + 'Return the fetched response body exactly as received: raw body only, no commentary, no Markdown, and no code fences.', + `URL: ${url}` + ].join('\n'), + timeout: FetchService.WEB_FETCH_TIMEOUT, + tools: { + // Provider-executed tool: lets OpenRouter perform the actual web + // request server-side via its `web_fetch` engine. `id` and `args` + // are the OpenRouter-specific identifiers. The input schema is left + // open as the arguments are supplied by the model. + web_fetch: tool({ + args: { engine: 'openrouter' }, + id: 'openrouter.web_fetch', + inputSchema: jsonSchema({ + additionalProperties: true, + type: 'object' + }), + isProviderExecuted: true, + type: 'provider' + }) + } + }); + + const candidates = [ + ...(sources ?? []).map((source) => { + return source.providerMetadata?.openrouter?.content; + }), + text + ]; + + for (const candidate of candidates) { + if (typeof candidate !== 'string') { + continue; + } + + const body = candidate.trim(); + + if (!body) { + continue; + } + + if (webFetchRoute.responseContentType?.includes('application/json')) { + try { + JSON.parse(body); + } catch { + continue; + } + } + + this.logger.debug(`Routed ${this.redactUrl(url)} via web fetch tool`); + + return new Response(body, { + headers: webFetchRoute.responseContentType + ? { 'content-type': webFetchRoute.responseContentType } + : undefined + }); + } + + return undefined; + } catch (error) { + this.logger.error( + `Web fetch tool failed for ${this.redactUrl(url)}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + + return undefined; + } + } + + /** + * Rewrites the origin (protocol, host and port) of a request when its domain + * matches a configured {@link ProxyRoute}, preserving path and query. Returns + * the input unchanged when no route matches or parsing fails. + */ + private applyProxyRoute(input: RequestInfo | URL): RequestInfo | URL { + let requestUrl: URL; + + try { + requestUrl = new URL( + input instanceof Request ? input.url : input.toString() + ); + } catch { + return input; + } + + const route = this.proxyRoutes.find(({ domain }) => { + return this.hostnameMatchesDomain({ + domain, + hostname: requestUrl.hostname + }); + }); + + if (!route) { + return input; + } + + try { + const proxyUrl = new URL(route.url); + + requestUrl.host = proxyUrl.host; + requestUrl.protocol = proxyUrl.protocol; + } catch { + this.logger.warn( + `Skipping proxy route for "${route.domain}": invalid url "${route.url}"` + ); + + return input; + } + + return input instanceof Request + ? new Request(requestUrl.toString(), input) + : requestUrl.toString(); + } + + private getMatchingWebFetchRoute(url: string) { + try { + const { hostname } = new URL(url); + + return this.webFetchRoutes.find(({ domain }) => { + return this.hostnameMatchesDomain({ domain, hostname }); + }); + } catch { + return undefined; + } + } + + private hostnameMatchesDomain({ + domain, + hostname + }: { + domain: string; + hostname: string; + }): boolean { + return hostname === domain || hostname.endsWith(`.${domain}`); + } + + private redactUrl(rawUrl: string): string { + try { + const url = new URL(rawUrl); + + const redacted = redactPaths({ + object: Object.fromEntries(url.searchParams), + paths: FetchService.REDACTED_QUERY_PARAM_NAMES + }); + + for (const [key, value] of Object.entries(redacted)) { + if (value === null) { + url.searchParams.set(key, '*******'); + } + } + + return url.toString(); + } catch { + return rawUrl; + } + } +} diff --git a/apps/api/src/services/fetch/interfaces/proxy-route.interface.ts b/apps/api/src/services/fetch/interfaces/proxy-route.interface.ts new file mode 100644 index 000000000..83edba6c0 --- /dev/null +++ b/apps/api/src/services/fetch/interfaces/proxy-route.interface.ts @@ -0,0 +1,19 @@ +/** + * Overrides the origin (protocol, host and port) of outgoing requests for a + * given domain. + * + * Configured via the `PROXY_ROUTES` property as a JSON array, e.g. + * + * [ + * { + * "domain": "example.com", + * "url": "http://example-proxy:8191" + * } + * ] + * + * Matches the domain itself and its subdomains (e.g. `api.example.com`). + */ +export interface ProxyRoute { + domain: string; + url: string; +} diff --git a/apps/api/src/services/fetch/interfaces/web-fetch-route.interface.ts b/apps/api/src/services/fetch/interfaces/web-fetch-route.interface.ts new file mode 100644 index 000000000..efff09398 --- /dev/null +++ b/apps/api/src/services/fetch/interfaces/web-fetch-route.interface.ts @@ -0,0 +1,19 @@ +/** + * Routes outgoing GET requests for a given domain through the OpenRouter + * `web_fetch` tool instead of a direct network request. + * + * Configured via the `WEB_FETCH_ROUTES` property as a JSON array, e.g. + * + * [ + * { + * "domain": "example.com", + * "responseContentType": "application/json" + * } + * ] + * + * Matches the domain itself and its subdomains (e.g. `api.example.com`). + */ +export interface WebFetchRoute { + domain: string; + responseContentType?: string; +} diff --git a/apps/api/src/services/i18n/i18n.service.ts b/apps/api/src/services/i18n/i18n.service.ts index 1cdb811a9..65c51b2f0 100644 --- a/apps/api/src/services/i18n/i18n.service.ts +++ b/apps/api/src/services/i18n/i18n.service.ts @@ -7,6 +7,8 @@ import { join } from 'node:path'; @Injectable() export class I18nService implements OnModuleInit { + private readonly logger = new Logger(I18nService.name); + private localesPath = join(__dirname, 'assets', 'locales'); private translations: { [locale: string]: cheerio.CheerioAPI } = {}; @@ -26,7 +28,7 @@ export class I18nService implements OnModuleInit { const $ = this.translations[languageCode]; if (!$) { - Logger.warn(`Translation not found for locale '${languageCode}'`); + this.logger.warn(`Translation not found for locale '${languageCode}'`); } let translatedText = $( @@ -36,7 +38,7 @@ export class I18nService implements OnModuleInit { ).text(); if (!translatedText) { - Logger.warn( + this.logger.warn( `Translation not found for id '${id}' in locale '${languageCode}'` ); } @@ -60,7 +62,7 @@ export class I18nService implements OnModuleInit { this.parseXml(xmlData); } } catch (error) { - Logger.error(error, 'I18nService'); + this.logger.error(error); } } diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index eb3ac86a3..7d9bfd1d4 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -10,18 +10,20 @@ export interface Environment extends CleanedEnvAccessors { API_KEY_FINANCIAL_MODELING_PREP: string; API_KEY_OPEN_FIGI: string; API_KEY_RAPID_API: string; - BULL_BOARD_IS_READ_ONLY: boolean; CACHE_QUOTES_TTL: number; CACHE_TTL: number; DATA_SOURCE_EXCHANGE_RATES: string; + DATA_SOURCE_FEAR_AND_GREED_INDEX_STOCKS: string; DATA_SOURCE_IMPORT: string; DATA_SOURCES: string[]; DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: string[]; ENABLE_FEATURE_AUTH_GOOGLE: boolean; ENABLE_FEATURE_AUTH_OIDC: boolean; ENABLE_FEATURE_AUTH_TOKEN: boolean; + ENABLE_FEATURE_CRON: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean; + ENABLE_FEATURE_RATE_LIMITING: boolean; ENABLE_FEATURE_READ_ONLY_MODE: boolean; ENABLE_FEATURE_STATISTICS: boolean; ENABLE_FEATURE_SUBSCRIPTION: boolean; @@ -36,16 +38,18 @@ export interface Environment extends CleanedEnvAccessors { MAX_CHART_ITEMS: number; OIDC_AUTHORIZATION_URL: string; OIDC_CALLBACK_URL: string; - OIDC_CLIENT_ID: string; - OIDC_CLIENT_SECRET: string; - OIDC_ISSUER: string; + OIDC_CLIENT_ID?: string; + OIDC_CLIENT_SECRET?: string; + OIDC_ISSUER?: string; OIDC_SCOPE: string[]; OIDC_TOKEN_URL: string; OIDC_USER_INFO_URL: string; PORT: number; PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: number; PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY: number; + PROCESSOR_GATHER_STATISTICS_CONCURRENCY: number; PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY: number; + PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_REMOVE_ON_FAIL: boolean; PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT: number; REDIS_DB: number; REDIS_HOST: string; @@ -54,6 +58,7 @@ export interface Environment extends CleanedEnvAccessors { REQUEST_TIMEOUT: number; ROOT_URL: string; STRIPE_SECRET_KEY: string; + TRUST_PROXY: boolean | number | string; TWITTER_ACCESS_TOKEN: string; TWITTER_ACCESS_TOKEN_SECRET: string; TWITTER_API_KEY: string; 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 87b08e1bd..ad388ce5c 100644 --- a/apps/api/src/services/market-data/market-data.service.ts +++ b/apps/api/src/services/market-data/market-data.service.ts @@ -1,6 +1,7 @@ import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface'; import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_TIMEOUT } from '@ghostfolio/common/config'; import { UpdateMarketDataDto } from '@ghostfolio/common/dtos'; import { resetHours } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; @@ -40,6 +41,19 @@ export class MarketDataService { }); } + public async getLatest({ + dataSource, + symbol + }: AssetProfileIdentifier): Promise { + return this.prismaService.marketData.findFirst({ + orderBy: [{ date: 'desc' }], + where: { + dataSource, + symbol + } + }); + } + public async getMax({ dataSource, symbol }: AssetProfileIdentifier) { return this.prismaService.marketData.findFirst({ select: { @@ -142,52 +156,55 @@ export class MarketDataService { dataSource, symbol }: AssetProfileIdentifier & { data: Prisma.MarketDataUpdateInput[] }) { - await this.prismaService.$transaction(async (prisma) => { - if (data.length > 0) { - let minTime = Infinity; - let maxTime = -Infinity; + await this.prismaService.$transaction( + async (prisma) => { + if (data.length > 0) { + let minTime = Infinity; + let maxTime = -Infinity; - for (const { date } of data) { - const time = (date as Date).getTime(); + for (const { date } of data) { + const time = (date as Date).getTime(); - if (time < minTime) { - minTime = time; - } + if (time < minTime) { + minTime = time; + } - if (time > maxTime) { - maxTime = time; + if (time > maxTime) { + maxTime = time; + } } - } - const minDate = new Date(minTime); - const maxDate = new Date(maxTime); + const minDate = new Date(minTime); + const maxDate = new Date(maxTime); - await prisma.marketData.deleteMany({ - where: { - dataSource, - symbol, - date: { - gte: minDate, - lte: maxDate + await prisma.marketData.deleteMany({ + where: { + dataSource, + symbol, + date: { + gte: minDate, + lte: maxDate + } } - } - }); + }); - await prisma.marketData.createMany({ - data: data.map(({ date, marketPrice, state }) => ({ - dataSource, - symbol, - date: date as Date, - marketPrice: marketPrice as number, - state: state as MarketDataState - })), - skipDuplicates: true - }); - } - }); + await prisma.marketData.createMany({ + data: data.map(({ date, marketPrice, state }) => ({ + dataSource, + symbol, + date: date as Date, + marketPrice: marketPrice as number, + state: state as MarketDataState + })), + skipDuplicates: true + }); + } + }, + { timeout: DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_TIMEOUT } + ); } - public async updateAssetProfileIdentifier( + public updateAssetProfileIdentifier( oldAssetProfileIdentifier: AssetProfileIdentifier, newAssetProfileIdentifier: AssetProfileIdentifier ) { diff --git a/apps/api/src/services/prisma/prisma.service.ts b/apps/api/src/services/prisma/prisma.service.ts index cdbc1cdfd..ebbd3afd4 100644 --- a/apps/api/src/services/prisma/prisma.service.ts +++ b/apps/api/src/services/prisma/prisma.service.ts @@ -14,6 +14,8 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(PrismaService.name); + public constructor(configService: ConfigService) { const adapter = new PrismaPg({ connectionString: configService.get('DATABASE_URL') @@ -43,7 +45,7 @@ export class PrismaService try { await this.$connect(); } catch (error) { - Logger.error(error, 'PrismaService'); + this.logger.error(error); } } diff --git a/apps/api/src/services/property/property.service.ts b/apps/api/src/services/property/property.service.ts index 212635f49..6d8130bbc 100644 --- a/apps/api/src/services/property/property.service.ts +++ b/apps/api/src/services/property/property.service.ts @@ -3,29 +3,42 @@ import { PROPERTY_CURRENCIES, PROPERTY_IS_USER_SIGNUP_ENABLED } from '@ghostfolio/common/config'; +import { PropertyKey } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; +import { Property } from '@prisma/client'; +import { addMilliseconds, isBefore } from 'date-fns'; +import ms from 'ms'; import { PropertyValue } from './interfaces/interfaces'; @Injectable() export class PropertyService { + private static readonly CACHE_TTL = ms('1 minute'); + + private cachedProperties: Promise; + private cachedPropertiesExpiresAt: Date; + public constructor(private readonly prismaService: PrismaService) {} - public async delete({ key }: { key: string }) { - return this.prismaService.property.delete({ + public async delete({ key }: { key: PropertyKey }) { + const property = await this.prismaService.property.delete({ where: { key } }); + + this.invalidateCache(); + + return property; } - public async get() { + public async get({ skipCache = false } = {}) { const response: { [key: string]: PropertyValue; } = { [PROPERTY_CURRENCIES]: [] }; - const properties = await this.prismaService.property.findMany(); + const properties = await this.getProperties({ skipCache }); for (const property of properties) { let value = property.value; @@ -40,8 +53,11 @@ export class PropertyService { return response; } - public async getByKey(aKey: string) { - const properties = await this.get(); + public async getByKey( + aKey: PropertyKey, + { skipCache = false } = {} + ) { + const properties = await this.get({ skipCache }); return properties[aKey] as TValue; } @@ -51,11 +67,54 @@ export class PropertyService { ); } - public async put({ key, value }: { key: string; value: string }) { - return this.prismaService.property.upsert({ + public async put({ key, value }: { key: PropertyKey; value: string }) { + const property = await this.prismaService.property.upsert({ create: { key, value }, update: { value }, where: { key } }); + + this.invalidateCache(); + + return property; + } + + /** + * Returns the properties from the in-memory cache, falling back to the + * database. Callers which write back a modified property must set + * skipCache to avoid basing the write on a stale read. + */ + private async getProperties({ skipCache = false } = {}) { + if (skipCache) { + return this.prismaService.property.findMany(); + } + + if ( + this.cachedProperties && + isBefore(new Date(), this.cachedPropertiesExpiresAt) + ) { + return this.cachedProperties; + } + + const properties = this.prismaService.property.findMany().catch((error) => { + if (this.cachedProperties === properties) { + this.invalidateCache(); + } + + throw error; + }); + + this.cachedProperties = properties; + this.cachedPropertiesExpiresAt = addMilliseconds( + new Date(), + PropertyService.CACHE_TTL + ); + + return this.cachedProperties; + } + + private invalidateCache() { + this.cachedProperties = undefined; + this.cachedPropertiesExpiresAt = undefined; } } diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.module.ts b/apps/api/src/services/queues/data-gathering/data-gathering.module.ts index 5672df5e8..d66411797 100644 --- a/apps/api/src/services/queues/data-gathering/data-gathering.module.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.module.ts @@ -23,13 +23,13 @@ import { DataGatheringProcessor } from './data-gathering.processor'; adapter: BullAdapter, name: DATA_GATHERING_QUEUE, options: { - displayName: 'Data Gathering', - readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false' + displayName: 'Data Gathering' } }), BullModule.registerQueue({ limiter: { - duration: ms('4 seconds'), + duration: ms('3 seconds'), + groupKey: 'dataSource', max: 1 }, name: DATA_GATHERING_QUEUE diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.processor.ts b/apps/api/src/services/queues/data-gathering/data-gathering.processor.ts index 1a4038652..8b7e3489f 100644 --- a/apps/api/src/services/queues/data-gathering/data-gathering.processor.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.processor.ts @@ -10,7 +10,11 @@ import { GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME } from '@ghostfolio/common/config'; -import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper'; +import { + DATE_FORMAT, + getAssetProfileIdentifier, + getStartOfUtcDate +} from '@ghostfolio/common/helper'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { Process, Processor } from '@nestjs/bull'; @@ -32,6 +36,8 @@ import { DataGatheringService } from './data-gathering.service'; @Injectable() @Processor(DATA_GATHERING_QUEUE) export class DataGatheringProcessor { + private readonly logger = new Logger(DataGatheringProcessor.name); + public constructor( private readonly dataGatheringService: DataGatheringService, private readonly dataProviderService: DataProviderService, @@ -51,16 +57,14 @@ export class DataGatheringProcessor { const { dataSource, symbol } = job.data; try { - Logger.log( - `Asset profile data gathering has been started for ${symbol} (${dataSource})`, - `DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})` + this.logger.log( + `Asset profile data gathering has been started for ${symbol} (${dataSource})` ); await this.dataGatheringService.gatherAssetProfiles([job.data]); - Logger.log( - `Asset profile data gathering has been completed for ${symbol} (${dataSource})`, - `DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})` + this.logger.log( + `Asset profile data gathering has been completed for ${symbol} (${dataSource})` ); } catch (error) { if (error instanceof AssetProfileDelistedError) { @@ -74,18 +78,14 @@ export class DataGatheringProcessor { } ); - Logger.log( - `Asset profile data gathering has been discarded for ${symbol} (${dataSource})`, - `DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})` + this.logger.log( + `Asset profile data gathering has been discarded for ${symbol} (${dataSource})` ); return job.discard(); } - Logger.error( - error, - `DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})` - ); + this.logger.error(error); throw error; } @@ -105,12 +105,11 @@ export class DataGatheringProcessor { try { let currentDate = parseISO(date as unknown as string); - Logger.log( + this.logger.log( `Historical market data gathering has been started for ${symbol} (${dataSource}) at ${format( currentDate, DATE_FORMAT - )}${force ? ' (forced update)' : ''}`, - `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})` + )}${force ? ' (forced update)' : ''}` ); const historicalData = await this.dataProviderService.getHistoricalRaw({ @@ -119,6 +118,11 @@ export class DataGatheringProcessor { to: new Date() }); + const assetProfileIdentifier = getAssetProfileIdentifier({ + dataSource, + symbol + }); + const data: Prisma.MarketDataUpdateInput[] = []; let lastMarketPrice: number; @@ -136,12 +140,14 @@ export class DataGatheringProcessor { ) ) { if ( - historicalData[symbol]?.[format(currentDate, DATE_FORMAT)] - ?.marketPrice + historicalData[assetProfileIdentifier]?.[ + format(currentDate, DATE_FORMAT) + ]?.marketPrice ) { lastMarketPrice = - historicalData[symbol]?.[format(currentDate, DATE_FORMAT)] - ?.marketPrice; + historicalData[assetProfileIdentifier]?.[ + format(currentDate, DATE_FORMAT) + ]?.marketPrice; } if (lastMarketPrice) { @@ -167,12 +173,11 @@ export class DataGatheringProcessor { await this.marketDataService.updateMany({ data }); } - Logger.log( + this.logger.log( `Historical market data gathering has been completed for ${symbol} (${dataSource}) at ${format( currentDate, DATE_FORMAT - )}`, - `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})` + )}` ); } catch (error) { if (error instanceof AssetProfileDelistedError) { @@ -186,18 +191,14 @@ export class DataGatheringProcessor { } ); - Logger.log( - `Historical market data gathering has been discarded for ${symbol} (${dataSource})`, - `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})` + this.logger.log( + `Historical market data gathering has been discarded for ${symbol} (${dataSource})` ); return job.discard(); } - Logger.error( - error, - `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})` - ); + this.logger.error(error); throw error; } diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts index cec63c3eb..1a2fa720e 100644 --- a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts @@ -2,6 +2,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; @@ -17,6 +18,7 @@ import { import { DATE_FORMAT, getAssetProfileIdentifier, + getStartOfUtcDate, resetHours } from '@ghostfolio/common/helper'; import { @@ -26,7 +28,7 @@ import { import { InjectQueue } from '@nestjs/bull'; import { Inject, Injectable, Logger } from '@nestjs/common'; -import { DataSource } from '@prisma/client'; +import { Prisma } from '@prisma/client'; import { JobOptions, Queue } from 'bull'; import { format, min, subDays, subMilliseconds, subYears } from 'date-fns'; import { isEmpty } from 'lodash'; @@ -34,6 +36,8 @@ import ms, { StringValue } from 'ms'; @Injectable() export class DataGatheringService { + private readonly logger = new Logger(DataGatheringService.name); + public constructor( @Inject('DataEnhancers') private readonly dataEnhancers: DataEnhancerInterface[], @@ -41,6 +45,7 @@ export class DataGatheringService { private readonly dataGatheringQueue: Queue, private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly marketDataService: MarketDataService, private readonly prismaService: PrismaService, private readonly propertyService: PropertyService, private readonly symbolProfileService: SymbolProfileService @@ -64,93 +69,6 @@ export class DataGatheringService { return this.dataGatheringQueue.addBulk(jobs); } - public async gather7Days() { - 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, - priority: DATA_GATHERING_QUEUE_PRIORITY_LOW - }); - } - - public async gatherSymbol({ dataSource, date, symbol }: DataGatheringItem) { - const dataGatheringItems = (await this.getSymbolsMax()) - .filter((dataGatheringItem) => { - return ( - dataGatheringItem.dataSource === dataSource && - dataGatheringItem.symbol === symbol - ); - }) - .map((item) => ({ - ...item, - date: date ?? item.date - })); - - await this.gatherSymbols({ - dataGatheringItems, - force: true, - priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH - }); - } - - public async gatherSymbolForDate({ - dataSource, - date, - symbol - }: { - dataSource: DataSource; - date: Date; - symbol: string; - }) { - try { - const historicalData = await this.dataProviderService.getHistoricalRaw({ - assetProfileIdentifiers: [{ dataSource, symbol }], - from: date, - to: date - }); - - const marketPrice = - historicalData[symbol][format(date, DATE_FORMAT)].marketPrice; - - if (marketPrice) { - return await this.prismaService.marketData.upsert({ - create: { - dataSource, - date, - marketPrice, - symbol - }, - update: { marketPrice }, - where: { dataSource_date_symbol: { dataSource, date, symbol } } - }); - } - } catch (error) { - Logger.error(error, 'DataGatheringService'); - } finally { - return undefined; - } - } - public async gatherAssetProfiles( aAssetProfileIdentifiers?: AssetProfileIdentifier[] ) { @@ -175,31 +93,45 @@ export class DataGatheringService { assetProfileIdentifiers ); - for (const [symbol, assetProfile] of Object.entries(assetProfiles)) { - const symbolMapping = symbolProfiles.find((symbolProfile) => { - return symbolProfile.symbol === symbol; - })?.symbolMapping; + for (const assetProfile of Object.values(assetProfiles)) { + const { symbol } = assetProfile; + + const symbolProfile = symbolProfiles.find( + ({ symbol: symbolProfileSymbol }) => { + return symbolProfileSymbol === symbol; + } + ); + + const symbolMapping = symbolProfile?.symbolMapping; + + let enhancedAssetProfile = symbolProfile + ? { + ...assetProfile, + assetClass: symbolProfile.assetClass ?? assetProfile.assetClass, + assetSubClass: + symbolProfile.assetSubClass ?? assetProfile.assetSubClass + } + : assetProfile; for (const dataEnhancer of this.dataEnhancers) { try { - assetProfiles[symbol] = await dataEnhancer.enhance({ - response: assetProfile, + enhancedAssetProfile = await dataEnhancer.enhance({ + response: enhancedAssetProfile, symbol: symbolMapping?.[dataEnhancer.getName()] ?? symbol }); } catch (error) { - Logger.error( + this.logger.error( `Failed to enhance data for ${symbol} (${ assetProfile.dataSource }) by ${dataEnhancer.getName()}`, - error, - 'DataGatheringService' + error ); } } + const { assetClass, assetSubClass } = assetProfile; + const { - assetClass, - assetSubClass, countries, currency, cusip, @@ -212,7 +144,7 @@ export class DataGatheringService { name, sectors, url - } = assetProfile; + } = enhancedAssetProfile; try { await this.prismaService.symbolProfile.upsert({ @@ -256,11 +188,7 @@ export class DataGatheringService { } }); } catch (error) { - Logger.error( - `${symbol}: ${error?.meta?.cause}`, - error, - 'DataGatheringService' - ); + this.logger.error(`${symbol}: ${error?.meta?.cause}`, error); if (assetProfileIdentifiers.length === 1) { throw error; @@ -269,6 +197,137 @@ export class DataGatheringService { } } + public async gatherHourlyMarketData() { + try { + await this.exchangeRateDataService.loadCurrencies(); + } catch (error) { + this.logger.error('Could not gather exchange rates', error); + } + + const assetProfileIdentifiers = + await this.getHourlyAssetProfileIdentifiers(); + + if (assetProfileIdentifiers.length <= 0) { + return; + } + + const date = getStartOfUtcDate(new Date()); + + try { + const quotes = await this.dataProviderService.getQuotes({ + items: assetProfileIdentifiers, + useCache: false + }); + + const data: Prisma.MarketDataUpdateInput[] = []; + + for (const { dataSource, symbol } of assetProfileIdentifiers) { + const quote = quotes[getAssetProfileIdentifier({ dataSource, symbol })]; + + if (!quote?.marketPrice) { + continue; + } + + data.push({ + dataSource, + date, + symbol, + marketPrice: quote.marketPrice, + state: 'INTRADAY' + }); + } + + await this.marketDataService.updateMany({ data }); + } catch (error) { + this.logger.error('Could not gather hourly market data', error); + } + } + + public async gatherMax() { + const dataGatheringItems = await this.getSymbolsMax(); + await this.gatherSymbols({ + dataGatheringItems, + priority: DATA_GATHERING_QUEUE_PRIORITY_LOW + }); + } + + public async gatherRecentMarketData() { + 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 gatherSymbol({ dataSource, date, symbol }: DataGatheringItem) { + const dataGatheringItems = (await this.getSymbolsMax()) + .filter((dataGatheringItem) => { + return ( + dataGatheringItem.dataSource === dataSource && + dataGatheringItem.symbol === symbol + ); + }) + .map((item) => ({ + ...item, + date: date ?? item.date + })); + + await this.gatherSymbols({ + dataGatheringItems, + force: true, + priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH + }); + } + + public async gatherSymbolForDate({ + dataSource, + date, + symbol + }: { date: Date } & AssetProfileIdentifier) { + try { + const historicalData = await this.dataProviderService.getHistoricalRaw({ + assetProfileIdentifiers: [{ dataSource, symbol }], + from: date, + to: date + }); + + const marketPrice = + historicalData[getAssetProfileIdentifier({ dataSource, symbol })][ + format(date, DATE_FORMAT) + ].marketPrice; + + if (marketPrice) { + return await this.prismaService.marketData.upsert({ + create: { + dataSource, + date, + marketPrice, + symbol + }, + update: { marketPrice }, + where: { dataSource_date_symbol: { dataSource, date, symbol } } + }); + } + } catch (error) { + this.logger.error(error); + } finally { + return undefined; + } + } + public async gatherSymbols({ dataGatheringItems, force = false, @@ -379,6 +438,36 @@ export class DataGatheringService { return min([aStartDate, subYears(new Date(), 10)]); } + private async getHourlyAssetProfileIdentifiers(): Promise< + AssetProfileIdentifier[] + > { + const symbolProfiles = await this.prismaService.symbolProfile.findMany({ + orderBy: [{ symbol: 'asc' }, { dataSource: 'asc' }], + select: { + dataSource: true, + scraperConfiguration: true, + symbol: true + }, + where: { + dataGatheringFrequency: 'HOURLY', + isActive: true + } + }); + + return symbolProfiles + .filter(({ dataSource, scraperConfiguration }) => { + const manualDataSourceWithScraperConfiguration = + dataSource === 'MANUAL' && !isEmpty(scraperConfiguration); + + return ( + dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration + ); + }) + .map(({ dataSource, symbol }) => { + return { dataSource, symbol }; + }); + } + private async getSymbols7D({ withUserSubscription = false }: { @@ -459,14 +548,12 @@ export class DataGatheringService { } }) ) - .filter((symbolProfile) => { + .filter(({ dataSource, scraperConfiguration }) => { const manualDataSourceWithScraperConfiguration = - symbolProfile.dataSource === 'MANUAL' && - !isEmpty(symbolProfile.scraperConfiguration); + dataSource === 'MANUAL' && !isEmpty(scraperConfiguration); return ( - symbolProfile.dataSource !== 'MANUAL' || - manualDataSourceWithScraperConfiguration + dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration ); }) .map((symbolProfile) => { diff --git a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts index c90f826f6..0da529821 100644 --- a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts +++ b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts @@ -29,8 +29,7 @@ import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor'; adapter: BullAdapter, name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE, options: { - displayName: 'Portfolio Snapshot Computation', - readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false' + displayName: 'Portfolio Snapshot Computation' } }), BullModule.registerQueue({ diff --git a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts index f3aa6e77e..2ade39a8a 100644 --- a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts +++ b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts @@ -21,6 +21,8 @@ import { PortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue @Injectable() @Processor(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE) export class PortfolioSnapshotProcessor { + private readonly logger = new Logger(PortfolioSnapshotProcessor.name); + public constructor( private readonly accountBalanceService: AccountBalanceService, private readonly activitiesService: ActivitiesService, @@ -41,9 +43,8 @@ export class PortfolioSnapshotProcessor { try { const startTime = performance.now(); - Logger.log( - `Portfolio snapshot calculation of user '${job.data.userId}' has been started`, - `PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})` + this.logger.log( + `Portfolio snapshot calculation of user '${job.data.userId}' has been started` ); const { activities } = @@ -72,12 +73,11 @@ export class PortfolioSnapshotProcessor { const snapshot = await portfolioCalculator.computeSnapshot(); - Logger.log( + this.logger.log( `Portfolio snapshot calculation of user '${job.data.userId}' has been completed in ${( (performance.now() - startTime) / 1000 - ).toFixed(3)} seconds`, - `PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})` + ).toFixed(3)} seconds` ); const expiration = addMilliseconds( @@ -87,7 +87,7 @@ export class PortfolioSnapshotProcessor { : 0 ); - this.redisCacheService.set( + await this.redisCacheService.set( this.redisCacheService.getPortfolioSnapshotKey({ filters: job.data.filters, userId: job.data.userId @@ -101,10 +101,7 @@ export class PortfolioSnapshotProcessor { return snapshot; } catch (error) { - Logger.error( - error, - `PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})` - ); + this.logger.error(error); throw new Error(error); } diff --git a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock.ts b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock.ts index 898718106..fddbd01ab 100644 --- a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock.ts +++ b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock.ts @@ -1,32 +1,47 @@ -import { Job, JobOptions } from 'bull'; +import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; + +import type { Job, JobId, JobOptions } from 'bull'; +import ms from 'ms'; import { setTimeout } from 'timers/promises'; import { PortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface'; export const PortfolioSnapshotServiceMock = { - addJobToQueue({ + addJobToQueue: ({ opts }: { data: PortfolioSnapshotQueueJob; name: string; opts?: JobOptions; - }): Promise> { - const mockJob: Partial> = { + }): Promise => { + const mockJob: Partial = { finished: async () => { await setTimeout(100); - return Promise.resolve(); + // Mimic the processor which caches the computed portfolio snapshot + // under the job id + await RedisCacheServiceMock.set( + opts?.jobId as string, + JSON.stringify({ + expiration: Date.now() + ms('1 minute'), + portfolioSnapshot: {} + } as unknown as PortfolioSnapshotValue) + ); } }; - this.jobsStore.set(opts?.jobId, mockJob); + PortfolioSnapshotServiceMock.jobsStore.set(opts?.jobId, mockJob); - return Promise.resolve(mockJob as Job); + return Promise.resolve(mockJob as Job); }, - getJob(jobId: string): Promise> { - const job = this.jobsStore.get(jobId); + getJob: (jobId: JobId): Promise => { + const job = PortfolioSnapshotServiceMock.jobsStore.get(jobId); - return Promise.resolve(job as Job); + return Promise.resolve(job as Job); }, - jobsStore: new Map>>() + jobsStore: new Map>(), + reset: () => { + PortfolioSnapshotServiceMock.jobsStore.clear(); + } }; diff --git a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.ts b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.ts index d7449a9cc..99fa5aca2 100644 --- a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.ts +++ b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.ts @@ -1,3 +1,4 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE } from '@ghostfolio/common/config'; import { InjectQueue } from '@nestjs/bull'; @@ -9,6 +10,7 @@ import { PortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue @Injectable() export class PortfolioSnapshotService { public constructor( + private readonly configurationService: ConfigurationService, @InjectQueue(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE) private readonly portfolioSnapshotQueue: Queue ) {} @@ -22,7 +24,12 @@ export class PortfolioSnapshotService { name: string; opts?: JobOptions; }) { - return this.portfolioSnapshotQueue.add(name, data, opts); + return this.portfolioSnapshotQueue.add(name, data, { + ...opts, + removeOnFail: this.configurationService.get( + 'PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_REMOVE_ON_FAIL' + ) + }); } public async getJob(jobId: string) { diff --git a/apps/api/src/services/queues/statistics-gathering/interfaces/interfaces.ts b/apps/api/src/services/queues/statistics-gathering/interfaces/interfaces.ts new file mode 100644 index 000000000..3c316720f --- /dev/null +++ b/apps/api/src/services/queues/statistics-gathering/interfaces/interfaces.ts @@ -0,0 +1,7 @@ +export interface BetterStackUptimeSlaResponse { + data: { + attributes: { + availability: number; + }; + }; +} diff --git a/apps/api/src/services/queues/statistics-gathering/statistics-gathering.module.ts b/apps/api/src/services/queues/statistics-gathering/statistics-gathering.module.ts index 60b963c69..6ef14e29c 100644 --- a/apps/api/src/services/queues/statistics-gathering/statistics-gathering.module.ts +++ b/apps/api/src/services/queues/statistics-gathering/statistics-gathering.module.ts @@ -1,4 +1,5 @@ import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { STATISTICS_GATHERING_QUEUE } from '@ghostfolio/common/config'; @@ -19,8 +20,7 @@ import { StatisticsGatheringService } from './statistics-gathering.service'; adapter: BullAdapter, name: STATISTICS_GATHERING_QUEUE, options: { - displayName: 'Statistics Gathering', - readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false' + displayName: 'Statistics Gathering' } }) ] @@ -29,6 +29,7 @@ import { StatisticsGatheringService } from './statistics-gathering.service'; name: STATISTICS_GATHERING_QUEUE }), ConfigurationModule, + FetchModule, PropertyModule ], providers: [StatisticsGatheringProcessor, StatisticsGatheringService] diff --git a/apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts b/apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts index 1312d49ea..21d009805 100644 --- a/apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts +++ b/apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts @@ -1,6 +1,8 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { + DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY, GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME, GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME, GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME, @@ -23,20 +25,31 @@ import { Injectable, Logger } from '@nestjs/common'; import * as cheerio from 'cheerio'; import { format, subDays } from 'date-fns'; +import { BetterStackUptimeSlaResponse } from './interfaces/interfaces'; + +const GATHER_STATISTICS_CONCURRENCY = parseInt( + process.env.PROCESSOR_GATHER_STATISTICS_CONCURRENCY ?? + DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY.toString(), + 10 +); + @Injectable() @Processor(STATISTICS_GATHERING_QUEUE) export class StatisticsGatheringProcessor { + private readonly logger = new Logger(StatisticsGatheringProcessor.name); + public constructor( private readonly configurationService: ConfigurationService, + private readonly fetchService: FetchService, private readonly propertyService: PropertyService ) {} - @Process(GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME) + @Process({ + concurrency: GATHER_STATISTICS_CONCURRENCY, + name: GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME + }) public async gatherDockerHubPullsStatistics() { - Logger.log( - 'Docker Hub pulls statistics gathering has been started', - 'StatisticsGatheringProcessor' - ); + this.logger.log('Docker Hub pulls statistics gathering has been started'); const dockerHubPulls = await this.countDockerHubPulls(); @@ -45,17 +58,16 @@ export class StatisticsGatheringProcessor { value: String(dockerHubPulls) }); - Logger.log( - 'Docker Hub pulls statistics gathering has been completed', - 'StatisticsGatheringProcessor' - ); + this.logger.log('Docker Hub pulls statistics gathering has been completed'); } - @Process(GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME) + @Process({ + concurrency: GATHER_STATISTICS_CONCURRENCY, + name: GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME + }) public async gatherGitHubContributorsStatistics() { - Logger.log( - 'GitHub contributors statistics gathering has been started', - 'StatisticsGatheringProcessor' + this.logger.log( + 'GitHub contributors statistics gathering has been started' ); const gitHubContributors = await this.countGitHubContributors(); @@ -65,18 +77,17 @@ export class StatisticsGatheringProcessor { value: String(gitHubContributors) }); - Logger.log( - 'GitHub contributors statistics gathering has been completed', - 'StatisticsGatheringProcessor' + this.logger.log( + 'GitHub contributors statistics gathering has been completed' ); } - @Process(GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME) + @Process({ + concurrency: GATHER_STATISTICS_CONCURRENCY, + name: GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME + }) public async gatherGitHubStargazersStatistics() { - Logger.log( - 'GitHub stargazers statistics gathering has been started', - 'StatisticsGatheringProcessor' - ); + this.logger.log('GitHub stargazers statistics gathering has been started'); const gitHubStargazers = await this.countGitHubStargazers(); @@ -85,31 +96,29 @@ export class StatisticsGatheringProcessor { value: String(gitHubStargazers) }); - Logger.log( - 'GitHub stargazers statistics gathering has been completed', - 'StatisticsGatheringProcessor' + this.logger.log( + 'GitHub stargazers statistics gathering has been completed' ); } - @Process(GATHER_STATISTICS_UPTIME_PROCESS_JOB_NAME) + @Process({ + concurrency: GATHER_STATISTICS_CONCURRENCY, + name: GATHER_STATISTICS_UPTIME_PROCESS_JOB_NAME + }) public async gatherUptimeStatistics() { const monitorId = await this.propertyService.getByKey( PROPERTY_BETTER_UPTIME_MONITOR_ID ); if (!monitorId) { - Logger.log( - `Uptime statistics gathering has been skipped as no ${PROPERTY_BETTER_UPTIME_MONITOR_ID} is configured`, - 'StatisticsGatheringProcessor' + this.logger.log( + `Uptime statistics gathering has been skipped as no ${PROPERTY_BETTER_UPTIME_MONITOR_ID} is configured` ); return; } - Logger.log( - 'Uptime statistics gathering has been started', - 'StatisticsGatheringProcessor' - ); + this.logger.log('Uptime statistics gathering has been started'); const uptime = await this.getUptime(monitorId); @@ -118,39 +127,37 @@ export class StatisticsGatheringProcessor { value: String(uptime) }); - Logger.log( - 'Uptime statistics gathering has been completed', - 'StatisticsGatheringProcessor' - ); + this.logger.log('Uptime statistics gathering has been completed'); } private async countDockerHubPulls(): Promise { try { - const { pull_count } = (await fetch( - 'https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio', - { + const { pull_count } = await this.fetchService + .fetch('https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio', { headers: { 'User-Agent': 'request' }, signal: AbortSignal.timeout( this.configurationService.get('REQUEST_TIMEOUT') ) - } - ).then((res) => res.json())) as { pull_count: number }; + }) + .then<{ pull_count: number }>((res) => res.json()); return pull_count; } catch (error) { - Logger.error(error, 'StatisticsGatheringProcessor - DockerHub'); + this.logger.error(error); throw error; } } - private async countGitHubContributors(): Promise { + private async countGitHubContributors(): Promise { try { - const body = await fetch('https://github.com/ghostfolio/ghostfolio', { - signal: AbortSignal.timeout( - this.configurationService.get('REQUEST_TIMEOUT') - ) - }).then((res) => res.text()); + const body = await this.fetchService + .fetch('https://github.com/ghostfolio/ghostfolio', { + signal: AbortSignal.timeout( + this.configurationService.get('REQUEST_TIMEOUT') + ) + }) + .then((res) => res.text()); const $ = cheerio.load(body); @@ -166,7 +173,7 @@ export class StatisticsGatheringProcessor { value }); } catch (error) { - Logger.error(error, 'StatisticsGatheringProcessor - GitHub'); + this.logger.error(error); throw error; } @@ -174,19 +181,18 @@ export class StatisticsGatheringProcessor { private async countGitHubStargazers(): Promise { try { - const { stargazers_count } = (await fetch( - 'https://api.github.com/repos/ghostfolio/ghostfolio', - { + const { stargazers_count } = await this.fetchService + .fetch('https://api.github.com/repos/ghostfolio/ghostfolio', { headers: { 'User-Agent': 'request' }, signal: AbortSignal.timeout( this.configurationService.get('REQUEST_TIMEOUT') ) - } - ).then((res) => res.json())) as { stargazers_count: number }; + }) + .then<{ stargazers_count: number }>((res) => res.json()); return stargazers_count; } catch (error) { - Logger.error(error, 'StatisticsGatheringProcessor - GitHub'); + this.logger.error(error); throw error; } @@ -194,26 +200,28 @@ export class StatisticsGatheringProcessor { private async getUptime(monitorId: string): Promise { try { - const { data } = await fetch( - `https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format( - subDays(new Date(), 90), - DATE_FORMAT - )}&to${format(new Date(), DATE_FORMAT)}`, - { - headers: { - [HEADER_KEY_TOKEN]: `Bearer ${this.configurationService.get( - 'API_KEY_BETTER_UPTIME' - )}` - }, - signal: AbortSignal.timeout( - this.configurationService.get('REQUEST_TIMEOUT') - ) - } - ).then((res) => res.json()); + const { data } = await this.fetchService + .fetch( + `https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format( + subDays(new Date(), 90), + DATE_FORMAT + )}&to=${format(new Date(), DATE_FORMAT)}`, + { + headers: { + [HEADER_KEY_TOKEN]: `Bearer ${this.configurationService.get( + 'API_KEY_BETTER_UPTIME' + )}` + }, + signal: AbortSignal.timeout( + this.configurationService.get('REQUEST_TIMEOUT') + ) + } + ) + .then((res) => res.json()); return data.attributes.availability / 100; } catch (error) { - Logger.error(error, 'StatisticsGatheringProcessor - Better Stack'); + this.logger.error(error); throw error; } 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 4c2c42589..ebc8a94c7 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -1,8 +1,9 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { UNKNOWN_KEY } from '@ghostfolio/common/config'; +import { applyAssetProfileOverrides } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, - EnhancedSymbolProfile, + EnhancedAssetProfile, Holding, ScraperConfiguration } from '@ghostfolio/common/interfaces'; @@ -10,7 +11,12 @@ import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Injectable } from '@nestjs/common'; -import { Prisma, SymbolProfile, SymbolProfileOverrides } from '@prisma/client'; +import { + AssetProfileOverrides, + DataSource, + Prisma, + SymbolProfile +} from '@prisma/client'; import { continents, countries } from 'countries-list'; @Injectable() @@ -29,6 +35,15 @@ export class SymbolProfileService { }); } + public deleteAssetProfileOverrides({ + dataSource, + symbol + }: AssetProfileIdentifier) { + return this.prismaService.assetProfileOverrides.deleteMany({ + where: { symbolProfile: { dataSource, symbol } } + }); + } + public async deleteById(id: string) { return this.prismaService.symbolProfile.delete({ where: { id } @@ -70,9 +85,50 @@ export class SymbolProfileService { }); } + public getAssetProfileUpdateInput( + { dataSource }: AssetProfileIdentifier, + data: Prisma.SymbolProfileUpdateInput + ): Prisma.SymbolProfileUpdateInput { + if (dataSource === DataSource.MANUAL) { + return data; + } + + return { + assetProfileOverrides: { + upsert: { + create: + data as Prisma.AssetProfileOverridesCreateWithoutSymbolProfileInput, + update: + data as Prisma.AssetProfileOverridesUpdateWithoutSymbolProfileInput + } + } + }; + } + + public async getCustomSymbolProfilesByNames({ + names, + userId + }: { + names: string[]; + userId: string; + }): Promise[]> { + if (names.length === 0) { + return []; + } + + return this.prismaService.symbolProfile.findMany({ + select: { name: true, symbol: true }, + where: { + userId, + dataSource: DataSource.MANUAL, + name: { in: names } + } + }); + } + public async getSymbolProfiles( aAssetProfileIdentifiers: AssetProfileIdentifier[] - ): Promise { + ): Promise { return this.prismaService.symbolProfile .findMany({ include: { @@ -86,7 +142,7 @@ export class SymbolProfileService { select: { date: true }, take: 1 }, - SymbolProfileOverrides: true + assetProfileOverrides: true }, where: { OR: aAssetProfileIdentifiers.map(({ dataSource, symbol }) => { @@ -104,14 +160,14 @@ export class SymbolProfileService { public async getSymbolProfilesByIds( symbolProfileIds: string[] - ): Promise { + ): Promise { return this.prismaService.symbolProfile .findMany({ include: { _count: { select: { activities: true, watchedBy: true } }, - SymbolProfileOverrides: true + assetProfileOverrides: true }, where: { id: { @@ -148,34 +204,36 @@ export class SymbolProfileService { { dataSource, symbol }: AssetProfileIdentifier, { assetClass, + assetProfileOverrides, assetSubClass, comment, countries, currency, + dataGatheringFrequency, holdings, isActive, name, scraperConfiguration, sectors, symbolMapping, - SymbolProfileOverrides, url }: Prisma.SymbolProfileUpdateInput ) { return this.prismaService.symbolProfile.update({ data: { assetClass, + assetProfileOverrides, assetSubClass, comment, countries, currency, + dataGatheringFrequency, holdings, isActive, name, scraperConfiguration, sectors, symbolMapping, - SymbolProfileOverrides, url }, where: { dataSource_symbol: { dataSource, symbol } } @@ -188,25 +246,32 @@ export class SymbolProfileService { activities?: { date: Date; }[]; - SymbolProfileOverrides: SymbolProfileOverrides; + assetProfileOverrides: AssetProfileOverrides; })[] - ): EnhancedSymbolProfile[] { + ): EnhancedAssetProfile[] { return symbolProfiles.map((symbolProfile) => { + const symbolProfileWithOverrides = applyAssetProfileOverrides( + symbolProfile, + symbolProfile.assetProfileOverrides + ); + const item = { - ...symbolProfile, + ...symbolProfileWithOverrides, activitiesCount: 0, countries: this.getCountries( - symbolProfile?.countries as unknown as Prisma.JsonArray + symbolProfileWithOverrides?.countries as unknown as Prisma.JsonArray ), dateOfFirstActivity: undefined as Date, holdings: this.getHoldings( - symbolProfile?.holdings as unknown as Prisma.JsonArray + symbolProfileWithOverrides?.holdings as unknown as Prisma.JsonArray + ), + scraperConfiguration: this.getScraperConfiguration( + symbolProfileWithOverrides ), - scraperConfiguration: this.getScraperConfiguration(symbolProfile), sectors: this.getSectors( - symbolProfile?.sectors as unknown as Prisma.JsonArray + symbolProfileWithOverrides?.sectors as unknown as Prisma.JsonArray ), - symbolMapping: this.getSymbolMapping(symbolProfile), + symbolMapping: this.getSymbolMapping(symbolProfileWithOverrides), watchedByCount: 0 }; @@ -217,45 +282,7 @@ export class SymbolProfileService { item.dateOfFirstActivity = symbolProfile.activities?.[0]?.date; delete item.activities; - if (item.SymbolProfileOverrides) { - item.assetClass = - item.SymbolProfileOverrides.assetClass ?? item.assetClass; - item.assetSubClass = - item.SymbolProfileOverrides.assetSubClass ?? item.assetSubClass; - - if ( - (item.SymbolProfileOverrides.countries as unknown as Prisma.JsonArray) - ?.length > 0 - ) { - item.countries = this.getCountries( - item.SymbolProfileOverrides.countries as unknown as Prisma.JsonArray - ); - } - - if ( - (item.SymbolProfileOverrides.holdings as unknown as Holding[]) - ?.length > 0 - ) { - item.holdings = this.getHoldings( - item.SymbolProfileOverrides.holdings as unknown as Prisma.JsonArray - ); - } - - item.name = item.SymbolProfileOverrides.name ?? item.name; - - if ( - (item.SymbolProfileOverrides.sectors as unknown as Sector[])?.length > - 0 - ) { - item.sectors = this.getSectors( - item.SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray - ); - } - - item.url = item.SymbolProfileOverrides.url ?? item.url; - - delete item.SymbolProfileOverrides; - } + delete item.assetProfileOverrides; return item; }); diff --git a/apps/api/src/services/tag/tag.service.ts b/apps/api/src/services/tag/tag.service.ts index f4cbd4cb1..de052f9a1 100644 --- a/apps/api/src/services/tag/tag.service.ts +++ b/apps/api/src/services/tag/tag.service.ts @@ -1,7 +1,8 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; -import { Injectable } from '@nestjs/common'; +import { HttpException, Injectable } from '@nestjs/common'; import { Prisma, Tag } from '@prisma/client'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; @Injectable() export class TagService { @@ -19,7 +20,7 @@ export class TagService { public async getTag( tagWhereUniqueInput: Prisma.TagWhereUniqueInput - ): Promise { + ): Promise { return this.prismaService.tag.findUnique({ where: tagWhereUniqueInput }); @@ -52,6 +53,11 @@ export class TagService { include: { _count: { select: { + accounts: { + where: { + userId + } + }, activities: { where: { userId @@ -80,27 +86,28 @@ export class TagService { id, name, userId, - isUsed: _count.activities > 0 + isUsed: _count.accounts > 0 || _count.activities > 0 })) .sort((a, b) => { return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); }); } - public async getTagsWithActivityCount() { - const tagsWithOrderCount = await this.prismaService.tag.findMany({ + public async getTagsWithAccountAndActivityCount() { + const tagsWithAccountAndOrderCount = await this.prismaService.tag.findMany({ include: { _count: { - select: { activities: true } + select: { accounts: true, activities: true } } } }); - return tagsWithOrderCount.map(({ _count, id, name, userId }) => { + return tagsWithAccountAndOrderCount.map(({ _count, id, name, userId }) => { return { id, name, userId, + accountCount: _count.accounts, activityCount: _count.activities }; }); @@ -118,4 +125,39 @@ export class TagService { where }); } + + public async validateTagIds({ + tagIds, + userId + }: { + tagIds: string[]; + userId: string; + }) { + if (!tagIds?.length) { + return; + } + + if (!userId) { + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + + const uniqueTagIds = Array.from(new Set(tagIds)); + + const tagsCount = await this.prismaService.tag.count({ + where: { + id: { in: uniqueTagIds }, + OR: [{ userId }, { userId: null }] + } + }); + + if (tagsCount !== uniqueTagIds.length) { + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + } } diff --git a/apps/api/src/services/twitter-bot/twitter-bot.module.ts b/apps/api/src/services/twitter-bot/twitter-bot.module.ts index 80d53169c..bdb1a7988 100644 --- a/apps/api/src/services/twitter-bot/twitter-bot.module.ts +++ b/apps/api/src/services/twitter-bot/twitter-bot.module.ts @@ -1,13 +1,19 @@ import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service'; import { Module } from '@nestjs/common'; @Module({ exports: [TwitterBotService], - imports: [BenchmarkModule, ConfigurationModule, SymbolModule], + imports: [ + BenchmarkModule, + ConfigurationModule, + DataProviderModule, + SymbolModule + ], providers: [TwitterBotService] }) export class TwitterBotModule {} 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 b424f7198..2dbed82d2 100644 --- a/apps/api/src/services/twitter-bot/twitter-bot.service.ts +++ b/apps/api/src/services/twitter-bot/twitter-bot.service.ts @@ -1,10 +1,8 @@ import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { - ghostfolioFearAndGreedIndexDataSourceStocks, - ghostfolioFearAndGreedIndexSymbol -} from '@ghostfolio/common/config'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { ghostfolioFearAndGreedIndexSymbolStocks } from '@ghostfolio/common/config'; import { resolveFearAndGreedIndex, resolveMarketCondition @@ -12,15 +10,19 @@ import { import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { isWeekend } from 'date-fns'; +import { round } from 'lodash'; import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2'; @Injectable() export class TwitterBotService implements OnModuleInit { + private readonly logger = new Logger(TwitterBotService.name); + private twitterClient: TwitterApiReadWrite; public constructor( private readonly benchmarkService: BenchmarkService, private readonly configurationService: ConfigurationService, + private readonly dataProviderService: DataProviderService, private readonly symbolService: SymbolService ) {} @@ -46,8 +48,9 @@ export class TwitterBotService implements OnModuleInit { try { const symbolItem = await this.symbolService.get({ dataGatheringItem: { - dataSource: ghostfolioFearAndGreedIndexDataSourceStocks, - symbol: ghostfolioFearAndGreedIndexSymbol + dataSource: + this.dataProviderService.getDataSourceForFearAndGreedIndexStocks(), + symbol: ghostfolioFearAndGreedIndexSymbolStocks } }); @@ -56,9 +59,9 @@ export class TwitterBotService implements OnModuleInit { symbolItem.marketPrice ); - let status = `Current market mood is ${emoji} ${text.toLowerCase()} (${ + let status = `Current market mood is ${emoji} ${text.toLowerCase()} (${round( symbolItem.marketPrice - }/100)`; + )}/100)`; const benchmarkListing = await this.getBenchmarkListing(); @@ -71,13 +74,12 @@ export class TwitterBotService implements OnModuleInit { const { data: createdTweet } = await this.twitterClient.v2.tweet(status); - Logger.log( - `Fear & Greed Index has been posted: https://x.com/ghostfolio_/status/${createdTweet.id}`, - 'TwitterBotService' + this.logger.log( + `Fear & Greed Index has been posted: https://x.com/ghostfolio_/status/${createdTweet.id}` ); } } catch (error) { - Logger.error(error, 'TwitterBotService'); + this.logger.error(error); } } @@ -88,16 +90,16 @@ export class TwitterBotService implements OnModuleInit { }); return benchmarks - .map(({ marketCondition, name, performances }) => { - let changeFormAllTimeHigh = ( - performances.allTimeHigh.performancePercent * 100 - ).toFixed(1); + .map(({ name, performances }) => { + const performancePercent = round( + performances.allTimeHigh.performancePercent, + 3 + ); - if (Math.abs(parseFloat(changeFormAllTimeHigh)) === 0) { - changeFormAllTimeHigh = '0.0'; - } + const marketCondition = + this.benchmarkService.getMarketCondition(performancePercent); - return `${name} ${changeFormAllTimeHigh}%${ + return `${name} ${(performancePercent * 100).toFixed(1)}%${ marketCondition !== 'NEUTRAL_MARKET' ? ' ' + resolveMarketCondition(marketCondition).emoji : '' diff --git a/apps/api/webpack.config.js b/apps/api/webpack.config.js index 2cc38b985..e59eee0bf 100644 --- a/apps/api/webpack.config.js +++ b/apps/api/webpack.config.js @@ -1,6 +1,49 @@ -const { composePlugins, withNx } = require('@nx/webpack'); +const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin'); +const path = require('path'); -module.exports = composePlugins(withNx(), (config, { options, context }) => { - // Customize webpack config here - return config; +// These options were migrated by @nx/webpack:convert-to-inferred from +// the project.json file and merged with the options in this file +const configValues = { + build: { + default: { + compiler: 'tsc', + deleteOutputPath: false, + main: './src/main.ts', + outputPath: 'dist/apps/api', + outputHashing: 'none', + sourceMap: true, + target: 'node', + tsConfig: './tsconfig.app.json' + }, + production: { + extractLicenses: true, + fileReplacements: [ + { + replace: path.resolve(__dirname, './src/environments/environment.ts'), + with: path.resolve( + __dirname, + './src/environments/environment.prod.ts' + ) + } + ], + generatePackageJson: true, + inspect: false, + optimization: true + } + } +}; + +// Determine the correct configValue to use based on the configuration +const configuration = process.env.NX_TASK_TARGET_CONFIGURATION || 'default'; + +const buildOptions = { + ...configValues.build.default, + ...configValues.build[configuration] +}; + +/** + * @type {import('webpack').WebpackOptionsNormalized} + */ +module.exports = async () => ({ + plugins: [new NxAppWebpackPlugin(buildOptions)] }); diff --git a/apps/client/eslint.config.cjs b/apps/client/eslint.config.cjs index 96ecefd50..bcbe2c1c1 100644 --- a/apps/client/eslint.config.cjs +++ b/apps/client/eslint.config.cjs @@ -47,7 +47,9 @@ module.exports = [ { files: ['**/*.ts', '**/*.tsx'], // Override or add rules here - rules: {} + rules: { + '@typescript-eslint/prefer-nullish-coalescing': 'error' + } }, { files: ['**/*.js', '**/*.jsx'], diff --git a/apps/client/jest.config.ts b/apps/client/jest.config.ts index 04378bdbd..26d772e11 100644 --- a/apps/client/jest.config.ts +++ b/apps/client/jest.config.ts @@ -1,4 +1,3 @@ -/* eslint-disable */ export default { displayName: 'client', diff --git a/apps/client/project.json b/apps/client/project.json index 1fbc1ed0e..6f25fa914 100644 --- a/apps/client/project.json +++ b/apps/client/project.json @@ -26,6 +26,10 @@ "baseHref": "/it/", "translation": "apps/client/src/locales/messages.it.xlf" }, + "ja": { + "baseHref": "/ja/", + "translation": "apps/client/src/locales/messages.ja.xlf" + }, "ko": { "baseHref": "/ko/", "translation": "apps/client/src/locales/messages.ko.xlf" @@ -118,6 +122,10 @@ "baseHref": "/it/", "localize": ["it"] }, + "development-ja": { + "baseHref": "/ja/", + "localize": ["ja"] + }, "development-ko": { "baseHref": "/ko/", "localize": ["ko"] @@ -203,9 +211,6 @@ { "command": "shx cp apps/client/src/assets/favicon.ico dist/apps/client" }, - { - "command": "shx cp apps/client/src/assets/index.html dist/apps/client" - }, { "command": "shx cp apps/client/src/assets/robots.txt dist/apps/client" }, @@ -247,6 +252,9 @@ "development-it": { "buildTarget": "client:build:development-it" }, + "development-ja": { + "buildTarget": "client:build:development-ja" + }, "development-ko": { "buildTarget": "client:build:development-ko" }, @@ -289,6 +297,7 @@ "messages.es.xlf", "messages.fr.xlf", "messages.it.xlf", + "messages.ja.xlf", "messages.ko.xlf", "messages.nl.xlf", "messages.pl.xlf", diff --git a/apps/client/src/app/adapter/custom-date-adapter.ts b/apps/client/src/app/adapter/custom-date-adapter.ts index a1326b823..5a7790b92 100644 --- a/apps/client/src/app/adapter/custom-date-adapter.ts +++ b/apps/client/src/app/adapter/custom-date-adapter.ts @@ -6,7 +6,7 @@ import { addYears, format, getYear, parse } from 'date-fns'; export class CustomDateAdapter extends NativeDateAdapter { public constructor( - @Inject(MAT_DATE_LOCALE) public locale: string, + @Inject(MAT_DATE_LOCALE) public override locale: string, @Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string ) { super(matDateLocale); @@ -15,21 +15,21 @@ export class CustomDateAdapter extends NativeDateAdapter { /** * Formats a date as a string */ - public format(aDate: Date): string { + public override format(aDate: Date): string { return format(aDate, getDateFormatString(this.locale)); } /** * Sets the first day of the week to Monday */ - public getFirstDayOfWeek(): number { + public override getFirstDayOfWeek(): number { return 1; } /** * Parses a date from a provided value */ - public parse(aValue: string): Date { + public override parse(aValue: string): Date { let date = parse(aValue, getDateFormatString(this.locale), new Date()); if (getYear(date) < 1900) { diff --git a/apps/client/src/app/app.component.ts b/apps/client/src/app/app.component.ts index e1967970d..65b5e95b0 100644 --- a/apps/client/src/app/app.component.ts +++ b/apps/client/src/app/app.component.ts @@ -1,5 +1,9 @@ import { getCssVariable } from '@ghostfolio/common/helper'; -import { InfoItem, User } from '@ghostfolio/common/interfaces'; +import { + AssetProfileIdentifier, + InfoItem, + User +} from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes'; import { ColorScheme } from '@ghostfolio/common/types'; @@ -27,7 +31,6 @@ import { RouterLink, RouterOutlet } from '@angular/router'; -import { DataSource } from '@prisma/client'; import { Chart } from 'chart.js'; import { addIcons } from 'ionicons'; import { openOutline } from 'ionicons/icons'; @@ -37,6 +40,7 @@ import { filter } from 'rxjs/operators'; import { GfFooterComponent } from './components/footer/footer.component'; import { GfHeaderComponent } from './components/header/header.component'; import { GfHoldingDetailDialogComponent } from './components/holding-detail-dialog/holding-detail-dialog.component'; +import { HoldingDetailDialogResult } from './components/holding-detail-dialog/interfaces/interfaces'; import { GfAppQueryParams } from './interfaces/interfaces'; import { ImpersonationStorageService } from './services/impersonation-storage.service'; import { UserService } from './services/user/user.service'; @@ -53,12 +57,12 @@ export class GfAppComponent implements OnInit { public currentRoute: string; public currentSubRoute: string; public deviceType: string; - public hasImpersonationId: boolean; public hasInfoMessage: boolean; public hasPermissionToChangeDateRange: boolean; public hasPermissionToChangeFilters: boolean; public hasPromotion = false; public hasTabs = false; + public impersonationId: string | null; public info: InfoItem; public pageTitle: string; public routerLinkRegister = publicRoutes.register.routerLink; @@ -112,7 +116,7 @@ export class GfAppComponent implements OnInit { .onChangeHasImpersonation() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((impersonationId) => { - this.hasImpersonationId = !!impersonationId; + this.impersonationId = impersonationId; }); this.router.events @@ -131,7 +135,10 @@ export class GfAppComponent implements OnInit { this.currentSubRoute === internalRoutes.home.subRoutes?.holdings.path) || (this.currentRoute === internalRoutes.portfolio.path && - !this.currentSubRoute)) && + !this.currentSubRoute) || + (this.currentRoute === internalRoutes.portfolio.path && + this.currentSubRoute === + internalRoutes.portfolio.subRoutes?.activities.path)) && this.user?.settings?.viewMode !== 'ZEN' ) { this.hasPermissionToChangeDateRange = true; @@ -269,10 +276,7 @@ export class GfAppComponent implements OnInit { private openHoldingDetailDialog({ dataSource, symbol - }: { - dataSource: DataSource; - symbol: string; - }) { + }: AssetProfileIdentifier) { this.userService .get() .pipe(takeUntilDestroyed(this.destroyRef)) @@ -287,13 +291,12 @@ export class GfAppComponent implements OnInit { baseCurrency: this.user?.settings?.baseCurrency, colorScheme: this.user?.settings?.colorScheme, deviceType: this.deviceType, - hasImpersonationId: this.hasImpersonationId, hasPermissionToAccessAdminControl: hasPermission( this.user?.permissions, permissions.accessAdminControl ), hasPermissionToCreateActivity: - !this.hasImpersonationId && + !this.impersonationId && hasPermission( this.user?.permissions, permissions.createActivity @@ -304,12 +307,13 @@ export class GfAppComponent implements OnInit { permissions.reportDataGlitch ), hasPermissionToUpdateActivity: - !this.hasImpersonationId && + !this.impersonationId && hasPermission( this.user?.permissions, permissions.updateActivity ) && !this.user?.settings?.isRestrictedView, + impersonationId: this.impersonationId, locale: this.user?.settings?.locale }, height: this.deviceType === 'mobile' ? '98vh' : '80vh', @@ -319,7 +323,11 @@ export class GfAppComponent implements OnInit { dialogRef .afterClosed() .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { + .subscribe((result: HoldingDetailDialogResult) => { + if (result?.isNavigating) { + return; + } + void this.router.navigate([], { queryParams: { dataSource: null, diff --git a/apps/client/src/app/app.routes.ts b/apps/client/src/app/app.routes.ts index 9588cee68..2c0591b2c 100644 --- a/apps/client/src/app/app.routes.ts +++ b/apps/client/src/app/app.routes.ts @@ -128,8 +128,7 @@ export const routes: Routes = [ import('./pages/webauthn/webauthn-page.component').then( (c) => c.GfWebauthnPageComponent ), - path: internalRoutes.webauthn.path, - title: internalRoutes.webauthn.title + path: internalRoutes.webauthn.path }, { path: internalRoutes.zen.path, 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 8ba906e0f..9d87cbd79 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 @@ -2,14 +2,14 @@ - - @@ -105,3 +105,14 @@
Alias + {{ element.alias }} Grantee + {{ element.grantee }}
+ +@if (isLoading()) { + +} 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 122b4f88b..00c5c1a28 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 @@ -31,6 +31,7 @@ import { removeCircleOutline } from 'ionicons/icons'; import ms from 'ms'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -40,6 +41,7 @@ import ms from 'ms'; MatButtonModule, MatMenuModule, MatTableModule, + NgxSkeletonLoaderModule, RouterModule ], schemas: [CUSTOM_ELEMENTS_SCHEMA], @@ -68,6 +70,10 @@ export class GfAccessTableComponent { return columns; }); + protected readonly isLoading = computed(() => { + return !this.accesses(); + }); + private readonly clipboard = inject(Clipboard); private readonly notificationService = inject(NotificationService); private readonly snackBar = inject(MatSnackBar); 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 a429d9e64..a0350ee6b 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,8 +1,9 @@ import { GfInvestmentChartComponent } from '@ghostfolio/client/components/investment-chart/investment-chart.component'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { + DEFAULT_DATE_RANGE, DEFAULT_PAGE_SIZE, - NUMERICAL_PRECISION_THRESHOLD_6_FIGURES + NUMERICAL_PRECISION_THRESHOLD_4_FIGURES } from '@ghostfolio/common/config'; import { CreateAccountBalanceDto } from '@ghostfolio/common/dtos'; import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper'; @@ -13,14 +14,19 @@ import { PortfolioPosition, User } from '@ghostfolio/common/interfaces'; -import { hasPermission, permissions } from '@ghostfolio/common/permissions'; -import { internalRoutes } from '@ghostfolio/common/routes/routes'; +import { + hasPermission, + hasReadRestrictedAccessPermission, + permissions +} from '@ghostfolio/common/permissions'; import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer'; import { GfDialogHeaderComponent } from '@ghostfolio/ui/dialog-header'; import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table'; +import { translate } from '@ghostfolio/ui/i18n'; import { DataService } from '@ghostfolio/ui/services'; +import { GfTagsSelectorComponent } from '@ghostfolio/ui/tags-selector'; import { GfValueComponent } from '@ghostfolio/ui/value'; import { @@ -40,21 +46,26 @@ import { PageEvent } from '@angular/material/paginator'; import { Sort, SortDirection } from '@angular/material/sort'; import { MatTableDataSource } from '@angular/material/table'; import { MatTabsModule } from '@angular/material/tabs'; -import { Router } from '@angular/router'; +import { NavigationStart, Router } from '@angular/router'; import { IonIcon } from '@ionic/angular/standalone'; +import { Tag } from '@prisma/client'; import { Big } from 'big.js'; import { format, parseISO } from 'date-fns'; import { addIcons } from 'ionicons'; import { albumsOutline, cashOutline, + readerOutline, swapVerticalOutline } from 'ionicons/icons'; import { isNumber } from 'lodash'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; -import { forkJoin } from 'rxjs'; +import { filter, forkJoin } from 'rxjs'; -import { AccountDetailDialogParams } from './interfaces/interfaces'; +import { + AccountDetailDialogParams, + AccountDetailDialogResult +} from './interfaces/interfaces'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -66,6 +77,7 @@ import { AccountDetailDialogParams } from './interfaces/interfaces'; GfDialogHeaderComponent, GfHoldingsTableComponent, GfInvestmentChartComponent, + GfTagsSelectorComponent, GfValueComponent, IonIcon, MatButtonModule, @@ -94,7 +106,7 @@ export class GfAccountDetailDialogComponent implements OnInit { protected holdings: PortfolioPosition[]; protected interestInBaseCurrency: number; protected interestInBaseCurrencyPrecision = 2; - protected isLoadingActivities: boolean; + protected isLoading = true; protected isLoadingChart: boolean; protected name: string | null; protected pageIndex = 0; @@ -102,6 +114,7 @@ export class GfAccountDetailDialogComponent implements OnInit { protected platformName: string; protected sortColumn = 'date'; protected sortDirection: SortDirection = 'desc'; + protected tags: Tag[]; protected totalItems: number; protected user: User; protected valueInBaseCurrency: number; @@ -112,11 +125,24 @@ export class GfAccountDetailDialogComponent implements OnInit { private readonly dataService = inject(DataService); private readonly destroyRef = inject(DestroyRef); private readonly dialogRef = - inject>(MatDialogRef); + inject< + MatDialogRef + >(MatDialogRef); private readonly router = inject(Router); private readonly userService = inject(UserService); public constructor() { + this.router.events + .pipe( + filter((event) => { + return event instanceof NavigationStart; + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => { + this.dialogRef.close({ isNavigating: true }); + }); + this.userService.stateChanged .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((state) => { @@ -132,7 +158,12 @@ export class GfAccountDetailDialogComponent implements OnInit { } }); - addIcons({ albumsOutline, cashOutline, swapVerticalOutline }); + addIcons({ + albumsOutline, + cashOutline, + readerOutline, + swapVerticalOutline + }); } public ngOnInit() { @@ -154,17 +185,6 @@ export class GfAccountDetailDialogComponent implements OnInit { this.fetchActivities(); } - protected onCloneActivity(aActivity: Activity) { - this.router.navigate( - internalRoutes.portfolio.subRoutes.activities.routerLink, - { - queryParams: { activityId: aActivity.id, createDialog: true } - } - ); - - this.dialogRef.close(); - } - protected onClose() { this.dialogRef.close(); } @@ -207,20 +227,12 @@ export class GfAccountDetailDialogComponent implements OnInit { this.fetchActivities(); } - protected onUpdateActivity(aActivity: Activity) { - this.router.navigate( - internalRoutes.portfolio.subRoutes.activities.routerLink, - { - queryParams: { activityId: aActivity.id, editDialog: true } - } - ); - - this.dialogRef.close(); - } - protected showValuesInPercentage() { return ( - this.data.hasImpersonationId || this.user?.settings?.isRestrictedView + hasReadRestrictedAccessPermission({ + accesses: this.user?.access, + impersonationId: this.data.impersonationId + }) || this.user?.settings?.isRestrictedView ); } @@ -237,6 +249,7 @@ export class GfAccountDetailDialogComponent implements OnInit { interestInBaseCurrency, name, platform, + tags, value, valueInBaseCurrency }) => { @@ -244,7 +257,7 @@ export class GfAccountDetailDialogComponent implements OnInit { this.balance = balance; if ( - this.balance >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES && + this.balance >= NUMERICAL_PRECISION_THRESHOLD_4_FIGURES && this.data.deviceType === 'mobile' ) { this.balancePrecision = 0; @@ -256,7 +269,7 @@ export class GfAccountDetailDialogComponent implements OnInit { if ( this.data.deviceType === 'mobile' && this.dividendInBaseCurrency >= - NUMERICAL_PRECISION_THRESHOLD_6_FIGURES + NUMERICAL_PRECISION_THRESHOLD_4_FIGURES ) { this.dividendInBaseCurrencyPrecision = 0; } @@ -266,7 +279,7 @@ export class GfAccountDetailDialogComponent implements OnInit { if ( this.data.deviceType === 'mobile' && - this.equity >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES + this.equity >= NUMERICAL_PRECISION_THRESHOLD_4_FIGURES ) { this.equityPrecision = 0; } @@ -279,23 +292,32 @@ export class GfAccountDetailDialogComponent implements OnInit { if ( this.data.deviceType === 'mobile' && this.interestInBaseCurrency >= - NUMERICAL_PRECISION_THRESHOLD_6_FIGURES + NUMERICAL_PRECISION_THRESHOLD_4_FIGURES ) { this.interestInBaseCurrencyPrecision = 0; } this.name = name; this.platformName = platform?.name ?? '-'; + + this.tags = + tags?.map((tag) => { + return { + ...tag, + name: translate(tag.name) + }; + }) ?? []; + this.valueInBaseCurrency = valueInBaseCurrency; + this.isLoading = false; + this.changeDetectorRef.markForCheck(); } ); } private fetchActivities() { - this.isLoadingActivities = true; - this.dataService .fetchActivities({ filters: [{ id: this.data.accountId, type: 'ACCOUNT' }], @@ -309,8 +331,6 @@ export class GfAccountDetailDialogComponent implements OnInit { this.dataSource = new MatTableDataSource(activities); this.totalItems = count; - this.isLoadingActivities = false; - this.changeDetectorRef.markForCheck(); }); } @@ -330,9 +350,8 @@ export class GfAccountDetailDialogComponent implements OnInit { type: 'ACCOUNT' } ], - range: 'max', - withExcludedAccounts: true, - withItems: true + range: DEFAULT_DATE_RANGE, + withExcludedAccounts: true }) .pipe(takeUntilDestroyed(this.destroyRef)) }).subscribe({ 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 cd397e35e..cb3246c00 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 @@ -12,6 +12,7 @@ -
-
- Cash Balance -
-
- Equity -
-
- Interest -
-
- Dividend -
-
- Activities -
-
- Platform -
-
- + + + +
Overview
+
+
+
+
+ Cash Balance +
+
+ Equity +
+
+ Interest +
+
+ Dividend +
+
+ + @if (activitiesCount === 1) { + Activity + } @else { + Activities + } + +
+
+ Platform +
+ @if (user?.settings?.isExperimentalFeatures) { +
+ +
+ } +
+
+
@@ -124,16 +158,14 @@ [pageSize]="pageSize" [showAccountColumn]="false" [showActions]=" - !data.hasImpersonationId && data.hasPermissionToCreateActivity && + !data.impersonationId && user?.settings?.isExperimentalFeatures && !user?.settings?.isRestrictedView " [sortColumn]="sortColumn" [sortDirection]="sortDirection" [totalItems]="totalItems" - (activityToClone)="onCloneActivity($event)" - (activityToUpdate)="onUpdateActivity($event)" (export)="onExport()" (pageChanged)="onChangePage($event)" (sortChanged)="onSortChanged($event)" @@ -148,10 +180,11 @@ [accountBalances]="accountBalances" [accountCurrency]="currency" [accountId]="data.accountId" + [currentBalance]="balance" [locale]="user?.settings?.locale" [showActions]=" - !data.hasImpersonationId && hasPermissionToDeleteAccountBalance && + !data.impersonationId && !user.settings.isRestrictedView " (accountBalanceCreated)="onAddAccountBalance($event)" 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 01c84e956..0e7d04f2c 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 @@ -1,6 +1,10 @@ export interface AccountDetailDialogParams { accountId: string; deviceType: string; - hasImpersonationId: boolean; hasPermissionToCreateActivity: boolean; + impersonationId: string | null; +} + +export interface AccountDetailDialogResult { + isNavigating?: boolean; } 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 b4c228881..fd90bff2c 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,8 +1,5 @@ -import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { - BULL_BOARD_COOKIE_NAME, - BULL_BOARD_ROUTE, DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_LOW, DATA_GATHERING_QUEUE_PRIORITY_MEDIUM, @@ -10,7 +7,6 @@ import { } from '@ghostfolio/common/config'; import { getDateWithTimeFormatString } from '@ghostfolio/common/helper'; import { AdminJobs, User } from '@ghostfolio/common/interfaces'; -import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { NotificationService } from '@ghostfolio/ui/notifications'; import { AdminService } from '@ghostfolio/ui/services'; @@ -106,7 +102,6 @@ export class GfAdminJobsComponent implements OnInit { 'actions' ]; - protected hasPermissionToAccessBullBoard = false; protected isLoading = false; protected readonly statusFilterOptions = QUEUE_JOB_STATUS_LIST; @@ -116,7 +111,6 @@ export class GfAdminJobsComponent implements OnInit { private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly destroyRef = inject(DestroyRef); private readonly notificationService = inject(NotificationService); - private readonly tokenStorageService = inject(TokenStorageService); private readonly userService = inject(UserService); public constructor() { @@ -129,11 +123,6 @@ export class GfAdminJobsComponent implements OnInit { this.defaultDateTimeFormat = getDateWithTimeFormatString( this.user.settings.locale ); - - this.hasPermissionToAccessBullBoard = hasPermission( - this.user.permissions, - permissions.accessAdminControlBullBoard - ); } }); @@ -193,18 +182,6 @@ export class GfAdminJobsComponent implements OnInit { }); } - protected onOpenBullBoard() { - const token = this.tokenStorageService.getToken(); - - document.cookie = [ - `${BULL_BOARD_COOKIE_NAME}=${encodeURIComponent(token)}`, - 'path=/', - 'SameSite=Strict' - ].join('; '); - - window.open(BULL_BOARD_ROUTE, '_blank'); - } - protected onViewData(aData: AdminJobs['jobs'][0]['data']) { this.notificationService.alert({ title: JSON.stringify(aData, null, ' ') 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 d57704b86..fe893690b 100644 --- a/apps/client/src/app/components/admin-jobs/admin-jobs.html +++ b/apps/client/src/app/components/admin-jobs/admin-jobs.html @@ -1,15 +1,6 @@
- @if (hasPermissionToAccessBullBoard) { -
- -
- } -
@@ -36,7 +27,7 @@ diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts index 72a3c337a..8592070ee 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts @@ -1,20 +1,20 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; import { DEFAULT_COLOR_SCHEME, - DEFAULT_PAGE_SIZE, - locale + DEFAULT_LOCALE, + DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config'; -import { getDateFormatString } from '@ghostfolio/common/helper'; +import { canDeleteAssetProfile } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, + AssetProfileItem, Filter, InfoItem, User } from '@ghostfolio/common/interfaces'; -import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; -import { GfSymbolPipe } from '@ghostfolio/common/pipes'; import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter'; +import { GfFabComponent } from '@ghostfolio/ui/fab'; import { translate } from '@ghostfolio/ui/i18n'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { AdminService, DataService } from '@ghostfolio/ui/services'; @@ -43,6 +43,7 @@ import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSort, MatSortModule, @@ -64,6 +65,7 @@ import { ellipsisVertical, trashOutline } from 'ionicons/icons'; +import ms from 'ms'; import { DeviceDetectorService } from 'ngx-device-detector'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { Subject } from 'rxjs'; @@ -77,18 +79,18 @@ import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/in @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - host: { class: 'has-fab' }, imports: [ CommonModule, GfActivitiesFilterComponent, + GfFabComponent, GfPremiumIndicatorComponent, - GfSymbolPipe, GfValueComponent, IonIcon, MatButtonModule, MatCheckboxModule, MatMenuModule, MatPaginatorModule, + MatSnackBarModule, MatSortModule, MatTableModule, NgxSkeletonLoaderModule, @@ -101,6 +103,7 @@ import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/in }) export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { protected readonly adminMarketDataService = inject(AdminMarketDataService); + protected readonly allFilters: Filter[] = [ ...Object.keys(AssetSubClass) .filter((assetSubClass) => { @@ -146,16 +149,17 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { type: 'PRESET_ID' as Filter['type'] } ]; - protected dataSource = new MatTableDataSource(); - protected defaultDateFormat: string; + protected readonly canDeleteAssetProfile = canDeleteAssetProfile; + protected dataSource = new MatTableDataSource(); protected readonly displayedColumns: string[] = []; protected readonly filters$ = new Subject(); protected isLoading = true; protected readonly isUUID = isUUID; protected pageSize = DEFAULT_PAGE_SIZE; protected placeholder = ''; - protected readonly selection = new SelectionModel(true); + protected readonly selection = new SelectionModel(true); protected totalItems = 0; + protected readonly translate = translate; protected user: User; private activeFilters: Filter[] = []; @@ -176,6 +180,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { private readonly dialog = inject(MatDialog); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); + private readonly snackBar = inject(MatSnackBar); private readonly userService = inject(UserService); public constructor() { @@ -230,10 +235,6 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { .subscribe((state) => { if (state?.user) { this.user = state.user; - - this.defaultDateFormat = getDateFormatString( - this.user.settings.locale - ); } }); @@ -242,7 +243,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { .subscribe((filters) => { this.activeFilters = filters; - this.loadData(); + this.reloadData({ pageIndex: 0 }); }); addIcons({ @@ -288,25 +289,24 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { dataSource, symbol }: AssetProfileIdentifier) { - this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol }); + this.adminMarketDataService + .deleteAssetProfile({ dataSource, symbol }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.reloadData(); + }); } protected onDeleteAssetProfiles() { - this.adminMarketDataService.deleteAssetProfiles( - this.selection.selected.map(({ dataSource, symbol }) => { - return { dataSource, symbol }; - }) - ); - } - - protected onGather7Days() { - this.adminService - .gather7Days() + this.adminMarketDataService + .deleteAssetProfiles( + this.selection.selected.map(({ dataSource, symbol }) => { + return { dataSource, symbol }; + }) + ) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { - setTimeout(() => { - window.location.reload(); - }, 300); + this.reloadData(); }); } @@ -315,9 +315,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { .gatherMax() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { - setTimeout(() => { - window.location.reload(); - }, 300); + this.notifyDataGatheringHasBeenStarted(); }); } @@ -325,7 +323,18 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { this.adminService .gatherProfileData() .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(); + .subscribe(() => { + this.notifyDataGatheringHasBeenStarted(); + }); + } + + protected onGatherRecentMarketData() { + this.adminService + .gatherRecentMarketData() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.notifyDataGatheringHasBeenStarted(); + }); } protected onOpenAssetProfileDialog({ @@ -369,8 +378,8 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { this.selection.clear(); - this.adminService - .fetchAdminMarketData({ + this.dataService + .fetchAssetProfiles({ sortColumn, sortDirection, filters: this.activeFilters, @@ -378,15 +387,15 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { take: this.pageSize }) .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(({ count, marketData }) => { + .subscribe(({ assetProfiles, count }) => { this.totalItems = count; this.dataSource = new MatTableDataSource( - marketData.map((marketDataItem) => { + assetProfiles.map((assetProfile) => { return { - ...marketDataItem, + ...assetProfile, isBenchmark: this.benchmarks.some(({ id }) => { - return id === marketDataItem.id; + return id === assetProfile.id; }) }; }) @@ -399,13 +408,20 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { }); } + private notifyDataGatheringHasBeenStarted() { + this.snackBar.open( + '✅ ' + $localize`Data gathering has been started.`, + undefined, + { + duration: ms('3 seconds') + } + ); + } + private openAssetProfileDialog({ dataSource, symbol - }: { - dataSource: DataSource; - symbol: string; - }) { + }: AssetProfileIdentifier) { this.userService .get() .pipe(takeUntilDestroyed(this.destroyRef)) @@ -414,7 +430,8 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { const dialogRef = this.dialog.open< GfAssetProfileDialogComponent, - AssetProfileDialogParams + AssetProfileDialogParams, + AssetProfileIdentifier >(GfAssetProfileDialogComponent, { autoFocus: false, data: { @@ -423,7 +440,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { colorScheme: this.user?.settings.colorScheme ?? DEFAULT_COLOR_SCHEME, deviceType: this.deviceType(), - locale: this.user?.settings?.locale ?? locale + locale: this.user?.settings?.locale ?? DEFAULT_LOCALE } satisfies AssetProfileDialogParams, height: this.deviceType() === 'mobile' ? '98vh' : '80vh', width: this.deviceType() === 'mobile' ? '100vw' : '50rem' @@ -432,15 +449,15 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { dialogRef .afterClosed() .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe( - (newAssetProfileIdentifier: AssetProfileIdentifier | undefined) => { - if (newAssetProfileIdentifier) { - this.onOpenAssetProfileDialog(newAssetProfileIdentifier); - } else { - this.router.navigate(['.'], { relativeTo: this.route }); - } + .subscribe((newAssetProfileIdentifier) => { + if (newAssetProfileIdentifier) { + this.onOpenAssetProfileDialog(newAssetProfileIdentifier); + } else { + this.reloadData(); + + this.router.navigate(['.'], { relativeTo: this.route }); } - ); + }); }); } @@ -458,7 +475,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { autoFocus: false, data: { deviceType: this.deviceType(), - locale: this.user?.settings?.locale ?? locale + locale: this.user?.settings?.locale ?? DEFAULT_LOCALE } satisfies CreateAssetProfileDialogParams, width: this.deviceType() === 'mobile' ? '100vw' : '50rem' }); @@ -490,4 +507,14 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { }); }); } + + private reloadData({ + pageIndex = this.paginator().pageIndex + }: { pageIndex?: number } = {}) { + this.loadData({ + pageIndex, + sortColumn: this.sort().active, + sortDirection: this.sort().direction + }); + } } 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 e2c6d1a87..f6744b263 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 @@ -52,7 +52,7 @@ @if ( - adminMarketDataService.hasPermissionToDeleteAssetProfile({ + canDeleteAssetProfile({ activitiesCount: element.activitiesCount, isBenchmark: element.isBenchmark, symbol: element.symbol, @@ -87,13 +87,15 @@ > Name - +
{{ element.name }}
@if (!isUUID(element.symbol)) {
- {{ - element.symbol | gfSymbol - }} + {{ element.symbol }}
} @@ -113,8 +115,17 @@ Asset Class - - {{ element.assetClass }} + +
{{ translate(element.assetClass) }}
+ @if (element.assetClass) { +
+ {{ element.assetClass }} +
+ }
@@ -122,8 +133,17 @@ Asset Sub Class - - {{ element.assetSubClass }} + +
{{ translate(element.assetSubClass) }}
+ @if (element.assetSubClass) { +
+ {{ element.assetSubClass }} +
+ } @@ -147,7 +167,13 @@ First Activity - {{ (element.date | date: defaultDateFormat) ?? '' }} + @if (element.date) { + + } @@ -220,7 +246,7 @@ class="no-max-width" xPosition="before" > - @@ -271,10 +308,11 @@
+
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 index 9528687a8..c4d45b7f0 100644 --- 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 @@ -1,19 +1,10 @@ -import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config'; import { ConfirmationDialogType } from '@ghostfolio/common/enums'; -import { - getCurrencyFromSymbol, - isDerivedCurrency, - isRootCurrency -} from '@ghostfolio/common/helper'; -import { - AssetProfileIdentifier, - AdminMarketDataItem -} from '@ghostfolio/common/interfaces'; +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { NotificationService } from '@ghostfolio/ui/notifications'; import { AdminService } from '@ghostfolio/ui/services'; import { Injectable } from '@angular/core'; -import { EMPTY, catchError, finalize, forkJoin } from 'rxjs'; +import { EMPTY, Subject, catchError, finalize, forkJoin } from 'rxjs'; @Injectable() export class AdminMarketDataService { @@ -23,24 +14,30 @@ export class AdminMarketDataService { ) {} public deleteAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) { + const assetProfileDeleted = new Subject(); + this.notificationService.confirm({ confirmFn: () => { this.adminService .deleteProfileData({ dataSource, symbol }) .subscribe(() => { - setTimeout(() => { - window.location.reload(); - }, 300); + assetProfileDeleted.next(); + assetProfileDeleted.complete(); }); }, confirmType: ConfirmationDialogType.Warn, title: $localize`Do you really want to delete this asset profile?` }); + + return assetProfileDeleted.asObservable(); } public deleteAssetProfiles( aAssetProfileIdentifiers: AssetProfileIdentifier[] ) { + const assetProfileCount = aAssetProfileIdentifiers.length; + const assetProfilesDeleted = new Subject(); + this.notificationService.confirm({ confirmFn: () => { const deleteRequests = aAssetProfileIdentifiers.map( @@ -53,38 +50,28 @@ export class AdminMarketDataService { .pipe( catchError(() => { this.notificationService.alert({ - title: $localize`Oops! Could not delete profiles.` + title: + assetProfileCount === 1 + ? $localize`Oops! Could not delete the asset profile.` + : $localize`Oops! Could not delete the asset profiles.` }); return EMPTY; }), finalize(() => { - window.location.reload(); + assetProfilesDeleted.next(); + assetProfilesDeleted.complete(); }) ) .subscribe(); }, confirmType: ConfirmationDialogType.Warn, - title: $localize`Do you really want to delete these profiles?` + title: + assetProfileCount === 1 + ? $localize`Do you really want to delete this asset profile?` + : $localize`Do you really want to delete these ${assetProfileCount}:count: asset profiles?` }); - } - public hasPermissionToDeleteAssetProfile({ - activitiesCount, - isBenchmark, - symbol, - watchedByCount - }: Pick< - AdminMarketDataItem, - 'activitiesCount' | 'isBenchmark' | 'symbol' | 'watchedByCount' - >) { - return ( - activitiesCount === 0 && - !isBenchmark && - !isDerivedCurrency(getCurrencyFromSymbol(symbol)) && - !isRootCurrency(getCurrencyFromSymbol(symbol)) && - !symbol.startsWith(ghostfolioScraperApiSymbolPrefix) && - watchedByCount === 0 - ); + return assetProfilesDeleted.asObservable(); } } diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss index 73c0c0d74..db23cf0a7 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss @@ -14,4 +14,8 @@ top: 0; } } + + .mat-mdc-dialog-title { + padding-right: 0.5rem !important; + } } 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 f0721c87f..8fc012488 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 @@ -5,10 +5,17 @@ import { PROPERTY_IS_DATA_GATHERING_ENABLED } from '@ghostfolio/common/config'; import { UpdateAssetProfileDto } from '@ghostfolio/common/dtos'; +import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { + canDeleteAssetProfile, DATE_FORMAT, + getCountryName, getCurrencyFromSymbol, - isCurrency + getDateFormatString, + getStringOrNull, + getStringOrUndefined, + isCurrency, + isSplitRatio } from '@ghostfolio/common/helper'; import { AdminMarketDataDetails, @@ -32,6 +39,7 @@ import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplet import { GfValueComponent } from '@ghostfolio/ui/value'; import { TextFieldModule } from '@angular/cdk/text-field'; +import { CommonModule } from '@angular/common'; import { HttpErrorResponse } from '@angular/common/http'; import { ChangeDetectionStrategy, @@ -58,6 +66,7 @@ import { MatCheckboxChange, MatCheckboxModule } from '@angular/material/checkbox'; +import { MatDatepickerModule } from '@angular/material/datepicker'; import { MAT_DIALOG_DATA, MatDialogModule, @@ -71,7 +80,10 @@ import { MatTabsModule } from '@angular/material/tabs'; import { IonIcon } from '@ionic/angular/standalone'; import { AssetClass, + AssetProfileSplit, AssetSubClass, + DataGatheringFrequency, + DataSource, MarketData, Prisma, SymbolProfile @@ -81,11 +93,14 @@ import { format } from 'date-fns'; import { StatusCodes } from 'http-status-codes'; import { addIcons } from 'ionicons'; import { + calendarClearOutline, codeSlashOutline, createOutline, ellipsisVertical, + gitCompareOutline, readerOutline, - serverOutline + serverOutline, + trashOutline } from 'ionicons/icons'; import { isBoolean } from 'lodash'; import ms from 'ms'; @@ -98,6 +113,7 @@ import { AssetProfileDialogParams } from './interfaces/interfaces'; changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'd-flex flex-column h-100' }, imports: [ + CommonModule, FormsModule, GfCurrencySelectorComponent, GfEntityLogoComponent, @@ -109,6 +125,7 @@ import { AssetProfileDialogParams } from './interfaces/interfaces'; IonIcon, MatButtonModule, MatCheckboxModule, + MatDatepickerModule, MatDialogModule, MatInputModule, MatMenuModule, @@ -153,6 +170,7 @@ export class GfAssetProfileDialogComponent implements OnInit { comment: '', countries: ['', jsonValidator()], currency: '', + dataGatheringFrequency: new FormControl('DAILY'), historicalData: this.formBuilder.group({ csvString: '' }), @@ -188,6 +206,27 @@ export class GfAssetProfileDialogComponent implements OnInit { } ); + protected readonly assetProfileSplitForm = this.formBuilder.group( + { + date: new FormControl(null, Validators.required), + denominator: new FormControl(null, Validators.required), + numerator: new FormControl(null, Validators.required) + }, + { + validators: (control: AbstractControl): ValidationErrors | null => { + const { denominator, numerator } = control.value as { + denominator: number; + numerator: number; + }; + + return isSplitRatio({ denominator, numerator }) + ? null + : { invalidSplitRatio: true }; + } + } + ); + + protected readonly canDeleteAssetProfile = canDeleteAssetProfile; protected canEditAssetProfile = true; protected countries: { @@ -196,6 +235,22 @@ export class GfAssetProfileDialogComponent implements OnInit { protected currencies: string[] = []; + protected readonly dataGatheringFrequencyValues: { + value: DataGatheringFrequency; + viewValue: string; + }[] = [ + { + value: 'DAILY', + viewValue: $localize`Daily` + }, + { + value: 'HOURLY', + viewValue: $localize`Hourly` + } + ]; + + protected readonly DataSource = DataSource; + protected readonly dateRangeOptions = [ { label: $localize`Current week` + ' (' + $localize`WTD` + ')', @@ -222,10 +277,13 @@ export class GfAssetProfileDialogComponent implements OnInit { value: 'max' } ]; + protected defaultDateFormat: string; + protected readonly getCountryName = getCountryName; protected historicalDataItems: LineChartItem[]; protected isBenchmark = false; protected isDataGatheringEnabled: boolean; protected isEditAssetProfileIdentifierMode = false; + protected isLoading = true; protected readonly isUUID = isUUID; protected marketDataItems: MarketData[] = []; @@ -244,6 +302,10 @@ export class GfAssetProfileDialogComponent implements OnInit { [name: string]: { name: string; value: number }; }; + protected splits: AssetProfileSplit[] = []; + + protected readonly translate = translate; + protected user: User; private benchmarks: Partial[]; @@ -255,18 +317,24 @@ export class GfAssetProfileDialogComponent implements OnInit { @Inject(MAT_DIALOG_DATA) protected data: AssetProfileDialogParams, private dataService: DataService, private destroyRef: DestroyRef, - private dialogRef: MatDialogRef, + private dialogRef: MatDialogRef< + GfAssetProfileDialogComponent, + AssetProfileIdentifier + >, private formBuilder: FormBuilder, private notificationService: NotificationService, private snackBar: MatSnackBar, private userService: UserService ) { addIcons({ + calendarClearOutline, codeSlashOutline, createOutline, ellipsisVertical, + gitCompareOutline, readerOutline, - serverOutline + serverOutline, + trashOutline }); } @@ -279,6 +347,7 @@ export class GfAssetProfileDialogComponent implements OnInit { this.benchmarks = benchmarks; this.currencies = currencies; + this.defaultDateFormat = getDateFormatString(this.data.locale); this.initialize(); } @@ -333,8 +402,9 @@ export class GfAssetProfileDialogComponent implements OnInit { symbol: this.data.symbol }) .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(({ assetProfile, marketData }) => { + .subscribe(({ assetProfile, marketData, splits }) => { this.assetProfile = assetProfile; + this.splits = splits ?? []; this.assetClassLabel = translate(this.assetProfile?.assetClass ?? ''); this.assetSubClassLabel = translate( @@ -365,9 +435,9 @@ export class GfAssetProfileDialogComponent implements OnInit { this.assetProfile?.countries && this.assetProfile.countries.length > 0 ) { - for (const { code, name, weight } of this.assetProfile.countries) { + for (const { code, weight } of this.assetProfile.countries) { this.countries[code] = { - name, + name: getCountryName({ code }), value: weight }; } @@ -379,7 +449,7 @@ export class GfAssetProfileDialogComponent implements OnInit { ) { for (const { name, weight } of this.assetProfile.sectors) { this.sectors[name] = { - name, + name: translate(name), value: weight }; } @@ -395,6 +465,8 @@ export class GfAssetProfileDialogComponent implements OnInit { }) ?? [] ), currency: this.assetProfile?.currency ?? null, + dataGatheringFrequency: + this.assetProfile?.dataGatheringFrequency ?? 'DAILY', historicalData: { csvString: GfAssetProfileDialogComponent.HISTORICAL_DATA_TEMPLATE }, @@ -425,6 +497,8 @@ export class GfAssetProfileDialogComponent implements OnInit { this.assetProfileForm.markAsPristine(); + this.isLoading = false; + this.changeDetectorRef.markForCheck(); }); } @@ -443,13 +517,29 @@ export class GfAssetProfileDialogComponent implements OnInit { this.dialogRef.close(); } + protected onConvertToManualDataSource() { + this.patchAssetProfileIdentifier({ + getErrorMessage: () => { + return ( + '😞 ' + + $localize`An error occurred while converting the data source to ${DataSource.MANUAL}.` + ); + }, + title: $localize`Do you really want to convert the data source to ${DataSource.MANUAL}?`, + updateAssetProfileDto: { dataSource: DataSource.MANUAL } + }); + } + protected onDeleteProfileData({ dataSource, symbol }: AssetProfileIdentifier) { - this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol }); - - this.dialogRef.close(); + this.adminMarketDataService + .deleteAssetProfile({ dataSource, symbol }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.dialogRef.close(); + }); } protected onGatherProfileDataBySymbol({ @@ -475,6 +565,45 @@ export class GfAssetProfileDialogComponent implements OnInit { .subscribe(); } + protected onAddSplit() { + const { date, denominator, numerator } = + this.assetProfileSplitForm.getRawValue(); + + if (!date || !denominator || !numerator) { + return; + } + + this.adminService + .postAssetProfileSplit({ + dataSource: this.data.dataSource, + split: { + denominator, + numerator, + date: format(date, DATE_FORMAT) + }, + symbol: this.data.symbol + }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.assetProfileSplitForm.reset(); + + this.initialize(); + }); + } + + protected onDeleteSplit(aId: string) { + this.adminService + .deleteAssetProfileSplit({ + dataSource: this.data.dataSource, + id: aId, + symbol: this.data.symbol + }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.initialize(); + }); + } + protected onMarketDataChanged(withRefresh: boolean = false) { if (withRefresh) { this.initialize(); @@ -524,9 +653,10 @@ export class GfAssetProfileDialogComponent implements OnInit { this.assetProfileForm.controls.scraperConfiguration.controls.headers .value ?? '{}' ) as Record, - locale: + locale: getStringOrUndefined( this.assetProfileForm.controls.scraperConfiguration.controls.locale - ?.value ?? undefined, + ?.value + ), mode: this.assetProfileForm.controls.scraperConfiguration.controls.mode ?.value ?? undefined, @@ -575,13 +705,16 @@ export class GfAssetProfileDialogComponent implements OnInit { assetClass: this.assetProfileForm.controls.assetClass.value ?? undefined, assetSubClass: this.assetProfileForm.controls.assetSubClass.value ?? undefined, - comment: this.assetProfileForm.controls.comment.value || undefined, + comment: getStringOrNull(this.assetProfileForm.controls.comment.value), currency: this.assetProfileForm.controls.currency.value ?? undefined, + dataGatheringFrequency: + this.assetProfileForm.controls.dataGatheringFrequency.value ?? + undefined, isActive: isBoolean(this.assetProfileForm.controls.isActive.value) ? this.assetProfileForm.controls.isActive.value : undefined, - name: this.assetProfileForm.controls.name.value || undefined, - url: this.assetProfileForm.controls.url.value || undefined + name: this.assetProfileForm.controls.name.value ?? undefined, + url: getStringOrNull(this.assetProfileForm.controls.url.value) }; try { @@ -639,13 +772,16 @@ export class GfAssetProfileDialogComponent implements OnInit { } protected async onSubmitAssetProfileIdentifierForm() { + const newAssetProfileIdentifier = + this.assetProfileIdentifierForm.controls.assetProfileIdentifier.value; + + if (!newAssetProfileIdentifier?.dataSource) { + return; + } + const assetProfileIdentifier: UpdateAssetProfileDto = { - dataSource: - this.assetProfileIdentifierForm.controls.assetProfileIdentifier.value - ?.dataSource ?? undefined, - symbol: - this.assetProfileIdentifierForm.controls.assetProfileIdentifier.value - ?.symbol ?? undefined + dataSource: newAssetProfileIdentifier.dataSource, + symbol: newAssetProfileIdentifier.symbol }; try { @@ -660,46 +796,19 @@ export class GfAssetProfileDialogComponent implements OnInit { return; } - this.adminService - .patchAssetProfile( - { - dataSource: this.data.dataSource, - symbol: this.data.symbol - }, - assetProfileIdentifier - ) - .pipe( - catchError((error: HttpErrorResponse) => { - if (error.status === StatusCodes.CONFLICT) { - this.snackBar.open( - $localize`${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}) is already in use.`, - undefined, - { - duration: ms('3 seconds') - } - ); - } else { - this.snackBar.open( - $localize`An error occurred while updating to ${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}).`, - undefined, - { - duration: ms('3 seconds') - } - ); - } + this.patchAssetProfileIdentifier({ + getErrorMessage: (error) => { + if (error.status === StatusCodes.CONFLICT) { + // TODO: Ask if the user wants to merge the two asset profiles - return EMPTY; - }), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe(() => { - const newAssetProfileIdentifier = { - dataSource: assetProfileIdentifier.dataSource, - symbol: assetProfileIdentifier.symbol - }; + return $localize`${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}) is already in use.`; + } - this.dialogRef.close(newAssetProfileIdentifier); - }); + return $localize`An error occurred while updating to ${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}).`; + }, + title: $localize`Do you really want to convert this asset profile to ${newAssetProfileIdentifier.symbol} (${newAssetProfileIdentifier.dataSource})?`, + updateAssetProfileDto: assetProfileIdentifier + }); } protected onTestMarketData() { @@ -714,9 +823,10 @@ export class GfAssetProfileDialogComponent implements OnInit { this.assetProfileForm.controls.scraperConfiguration.controls.headers .value ?? '{}' ) as Record, - locale: + locale: getStringOrUndefined( this.assetProfileForm.controls.scraperConfiguration.controls.locale - ?.value || undefined, + ?.value + ), mode: this.assetProfileForm.controls.scraperConfiguration.controls .mode?.value, selector: @@ -797,4 +907,42 @@ export class GfAssetProfileDialogComponent implements OnInit { return null; } + + private patchAssetProfileIdentifier({ + getErrorMessage, + title, + updateAssetProfileDto + }: { + getErrorMessage: (error: HttpErrorResponse) => string; + title: string; + updateAssetProfileDto: UpdateAssetProfileDto; + }) { + this.notificationService.confirm({ + title, + confirmFn: () => { + this.adminService + .patchAssetProfile( + { + dataSource: this.data.dataSource, + symbol: this.data.symbol + }, + updateAssetProfileDto + ) + .pipe( + catchError((error: HttpErrorResponse) => { + this.snackBar.open(getErrorMessage(error), undefined, { + duration: ms('3 seconds') + }); + + return EMPTY; + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(({ dataSource, symbol }) => { + this.dialogRef.close({ dataSource, symbol }); + }); + }, + confirmType: ConfirmationDialogType.Primary + }); + } } 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 a14546973..bcda048f8 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 @@ -1,10 +1,10 @@
-
-

- {{ assetProfile?.name ?? data.symbol }} -

+
+ {{ + assetProfile?.name ?? data.symbol + }} + @if (data.dataSource !== DataSource.MANUAL) { +

or

+ + }
} @else {
- Symbol
@@ -173,6 +192,7 @@ - Currency
ISINFirst ActivityActivitiesSector
@@ -269,7 +296,11 @@ i18n size="medium" [locale]="data.locale" - [value]="assetProfile?.countries[0].name" + [value]=" + getCountryName({ + code: assetProfile?.countries[0].code + }) + " >Country
@@ -440,6 +471,21 @@ >
+
+ + Data Gathering Frequency + + @for ( + dataGatheringFrequencyValue of dataGatheringFrequencyValues; + track dataGatheringFrequencyValue.value + ) { + {{ + dataGatheringFrequencyValue.viewValue + }} + } + + +
@@ -465,6 +511,124 @@ + + @if (false && user?.settings?.isExperimentalFeatures) { + + + +
Splits
+
+
+
+
+ @if (splits.length > 0) { + + + + + + + + + + @for (split of splits; track split.id) { + + + + + + } + +
Date + Split Ratio +
+ {{ split.date | date: defaultDateFormat }} + + {{ split.numerator }}:{{ split.denominator }} + + +
+ } +
+ + Date + + + + + + +
+ + Shares After + + +
:
+ + Shares Before + + +
+
+ +
+
+
+
+
+
+ } @if (assetProfile?.dataSource === 'MANUAL') { diff --git a/apps/client/src/app/components/admin-overview/admin-overview.component.ts b/apps/client/src/app/components/admin-overview/admin-overview.component.ts index 3b8fdc38c..733200c91 100644 --- a/apps/client/src/app/components/admin-overview/admin-overview.component.ts +++ b/apps/client/src/app/components/admin-overview/admin-overview.component.ts @@ -27,6 +27,7 @@ import { GfValueComponent } from '@ghostfolio/ui/value'; import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard'; import { CommonModule } from '@angular/common'; import { + ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, @@ -62,8 +63,11 @@ import { trashOutline } from 'ionicons/icons'; import ms, { StringValue } from 'ms'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import { catchError, of, switchMap } from 'rxjs'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ ClipboardModule, CommonModule, @@ -77,6 +81,7 @@ import ms, { StringValue } from 'ms'; MatSnackBarModule, MatSlideToggleModule, MatTableModule, + NgxSkeletonLoaderModule, ReactiveFormsModule, RouterModule ], @@ -88,13 +93,21 @@ export class GfAdminOverviewComponent implements OnInit { protected activitiesCount: number; protected couponDuration: StringValue = '14 days'; protected readonly couponsDataSource = new MatTableDataSource(); - protected readonly couponsDisplayedColumns = ['code', 'duration', 'actions']; + protected readonly couponsDisplayedColumns = [ + 'code', + 'duration', + 'createdAt', + 'actions' + ]; protected hasPermissionForSubscription: boolean; protected hasPermissionForSystemMessage: boolean; protected hasPermissionToSyncDemoUserAccount: boolean; protected hasPermissionToToggleReadOnlyMode: boolean; protected readonly info: InfoItem; protected isDataGatheringEnabled: boolean; + protected isLoading = false; + protected isReadOnlyMode: boolean; + protected isUserSignupEnabled: boolean; protected readonly permissions = permissions; protected systemMessage: SystemMessage; protected userCount: number; @@ -140,6 +153,8 @@ export class GfAdminOverviewComponent implements OnInit { permissions.toggleReadOnlyMode ); } + + this.changeDetectorRef.markForCheck(); }); addIcons({ @@ -167,6 +182,8 @@ export class GfAdminOverviewComponent implements OnInit { } public ngOnInit() { + this.isLoading = true; + this.fetchAdminData(); } @@ -197,12 +214,20 @@ export class GfAdminOverviewComponent implements OnInit { protected onAddCoupon() { const newCoupon: Coupon = { code: `${ghostfolioPrefix}${this.generateCouponCode(14)}`, + createdAt: new Date().toISOString(), duration: this.couponDuration }; + const hasCopiedCouponCode = this.clipboard.copy(newCoupon.code); + const coupons = [...this.couponsDataSource.data, newCoupon]; - this.saveCoupons({ coupons, codeToCopy: newCoupon.code }); + this.saveCoupons({ + coupons, + snackBarMessage: hasCopiedCouponCode + ? '✅ ' + $localize`${newCoupon.code} has been copied to the clipboard` + : '✅ ' + $localize`Coupon ${newCoupon.code} has been created` + }); } protected onChangeCouponDuration(aCouponDuration: StringValue) { @@ -250,9 +275,15 @@ export class GfAdminOverviewComponent implements OnInit { .flush() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { - setTimeout(() => { - window.location.reload(); - }, 300); + this.dataService.updateInfo(); + + this.snackBar.open( + '✅ ' + $localize`Cache has been flushed.`, + undefined, + { + duration: ms('3 seconds') + } + ); }); }, confirmType: ConfirmationDialogType.Warn, @@ -322,10 +353,17 @@ export class GfAdminOverviewComponent implements OnInit { this.isDataGatheringEnabled = settings[PROPERTY_IS_DATA_GATHERING_ENABLED] === false ? false : true; + this.isReadOnlyMode = settings[PROPERTY_IS_READ_ONLY_MODE] === true; + + this.isUserSignupEnabled = + settings[PROPERTY_IS_USER_SIGNUP_ENABLED] === false ? false : true; + this.systemMessage = settings[PROPERTY_SYSTEM_MESSAGE] as SystemMessage; this.userCount = userCount; this.version = version; + this.isLoading = false; + this.changeDetectorRef.markForCheck(); }); } @@ -348,20 +386,29 @@ export class GfAdminOverviewComponent implements OnInit { .putAdminSetting(key, { value: value || value === false ? JSON.stringify(value) : undefined }) - .pipe(takeUntilDestroyed(this.destroyRef)) + .pipe( + switchMap(() => { + return this.userService.get(true); + }), + catchError(() => { + // Refresh anyway to reflect the actual state of the settings + return of(undefined); + }), + takeUntilDestroyed(this.destroyRef) + ) .subscribe(() => { - setTimeout(() => { - window.location.reload(); - }, 300); + this.dataService.updateInfo(); + + this.fetchAdminData(); }); } private saveCoupons({ - codeToCopy, - coupons + coupons, + snackBarMessage }: { - codeToCopy?: string; coupons: Coupon[]; + snackBarMessage?: string; }) { this.dataService .putAdminSetting(PROPERTY_COUPONS, { @@ -371,14 +418,10 @@ export class GfAdminOverviewComponent implements OnInit { .subscribe(() => { this.couponsDataSource.data = coupons; - if (codeToCopy) { - this.clipboard.copy(codeToCopy); - - this.snackBar.open( - '✅ ' + $localize`${codeToCopy} has been copied to the clipboard`, - undefined, - { duration: ms('3 seconds') } - ); + if (snackBarMessage) { + this.snackBar.open(snackBarMessage, undefined, { + duration: ms('3 seconds') + }); } this.changeDetectorRef.markForCheck(); diff --git a/apps/client/src/app/components/admin-overview/admin-overview.html b/apps/client/src/app/components/admin-overview/admin-overview.html index bccedd251..cc999629b 100644 --- a/apps/client/src/app/components/admin-overview/admin-overview.html +++ b/apps/client/src/app/components/admin-overview/admin-overview.html @@ -7,6 +7,7 @@ i18n size="large" [enableCopyToClipboardButton]="true" + [isLoading]="isLoading" [value]="version" >Version
@@ -19,6 +20,7 @@ Users @@ -66,7 +68,8 @@ @@ -79,6 +82,7 @@ color="primary" hideIcon="true" [checked]="isDataGatheringEnabled" + [disabled]="isLoading" (change)="onEnableDataGatheringChange($event)" /> @@ -89,7 +93,9 @@
@if (systemMessage) {
-
{{ systemMessage | json }}
+
+ {{ systemMessage | json }} +
} - @if (!info?.systemMessage) { + @if (!systemMessage) {
diff --git a/apps/client/src/app/components/admin-platform/admin-platform.component.html b/apps/client/src/app/components/admin-platform/admin-platform.component.html index 44f5a6eab..7c699e557 100644 --- a/apps/client/src/app/components/admin-platform/admin-platform.component.html +++ b/apps/client/src/app/components/admin-platform/admin-platform.component.html @@ -22,13 +22,12 @@ Name - @if (element.url) { - - } + {{ element.name }} @@ -96,3 +95,9 @@ + + diff --git a/apps/client/src/app/components/admin-platform/admin-platform.component.ts b/apps/client/src/app/components/admin-platform/admin-platform.component.ts index a9d135068..727585478 100644 --- a/apps/client/src/app/components/admin-platform/admin-platform.component.ts +++ b/apps/client/src/app/components/admin-platform/admin-platform.component.ts @@ -1,7 +1,8 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config'; import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos'; import { ConfirmationDialogType } from '@ghostfolio/common/enums'; -import { getLocale } from '@ghostfolio/common/helper'; +import { getLocale, getLowercase } from '@ghostfolio/common/helper'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; import { NotificationService } from '@ghostfolio/ui/notifications'; import { AdminService, DataService } from '@ghostfolio/ui/services'; @@ -22,6 +23,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; import { MatMenuModule } from '@angular/material/menu'; +import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator'; import { MatSort, MatSortModule } from '@angular/material/sort'; import { MatTableDataSource, MatTableModule } from '@angular/material/table'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; @@ -33,7 +35,6 @@ import { ellipsisHorizontal, trashOutline } from 'ionicons/icons'; -import { get } from 'lodash'; import { DeviceDetectorService } from 'ngx-device-detector'; import { GfCreateOrUpdatePlatformDialogComponent } from './create-or-update-platform-dialog/create-or-update-platform-dialog.component'; @@ -47,6 +48,7 @@ import { CreateOrUpdatePlatformDialogParams } from './create-or-update-platform- IonIcon, MatButtonModule, MatMenuModule, + MatPaginatorModule, MatSortModule, MatTableModule, RouterModule @@ -60,11 +62,13 @@ export class GfAdminPlatformComponent implements OnInit { protected dataSource = new MatTableDataSource(); protected readonly displayedColumns = ['name', 'url', 'accounts', 'actions']; + protected readonly pageSize = DEFAULT_PAGE_SIZE; protected platforms: Platform[]; private readonly deviceType = computed( () => this.deviceDetectorService.deviceInfo().deviceType ); + private readonly paginator = viewChild.required(MatPaginator); private readonly sort = viewChild.required(MatSort); private readonly adminService = inject(AdminService); @@ -146,8 +150,9 @@ export class GfAdminPlatformComponent implements OnInit { this.platforms = platforms; this.dataSource = new MatTableDataSource(platforms); + this.dataSource.paginator = this.paginator(); this.dataSource.sort = this.sort(); - this.dataSource.sortingDataAccessor = get; + this.dataSource.sortingDataAccessor = getLowercase; this.dataService.updateInfo(); diff --git a/apps/client/src/app/components/admin-settings/admin-settings.component.html b/apps/client/src/app/components/admin-settings/admin-settings.component.html index 76af96c4e..b3ad389d6 100644 --- a/apps/client/src/app/components/admin-settings/admin-settings.component.html +++ b/apps/client/src/app/components/admin-settings/admin-settings.component.html @@ -21,8 +21,8 @@ Get Access @@ -59,7 +59,11 @@
- +
@if (isGhostfolioDataProvider(element)) { newpopular } @@ -105,8 +109,13 @@ @if ( - !isGhostfolioDataProvider(element) || - isGhostfolioApiKeyValid === true + hasGhostfolioApiKey && + isGhostfolioApiKeyValid === false && + isGhostfolioDataProvider(element) + ) { + Invalid API key + } @else if ( + hasGhostfolioApiKey || !isGhostfolioDataProvider(element) ) { } @@ -162,7 +171,7 @@ @if (isGhostfolioDataProvider(element)) { - @if (isGhostfolioApiKeyValid === true) { + @if (hasGhostfolioApiKey) { - + - } @else if (isGhostfolioApiKeyValid === false) { + } @else if (hasGhostfolioApiKey === false) { - diff --git a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html index 4d74c2559..328cccba1 100644 --- a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html +++ b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html @@ -53,6 +53,6 @@
diff --git a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts index d2dc9e1bb..0091ae5d7 100644 --- a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts +++ b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts @@ -1,23 +1,23 @@ import { - getTooltipOptions, - getVerticalHoverLinePlugin + getChartBorderColor, + getChartElementsOptions, + getTimeAxisOptions, + getValueAxisOptions, + getVerticalHoverLinePlugin, + getZeroLineAnnotation } from '@ghostfolio/common/chart-helper'; import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config'; -import { - getBackgroundColor, - getDateFormatString, - getLocale, - getTextColor, - parseDate -} from '@ghostfolio/common/helper'; +import { getLocale, parseDate } from '@ghostfolio/common/helper'; import { LineChartItem, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { ColorScheme } from '@ghostfolio/common/types'; -import { registerChartConfiguration } from '@ghostfolio/ui/chart'; +import { + getTimeSeriesTooltipOptions, + registerChartConfiguration +} from '@ghostfolio/ui/chart'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; -import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -45,7 +45,6 @@ import { type TooltipOptions } from 'chart.js'; import 'chartjs-adapter-date-fns'; -import annotationPlugin from 'chartjs-plugin-annotation'; import { addIcons } from 'ionicons'; import { arrowForwardOutline } from 'ionicons/icons'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; @@ -53,7 +52,6 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, imports: [ - CommonModule, FormsModule, GfPremiumIndicatorComponent, IonIcon, @@ -88,7 +86,6 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy { public constructor() { Chart.register( - annotationPlugin, LinearScale, LineController, LineElement, @@ -170,28 +167,13 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy { data, options: { animation: false, - elements: { - line: { - tension: 0 - }, - point: { - hoverBackgroundColor: getBackgroundColor(this.colorScheme()), - hoverRadius: 2, - radius: 0 - } - }, + elements: getChartElementsOptions(this.colorScheme()), interaction: { intersect: false, mode: 'index' }, maintainAspectRatio: true, plugins: { annotation: { annotations: { - yAxis: { - borderColor: `rgba(${getTextColor(this.colorScheme())}, 0.1)`, - borderWidth: 1, - scaleID: 'y', - type: 'line', - value: 0 - } + yAxis: getZeroLineAnnotation(this.colorScheme()) } }, legend: { @@ -199,54 +181,21 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy { }, tooltip: this.getTooltipPluginConfiguration(), verticalHoverLine: { - color: `rgba(${getTextColor(this.colorScheme())}, 0.1)` + color: getChartBorderColor(this.colorScheme()) } }, responsive: true, scales: { - x: { - border: { - color: `rgba(${getTextColor(this.colorScheme())}, 0.1)`, - width: 1 - }, - display: true, - grid: { - display: false - }, - type: 'time', - time: { - tooltipFormat: getDateFormatString(this.locale()), - unit: 'year' + x: getTimeAxisOptions({ + colorScheme: this.colorScheme(), + locale: this.locale() + }), + y: getValueAxisOptions({ + colorScheme: this.colorScheme(), + tickCallback: (tickValue) => { + return `${Number(tickValue).toFixed(2)} %`; } - }, - y: { - border: { - width: 0 - }, - display: true, - grid: { - color: ({ scale, tick }) => { - if ( - tick.value === 0 || - tick.value === scale.max || - tick.value === scale.min - ) { - return `rgba(${getTextColor(this.colorScheme())}, 0.1)`; - } - - return 'transparent'; - } - }, - position: 'right', - ticks: { - callback: (value: number) => { - return `${value.toFixed(2)} %`; - }, - display: true, - mirror: true, - z: 1 - } - } + }) } }, plugins: [ @@ -259,16 +208,10 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy { } private getTooltipPluginConfiguration(): Partial> { - return { - ...getTooltipOptions({ - colorScheme: this.colorScheme(), - locale: this.locale(), - unit: '%' - }), - mode: 'index', - position: 'top', - xAlign: 'center', - yAlign: 'bottom' - }; + return getTimeSeriesTooltipOptions<'line'>({ + colorScheme: this.colorScheme(), + locale: this.locale(), + unit: '%' + }); } } diff --git a/apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.html b/apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.html deleted file mode 100644 index 67274ae38..000000000 --- a/apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.html +++ /dev/null @@ -1,24 +0,0 @@ -
-
-
{{ fearAndGreedIndexEmoji }}
-
-
- {{ fearAndGreedIndexText }} - {{ fearAndGreedIndex }}/100 -
- Current Market Mood -
-
- @if (!fearAndGreedIndex) { - - } -
diff --git a/apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.scss b/apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.scss deleted file mode 100644 index dfe024a03..000000000 --- a/apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.scss +++ /dev/null @@ -1,8 +0,0 @@ -:host { - display: block; - - ngx-skeleton-loader { - bottom: 0; - top: 0; - } -} diff --git a/apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.ts b/apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.ts deleted file mode 100644 index 32e2cc29a..000000000 --- a/apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { resolveFearAndGreedIndex } from '@ghostfolio/common/helper'; -import { translate } from '@ghostfolio/ui/i18n'; - -import { - ChangeDetectionStrategy, - Component, - Input, - OnChanges -} from '@angular/core'; -import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; - -@Component({ - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [NgxSkeletonLoaderModule], - selector: 'gf-fear-and-greed-index', - styleUrls: ['./fear-and-greed-index.component.scss'], - templateUrl: './fear-and-greed-index.component.html' -}) -export class GfFearAndGreedIndexComponent implements OnChanges { - @Input() fearAndGreedIndex: number; - - public fearAndGreedIndexEmoji: string; - public fearAndGreedIndexText: string; - - public ngOnChanges() { - const { emoji, key } = resolveFearAndGreedIndex(this.fearAndGreedIndex); - - this.fearAndGreedIndexEmoji = emoji; - this.fearAndGreedIndexText = translate(key); - } -} diff --git a/apps/client/src/app/components/footer/footer.component.html b/apps/client/src/app/components/footer/footer.component.html index 45626620e..5f0016317 100644 --- a/apps/client/src/app/components/footer/footer.component.html +++ b/apps/client/src/app/components/footer/footer.component.html @@ -143,11 +143,16 @@ +
  • + Korean (한국어) +
  • Nederlands
  • diff --git a/apps/client/src/app/components/header/header.component.html b/apps/client/src/app/components/header/header.component.html index 283e930e0..35f072d72 100644 --- a/apps/client/src/app/components/header/header.component.html +++ b/apps/client/src/app/components/header/header.component.html @@ -1,14 +1,14 @@ - @if (user) { -
    + @if (user()) { + @@ -20,11 +20,11 @@ mat-button [class]="{ 'font-weight-bold': - currentRoute === internalRoutes.home.path || - currentRoute === internalRoutes.zen.path, + currentRoute() === internalRoutes.home.path || + currentRoute() === internalRoutes.zen.path, 'text-decoration-underline': - currentRoute === internalRoutes.home.path || - currentRoute === internalRoutes.zen.path + currentRoute() === internalRoutes.home.path || + currentRoute() === internalRoutes.zen.path }" [routerLink]="['/']" >OverviewPortfolioAccountsAdmin ControlResources @if ( - hasPermissionForSubscription && user?.subscription?.type === 'Basic' + hasPermissionForSubscription && user()?.subscription?.type === 'Basic' ) {
  • Pricing - @if (currentRoute !== routePricing && hasPromotion) { + @if (currentRoute() !== routePricing && hasPromotion()) { % } @@ -116,8 +117,8 @@ i18n mat-button [class]="{ - 'font-weight-bold': currentRoute === routeAbout, - 'text-decoration-underline': currentRoute === routeAbout + 'font-weight-bold': currentRoute() === routeAbout, + 'text-decoration-underline': currentRoute() === routeAbout }" [routerLink]="routerLinkAbout" >About @if ( - hasPermissionForSubscription && user?.subscription?.type === 'Basic' + hasPermissionForSubscription && + user()?.subscription?.type === 'Basic' ) { - @if (user.subscription.offer.isRenewal) { + @if (user().subscription.offer.isRenewal) { Renew Plan } @else { Upgrade Plan @@ -199,7 +203,7 @@ >
    } - @if (user?.access?.length > 0) { + @if (user()?.access?.length > 0) { - @for (accessItem of user?.access; track accessItem) { + @for (accessItem of user()?.access; track accessItem) {
  • } - @if (user === null) { -
    + @if (!user()) { + @@ -349,8 +354,8 @@ i18n mat-button [class]="{ - 'font-weight-bold': currentRoute === routeFeatures, - 'text-decoration-underline': currentRoute === routeFeatures + 'font-weight-bold': currentRoute() === routeFeatures, + 'text-decoration-underline': currentRoute() === routeFeatures }" [routerLink]="routerLinkFeatures" >FeaturesAbout Pricing - @if (currentRoute !== routePricing && hasPromotion) { + @if (currentRoute() !== routePricing && hasPromotion()) { % } @@ -396,8 +401,8 @@ i18n mat-button [class]="{ - 'font-weight-bold': currentRoute === routeMarkets, - 'text-decoration-underline': currentRoute === routeMarkets + 'font-weight-bold': currentRoute() === routeMarkets, + 'text-decoration-underline': currentRoute() === routeMarkets }" [routerLink]="routerLinkMarkets" >MarketsSign in - @if (currentRoute !== 'register' && hasPermissionToCreateUser) { + @if (currentRoute() !== 'register' && hasPermissionToCreateUser) {
  • (); - - @ViewChild('assistant') assistantElement: GfAssistantComponent; - @ViewChild('assistantTrigger') assistentMenuTriggerElement: MatMenuTrigger; - - public hasFilters: boolean; - public hasImpersonationId: boolean; - public hasPermissionForAuthGoogle: boolean; - public hasPermissionForAuthOidc: boolean; - public hasPermissionForAuthToken: boolean; - public hasPermissionForSubscription: boolean; - public hasPermissionToAccessAdminControl: boolean; - public hasPermissionToAccessAssistant: boolean; - public hasPermissionToAccessFearAndGreedIndex: boolean; - public hasPermissionToCreateUser: boolean; - public impersonationId: string; - public internalRoutes = internalRoutes; - public isMenuOpen: boolean; - public routeAbout = publicRoutes.about.path; - public routeFeatures = publicRoutes.features.path; - public routeMarkets = publicRoutes.markets.path; - public routePricing = publicRoutes.pricing.path; - public routeResources = publicRoutes.resources.path; - public routerLinkAbout = publicRoutes.about.routerLink; - public routerLinkAccount = internalRoutes.account.routerLink; - public routerLinkAccounts = internalRoutes.accounts.routerLink; - public routerLinkAdminControl = internalRoutes.adminControl.routerLink; - public routerLinkFeatures = publicRoutes.features.routerLink; - public routerLinkMarkets = publicRoutes.markets.routerLink; - public routerLinkPortfolio = internalRoutes.portfolio.routerLink; - public routerLinkPricing = publicRoutes.pricing.routerLink; - public routerLinkRegister = publicRoutes.register.routerLink; - public routerLinkResources = publicRoutes.resources.routerLink; - - public constructor( - private dataService: DataService, - private destroyRef: DestroyRef, - private dialog: MatDialog, - private impersonationStorageService: ImpersonationStorageService, - private layoutService: LayoutService, - private notificationService: NotificationService, - private router: Router, - private settingsStorageService: SettingsStorageService, - private tokenStorageService: TokenStorageService, - private userService: UserService - ) { + public readonly currentRoute = input.required(); + public readonly deviceType = input.required(); + public readonly hasPermissionToChangeDateRange = input.required(); + public readonly hasPermissionToChangeFilters = input.required(); + public readonly hasPromotion = input.required(); + public readonly hasTabs = input.required(); + public readonly info = input.required(); + public readonly pageTitle = input.required(); + public readonly user = input.required(); + + public readonly signOut = output(); + + protected readonly assistantElement = + viewChild.required('assistant'); + protected readonly assistentMenuTriggerElement = + viewChild.required('assistantTrigger'); + + protected hasFilters: boolean; + protected hasImpersonationId: boolean; + protected hasPermissionForAuthGoogle: boolean; + protected hasPermissionForAuthOidc: boolean; + protected hasPermissionForAuthToken: boolean; + protected hasPermissionForSubscription: boolean; + protected hasPermissionToAccessAdminControl: boolean; + protected hasPermissionToAccessAssistant: boolean; + protected hasPermissionToAccessFearAndGreedIndex: boolean; + protected hasPermissionToCreateUser: boolean; + protected impersonationId: string | null; + protected readonly internalRoutes = internalRoutes; + protected isMenuOpen: boolean; + protected readonly routeAbout = publicRoutes.about.path; + protected readonly routeFeatures = publicRoutes.features.path; + protected readonly routeMarkets = publicRoutes.markets.path; + protected readonly routePricing = publicRoutes.pricing.path; + protected readonly routeResources = publicRoutes.resources.path; + protected readonly routerLinkAbout = publicRoutes.about.routerLink; + protected readonly routerLinkAccount = internalRoutes.account.routerLink; + protected readonly routerLinkAccounts = internalRoutes.accounts.routerLink; + protected readonly routerLinkAdminControl = + internalRoutes.adminControl.routerLink; + protected readonly routerLinkFeatures = publicRoutes.features.routerLink; + protected readonly routerLinkMarkets = publicRoutes.markets.routerLink; + protected readonly routerLinkPortfolio = internalRoutes.portfolio.routerLink; + protected readonly routerLinkPricing = publicRoutes.pricing.routerLink; + protected readonly routerLinkRegister = publicRoutes.register.routerLink; + protected readonly routerLinkResources = publicRoutes.resources.routerLink; + + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly dialog = inject(MatDialog); + private readonly impersonationStorageService = inject( + ImpersonationStorageService + ); + private readonly layoutService = inject(LayoutService); + private readonly notificationService = inject(NotificationService); + private readonly router = inject(Router); + private readonly settingsStorageService = inject(SettingsStorageService); + private readonly tokenStorageService = inject(TokenStorageService); + private readonly userService = inject(UserService); + + public constructor() { this.impersonationStorageService .onChangeHasImpersonation() .pipe(takeUntilDestroyed(this.destroyRef)) @@ -162,55 +156,71 @@ export class GfHeaderComponent implements OnChanges { }); } + @HostListener('window:keydown', ['$event']) + protected openAssistantWithHotKey(event: KeyboardEvent) { + if ( + event.key === '/' && + event.target instanceof Element && + event.target?.nodeName?.toLowerCase() !== 'input' && + event.target?.nodeName?.toLowerCase() !== 'textarea' && + this.hasPermissionToAccessAssistant + ) { + this.assistantElement().setIsOpen(true); + this.assistentMenuTriggerElement().openMenu(); + + event.preventDefault(); + } + } + public ngOnChanges() { this.hasFilters = this.userService.hasFilters(); this.hasPermissionForAuthGoogle = hasPermission( - this.info?.globalPermissions, + this.info()?.globalPermissions, permissions.enableAuthGoogle ); this.hasPermissionForAuthOidc = hasPermission( - this.info?.globalPermissions, + this.info()?.globalPermissions, permissions.enableAuthOidc ); this.hasPermissionForAuthToken = hasPermission( - this.info?.globalPermissions, + this.info()?.globalPermissions, permissions.enableAuthToken ); this.hasPermissionForSubscription = hasPermission( - this.info?.globalPermissions, + this.info()?.globalPermissions, permissions.enableSubscription ); this.hasPermissionToAccessAdminControl = hasPermission( - this.user?.permissions, + this.user()?.permissions, permissions.accessAdminControl ); this.hasPermissionToAccessAssistant = hasPermission( - this.user?.permissions, + this.user()?.permissions, permissions.accessAssistant ); this.hasPermissionToAccessFearAndGreedIndex = hasPermission( - this.info?.globalPermissions, + this.info()?.globalPermissions, permissions.enableFearAndGreedIndex ); this.hasPermissionToCreateUser = hasPermission( - this.info?.globalPermissions, + this.info()?.globalPermissions, permissions.createUserAccount ); } - public closeAssistant() { - this.assistentMenuTriggerElement?.closeMenu(); + protected closeAssistant() { + this.assistentMenuTriggerElement().closeMenu(); } - public impersonateAccount(aId: string) { + protected impersonateAccount(aId: string) { if (aId) { this.impersonationStorageService.setId(aId); } else { @@ -220,7 +230,7 @@ export class GfHeaderComponent implements OnChanges { window.location.reload(); } - public onDateRangeChange(dateRange: DateRange) { + protected onDateRangeChange(dateRange: DateRange) { this.dataService .putUserSetting({ dateRange }) .pipe(takeUntilDestroyed(this.destroyRef)) @@ -232,7 +242,7 @@ export class GfHeaderComponent implements OnChanges { }); } - public onFiltersChanged(filters: Filter[]) { + protected onFiltersChanged(filters: Filter[]) { const userSetting: UpdateUserSettingDto = {}; for (const filter of filters) { @@ -260,32 +270,33 @@ export class GfHeaderComponent implements OnChanges { }); } - public onLogoClick() { - if (['home', 'zen'].includes(this.currentRoute)) { + protected onLogoClick() { + if (['home', 'zen'].includes(this.currentRoute())) { this.layoutService.getShouldReloadSubject().next(); } } - public onMenuClosed() { + protected onMenuClosed() { this.isMenuOpen = false; } - public onMenuOpened() { + protected onMenuOpened() { this.isMenuOpen = true; } - public onOpenAssistant() { - this.assistantElement.initialize(); + protected onOpenAssistant() { + this.assistantElement().initialize(); } - public onSignOut() { - this.signOut.next(); + protected onSignOut() { + this.signOut.emit(); } - public openLoginDialog() { + protected openLoginDialog() { const dialogRef = this.dialog.open< GfLoginWithAccessTokenDialogComponent, - LoginWithAccessTokenDialogParams + LoginWithAccessTokenDialogParams, + LoginWithAccessTokenDialogResult >(GfLoginWithAccessTokenDialogComponent, { autoFocus: false, data: { @@ -306,10 +317,15 @@ export class GfHeaderComponent implements OnChanges { this.dataService .loginAnonymous(data?.accessToken) .pipe( - catchError(() => { - this.notificationService.alert({ - title: $localize`Oops! Incorrect Security Token.` - }); + catchError((error: HttpErrorResponse) => { + if (error.status !== StatusCodes.TOO_MANY_REQUESTS) { + // The notification for too many requests is handled in the + // HttpResponseInterceptor + + this.notificationService.alert({ + title: $localize`Oops! Incorrect Security Token.` + }); + } return EMPTY; }), @@ -322,7 +338,7 @@ export class GfHeaderComponent implements OnChanges { }); } - public setToken(aToken: string) { + private setToken(aToken: string) { this.tokenStorageService.saveToken( aToken, this.settingsStorageService.getSetting(KEY_STAY_SIGNED_IN) === 'true' diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts index 8c42e37ea..57b8196d6 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts @@ -1,22 +1,28 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; import { DEFAULT_PAGE_SIZE, + E_MAIL_LINE_BREAK, NUMERICAL_PRECISION_THRESHOLD_3_FIGURES, - NUMERICAL_PRECISION_THRESHOLD_5_FIGURES, - NUMERICAL_PRECISION_THRESHOLD_6_FIGURES + NUMERICAL_PRECISION_THRESHOLD_4_FIGURES } from '@ghostfolio/common/config'; import { CreateOrderDto } from '@ghostfolio/common/dtos'; -import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper'; +import { + DATE_FORMAT, + downloadAsFile, + getCountryName +} from '@ghostfolio/common/helper'; import { Activity, DataProviderInfo, - EnhancedSymbolProfile, + EnhancedAssetProfile, Filter, LineChartItem, + NullableLineChartItem, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { internalRoutes } from '@ghostfolio/common/routes/routes'; +import { AccountWithValue } from '@ghostfolio/common/types'; import { GfAccountsTableComponent } from '@ghostfolio/ui/accounts-table'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfDataProviderCreditsComponent } from '@ghostfolio/ui/data-provider-credits'; @@ -36,11 +42,16 @@ import { ChangeDetectorRef, Component, DestroyRef, - Inject, - OnInit + OnInit, + inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { + FormBuilder, + FormControl, + FormGroup, + ReactiveFormsModule +} from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatChipsModule } from '@angular/material/chips'; import { @@ -53,9 +64,9 @@ import { PageEvent } from '@angular/material/paginator'; import { SortDirection } from '@angular/material/sort'; import { MatTableDataSource } from '@angular/material/table'; import { MatTabsModule } from '@angular/material/tabs'; -import { Router, RouterModule } from '@angular/router'; +import { NavigationStart, Router, RouterModule } from '@angular/router'; import { IonIcon } from '@ionic/angular/standalone'; -import { Account, MarketData, Tag } from '@prisma/client'; +import { MarketData, Tag } from '@prisma/client'; import { isUUID } from 'class-validator'; import { format, isSameMonth, isToday, parseISO } from 'date-fns'; import { addIcons } from 'ionicons'; @@ -68,10 +79,14 @@ import { swapVerticalOutline, walletOutline } from 'ionicons/icons'; +import { isNumber, round, uniqBy } from 'lodash'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; -import { switchMap } from 'rxjs/operators'; +import { filter, switchMap } from 'rxjs/operators'; -import { HoldingDetailDialogParams } from './interfaces/interfaces'; +import { + HoldingDetailDialogParams, + HoldingDetailDialogResult +} from './interfaces/interfaces'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -103,73 +118,105 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces'; templateUrl: 'holding-detail-dialog.html' }) export class GfHoldingDetailDialogComponent implements OnInit { - public activitiesCount: number; - public accounts: Account[]; - public assetClass: string; - public assetSubClass: string; - public averagePrice: number; - public averagePricePrecision = 2; - public benchmarkDataItems: LineChartItem[]; - public benchmarkLabel = $localize`Average Unit Price`; - public countries: { + protected accounts: AccountWithValue[]; + protected activitiesCount: number; + protected assetClass: string; + protected assetProfile: Pick< + EnhancedAssetProfile, + | 'assetClass' + | 'assetSubClass' + | 'countries' + | 'currency' + | 'dataSource' + | 'isin' + | 'name' + | 'sectors' + | 'symbol' + | 'userId' + >; + protected assetSubClass: string; + protected averagePrice: number; + protected averagePricePrecision = 2; + protected benchmarkDataItems: NullableLineChartItem[]; + protected readonly benchmarkLabel = $localize`Average Unit Price`; + protected countries: { [code: string]: { name: string; value: number }; }; - public dataProviderInfo: DataProviderInfo; - public dataSource: MatTableDataSource; - public dateOfFirstActivity: string; - public dividendInBaseCurrency: number; - public dividendInBaseCurrencyPrecision = 2; - public dividendYieldPercentWithCurrencyEffect: number; - public feeInBaseCurrency: number; - public hasPermissionToCreateOwnTag: boolean; - public hasPermissionToReadMarketDataOfOwnAssetProfile: boolean; - public historicalDataItems: LineChartItem[]; - public holdingForm: FormGroup; - public investmentInBaseCurrencyWithCurrencyEffect: number; - public investmentInBaseCurrencyWithCurrencyEffectPrecision = 2; - public isUUID = isUUID; - public marketDataItems: MarketData[] = []; - public marketPrice: number; - public marketPriceMax: number; - public marketPriceMaxPrecision = 2; - public marketPriceMin: number; - public marketPriceMinPrecision = 2; - public marketPricePrecision = 2; - public netPerformance: number; - public netPerformancePrecision = 2; - public netPerformancePercent: number; - public netPerformancePercentWithCurrencyEffect: number; - public netPerformancePercentWithCurrencyEffectPrecision = 2; - public netPerformanceWithCurrencyEffect: number; - public netPerformanceWithCurrencyEffectPrecision = 2; - public pageIndex = 0; - public pageSize = DEFAULT_PAGE_SIZE; - public quantity: number; - public quantityPrecision = 2; - public reportDataGlitchMail: string; - public routerLinkAdminControlMarketData = + protected dataProviderInfo: DataProviderInfo; + protected dataSource: MatTableDataSource; + protected dateOfFirstActivity: Date; + protected dividendInBaseCurrency: number; + protected dividendInBaseCurrencyPrecision = 2; + protected dividendYieldPercentWithCurrencyEffect: number; + protected feeInBaseCurrency: number; + protected readonly getCountryName = getCountryName; + protected hasPermissionToCreateOwnTag: boolean; + protected hasPermissionToReadMarketDataOfOwnAssetProfile: boolean; + protected historicalDataItems: LineChartItem[]; + protected holdingForm: FormGroup<{ + tags: FormControl; + }>; + protected investmentInBaseCurrencyWithCurrencyEffect: number; + protected investmentInBaseCurrencyWithCurrencyEffectPrecision = 2; + protected isLoading = true; + protected readonly isUUID = isUUID; + protected marketDataItems: MarketData[] = []; + protected marketPrice: number; + protected marketPriceMax: number; + protected marketPriceMaxPrecision = 2; + protected marketPriceMin: number; + protected marketPriceMinPrecision = 2; + protected marketPricePrecision = 2; + protected netPerformancePercentWithCurrencyEffect: number; + protected netPerformancePercentWithCurrencyEffectPrecision = 2; + protected netPerformanceWithCurrencyEffect: number; + protected netPerformanceWithCurrencyEffectPrecision = 2; + protected pageIndex = 0; + protected readonly pageSize = DEFAULT_PAGE_SIZE; + protected quantity: number; + protected quantityPrecision = 2; + protected reportDataGlitchMailHref: string; + protected readonly round = round; + protected readonly routerLinkAdminControlMarketData = internalRoutes.adminControl.subRoutes.marketData.routerLink; - public sectors: { + protected sectors: { [name: string]: { name: string; value: number }; }; - public sortColumn = 'date'; - public sortDirection: SortDirection = 'desc'; - public SymbolProfile: EnhancedSymbolProfile; - public tags: Tag[]; - public tagsAvailable: Tag[]; - public user: User; - public value: number; - - public constructor( - private changeDetectorRef: ChangeDetectorRef, - private dataService: DataService, - private destroyRef: DestroyRef, - public dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data: HoldingDetailDialogParams, - private formBuilder: FormBuilder, - private router: Router, - private userService: UserService - ) { + protected sortColumn = 'date'; + protected sortDirection: SortDirection = 'desc'; + protected tagsAvailable: Tag[]; + protected tagsOfAccounts: Tag[]; + protected readonly translate = translate; + protected user: User; + protected value: number; + + protected readonly data = inject(MAT_DIALOG_DATA); + protected readonly dialogRef = + inject< + MatDialogRef + >(MatDialogRef); + + private tags: Tag[]; + + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly formBuilder = inject(FormBuilder); + private readonly router = inject(Router); + private readonly userService = inject(UserService); + + public constructor() { + this.router.events + .pipe( + filter((event) => { + return event instanceof NavigationStart; + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => { + this.dialogRef.close({ isNavigating: true }); + }); + addIcons({ arrowDownCircleOutline, createOutline, @@ -185,12 +232,11 @@ export class GfHoldingDetailDialogComponent implements OnInit { const filters = this.getActivityFilters(); this.holdingForm = this.formBuilder.group({ - tags: [] as string[] + tags: new FormControl([]) }); - this.holdingForm - .get('tags') - .valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) + this.holdingForm.controls.tags.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((tags: Tag[]) => { const newTag = tags.find(({ id }) => { return id === undefined; @@ -238,6 +284,18 @@ export class GfHoldingDetailDialogComponent implements OnInit { .subscribe(({ accounts }) => { this.accounts = accounts; + this.tagsOfAccounts = uniqBy( + accounts.flatMap(({ tags }) => { + return tags ?? []; + }), + 'id' + ).map((tag) => { + return { + ...tag, + name: translate(tag.name) + }; + }); + this.changeDetectorRef.markForCheck(); }); @@ -252,6 +310,7 @@ export class GfHoldingDetailDialogComponent implements OnInit { .subscribe( ({ activitiesCount, + assetProfile, averagePrice, dataProviderInfo, dateOfFirstActivity, @@ -263,20 +322,18 @@ export class GfHoldingDetailDialogComponent implements OnInit { marketPrice, marketPriceMax, marketPriceMin, - netPerformance, - netPerformancePercent, netPerformancePercentWithCurrencyEffect, netPerformanceWithCurrencyEffect, quantity, - SymbolProfile, tags, value }) => { this.activitiesCount = activitiesCount; + this.assetProfile = assetProfile; this.averagePrice = averagePrice; if ( - this.averagePrice >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES && + this.averagePrice >= NUMERICAL_PRECISION_THRESHOLD_4_FIGURES && this.data.deviceType === 'mobile' ) { this.averagePricePrecision = 0; @@ -285,13 +342,17 @@ export class GfHoldingDetailDialogComponent implements OnInit { this.benchmarkDataItems = []; this.countries = {}; this.dataProviderInfo = dataProviderInfo; - this.dateOfFirstActivity = dateOfFirstActivity; + + if (dateOfFirstActivity) { + this.dateOfFirstActivity = dateOfFirstActivity; + } + this.dividendInBaseCurrency = dividendInBaseCurrency; if ( this.data.deviceType === 'mobile' && this.dividendInBaseCurrency >= - NUMERICAL_PRECISION_THRESHOLD_6_FIGURES + NUMERICAL_PRECISION_THRESHOLD_4_FIGURES ) { this.dividendInBaseCurrencyPrecision = 0; } @@ -306,19 +367,19 @@ export class GfHoldingDetailDialogComponent implements OnInit { this.user?.permissions, permissions.readMarketDataOfOwnAssetProfile ) && - SymbolProfile?.dataSource === 'MANUAL' && - SymbolProfile?.userId === this.user?.id; + assetProfile?.dataSource === 'MANUAL' && + assetProfile?.userId === this.user?.id; this.historicalDataItems = historicalData.map( ({ averagePrice, date, marketPrice }) => { this.benchmarkDataItems.push({ date, - value: averagePrice + value: isNumber(averagePrice) ? averagePrice : null }); return { date, - value: marketPrice + value: marketPrice ?? 0 }; } ); @@ -329,7 +390,7 @@ export class GfHoldingDetailDialogComponent implements OnInit { if ( this.data.deviceType === 'mobile' && this.investmentInBaseCurrencyWithCurrencyEffect >= - NUMERICAL_PRECISION_THRESHOLD_6_FIGURES + NUMERICAL_PRECISION_THRESHOLD_4_FIGURES ) { this.investmentInBaseCurrencyWithCurrencyEffectPrecision = 0; } @@ -339,7 +400,7 @@ export class GfHoldingDetailDialogComponent implements OnInit { if ( this.data.deviceType === 'mobile' && - this.marketPriceMax >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES + this.marketPriceMax >= NUMERICAL_PRECISION_THRESHOLD_4_FIGURES ) { this.marketPriceMaxPrecision = 0; } @@ -348,29 +409,18 @@ export class GfHoldingDetailDialogComponent implements OnInit { if ( this.data.deviceType === 'mobile' && - this.marketPriceMin >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES + this.marketPriceMin >= NUMERICAL_PRECISION_THRESHOLD_4_FIGURES ) { this.marketPriceMinPrecision = 0; } if ( this.data.deviceType === 'mobile' && - this.marketPrice >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES + this.marketPrice >= NUMERICAL_PRECISION_THRESHOLD_4_FIGURES ) { this.marketPricePrecision = 0; } - this.netPerformance = netPerformance; - - if ( - this.data.deviceType === 'mobile' && - this.netPerformance >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES - ) { - this.netPerformancePrecision = 0; - } - - this.netPerformancePercent = netPerformancePercent; - this.netPerformancePercentWithCurrencyEffect = netPerformancePercentWithCurrencyEffect; @@ -388,7 +438,7 @@ export class GfHoldingDetailDialogComponent implements OnInit { if ( this.data.deviceType === 'mobile' && this.netPerformanceWithCurrencyEffect >= - NUMERICAL_PRECISION_THRESHOLD_5_FIGURES + NUMERICAL_PRECISION_THRESHOLD_4_FIGURES ) { this.netPerformanceWithCurrencyEffectPrecision = 0; } @@ -397,7 +447,7 @@ export class GfHoldingDetailDialogComponent implements OnInit { if (Number.isInteger(this.quantity)) { this.quantityPrecision = 0; - } else if (SymbolProfile?.assetSubClass === 'CRYPTOCURRENCY') { + } else if (assetProfile?.assetSubClass === 'CRYPTOCURRENCY') { if (this.quantity < 10) { this.quantityPrecision = 8; } else if (this.quantity < 1000) { @@ -407,9 +457,7 @@ export class GfHoldingDetailDialogComponent implements OnInit { } } - this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`; this.sectors = {}; - this.SymbolProfile = SymbolProfile; this.tags = tags.map((tag) => { return { @@ -422,42 +470,61 @@ export class GfHoldingDetailDialogComponent implements OnInit { this.value = value; - if (SymbolProfile?.assetClass) { - this.assetClass = translate(SymbolProfile?.assetClass); + const reportDataGlitchSubject = `Ghostfolio Data Glitch Report${ + this.assetProfile?.symbol ? ` (${this.assetProfile.symbol})` : '' + }`; + + this.reportDataGlitchMailHref = `mailto:hi@ghostfol.io?subject=${reportDataGlitchSubject}&body=${[ + 'Hello', + '', + 'I would like to report a data glitch for', + '', + `Symbol: ${this.assetProfile?.symbol}`, + `Data Source: ${this.assetProfile?.dataSource}`, + '', + 'Additional notes:', + '', + 'Can you please take a look?', + '', + 'Kind regards' + ].join(E_MAIL_LINE_BREAK)}`; + + if (this.assetProfile?.assetClass) { + this.assetClass = translate(this.assetProfile?.assetClass); } - if (SymbolProfile?.assetSubClass) { - this.assetSubClass = translate(SymbolProfile?.assetSubClass); + if (this.assetProfile?.assetSubClass) { + this.assetSubClass = translate(this.assetProfile?.assetSubClass); } - if (SymbolProfile?.countries?.length > 0) { - for (const country of SymbolProfile.countries) { + if (this.assetProfile?.countries?.length > 0) { + for (const country of this.assetProfile.countries) { this.countries[country.code] = { - name: country.name, + name: getCountryName({ code: country.code }), value: country.weight }; } } - if (SymbolProfile?.sectors?.length > 0) { - for (const sector of SymbolProfile.sectors) { + if (this.assetProfile?.sectors?.length > 0) { + for (const sector of this.assetProfile.sectors) { this.sectors[sector.name] = { - name: sector.name, + name: translate(sector.name), value: sector.weight }; } } - if (isToday(parseISO(this.dateOfFirstActivity))) { + if (isToday(this.dateOfFirstActivity)) { // Add average price this.historicalDataItems.push({ - date: this.dateOfFirstActivity, + date: this.dateOfFirstActivity.toISOString(), value: this.averagePrice }); // Add benchmark 1 this.benchmarkDataItems.push({ - date: this.dateOfFirstActivity, + date: this.dateOfFirstActivity.toISOString(), value: averagePrice }); @@ -488,7 +555,7 @@ export class GfHoldingDetailDialogComponent implements OnInit { if ( this.benchmarkDataItems[0]?.value === undefined && - isSameMonth(parseISO(this.dateOfFirstActivity), new Date()) + isSameMonth(this.dateOfFirstActivity, new Date()) ) { this.benchmarkDataItems[0].value = this.averagePrice; } @@ -506,6 +573,8 @@ export class GfHoldingDetailDialogComponent implements OnInit { this.fetchMarketData(); } + this.isLoading = false; + this.changeDetectorRef.markForCheck(); } ); @@ -516,9 +585,10 @@ export class GfHoldingDetailDialogComponent implements OnInit { if (state?.user) { this.user = state.user; - this.hasPermissionToCreateOwnTag = - hasPermission(this.user.permissions, permissions.createOwnTag) && - this.user?.settings?.isExperimentalFeatures; + this.hasPermissionToCreateOwnTag = hasPermission( + this.user?.permissions, + permissions.createOwnTag + ); this.tagsAvailable = this.user?.tags?.map((tag) => { @@ -533,39 +603,28 @@ export class GfHoldingDetailDialogComponent implements OnInit { }); } - public onChangePage(page: PageEvent) { + protected onChangePage(page: PageEvent) { this.pageIndex = page.pageIndex; this.fetchActivities(); } - public onCloneActivity(aActivity: Activity) { - this.router.navigate( - internalRoutes.portfolio.subRoutes.activities.routerLink, - { - queryParams: { activityId: aActivity.id, createDialog: true } - } - ); - - this.dialogRef.close(); - } - - public onClose() { + protected onClose() { this.dialogRef.close(); } - public onCloseHolding() { + protected onCloseHolding() { const today = new Date(); const activity: CreateOrderDto = { - accountId: this.accounts.length === 1 ? this.accounts[0].id : null, - comment: null, - currency: this.SymbolProfile.currency, - dataSource: this.SymbolProfile.dataSource, + accountId: this.accounts.length === 1 ? this.accounts[0].id : undefined, + comment: undefined, + currency: this.assetProfile?.currency ?? '', + dataSource: this.assetProfile?.dataSource, date: today.toISOString(), fee: 0, quantity: this.quantity, - symbol: this.SymbolProfile.symbol, + symbol: this.assetProfile?.symbol ?? '', tags: this.tags.map(({ id }) => { return id; }), @@ -585,7 +644,7 @@ export class GfHoldingDetailDialogComponent implements OnInit { }); } - public onExport() { + protected onExport() { const activityIds = this.dataSource.data.map(({ id }) => { return id; }); @@ -596,7 +655,7 @@ export class GfHoldingDetailDialogComponent implements OnInit { .subscribe((data) => { downloadAsFile({ content: data, - fileName: `ghostfolio-export-${this.SymbolProfile?.symbol}-${format( + fileName: `ghostfolio-export-${this.assetProfile?.symbol}-${format( parseISO(data.meta.date), 'yyyyMMddHHmm' )}.json`, @@ -605,23 +664,12 @@ export class GfHoldingDetailDialogComponent implements OnInit { }); } - public onMarketDataChanged(withRefresh = false) { + protected onMarketDataChanged(withRefresh = false) { if (withRefresh) { this.fetchMarketData(); } } - public onUpdateActivity(aActivity: Activity) { - this.router.navigate( - internalRoutes.portfolio.subRoutes.activities.routerLink, - { - queryParams: { activityId: aActivity.id, editDialog: true } - } - ); - - this.dialogRef.close(); - } - private fetchActivities(filters: Filter[] = this.getActivityFilters()) { this.dataService .fetchActivities({ diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html index 4b04a0986..8292ff598 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -1,7 +1,7 @@ @@ -12,6 +12,7 @@ @@ -54,14 +56,15 @@ size="medium" [colorizeSign]="true" [isCurrency]="true" + [isLoading]="isLoading" [locale]="data.locale" [precision]="netPerformanceWithCurrencyEffectPrecision" [unit]="data.baseCurrency" [value]="netPerformanceWithCurrencyEffect" > @if ( - SymbolProfile?.currency && - data.baseCurrency !== SymbolProfile?.currency + assetProfile?.currency && + data.baseCurrency !== assetProfile?.currency ) { Change with currency effect } @else { @@ -74,14 +77,15 @@ i18n size="medium" [colorizeSign]="true" + [isLoading]="isLoading" [isPercent]="true" [locale]="data.locale" [precision]="netPerformancePercentWithCurrencyEffectPrecision" [value]="netPerformancePercentWithCurrencyEffect" > @if ( - SymbolProfile?.currency && - data.baseCurrency !== SymbolProfile?.currency + assetProfile?.currency && + data.baseCurrency !== assetProfile?.currency ) { Performance with currency effect } @else { @@ -94,9 +98,10 @@ i18n size="medium" [isCurrency]="true" + [isLoading]="isLoading" [locale]="data.locale" [precision]="averagePricePrecision" - [unit]="SymbolProfile?.currency" + [unit]="assetProfile?.currency" [value]="averagePrice" >Average Unit Price @@ -106,9 +111,10 @@ i18n size="medium" [isCurrency]="true" + [isLoading]="isLoading" [locale]="data.locale" [precision]="marketPricePrecision" - [unit]="SymbolProfile?.currency" + [unit]="assetProfile?.currency" [value]="marketPrice" >Market Price @@ -118,13 +124,16 @@ i18n size="medium" [class.text-danger]=" - marketPriceMin?.toFixed(2) === marketPrice?.toFixed(2) && - marketPriceMax?.toFixed(2) !== marketPriceMin?.toFixed(2) + round(marketPriceMin, marketPriceMinPrecision) === + round(marketPrice, marketPricePrecision) && + round(marketPriceMax, marketPriceMaxPrecision) !== + round(marketPriceMin, marketPriceMinPrecision) " [isCurrency]="true" + [isLoading]="isLoading" [locale]="data.locale" [precision]="marketPriceMinPrecision" - [unit]="SymbolProfile?.currency" + [unit]="assetProfile?.currency" [value]="marketPriceMin" >Minimum Price @@ -134,13 +143,16 @@ i18n size="medium" [class.text-success]=" - marketPriceMax?.toFixed(2) === marketPrice?.toFixed(2) && - marketPriceMax?.toFixed(2) !== marketPriceMin?.toFixed(2) + round(marketPriceMax, marketPriceMaxPrecision) === + round(marketPrice, marketPricePrecision) && + round(marketPriceMax, marketPriceMaxPrecision) !== + round(marketPriceMin, marketPriceMinPrecision) " [isCurrency]="true" + [isLoading]="isLoading" [locale]="data.locale" [precision]="marketPriceMaxPrecision" - [unit]="SymbolProfile?.currency" + [unit]="assetProfile?.currency" [value]="marketPriceMax" >Maximum Price @@ -149,6 +161,7 @@ InvestmentInvested Capital
  • @if ( @@ -177,6 +191,7 @@ i18n size="medium" [isCurrency]="true" + [isLoading]="isLoading" [locale]="data.locale" [precision]="dividendInBaseCurrencyPrecision" [unit]="data.baseCurrency" @@ -188,6 +203,7 @@ First Activity @@ -250,29 +269,33 @@ >
    @if ( - SymbolProfile?.countries?.length > 0 || - SymbolProfile?.sectors?.length > 0 + assetProfile?.countries?.length > 0 || + assetProfile?.sectors?.length > 0 ) { @if ( - SymbolProfile?.countries?.length === 1 && - SymbolProfile?.sectors?.length === 1 + assetProfile?.countries?.length === 1 && + assetProfile?.sectors?.length === 1 ) {
    Sector
    - @if (SymbolProfile?.countries?.length === 1) { + @if (assetProfile?.countries?.length === 1) {
    Country
    @@ -309,8 +332,8 @@ i18n size="medium" [enableCopyToClipboardButton]="true" - [hidden]="!SymbolProfile?.symbol" - [value]="SymbolProfile?.symbol" + [hidden]="!assetProfile?.symbol" + [value]="assetProfile?.symbol" >Symbol
    @@ -318,11 +341,22 @@ ISIN +
    +
    + + +
    @if (dataProviderInfo) {
    @@ -348,7 +382,7 @@ [hasPermissionToCreateActivity]="false" [hasPermissionToDeleteActivity]="false" [hasPermissionToExportActivities]=" - !data.hasImpersonationId && !user?.settings?.isRestrictedView + !data.impersonationId && !user?.settings?.isRestrictedView " [hasPermissionToFilter]="false" [hasPermissionToOpenDetails]="false" @@ -356,8 +390,8 @@ [pageIndex]="pageIndex" [pageSize]="pageSize" [showActions]=" - !data.hasImpersonationId && data.hasPermissionToCreateActivity && + !data.impersonationId && user?.settings?.isExperimentalFeatures && !user?.settings?.isRestrictedView " @@ -366,8 +400,6 @@ [sortDirection]="sortDirection" [sortDisabled]="true" [totalItems]="activitiesCount" - (activityToClone)="onCloneActivity($event)" - (activityToUpdate)="onUpdateActivity($event)" (export)="onExport()" (pageChanged)="onChangePage($event)" /> @@ -400,12 +432,12 @@
    Market Data
    @@ -413,15 +445,6 @@ } -
    - - - @if ( data.hasPermissionToAccessAdminControl || (dataSource?.data.length > 0 && @@ -447,7 +470,10 @@ dataSource?.data.length > 0 && data.hasPermissionToReportDataGlitch === true ) { -
    Report Data Glitch...( + protected user: User; + protected readonly viewModeFormControl = new FormControl( GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE ); - public constructor( - private changeDetectorRef: ChangeDetectorRef, - private dataService: DataService, - private destroyRef: DestroyRef, - private deviceDetectorService: DeviceDetectorService, - private impersonationStorageService: ImpersonationStorageService, - private router: Router, - private userService: UserService - ) { + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly deviceDetectorService = inject(DeviceDetectorService); + private readonly impersonationStorageService = inject( + ImpersonationStorageService + ); + private readonly router = inject(Router); + private readonly userService = inject(UserService); + + public constructor() { addIcons({ gridOutline, reorderFourOutline }); } @@ -88,6 +93,8 @@ export class GfHomeHoldingsComponent implements OnInit { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((impersonationId) => { this.hasImpersonationId = !!impersonationId; + + this.changeDetectorRef.markForCheck(); }); this.userService.stateChanged @@ -107,14 +114,18 @@ export class GfHomeHoldingsComponent implements OnInit { ); this.initialize(); - - this.changeDetectorRef.markForCheck(); } + + this.changeDetectorRef.markForCheck(); }); this.viewModeFormControl.valueChanges .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((holdingsViewMode) => { + if (!holdingsViewMode) { + return; + } + this.dataService .putUserSetting({ holdingsViewMode }) .pipe(takeUntilDestroyed(this.destroyRef)) @@ -131,13 +142,13 @@ export class GfHomeHoldingsComponent implements OnInit { }); } - public onChangeHoldingType(aHoldingType: HoldingType) { + protected onChangeHoldingType(aHoldingType: HoldingType) { this.holdingType = aHoldingType; this.initialize(); } - public onHoldingClicked({ dataSource, symbol }: AssetProfileIdentifier) { + protected onHoldingClicked({ dataSource, symbol }: AssetProfileIdentifier) { if (dataSource && symbol) { this.router.navigate([], { queryParams: { dataSource, symbol, holdingDetailDialog: true } @@ -170,8 +181,8 @@ export class GfHomeHoldingsComponent implements OnInit { this.viewModeFormControl.setValue( this.deviceType === 'mobile' ? GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE - : this.user?.settings?.holdingsViewMode || - GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE, + : (this.user?.settings?.holdingsViewMode ?? + GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE), { emitEvent: false } ); } else if (this.holdingType === 'CLOSED') { diff --git a/apps/client/src/app/components/home-market/home-market.component.ts b/apps/client/src/app/components/home-market/home-market.component.ts deleted file mode 100644 index cb645f2ef..000000000 --- a/apps/client/src/app/components/home-market/home-market.component.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { GfFearAndGreedIndexComponent } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.component'; -import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config'; -import { resetHours } from '@ghostfolio/common/helper'; -import { - Benchmark, - HistoricalDataItem, - InfoItem, - User -} from '@ghostfolio/common/interfaces'; -import { hasPermission, permissions } from '@ghostfolio/common/permissions'; -import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark'; -import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; -import { DataService } from '@ghostfolio/ui/services'; - -import { - ChangeDetectorRef, - Component, - CUSTOM_ELEMENTS_SCHEMA, - DestroyRef, - OnInit -} from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { DeviceDetectorService } from 'ngx-device-detector'; - -@Component({ - imports: [ - GfBenchmarkComponent, - GfFearAndGreedIndexComponent, - GfLineChartComponent - ], - schemas: [CUSTOM_ELEMENTS_SCHEMA], - selector: 'gf-home-market', - styleUrls: ['./home-market.scss'], - templateUrl: './home-market.html' -}) -export class GfHomeMarketComponent implements OnInit { - public benchmarks: Benchmark[]; - public deviceType: string; - public fearAndGreedIndex: number; - public fearLabel = $localize`Fear`; - public greedLabel = $localize`Greed`; - public hasPermissionToAccessFearAndGreedIndex: boolean; - public historicalDataItems: HistoricalDataItem[]; - public info: InfoItem; - public readonly numberOfDays = 365; - public user: User; - - public constructor( - private changeDetectorRef: ChangeDetectorRef, - private dataService: DataService, - private destroyRef: DestroyRef, - private deviceDetectorService: DeviceDetectorService, - private userService: UserService - ) { - this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; - this.info = this.dataService.fetchInfo(); - - this.userService.stateChanged - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((state) => { - if (state?.user) { - this.user = state.user; - - this.changeDetectorRef.markForCheck(); - } - }); - } - - public ngOnInit() { - this.hasPermissionToAccessFearAndGreedIndex = hasPermission( - this.info?.globalPermissions, - permissions.enableFearAndGreedIndex - ); - - if (this.hasPermissionToAccessFearAndGreedIndex) { - this.dataService - .fetchSymbolItem({ - dataSource: this.info.fearAndGreedDataSource, - includeHistoricalData: this.numberOfDays, - symbol: ghostfolioFearAndGreedIndexSymbol - }) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(({ historicalData, marketPrice }) => { - this.fearAndGreedIndex = marketPrice; - this.historicalDataItems = [ - ...historicalData, - { - date: resetHours(new Date()).toISOString(), - value: marketPrice - } - ]; - - this.changeDetectorRef.markForCheck(); - }); - } - - this.dataService - .fetchBenchmarks() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(({ benchmarks }) => { - this.benchmarks = benchmarks; - - this.changeDetectorRef.markForCheck(); - }); - } -} diff --git a/apps/client/src/app/components/home-market/home-market.html b/apps/client/src/app/components/home-market/home-market.html deleted file mode 100644 index fc7230d35..000000000 --- a/apps/client/src/app/components/home-market/home-market.html +++ /dev/null @@ -1,52 +0,0 @@ -
    -

    Markets

    - @if (hasPermissionToAccessFearAndGreedIndex) { -
    -
    -
    - Last {{ numberOfDays }} Days -
    - - -
    -
    - } - -
    -
    - - @if (benchmarks?.length > 0) { -
    - - Calculations are based on delayed market data and may not be - displayed in real-time. -
    - } -
    -
    -
    diff --git a/apps/client/src/app/components/home-market/home-market.scss b/apps/client/src/app/components/home-market/home-market.scss deleted file mode 100644 index 5b523160d..000000000 --- a/apps/client/src/app/components/home-market/home-market.scss +++ /dev/null @@ -1,7 +0,0 @@ -:host { - display: block; - - gf-line-chart { - aspect-ratio: 16 / 9; - } -} diff --git a/apps/client/src/app/components/home-overview/home-overview.component.ts b/apps/client/src/app/components/home-overview/home-overview.component.ts index 0336cf169..24d582ff8 100644 --- a/apps/client/src/app/components/home-overview/home-overview.component.ts +++ b/apps/client/src/app/components/home-overview/home-overview.component.ts @@ -2,7 +2,11 @@ import { GfPortfolioPerformanceComponent } from '@ghostfolio/client/components/p import { LayoutService } from '@ghostfolio/client/core/layout.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { NUMERICAL_PRECISION_THRESHOLD_6_FIGURES } from '@ghostfolio/common/config'; +import { + DEFAULT_CURRENCY, + DEFAULT_DATE_RANGE, + NUMERICAL_PRECISION_THRESHOLD_6_FIGURES +} from '@ghostfolio/common/config'; import { AssetProfileIdentifier, LineChartItem, @@ -15,11 +19,13 @@ import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; import { DataService } from '@ghostfolio/ui/services'; import { - ChangeDetectorRef, + ChangeDetectionStrategy, Component, - CUSTOM_ELEMENTS_SCHEMA, + computed, DestroyRef, - OnInit + inject, + OnInit, + signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; @@ -27,79 +33,82 @@ import { RouterModule } from '@angular/router'; import { DeviceDetectorService } from 'ngx-device-detector'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ GfLineChartComponent, GfPortfolioPerformanceComponent, MatButtonModule, RouterModule ], - schemas: [CUSTOM_ELEMENTS_SCHEMA], selector: 'gf-home-overview', styleUrls: ['./home-overview.scss'], templateUrl: './home-overview.html' }) export class GfHomeOverviewComponent implements OnInit { - public deviceType: string; - public errors: AssetProfileIdentifier[]; - public hasError: boolean; - public hasImpersonationId: boolean; - public hasPermissionToCreateActivity: boolean; - public historicalDataItems: LineChartItem[]; - public isAllTimeHigh: boolean; - public isAllTimeLow: boolean; - public isLoadingPerformance = true; - public performance: PortfolioPerformance; - public performanceLabel = $localize`Performance`; - public precision = 2; - public routerLinkAccounts = internalRoutes.accounts.routerLink; - public routerLinkPortfolio = internalRoutes.portfolio.routerLink; - public routerLinkPortfolioActivities = + protected readonly errors = signal([]); + protected readonly hasImpersonationId = signal(false); + protected readonly historicalDataItems = signal(null); + protected readonly isLoadingPerformance = signal(true); + protected readonly performance = signal(null); + protected readonly performanceLabel = $localize`Performance`; + protected readonly precision = signal(2); + protected readonly user = signal(null); + + protected readonly routerLinkAccounts = internalRoutes.accounts.routerLink; + protected readonly routerLinkPortfolio = internalRoutes.portfolio.routerLink; + protected readonly routerLinkPortfolioActivities = internalRoutes.portfolio.subRoutes.activities.routerLink; - public showDetails = false; - public unit: string; - public user: User; - - public constructor( - private changeDetectorRef: ChangeDetectorRef, - private dataService: DataService, - private destroyRef: DestroyRef, - private deviceDetectorService: DeviceDetectorService, - private impersonationStorageService: ImpersonationStorageService, - private layoutService: LayoutService, - private userService: UserService - ) { + protected readonly routerLinkPortfolioActivitiesCreate = + internalRoutes.portfolio.subRoutes.activities.subRoutes.create.routerLink; + + protected readonly deviceType = computed( + () => this.deviceDetectorService.deviceInfo().deviceType + ); + + protected readonly hasPermissionToCreateActivity = computed(() => { + return hasPermission(this.user()?.permissions, permissions.createActivity); + }); + + protected readonly showDetails = computed(() => { + const user = this.user(); + + return user + ? !user.settings.isRestrictedView && user.settings.viewMode !== 'ZEN' + : false; + }); + + protected readonly unit = computed(() => { + return this.showDetails() + ? (this.user()?.settings?.baseCurrency ?? DEFAULT_CURRENCY) + : '%'; + }); + + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly deviceDetectorService = inject(DeviceDetectorService); + private readonly impersonationStorageService = inject( + ImpersonationStorageService + ); + private readonly layoutService = inject(LayoutService); + private readonly userService = inject(UserService); + + public constructor() { this.userService.stateChanged .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((state) => { if (state?.user) { - this.user = state.user; - - this.hasPermissionToCreateActivity = hasPermission( - this.user.permissions, - permissions.createActivity - ); - + this.user.set(state.user); this.update(); } }); } public ngOnInit() { - this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; - - this.showDetails = - !this.user.settings.isRestrictedView && - this.user.settings.viewMode !== 'ZEN'; - - this.unit = this.showDetails ? this.user.settings.baseCurrency : '%'; - this.impersonationStorageService .onChangeHasImpersonation() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((impersonationId) => { - this.hasImpersonationId = !!impersonationId; - - this.changeDetectorRef.markForCheck(); + this.hasImpersonationId.set(!!impersonationId); }); this.layoutService.shouldReloadContent$ @@ -110,40 +119,40 @@ export class GfHomeOverviewComponent implements OnInit { } private update() { - this.historicalDataItems = null; - this.isLoadingPerformance = true; + this.historicalDataItems.set(null); + this.isLoadingPerformance.set(true); this.dataService .fetchPortfolioPerformance({ - range: this.user?.settings?.dateRange + range: this.user()?.settings?.dateRange ?? DEFAULT_DATE_RANGE }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ chart, errors, performance }) => { - this.errors = errors; - this.performance = performance; - - this.historicalDataItems = chart.map( - ({ date, netPerformanceInPercentageWithCurrencyEffect }) => { - return { - date, - value: netPerformanceInPercentageWithCurrencyEffect * 100 - }; - } + this.errors.set(errors ?? []); + this.performance.set(performance); + + this.historicalDataItems.set( + chart?.map( + ({ date, netPerformanceInPercentageWithCurrencyEffect }) => { + return { + date, + value: (netPerformanceInPercentageWithCurrencyEffect ?? 0) * 100 + }; + } + ) ?? null ); + this.precision.set(2); + if ( - this.deviceType === 'mobile' && - this.performance.currentValueInBaseCurrency >= + this.deviceType() === 'mobile' && + performance.currentValueInBaseCurrency >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES ) { - this.precision = 0; + this.precision.set(0); } - this.isLoadingPerformance = false; - - this.changeDetectorRef.markForCheck(); + this.isLoadingPerformance.set(false); }); - - this.changeDetectorRef.markForCheck(); } } diff --git a/apps/client/src/app/components/home-overview/home-overview.html b/apps/client/src/app/components/home-overview/home-overview.html index b3b957d72..90a628a17 100644 --- a/apps/client/src/app/components/home-overview/home-overview.html +++ b/apps/client/src/app/components/home-overview/home-overview.html @@ -2,16 +2,16 @@ class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative" > @if ( - !hasImpersonationId && - hasPermissionToCreateActivity && - user?.activitiesCount === 0 + !hasImpersonationId() && + hasPermissionToCreateActivity() && + user()?.activitiesCount === 0 ) {

    Welcome to Ghostfolio

    Ready to take control of your personal finances?

      -
    1. +
    2. Setup your accounts
    - @if (user?.accounts?.length === 1) { + @if (user()?.accounts?.length === 1) { Setup accounts - } @else if (user?.accounts?.length > 1) { + } @else if (user()?.accounts?.length > 1) { Add activity @@ -67,13 +67,13 @@
    diff --git a/apps/client/src/app/components/home-summary/home-summary.component.ts b/apps/client/src/app/components/home-summary/home-summary.component.ts index 60960480d..a63876a54 100644 --- a/apps/client/src/app/components/home-summary/home-summary.component.ts +++ b/apps/client/src/app/components/home-summary/home-summary.component.ts @@ -1,27 +1,27 @@ import { GfPortfolioSummaryComponent } from '@ghostfolio/client/components/portfolio-summary/portfolio-summary.component'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { - InfoItem, - PortfolioSummary, - User -} from '@ghostfolio/common/interfaces'; +import { PortfolioSummary, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { DataService } from '@ghostfolio/ui/services'; import { - ChangeDetectorRef, + ChangeDetectionStrategy, Component, + computed, CUSTOM_ELEMENTS_SCHEMA, DestroyRef, - OnInit + inject, + OnInit, + signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatCardModule } from '@angular/material/card'; -import { MatSnackBarRef, TextOnlySnackBar } from '@angular/material/snack-bar'; import { DeviceDetectorService } from 'ngx-device-detector'; +import { switchMap } from 'rxjs'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, imports: [GfPortfolioSummaryComponent, MatCardModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], selector: 'gf-home-summary', @@ -29,87 +29,75 @@ import { DeviceDetectorService } from 'ngx-device-detector'; templateUrl: './home-summary.html' }) export class GfHomeSummaryComponent implements OnInit { - public deviceType: string; - public hasImpersonationId: boolean; - public hasPermissionForSubscription: boolean; - public hasPermissionToUpdateUserSettings: boolean; - public info: InfoItem; - public isLoading = true; - public snackBarRef: MatSnackBarRef; - public summary: PortfolioSummary; - public user: User; + protected readonly hasImpersonationId = signal(false); + protected readonly isLoading = signal(true); + protected readonly summary = signal(undefined); + protected readonly user = signal(undefined); + + protected readonly deviceType = computed( + () => this.deviceDetectorService.deviceInfo().deviceType + ); + + protected readonly hasPermissionToUpdateUserSettings = computed(() => { + const user = this.user(); - public constructor( - private changeDetectorRef: ChangeDetectorRef, - private dataService: DataService, - private destroyRef: DestroyRef, - private deviceDetectorService: DeviceDetectorService, - private impersonationStorageService: ImpersonationStorageService, - private userService: UserService - ) { - this.info = this.dataService.fetchInfo(); + return user + ? hasPermission(user.permissions, permissions.updateUserSettings) + : false; + }); - this.hasPermissionForSubscription = hasPermission( - this.info?.globalPermissions, - permissions.enableSubscription - ); + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly deviceDetectorService = inject(DeviceDetectorService); + private readonly impersonationStorageService = inject( + ImpersonationStorageService + ); + private readonly userService = inject(UserService); + public constructor() { this.userService.stateChanged .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((state) => { if (state?.user) { - this.user = state.user; - - this.hasPermissionToUpdateUserSettings = hasPermission( - this.user.permissions, - permissions.updateUserSettings - ); - + this.user.set(state.user); this.update(); } }); } public ngOnInit() { - this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; - this.impersonationStorageService .onChangeHasImpersonation() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((impersonationId) => { - this.hasImpersonationId = !!impersonationId; + this.hasImpersonationId.set(!!impersonationId); }); } - public onChangeEmergencyFund(emergencyFund: number) { + protected onChangeEmergencyFund(emergencyFund: number) { this.dataService .putUserSetting({ emergencyFund }) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { - this.userService - .get(true) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((user) => { - this.user = user; - - this.changeDetectorRef.markForCheck(); - }); + .pipe( + switchMap(() => this.userService.get(true)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((user) => { + this.user.set(user); }); } private update() { - this.isLoading = true; + this.isLoading.set(true); this.dataService .fetchPortfolioDetails() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ summary }) => { - this.summary = summary; - this.isLoading = false; + if (summary) { + this.summary.set(summary); + } - this.changeDetectorRef.markForCheck(); + this.isLoading.set(false); }); - - this.changeDetectorRef.markForCheck(); } } diff --git a/apps/client/src/app/components/home-summary/home-summary.html b/apps/client/src/app/components/home-summary/home-summary.html index 2ed988e8a..a6da72b03 100644 --- a/apps/client/src/app/components/home-summary/home-summary.html +++ b/apps/client/src/app/components/home-summary/home-summary.html @@ -5,17 +5,17 @@ diff --git a/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts b/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts index 7deace7de..97fa6f744 100644 --- a/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts +++ b/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts @@ -1,6 +1,6 @@ import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { locale as defaultLocale } from '@ghostfolio/common/config'; +import { DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { AssetProfileIdentifier, Benchmark, @@ -8,6 +8,7 @@ import { } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark'; +import { GfFabComponent } from '@ghostfolio/ui/fab'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { DataService } from '@ghostfolio/ui/services'; @@ -22,12 +23,8 @@ import { OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { IonIcon } from '@ionic/angular/standalone'; -import { addIcons } from 'ionicons'; -import { addOutline } from 'ionicons/icons'; import { DeviceDetectorService } from 'ngx-device-detector'; import { GfCreateWatchlistItemDialogComponent } from './create-watchlist-item-dialog/create-watchlist-item-dialog.component'; @@ -37,9 +34,8 @@ import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/ changeDetection: ChangeDetectionStrategy.OnPush, imports: [ GfBenchmarkComponent, + GfFabComponent, GfPremiumIndicatorComponent, - IonIcon, - MatButtonModule, RouterModule ], schemas: [CUSTOM_ELEMENTS_SCHEMA], @@ -108,8 +104,6 @@ export class GfHomeWatchlistComponent implements OnInit { this.changeDetectorRef.markForCheck(); } }); - - addIcons({ addOutline }); } public ngOnInit() { @@ -152,10 +146,9 @@ export class GfHomeWatchlistComponent implements OnInit { GfCreateWatchlistItemDialogComponent, CreateWatchlistItemDialogParams >(GfCreateWatchlistItemDialogComponent, { - autoFocus: false, data: { deviceType: this.deviceType(), - locale: this.user?.settings?.locale ?? defaultLocale + locale: this.user?.settings?.locale ?? DEFAULT_LOCALE }, width: this.deviceType() === 'mobile' ? '100vw' : '50rem' }); diff --git a/apps/client/src/app/components/home-watchlist/home-watchlist.html b/apps/client/src/app/components/home-watchlist/home-watchlist.html index a17259000..e2865b9cf 100644 --- a/apps/client/src/app/components/home-watchlist/home-watchlist.html +++ b/apps/client/src/app/components/home-watchlist/home-watchlist.html @@ -14,6 +14,7 @@ [deviceType]="deviceType()" [hasPermissionToDeleteItem]="hasPermissionToDeleteWatchlistItem" [locale]="user?.settings?.locale || undefined" + [showIcon]="true" [user]="user" (itemDeleted)="onWatchlistItemDeleted($event)" /> @@ -21,15 +22,5 @@
    @if (!hasImpersonationId && hasPermissionToCreateWatchlistItem) { -
    - - - -
    + } diff --git a/apps/client/src/app/components/investment-chart/investment-chart.component.html b/apps/client/src/app/components/investment-chart/investment-chart.component.html index 6f7b083e5..864050ea8 100644 --- a/apps/client/src/app/components/investment-chart/investment-chart.component.html +++ b/apps/client/src/app/components/investment-chart/investment-chart.component.html @@ -10,5 +10,5 @@ diff --git a/apps/client/src/app/components/investment-chart/investment-chart.component.ts b/apps/client/src/app/components/investment-chart/investment-chart.component.ts index 691133009..2a1ffd97c 100644 --- a/apps/client/src/app/components/investment-chart/investment-chart.component.ts +++ b/apps/client/src/app/components/investment-chart/investment-chart.component.ts @@ -1,22 +1,22 @@ import { - getTooltipOptions, + getChartBorderColor, + getChartElementsOptions, + getTimeAxisOptions, + getValueAxisOptions, getVerticalHoverLinePlugin, + getZeroLineAnnotation, transformTickToAbbreviation } from '@ghostfolio/common/chart-helper'; import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config'; -import { - getBackgroundColor, - getDateFormatString, - getLocale, - getTextColor, - parseDate -} from '@ghostfolio/common/helper'; +import { getLocale, parseDate } from '@ghostfolio/common/helper'; import { LineChartItem } from '@ghostfolio/common/interfaces'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { ColorScheme, GroupBy } from '@ghostfolio/common/types'; -import { registerChartConfiguration } from '@ghostfolio/ui/chart'; +import { + getTimeSeriesTooltipOptions, + registerChartConfiguration +} from '@ghostfolio/ui/chart'; -import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -41,15 +41,13 @@ import { type TooltipOptions } from 'chart.js'; import 'chartjs-adapter-date-fns'; -import annotationPlugin, { - type AnnotationOptions -} from 'chartjs-plugin-annotation'; -import { isAfter } from 'date-fns'; +import { type AnnotationOptions } from 'chartjs-plugin-annotation'; +import { isFuture } from 'date-fns'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CommonModule, NgxSkeletonLoaderModule], + imports: [NgxSkeletonLoaderModule], selector: 'gf-investment-chart', styleUrls: ['./investment-chart.component.scss'], templateUrl: './investment-chart.component.html' @@ -75,7 +73,6 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy { public constructor() { Chart.register( - annotationPlugin, BarController, BarElement, LinearScale, @@ -179,16 +176,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy { data: chartData, options: { animation: false, - elements: { - line: { - tension: 0 - }, - point: { - hoverBackgroundColor: getBackgroundColor(this.colorScheme), - hoverRadius: 2, - radius: 0 - } - }, + elements: getChartElementsOptions(this.colorScheme), interaction: { intersect: false, mode: 'index' }, maintainAspectRatio: true, plugins: { @@ -216,13 +204,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy { value: this.savingsRate } : undefined, - yAxis: { - borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`, - borderWidth: 1, - scaleID: 'y', - type: 'line', - value: 0 - } + yAxis: getZeroLineAnnotation(this.colorScheme) } }, legend: { @@ -230,54 +212,23 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy { }, tooltip: this.getTooltipPluginConfiguration(), verticalHoverLine: { - color: `rgba(${getTextColor(this.colorScheme)}, 0.1)` + color: getChartBorderColor(this.colorScheme) } }, responsive: true, scales: { - x: { - border: { - color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`, - width: this.groupBy ? 0 : 1 - }, - display: true, - grid: { - display: false - }, - type: 'time', - time: { - tooltipFormat: getDateFormatString(this.locale), - unit: 'year' - } - }, - y: { - border: { - display: false - }, + x: getTimeAxisOptions({ + borderWidth: this.groupBy ? 0 : 1, + colorScheme: this.colorScheme, + locale: this.locale + }), + y: getValueAxisOptions({ + colorScheme: this.colorScheme, display: !this.isInPercentage, - grid: { - color: ({ scale, tick }) => { - if ( - tick.value === 0 || - tick.value === scale.max || - tick.value === scale.min - ) { - return `rgba(${getTextColor(this.colorScheme)}, 0.1)`; - } - - return 'transparent'; - } - }, - position: 'right', - ticks: { - callback: (value: number) => { - return transformTickToAbbreviation(value); - }, - display: true, - mirror: true, - z: 1 + tickCallback: (tickValue) => { + return transformTickToAbbreviation(Number(tickValue)); } - } + }) } }, plugins: [ @@ -293,19 +244,13 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy { private getTooltipPluginConfiguration(): Partial< TooltipOptions<'bar' | 'line'> > { - return { - ...getTooltipOptions({ - colorScheme: this.colorScheme, - currency: this.isInPercentage ? undefined : this.currency, - groupBy: this.groupBy, - locale: this.isInPercentage ? undefined : this.locale, - unit: this.isInPercentage ? '%' : undefined - }), - mode: 'index', - position: 'top', - xAlign: 'center', - yAlign: 'bottom' - }; + return getTimeSeriesTooltipOptions<'bar' | 'line'>({ + colorScheme: this.colorScheme, + currency: this.isInPercentage ? undefined : this.currency, + groupBy: this.groupBy, + locale: this.isInPercentage ? undefined : this.locale, + unit: this.isInPercentage ? '%' : undefined + }); } private isInFuture(aContext: ScriptableLineSegmentContext, aValue: T) { @@ -315,6 +260,6 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy { return undefined; } - return isAfter(new Date(xValue), new Date()) ? aValue : undefined; + return isFuture(new Date(xValue)) ? aValue : undefined; } } diff --git a/apps/client/src/app/components/login-with-access-token-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/login-with-access-token-dialog/interfaces/interfaces.ts index e9222e142..b903fcfef 100644 --- a/apps/client/src/app/components/login-with-access-token-dialog/interfaces/interfaces.ts +++ b/apps/client/src/app/components/login-with-access-token-dialog/interfaces/interfaces.ts @@ -5,3 +5,7 @@ export interface LoginWithAccessTokenDialogParams { hasPermissionToUseAuthToken: boolean; title: string; } + +export interface LoginWithAccessTokenDialogResult { + accessToken: string | null; +} diff --git a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.component.ts b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.component.ts index d79c7a675..d6ce28c96 100644 --- a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.component.ts +++ b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.component.ts @@ -22,7 +22,10 @@ import { IonIcon } from '@ionic/angular/standalone'; import { addIcons } from 'ionicons'; import { eyeOffOutline, eyeOutline } from 'ionicons/icons'; -import { LoginWithAccessTokenDialogParams } from './interfaces/interfaces'; +import { + LoginWithAccessTokenDialogParams, + LoginWithAccessTokenDialogResult +} from './interfaces/interfaces'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -49,7 +52,10 @@ export class GfLoginWithAccessTokenDialogComponent { public constructor( @Inject(MAT_DIALOG_DATA) public data: LoginWithAccessTokenDialogParams, - public dialogRef: MatDialogRef, + public dialogRef: MatDialogRef< + GfLoginWithAccessTokenDialogComponent, + LoginWithAccessTokenDialogResult + >, private settingsStorageService: SettingsStorageService ) { addIcons({ eyeOffOutline, eyeOutline }); diff --git a/apps/client/src/app/components/markets/markets.component.ts b/apps/client/src/app/components/markets/markets.component.ts index d2f64f3fc..27f60d86a 100644 --- a/apps/client/src/app/components/markets/markets.component.ts +++ b/apps/client/src/app/components/markets/markets.component.ts @@ -1,15 +1,17 @@ -import { GfFearAndGreedIndexComponent } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.component'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { resetHours } from '@ghostfolio/common/helper'; import { Benchmark, HistoricalDataItem, + InfoItem, MarketDataOfMarketsResponse, ToggleOption, User } from '@ghostfolio/common/interfaces'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { FearAndGreedIndexMode } from '@ghostfolio/common/types'; import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark'; +import { GfFearAndGreedIndexComponent } from '@ghostfolio/ui/fear-and-greed-index'; import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; import { DataService } from '@ghostfolio/ui/services'; import { GfToggleComponent } from '@ghostfolio/ui/toggle'; @@ -18,9 +20,12 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + computed, CUSTOM_ELEMENTS_SCHEMA, DestroyRef, - OnInit + inject, + OnInit, + signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { DeviceDetectorService } from 'ngx-device-detector'; @@ -39,29 +44,50 @@ import { DeviceDetectorService } from 'ngx-device-detector'; templateUrl: './markets.html' }) export class GfMarketsComponent implements OnInit { - public benchmarks: Benchmark[]; - public deviceType: string; - public fearAndGreedIndex: number; - public fearAndGreedIndexData: MarketDataOfMarketsResponse['fearAndGreedIndex']; - public fearLabel = $localize`Fear`; - public greedLabel = $localize`Greed`; - public historicalDataItems: HistoricalDataItem[]; - public fearAndGreedIndexMode: FearAndGreedIndexMode = 'STOCKS'; - public fearAndGreedIndexModeOptions: ToggleOption[] = [ + protected readonly benchmarks = signal([]); + + protected readonly deviceType = computed( + () => this.deviceDetectorService.deviceInfo().deviceType + ); + + protected readonly fearAndGreedIndexModeOptions: ToggleOption[] = [ { label: $localize`Stocks`, value: 'STOCKS' }, { label: $localize`Cryptocurrencies`, value: 'CRYPTOCURRENCIES' } ]; - public readonly numberOfDays = 365; - public user: User; - - public constructor( - private changeDetectorRef: ChangeDetectorRef, - private dataService: DataService, - private destroyRef: DestroyRef, - private deviceDetectorService: DeviceDetectorService, - private userService: UserService - ) { - this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; + + protected readonly fearLabel = $localize`Fear`; + protected readonly greedLabel = $localize`Greed`; + protected readonly numberOfDays = 365; + + protected fearAndGreedIndex: number | undefined; + protected fearAndGreedIndexMode: FearAndGreedIndexMode = 'STOCKS'; + protected hasPermissionToAccessFearAndGreedIndex: boolean; + protected hasPermissionToReadMarketDataOfMarkets: boolean; + protected historicalDataItems: HistoricalDataItem[]; + protected isLoadingFearAndGreedIndex = true; + protected user: User; + + private fearAndGreedIndexData: MarketDataOfMarketsResponse['fearAndGreedIndex']; + + private readonly info: InfoItem; + + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly deviceDetectorService = inject(DeviceDetectorService); + private readonly userService = inject(UserService); + + public constructor() { + this.info = this.dataService.fetchInfo(); + + this.hasPermissionToAccessFearAndGreedIndex = hasPermission( + this.info?.globalPermissions, + permissions.enableFearAndGreedIndex + ); + + if (this.hasPermissionToAccessFearAndGreedIndex) { + this.fearAndGreedIndex = this.info.fearAndGreedStocksMarketPrice; + } this.userService.stateChanged .pipe(takeUntilDestroyed(this.destroyRef)) @@ -69,6 +95,20 @@ export class GfMarketsComponent implements OnInit { if (state?.user) { this.user = state.user; + this.hasPermissionToReadMarketDataOfMarkets = hasPermission( + this.user.permissions, + permissions.readMarketDataOfMarkets + ); + + if ( + this.hasPermissionToReadMarketDataOfMarkets && + !this.fearAndGreedIndexData + ) { + this.fetchMarketDataOfMarkets(); + } else { + this.isLoadingFearAndGreedIndex = false; + } + this.changeDetectorRef.markForCheck(); } }); @@ -76,27 +116,37 @@ export class GfMarketsComponent implements OnInit { public ngOnInit() { this.dataService - .fetchMarketDataOfMarkets({ includeHistoricalData: this.numberOfDays }) + .fetchBenchmarks() .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(({ fearAndGreedIndex }) => { - this.fearAndGreedIndexData = fearAndGreedIndex; + .subscribe(({ benchmarks }) => { + this.benchmarks.set(benchmarks); + }); + } - this.initialize(); + protected onChangeFearAndGreedIndexMode( + aFearAndGreedIndexMode: FearAndGreedIndexMode + ) { + this.fearAndGreedIndexMode = aFearAndGreedIndexMode; - this.changeDetectorRef.markForCheck(); - }); + this.initializeFearAndGreedIndex(); + } + private fetchMarketDataOfMarkets() { this.dataService - .fetchBenchmarks() + .fetchMarketDataOfMarkets({ includeHistoricalData: this.numberOfDays }) .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(({ benchmarks }) => { - this.benchmarks = benchmarks; + .subscribe(({ fearAndGreedIndex }) => { + this.fearAndGreedIndexData = fearAndGreedIndex; + + this.initializeFearAndGreedIndex(); + + this.isLoadingFearAndGreedIndex = false; this.changeDetectorRef.markForCheck(); }); } - public initialize() { + private initializeFearAndGreedIndex() { this.fearAndGreedIndex = this.fearAndGreedIndexData[this.fearAndGreedIndexMode]?.marketPrice; @@ -109,12 +159,4 @@ export class GfMarketsComponent implements OnInit { } ]; } - - public onChangeFearAndGreedIndexMode( - aFearAndGreedIndexMode: FearAndGreedIndexMode - ) { - this.fearAndGreedIndexMode = aFearAndGreedIndexMode; - - this.initialize(); - } } diff --git a/apps/client/src/app/components/markets/markets.html b/apps/client/src/app/components/markets/markets.html index 38234a785..bd9013a00 100644 --- a/apps/client/src/app/components/markets/markets.html +++ b/apps/client/src/app/components/markets/markets.html @@ -1,52 +1,62 @@

    Markets

    -
    -
    - @if (user?.settings?.isExperimentalFeatures) { -
    - +
    + @if (hasPermissionToReadMarketDataOfMarkets) { + @if (user?.settings?.isExperimentalFeatures) { +
    + +
    + } +
    + Last {{ numberOfDays }} Days +
    + -
    - } -
    - Last {{ numberOfDays }} Days + } +
    - -
    -
    + }
    - @if (benchmarks?.length > 0) { + @if (benchmarks()?.length > 0) {
    diff --git a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html index 9db996fbf..d6339a5ff 100644 --- a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html +++ b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html @@ -1,7 +1,7 @@
    - @if (errors?.length > 0 && !isLoading) { + @if (errors()?.length > 0 && !isLoading()) { }
    - @if (isLoading) { + @if (isLoading()) {
    - {{ unit }} + {{ unit() }}
    - @if (showDetails) { + @if (showDetails()) {
    diff --git a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts index dd3ce248e..a48d77a2d 100644 --- a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts +++ b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts @@ -13,10 +13,11 @@ import { GfValueComponent } from '@ghostfolio/ui/value'; import { ChangeDetectionStrategy, Component, + effect, ElementRef, - Input, - OnChanges, - ViewChild + inject, + input, + viewChild } from '@angular/core'; import { IonIcon } from '@ionic/angular/standalone'; import { CountUp } from 'countup.js'; @@ -32,60 +33,68 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; styleUrls: ['./portfolio-performance.component.scss'], templateUrl: './portfolio-performance.component.html' }) -export class GfPortfolioPerformanceComponent implements OnChanges { - @Input() deviceType: string; - @Input() errors: ResponseError['errors']; - @Input() isAllTimeHigh: boolean; - @Input() isAllTimeLow: boolean; - @Input() isLoading: boolean; - @Input() locale = getLocale(); - @Input() performance: PortfolioPerformance; - @Input() precision: number; - @Input() showDetails: boolean; - @Input() unit: string; +export class GfPortfolioPerformanceComponent { + public readonly errors = input(); + public readonly isLoading = input(); + public readonly locale = input(getLocale()); + public readonly performance = input.required(); + public readonly precision = input.required({ + transform: (value) => { + return value >= 0 ? value : 2; + } + }); + public readonly showDetails = input(false); + public readonly unit = input.required(); - @ViewChild('value') value: ElementRef; + private readonly value = + viewChild.required>('value'); - public constructor(private notificationService: NotificationService) { - addIcons({ timeOutline }); - } + private readonly notificationService = inject(NotificationService); - public ngOnChanges() { - this.precision = this.precision >= 0 ? this.precision : 2; + public constructor() { + addIcons({ timeOutline }); - if (this.isLoading) { - if (this.value?.nativeElement) { - this.value.nativeElement.innerHTML = ''; - } - } else { - if (isNumber(this.performance?.currentValueInBaseCurrency)) { - new CountUp('value', this.performance?.currentValueInBaseCurrency, { - decimal: getNumberFormatDecimal(this.locale), - decimalPlaces: this.precision, - duration: 1, - separator: getNumberFormatGroup(this.locale) - }).start(); - } else if (this.showDetails === false) { - new CountUp( - 'value', - this.performance?.netPerformancePercentageWithCurrencyEffect * 100, - { - decimal: getNumberFormatDecimal(this.locale), - decimalPlaces: 2, - duration: 1, - separator: getNumberFormatGroup(this.locale) - } - ).start(); + effect(() => { + if (this.isLoading()) { + if (this.value().nativeElement) { + this.value().nativeElement.innerHTML = ''; + } } else { - this.value.nativeElement.innerHTML = '*****'; + if (isNumber(this.performance().currentValueInBaseCurrency)) { + new CountUp('value', this.performance().currentValueInBaseCurrency, { + decimal: getNumberFormatDecimal(this.locale()), + decimalPlaces: this.precision(), + duration: 1, + separator: getNumberFormatGroup(this.locale()) + }).start(); + } else if (this.showDetails() === false) { + new CountUp( + 'value', + this.performance().netPerformancePercentageWithCurrencyEffect * 100, + { + decimal: getNumberFormatDecimal(this.locale()), + decimalPlaces: 2, + duration: 1, + separator: getNumberFormatGroup(this.locale()) + } + ).start(); + } else { + this.value().nativeElement.innerHTML = '*****'; + } } - } + }); } - public onShowErrors() { - const errorMessageParts = []; + protected onShowErrors() { + const errors = this.errors(); + + if (!errors?.length) { + return; + } + + const errorMessageParts: string[] = []; - for (const error of this.errors) { + for (const error of errors) { errorMessageParts.push(`${error.symbol} (${error.dataSource})`); } diff --git a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html index e14479425..520986c89 100644 --- a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html +++ b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html @@ -2,14 +2,18 @@
    Time in Market
    - +
    -
    +
    {{ summary?.activityCount }} {summary?.activityCount, plural, =1 {activity} @@ -33,10 +37,11 @@
    @@ -46,10 +51,11 @@
    @@ -57,19 +63,16 @@

    -
    Investment
    +
    Invested Capital
    @@ -79,12 +82,11 @@
    @@ -97,10 +99,11 @@
    @@ -113,17 +116,16 @@
    -
    +
    Net Performance
    @@ -156,16 +155,32 @@ class="justify-content-end" position="end" [isCurrency]="true" + [isLoading]="isLoading" [locale]="locale" [precision]="precision" [unit]="baseCurrency" - [value]="isLoading ? undefined : summary?.currentValueInBaseCurrency" + [value]="summary?.totalAssetsInBaseCurrency" />
    -
    - Emergency Fund +
    + @if (hasHoldingsBreakdown) { + + + + } + Holdings @if ( !hasImpersonationId && summary?.totalValueInBaseCurrency > 0 && @@ -173,78 +188,77 @@ ) { }
    -
    - @if ( - hasPermissionToUpdateUserSettings && - !isLoading && - !user?.settings?.isRestrictedView && - user?.subscription?.type !== 'Basic' - ) { - - } - -
    -
    -
    -
    Cash
    -
    -
    Assets
    -
    - + @if (hasHoldingsBreakdown && isHoldingsExpanded) { +
    +
    Investments
    +
    + +
    -
    +
    +
    Emergency Fund
    +
    + +
    +
    + }
    -
    - Buying Power +
    + @if (hasCashBreakdown) { + + + + } + Cash @if ( !hasImpersonationId && summary?.totalValueInBaseCurrency > 0 && @@ -252,22 +266,59 @@ ) { }
    -
    +
    + @if (hasCashBreakdown && isCashExpanded) { +
    +
    Buying Power
    +
    + +
    +
    +
    +
    Emergency Fund
    +
    + +
    +
    + }
    Excluded from Analysis @@ -278,19 +329,22 @@ ) { }
    @@ -309,10 +363,11 @@
    @@ -325,15 +380,16 @@
    -
    +
    Annualized Performance
    @@ -341,29 +397,81 @@ class="justify-content-end" position="end" [colorizeSign]="true" + [isLoading]="isLoading" [isPercent]="true" [locale]="locale" - [value]=" - isLoading - ? undefined - : summary?.annualizedPerformancePercentWithCurrencyEffect - " + [value]="summary?.annualizedPerformancePercentWithCurrencyEffect" />

    +
    +
    + Emergency Fund + @if ( + !hasImpersonationId && + summary?.totalValueInBaseCurrency > 0 && + user?.settings?.isExperimentalFeatures + ) { + + } +
    +
    + @if ( + hasPermissionToUpdateUserSettings && + !isLoading && + !user?.settings?.isRestrictedView && + user?.subscription?.type !== 'Basic' + ) { + + } + +
    +
    Interest
    @@ -373,10 +481,11 @@
    diff --git a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.scss b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.scss index 5d4e87f30..534976f11 100644 --- a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.scss +++ b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.scss @@ -1,3 +1,24 @@ :host { display: block; + + .caret-container { + width: 1rem; + + .caret { + font-size: 0.7rem; + transition: transform 150ms ease-in-out; + + &.caret-expanded { + transform: rotate(90deg); + } + } + } + + .indent-1 { + margin-left: 1rem; + } + + .indent-2 { + margin-left: 2rem; + } } diff --git a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts index 3d2760202..eac5ef9a6 100644 --- a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts +++ b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts @@ -8,19 +8,21 @@ import { GfValueComponent } from '@ghostfolio/ui/value'; import { ChangeDetectionStrategy, Component, - EventEmitter, + inject, Input, OnChanges, - Output + output } from '@angular/core'; import { MatTooltipModule } from '@angular/material/tooltip'; import { IonIcon } from '@ionic/angular/standalone'; import { formatDistanceToNow } from 'date-fns'; import { addIcons } from 'ionicons'; import { + caretForwardOutline, ellipsisHorizontalCircleOutline, informationCircleOutline } from 'ionicons/icons'; +import { isNumber } from 'lodash'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -40,44 +42,92 @@ export class GfPortfolioSummaryComponent implements OnChanges { @Input() summary: PortfolioSummary; @Input() user: User; - @Output() emergencyFundChanged = new EventEmitter(); + public emergencyFundChanged = output(); - public buyAndSellActivitiesTooltip = translate( + protected readonly buyAndSellActivitiesTooltip = translate( 'BUY_AND_SELL_ACTIVITIES_TOOLTIP' ); - public precision = 2; - public timeInMarket: string; + protected isCashExpanded = false; + protected isHoldingsExpanded = false; + protected precision = 2; + protected timeInMarket: string | undefined; - public get buyingPowerPercentage() { + private readonly notificationService = inject(NotificationService); + + public constructor() { + addIcons({ + caretForwardOutline, + ellipsisHorizontalCircleOutline, + informationCircleOutline + }); + } + + protected get cashPercentage() { return this.summary?.totalValueInBaseCurrency - ? this.summary.cash / this.summary.totalValueInBaseCurrency + ? this.summary.totalCashInBaseCurrency / + this.summary.totalValueInBaseCurrency : 0; } - public get emergencyFundPercentage() { + protected get emergencyFundPercentage() { return this.summary?.totalValueInBaseCurrency ? (this.summary.emergencyFund?.total || 0) / this.summary.totalValueInBaseCurrency : 0; } - public get excludedFromAnalysisPercentage() { + protected get excludedFromAnalysisPercentage() { return this.summary?.totalValueInBaseCurrency ? this.summary.excludedAccountsAndActivities / this.summary.totalValueInBaseCurrency : 0; } - public constructor(private notificationService: NotificationService) { - addIcons({ ellipsisHorizontalCircleOutline, informationCircleOutline }); + protected get hasCashBreakdown() { + return !this.isLoading && this.summary?.emergencyFund?.cash > 0; + } + + protected get hasHoldingsBreakdown() { + return !this.isLoading && this.summary?.emergencyFund?.assets > 0; + } + + protected get holdingsInBaseCurrency() { + if ( + !isNumber(this.summary?.totalAssetsInBaseCurrency) || + !isNumber(this.summary?.totalCashInBaseCurrency) + ) { + return null; + } + + return ( + this.summary.totalAssetsInBaseCurrency - + this.summary.totalCashInBaseCurrency + ); + } + + protected get holdingsPercentage() { + return this.summary?.totalValueInBaseCurrency && + isNumber(this.holdingsInBaseCurrency) + ? this.holdingsInBaseCurrency / this.summary.totalValueInBaseCurrency + : 0; + } + + protected get investmentsInBaseCurrency() { + if (!isNumber(this.holdingsInBaseCurrency)) { + return null; + } + + return ( + this.holdingsInBaseCurrency - (this.summary.emergencyFund?.assets ?? 0) + ); } public ngOnChanges() { if (this.summary) { if ( this.deviceType === 'mobile' && - this.summary.totalValueInBaseCurrency >= + (this.summary.totalValueInBaseCurrency ?? 0) >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES ) { this.precision = 0; @@ -91,14 +141,14 @@ export class GfPortfolioSummaryComponent implements OnChanges { } ); } else { - this.timeInMarket = '-'; + this.timeInMarket = '–'; } } else { this.timeInMarket = undefined; } } - public onEditEmergencyFund() { + protected onEditEmergencyFund() { this.notificationService.prompt({ confirmFn: (value) => { const emergencyFund = parseFloat(value.trim()) || 0; @@ -110,4 +160,12 @@ export class GfPortfolioSummaryComponent implements OnChanges { title: $localize`Please set the amount of your emergency fund.` }); } + + protected onToggleCash() { + this.isCashExpanded = !this.isCashExpanded; + } + + protected onToggleHoldings() { + this.isHoldingsExpanded = !this.isHoldingsExpanded; + } } diff --git a/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts b/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts index 5c2f3be79..4b2a9b0e6 100644 --- a/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts +++ b/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts @@ -1,8 +1,7 @@ -import { XRayRulesSettings } from '@ghostfolio/common/interfaces'; import { GfValueComponent } from '@ghostfolio/ui/value'; -import { Component, Inject } from '@angular/core'; -import { FormsModule } from '@angular/forms'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MAT_DIALOG_DATA, @@ -14,22 +13,38 @@ import { MatSliderModule } from '@angular/material/slider'; import { RuleSettingsDialogParams } from './interfaces/interfaces'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ - FormsModule, GfValueComponent, MatButtonModule, MatDialogModule, - MatSliderModule + MatSliderModule, + ReactiveFormsModule ], selector: 'gf-rule-settings-dialog', styleUrls: ['./rule-settings-dialog.scss'], templateUrl: './rule-settings-dialog.html' }) export class GfRuleSettingsDialogComponent { - public settings: XRayRulesSettings['AccountClusterRiskCurrentInvestment']; + protected readonly settingsForm: FormGroup; - public constructor( - @Inject(MAT_DIALOG_DATA) public data: RuleSettingsDialogParams, - public dialogRef: MatDialogRef - ) {} + protected readonly data = inject(MAT_DIALOG_DATA); + protected readonly dialogRef = + inject>(MatDialogRef); + private readonly formBuilder = inject(FormBuilder); + + public constructor() { + this.settingsForm = this.formBuilder.group({ + thresholdMax: [this.data.settings?.thresholdMax], + thresholdMin: [this.data.settings?.thresholdMin] + }); + } + + protected onSubmit() { + this.dialogRef.close({ + ...this.data.settings, + thresholdMax: this.settingsForm.get('thresholdMax')?.value, + thresholdMin: this.settingsForm.get('thresholdMin')?.value + }); + } } diff --git a/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html b/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html index c88a9dc9d..618626b63 100644 --- a/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html +++ b/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html @@ -1,132 +1,142 @@
    {{ data.categoryName }} › {{ data.rule.name }}
    -
    - @if ( - data.rule.configuration.thresholdMin && data.rule.configuration.thresholdMax - ) { -
    -
    - Threshold range: - - - - -
    -
    - - - - - - +
    +
    + @if ( + data.rule.configuration?.thresholdMin && + data.rule.configuration?.thresholdMax + ) { +
    +
    + Threshold range: + + - + +
    +
    + + + + + + +
    -
    - } @else { -
    -
    - Threshold Min: - -
    -
    - - - - - + } @else { +
    +
    + Threshold Min: + +
    +
    + + + + + +
    -
    -
    -
    - Threshold Max: - -
    -
    - - - - - +
    +
    + Threshold Max: + +
    +
    + + + + + +
    -
    - } -
    + } +
    -
    - - -
    +
    + + +
    +
    diff --git a/apps/client/src/app/components/rules/rules.component.html b/apps/client/src/app/components/rules/rules.component.html index 0c3153c52..97b41e61b 100644 --- a/apps/client/src/app/components/rules/rules.component.html +++ b/apps/client/src/app/components/rules/rules.component.html @@ -3,9 +3,7 @@
    @if (isLoading) { - } - - @if (rules !== null && rules !== undefined) { + } @else if (rules) { @for (rule of rules; track rule.key) { { + this.accounts = accounts; + this.hasExperimentalFeatures = settings.isExperimentalFeatures ?? false; + this.tags = getTagFilters(tags); + + this.changeDetectorRef.markForCheck(); + }); + this.accessForm .get('type') ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) @@ -97,23 +143,28 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { if (accessType === 'PRIVATE') { granteeUserIdControl?.setValidators(Validators.required); + this.accessForm.get('filters')?.setValue(null); } else { granteeUserIdControl?.clearValidators(); granteeUserIdControl?.setValue(null); - permissionsControl?.setValue(this.data.access.permissions[0]); + permissionsControl?.setValue( + access?.permissions[0] ?? AccessPermission.READ_RESTRICTED + ); } granteeUserIdControl?.updateValueAndValidity(); this.changeDetectorRef.markForCheck(); }); + + this.loadHoldings(); } - public onCancel() { + protected onCancel() { this.dialogRef.close(); } - public async onSubmit() { + protected async onSubmit() { if (this.mode === 'create') { await this.createAccess(); } else { @@ -121,9 +172,18 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { } } + private buildFilters(): Filter[] { + return getFiltersFromPortfolioFilterFormValue( + this.accessForm.get('filters')?.value + ); + } + private async createAccess() { + const filters = this.buildFilters(); + const access: CreateAccessDto = { alias: this.accessForm.get('alias')?.value, + filters: filters.length > 0 ? filters : undefined, granteeUserId: this.accessForm.get('granteeUserId')?.value, permissions: [this.accessForm.get('permissions')?.value] }; @@ -157,11 +217,33 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { } } + private loadHoldings() { + this.dataService + .fetchPortfolioHoldings() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(({ holdings }) => { + this.holdings = getHoldingsForFilter(holdings); + + this.updateFiltersFormControl(this.data.access?.settings?.filters); + + this.changeDetectorRef.markForCheck(); + }); + } + private async updateAccess() { + const accessId = this.data.access?.id; + + if (!accessId) { + return; + } + + const filters = this.buildFilters(); + const access: UpdateAccessDto = { alias: this.accessForm.get('alias')?.value, + filters: filters.length > 0 ? filters : undefined, granteeUserId: this.accessForm.get('granteeUserId')?.value, - id: this.data.access.id, + id: accessId, permissions: [this.accessForm.get('permissions')?.value] }; @@ -193,4 +275,14 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { console.error(error); } } + + private updateFiltersFormControl(filters: Filter[] | undefined) { + if (!filters?.length) { + return; + } + + this.accessForm + .get('filters') + ?.setValue(getPortfolioFilterFormValue(filters, this.holdings)); + } } diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html index 93614b55a..1736aa9fc 100644 --- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html +++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html @@ -59,6 +59,18 @@
    } + @if (canApplyFilters) { +

    Portfolio Filters

    + + }
    diff --git a/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts b/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts index b13a983fc..defe751ee 100644 --- a/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts +++ b/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts @@ -1,9 +1,9 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { E_MAIL_LINE_BREAK } from '@ghostfolio/common/config'; import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { getDateFormatString } from '@ghostfolio/common/helper'; import { User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; -import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { GfMembershipCardComponent } from '@ghostfolio/ui/membership-card'; import { NotificationService } from '@ghostfolio/ui/notifications'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; @@ -14,7 +14,8 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, - DestroyRef + DestroyRef, + inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; @@ -23,7 +24,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { RouterModule } from '@angular/router'; import ms, { StringValue } from 'ms'; import { EMPTY } from 'rxjs'; -import { catchError } from 'rxjs/operators'; +import { catchError, switchMap } from 'rxjs/operators'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -40,29 +41,34 @@ import { catchError } from 'rxjs/operators'; templateUrl: './user-account-membership.html' }) export class GfUserAccountMembershipComponent { - public baseCurrency: string; - public coupon: number; - public couponId: string; - public defaultDateFormat: string; - public durationExtension: StringValue; - public hasPermissionForSubscription: boolean; - public hasPermissionToCreateApiKey: boolean; - public hasPermissionToUpdateUserSettings: boolean; - public price: number; - public priceId: string; - public routerLinkPricing = publicRoutes.pricing.routerLink; - public trySubscriptionMail = - 'mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Trial&body=Hello%0D%0DI am interested in Ghostfolio Premium. Can you please send me a coupon code to try it for some time?%0D%0DKind regards'; - public user: User; - - public constructor( - private changeDetectorRef: ChangeDetectorRef, - private dataService: DataService, - private destroyRef: DestroyRef, - private notificationService: NotificationService, - private snackBar: MatSnackBar, - private userService: UserService - ) { + protected readonly baseCurrency: string; + protected coupon: number | undefined; + protected defaultDateFormat: string; + protected durationExtension: StringValue | undefined; + protected readonly hasPermissionForSubscription: boolean; + protected hasPermissionToCreateApiKey: boolean; + protected hasPermissionToUpdateUserSettings: boolean; + protected price: number; + protected readonly trySubscriptionMailHref = `mailto:hi@ghostfol.io?subject=Ghostfolio Premium Trial&body=${[ + 'Hello', + '', + 'I am interested in Ghostfolio Premium. Can you please send me a coupon code to try it for some time?', + '', + 'Kind regards' + ].join(E_MAIL_LINE_BREAK)}`; + protected user: User; + + private couponId: string | undefined; + private priceId: string; + + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly notificationService = inject(NotificationService); + private readonly snackBar = inject(MatSnackBar); + private readonly userService = inject(UserService); + + public constructor() { const { baseCurrency, globalPermissions } = this.dataService.fetchInfo(); this.baseCurrency = baseCurrency; @@ -104,7 +110,7 @@ export class GfUserAccountMembershipComponent { }); } - public onCheckout() { + protected onCheckout() { this.dataService .createStripeCheckoutSession({ couponId: this.couponId, @@ -125,7 +131,7 @@ export class GfUserAccountMembershipComponent { }); } - public onGenerateApiKey() { + protected onGenerateApiKey() { this.notificationService.confirm({ confirmFn: () => { this.dataService @@ -146,11 +152,9 @@ export class GfUserAccountMembershipComponent { ) .subscribe(({ apiKey }) => { this.notificationService.alert({ - discardLabel: $localize`Okay`, - message: - $localize`Set this API key in your self-hosted environment:` + - '
    ' + - apiKey, + copyValue: apiKey, + discardLabel: $localize`Close`, + message: $localize`Set this API key in your self-hosted environment:`, title: $localize`Ghostfolio Premium Data Provider API Key` }); }); @@ -160,7 +164,7 @@ export class GfUserAccountMembershipComponent { }); } - public onRedeemCoupon() { + protected onRedeemCoupon() { this.notificationService.prompt({ confirmFn: (value) => { const couponCode = value?.trim(); @@ -169,6 +173,9 @@ export class GfUserAccountMembershipComponent { this.dataService .redeemCoupon(couponCode) .pipe( + switchMap(() => { + return this.userService.get(true); + }), catchError(() => { this.snackBar.open( '😞 ' + $localize`Could not redeem coupon code`, @@ -183,27 +190,13 @@ export class GfUserAccountMembershipComponent { takeUntilDestroyed(this.destroyRef) ) .subscribe(() => { - const snackBarRef = this.snackBar.open( + this.snackBar.open( '✅ ' + $localize`Coupon code has been redeemed`, - $localize`Reload`, + undefined, { duration: ms('3 seconds') } ); - - snackBarRef - .afterDismissed() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { - window.location.reload(); - }); - - snackBarRef - .onAction() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { - window.location.reload(); - }); }); } }, diff --git a/apps/client/src/app/components/user-account-membership/user-account-membership.html b/apps/client/src/app/components/user-account-membership/user-account-membership.html index 31d239215..7028ea5c8 100644 --- a/apps/client/src/app/components/user-account-membership/user-account-membership.html +++ b/apps/client/src/app/components/user-account-membership/user-account-membership.html @@ -49,7 +49,10 @@ }
    @if (!user?.subscription?.expiresAt) { - Try Premium this.deviceDetectorService.deviceInfo().deviceType + ); + + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly dataService = inject(DataService); + private readonly deviceDetectorService = inject(DeviceDetectorService); + private readonly destroyRef = inject(DestroyRef); + private readonly notificationService = inject(NotificationService); + private readonly settingsStorageService = inject(SettingsStorageService); + private readonly snackBar = inject(MatSnackBar); + private readonly userService = inject(UserService); + private readonly webAuthnService = inject(WebAuthnService); + + public constructor() { const { baseCurrency, currencies } = this.dataService.fetchInfo(); this.baseCurrency = baseCurrency; @@ -122,11 +141,38 @@ export class GfUserAccountSettingsComponent implements OnInit { if (state?.user) { this.user = state.user; + const userDetailUrl = [ + window.location.origin, + DEFAULT_LANGUAGE_CODE, + internalRoutes.adminControl.path, + internalRoutes.adminControl.subRoutes.users.path, + this.user.id + ].join('/'); + + this.closeUserAccountMailHref = `mailto:hi@ghostfol.io?subject=Delete Account&body=${[ + 'Hello', + '', + 'Please delete my Ghostfolio account.', + '', + `User ID: ${this.user.id}`, + '', + 'Kind regards', + '', + '', + '---', + userDetailUrl + ].join(E_MAIL_LINE_BREAK)}`; + this.hasPermissionToDeleteOwnUser = hasPermission( this.user.permissions, permissions.deleteOwnUser ); + this.hasPermissionToRequestOwnUserDeletion = hasPermission( + this.user.permissions, + permissions.requestOwnUserDeletion + ); + this.hasPermissionToUpdateUserSettings = hasPermission( this.user.permissions, permissions.updateUserSettings @@ -137,9 +183,14 @@ export class GfUserAccountSettingsComponent implements OnInit { permissions.updateViewMode ); - this.locales.push(this.user.settings.locale); + if (this.user.settings.locale) { + this.locales.push(this.user.settings.locale); + } + this.locales = Array.from(new Set(this.locales)).sort(); + this.isLoading = false; + this.changeDetectorRef.markForCheck(); } }); @@ -151,11 +202,11 @@ export class GfUserAccountSettingsComponent implements OnInit { this.update(); } - public isCommunityLanguage() { + protected isCommunityLanguage() { return !['de', 'en'].includes(this.language); } - public onChangeUserSetting(aKey: string, aValue: string) { + protected onChangeUserSetting(aKey: string, aValue: string) { this.dataService .putUserSetting({ [aKey]: aValue }) .pipe(takeUntilDestroyed(this.destroyRef)) @@ -179,12 +230,12 @@ export class GfUserAccountSettingsComponent implements OnInit { }); } - public onCloseAccount() { + protected onCloseAccount() { this.notificationService.confirm({ confirmFn: () => { this.dataService .deleteOwnUser({ - accessToken: this.deleteOwnUserForm.get('accessToken').value + accessToken: this.deleteOwnUserForm.controls.accessToken.value }) .pipe( catchError(() => { @@ -207,7 +258,7 @@ export class GfUserAccountSettingsComponent implements OnInit { }); } - public onExperimentalFeaturesChange(aEvent: MatSlideToggleChange) { + protected onExperimentalFeaturesChange(aEvent: MatSlideToggleChange) { this.dataService .putUserSetting({ isExperimentalFeatures: aEvent.checked }) .pipe(takeUntilDestroyed(this.destroyRef)) @@ -223,15 +274,11 @@ export class GfUserAccountSettingsComponent implements OnInit { }); } - public onExport() { + protected onExport() { this.dataService .fetchExport() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((data) => { - for (const activity of data.activities) { - delete activity.id; - } - downloadAsFile({ content: data, fileName: `ghostfolio-export-${format( @@ -243,7 +290,7 @@ export class GfUserAccountSettingsComponent implements OnInit { }); } - public onRestrictedViewChange(aEvent: MatSlideToggleChange) { + protected onRestrictedViewChange(aEvent: MatSlideToggleChange) { this.dataService .putUserSetting({ isRestrictedView: aEvent.checked }) .pipe(takeUntilDestroyed(this.destroyRef)) @@ -259,7 +306,7 @@ export class GfUserAccountSettingsComponent implements OnInit { }); } - public async onSignInWithFingerprintChange(aEvent: MatSlideToggleChange) { + protected async onSignInWithFingerprintChange(aEvent: MatSlideToggleChange) { if (aEvent.checked) { try { await this.registerDevice(); @@ -282,7 +329,7 @@ export class GfUserAccountSettingsComponent implements OnInit { } } - public onViewModeChange(aEvent: MatSlideToggleChange) { + protected onViewModeChange(aEvent: MatSlideToggleChange) { this.dataService .putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' }) .pipe(takeUntilDestroyed(this.destroyRef)) diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.html b/apps/client/src/app/components/user-account-settings/user-account-settings.html index 4f62a6265..7dfbc329d 100644 --- a/apps/client/src/app/components/user-account-settings/user-account-settings.html +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.html @@ -111,6 +111,15 @@ >Italiano (Community) + @if (user?.settings?.isExperimentalFeatures) { Korean / 한국어 (
    -
    +
    Locale
    @@ -153,7 +162,10 @@
    - + {{ locale }} } + + + · + +
    @@ -268,69 +297,100 @@
    Ghostfolio User ID
    -
    {{ user?.id }}
    +
    + +
    -
    - @if (hasPermissionToDeleteOwnUser) { + @if ( + hasPermissionToDeleteOwnUser || hasPermissionToRequestOwnUserDeletion + ) {
    -
    -
    }
    diff --git a/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.scss b/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.scss index b63df0134..542c252a5 100644 --- a/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.scss +++ b/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.scss @@ -4,4 +4,8 @@ .mat-mdc-dialog-content { max-height: unset; } + + .mat-mdc-dialog-title { + padding-right: 0.5rem !important; + } } diff --git a/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts b/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts index 05001a6bb..3aec4d42c 100644 --- a/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts +++ b/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts @@ -1,5 +1,10 @@ +import { + canDeleteUser, + getCountryName, + getSum +} from '@ghostfolio/common/helper'; import { AdminUserResponse } from '@ghostfolio/common/interfaces'; -import { AdminService } from '@ghostfolio/ui/services'; +import { AdminService, DataService } from '@ghostfolio/ui/services'; import { GfValueComponent } from '@ghostfolio/ui/value'; import { @@ -8,7 +13,7 @@ import { Component, CUSTOM_ELEMENTS_SCHEMA, DestroyRef, - Inject, + inject, OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -16,7 +21,11 @@ import { MatButtonModule } from '@angular/material/button'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog'; import { MatMenuModule } from '@angular/material/menu'; +import { MatTableDataSource, MatTableModule } from '@angular/material/table'; import { IonIcon } from '@ionic/angular/standalone'; +import { Subscription } from '@prisma/client'; +import { Big } from 'big.js'; +import { differenceInDays } from 'date-fns'; import { addIcons } from 'ionicons'; import { ellipsisVertical } from 'ionicons/icons'; import { EMPTY } from 'rxjs'; @@ -35,7 +44,8 @@ import { IonIcon, MatButtonModule, MatDialogModule, - MatMenuModule + MatMenuModule, + MatTableModule ], schemas: [CUSTOM_ELEMENTS_SCHEMA], selector: 'gf-user-detail-dialog', @@ -43,18 +53,38 @@ import { templateUrl: './user-detail-dialog.html' }) export class GfUserDetailDialogComponent implements OnInit { - public user: AdminUserResponse; - - public constructor( - private adminService: AdminService, - private changeDetectorRef: ChangeDetectorRef, - @Inject(MAT_DIALOG_DATA) public data: UserDetailDialogParams, - private destroyRef: DestroyRef, - public dialogRef: MatDialogRef< - GfUserDetailDialogComponent, - UserDetailDialogResult - > - ) { + protected readonly baseCurrency: string; + protected readonly canDeleteUser = canDeleteUser; + protected readonly getCountryName = getCountryName; + protected isLoading = true; + + protected readonly subscriptionsDataSource = + new MatTableDataSource(); + + protected readonly subscriptionsDisplayedColumns = [ + 'createdAt', + 'type', + 'price', + 'expiresAt' + ]; + + protected user: AdminUserResponse; + + protected readonly data = inject(MAT_DIALOG_DATA); + + private readonly adminService = inject(AdminService); + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + + private readonly dialogRef = + inject>( + MatDialogRef + ); + + public constructor() { + this.baseCurrency = this.dataService.fetchInfo().baseCurrency; + addIcons({ ellipsisVertical }); @@ -74,18 +104,44 @@ export class GfUserDetailDialogComponent implements OnInit { .subscribe((user) => { this.user = user; + this.subscriptionsDataSource.data = this.user.subscriptions ?? []; + + this.isLoading = false; + this.changeDetectorRef.markForCheck(); }); } - public deleteUser() { + protected deleteUser() { this.dialogRef.close({ action: 'delete', userId: this.data.userId }); } - public onClose() { + protected getSum() { + return getSum( + this.subscriptionsDataSource.data + .filter(({ price }) => { + return price !== null; + }) + .map(({ price }) => { + return new Big(price ?? 0); + }) + ).toNumber(); + } + + protected getType({ createdAt, expiresAt, price }: Subscription) { + if (price) { + return $localize`Paid`; + } + + return differenceInDays(expiresAt, createdAt) <= 90 + ? $localize`Trial` + : $localize`Coupon`; + } + + protected onClose() { this.dialogRef.close(); } } diff --git a/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html b/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html index e9af86942..efc4fe5b4 100644 --- a/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html +++ b/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -1,6 +1,6 @@ -
    +
    - Role + Role
    @@ -46,6 +58,7 @@ i18n size="medium" [isDate]="true" + [isLoading]="isLoading" [locale]="data.locale" [value]="user?.createdAt" >Registration Date
    AuthenticationMembership
    - Country
    @@ -86,6 +107,7 @@ AccountsActivitiesAPI Requests Today + + @if (subscriptionsDataSource.data.length > 0) { +
    +
    +

    Subscription History

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + Creation + + + + Total + + Type + + {{ getType(element) }} + + Price + + @if (element.price === null) { + + {{ baseCurrency }} + } @else { + + } + + + + Expiration + + +
    +
    +
    +
    + } } diff --git a/apps/client/src/app/core/auth.guard.ts b/apps/client/src/app/core/auth.guard.ts index 3292f0ff7..6ac3417db 100644 --- a/apps/client/src/app/core/auth.guard.ts +++ b/apps/client/src/app/core/auth.guard.ts @@ -3,7 +3,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes'; import { DataService } from '@ghostfolio/ui/services'; -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, Router, @@ -14,12 +14,10 @@ import { catchError } from 'rxjs/operators'; @Injectable({ providedIn: 'root' }) export class AuthGuard { - public constructor( - private dataService: DataService, - private router: Router, - private settingsStorageService: SettingsStorageService, - private userService: UserService - ) {} + private readonly dataService = inject(DataService); + private readonly router = inject(Router); + private readonly settingsStorageService = inject(SettingsStorageService); + private readonly userService = inject(UserService); canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { const utmSource = route.queryParams?.utm_source; diff --git a/apps/client/src/app/core/auth.interceptor.ts b/apps/client/src/app/core/auth.interceptor.ts index 7491cecf1..9c06a11d5 100644 --- a/apps/client/src/app/core/auth.interceptor.ts +++ b/apps/client/src/app/core/auth.interceptor.ts @@ -13,20 +13,20 @@ import { HttpInterceptor, HttpRequest } from '@angular/common/http'; -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { Observable } from 'rxjs'; @Injectable() export class AuthInterceptor implements HttpInterceptor { - public constructor( - private impersonationStorageService: ImpersonationStorageService, - private tokenStorageService: TokenStorageService - ) {} + private readonly impersonationStorageService = inject( + ImpersonationStorageService + ); + private readonly tokenStorageService = inject(TokenStorageService); - public intercept( - req: HttpRequest, + public intercept( + req: HttpRequest, next: HttpHandler - ): Observable> { + ): Observable> { let request = req; if (request.headers.has(HEADER_KEY_SKIP_INTERCEPTOR)) { diff --git a/apps/client/src/app/core/http-response.interceptor.ts b/apps/client/src/app/core/http-response.interceptor.ts index 315e9d64e..7385e090c 100644 --- a/apps/client/src/app/core/http-response.interceptor.ts +++ b/apps/client/src/app/core/http-response.interceptor.ts @@ -12,7 +12,7 @@ import { HttpInterceptor, HttpRequest } from '@angular/common/http'; -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { MatSnackBar, MatSnackBarRef, @@ -22,31 +22,28 @@ import { Router } from '@angular/router'; import { StatusCodes } from 'http-status-codes'; import ms from 'ms'; import { Observable, throwError } from 'rxjs'; -import { catchError, tap } from 'rxjs/operators'; +import { catchError } from 'rxjs/operators'; @Injectable() export class HttpResponseInterceptor implements HttpInterceptor { - public info: InfoItem; - public snackBarRef: MatSnackBarRef; + private readonly info: InfoItem; + private snackBarRef: MatSnackBarRef | undefined; - public constructor( - private dataService: DataService, - private router: Router, - private snackBar: MatSnackBar, - private userService: UserService, - private webAuthnService: WebAuthnService - ) { + private readonly dataService = inject(DataService); + private readonly router = inject(Router); + private readonly snackBar = inject(MatSnackBar); + private readonly userService = inject(UserService); + private readonly webAuthnService = inject(WebAuthnService); + + public constructor() { this.info = this.dataService.fetchInfo(); } - public intercept( - request: HttpRequest, + public intercept( + request: HttpRequest, next: HttpHandler - ): Observable> { + ): Observable> { return next.handle(request).pipe( - tap((event: HttpEvent) => { - return event; - }), catchError((error: HttpErrorResponse) => { if (error.status === StatusCodes.FORBIDDEN) { if (!this.snackBarRef) { @@ -61,7 +58,7 @@ export class HttpResponseInterceptor implements HttpInterceptor { } ); } else if ( - !error.url.includes(internalRoutes.auth.routerLink.join('')) + !error.url?.includes(internalRoutes.auth.routerLink.join('')) ) { this.snackBarRef = this.snackBar.open( $localize`This action is not allowed.`, @@ -72,11 +69,11 @@ export class HttpResponseInterceptor implements HttpInterceptor { ); } - this.snackBarRef.afterDismissed().subscribe(() => { + this.snackBarRef?.afterDismissed().subscribe(() => { this.snackBarRef = undefined; }); - this.snackBarRef.onAction().subscribe(() => { + this.snackBarRef?.onAction().subscribe(() => { this.router.navigate(publicRoutes.pricing.routerLink); }); } @@ -92,26 +89,34 @@ export class HttpResponseInterceptor implements HttpInterceptor { } ); - this.snackBarRef.afterDismissed().subscribe(() => { + this.snackBarRef?.afterDismissed().subscribe(() => { this.snackBarRef = undefined; }); - this.snackBarRef.onAction().subscribe(() => { + this.snackBarRef?.onAction().subscribe(() => { window.location.reload(); }); } } else if (error.status === StatusCodes.TOO_MANY_REQUESTS) { - if (!this.snackBarRef) { - this.snackBarRef = this.snackBar.open( - $localize`Oops! It looks like you’re making too many requests. Please slow down a bit.` - ); + // Replace an already visible snack bar so that the rate limiting + // feedback is not swallowed + const snackBarRef = this.snackBar.open( + $localize`Oops! It looks like you’re making too many requests. Please slow down a bit.`, + undefined, + { + duration: ms('6 seconds') + } + ); - this.snackBarRef.afterDismissed().subscribe(() => { + snackBarRef.afterDismissed().subscribe(() => { + if (this.snackBarRef === snackBarRef) { this.snackBarRef = undefined; - }); - } + } + }); + + this.snackBarRef = snackBarRef; } else if (error.status === StatusCodes.UNAUTHORIZED) { - if (!error.url.includes('/data-providers/ghostfolio/status')) { + if (!error.url?.includes('/data-providers/ghostfolio/status')) { if (this.webAuthnService.isEnabled()) { this.router.navigate(internalRoutes.webauthn.routerLink); } else { diff --git a/apps/client/src/app/directives/file-drop/file-drop.directive.ts b/apps/client/src/app/directives/file-drop/file-drop.directive.ts index a7e628bc9..b46357005 100644 --- a/apps/client/src/app/directives/file-drop/file-drop.directive.ts +++ b/apps/client/src/app/directives/file-drop/file-drop.directive.ts @@ -1,28 +1,34 @@ -import { Directive, EventEmitter, HostListener, Output } from '@angular/core'; +import { Directive, output } from '@angular/core'; @Directive({ + host: { + '(dragenter)': 'onDragEnter($event)', + '(dragover)': 'onDragOver($event)', + '(drop)': 'onDrop($event)' + }, selector: '[gfFileDrop]' }) export class GfFileDropDirective { - @Output() filesDropped = new EventEmitter(); + public readonly filesDropped = output(); - @HostListener('dragenter', ['$event']) onDragEnter(event: DragEvent) { + public onDragEnter(event: DragEvent) { event.preventDefault(); event.stopPropagation(); } - @HostListener('dragover', ['$event']) onDragOver(event: DragEvent) { + public onDragOver(event: DragEvent) { event.preventDefault(); event.stopPropagation(); } - @HostListener('drop', ['$event']) onDrop(event: DragEvent) { + public onDrop(event: DragEvent) { event.preventDefault(); event.stopPropagation(); - // Prevent the browser's default behavior for handling the file drop - event.dataTransfer.dropEffect = 'copy'; - - this.filesDropped.emit(event.dataTransfer.files); + if (event.dataTransfer) { + // Prevent the browser's default behavior for handling the file drop + event.dataTransfer.dropEffect = 'copy'; + this.filesDropped.emit(event.dataTransfer.files); + } } } diff --git a/apps/client/src/app/pages/about/about-page.component.ts b/apps/client/src/app/pages/about/about-page.component.ts index fb632a586..615c0897d 100644 --- a/apps/client/src/app/pages/about/about-page.component.ts +++ b/apps/client/src/app/pages/about/about-page.component.ts @@ -8,7 +8,12 @@ import { } from '@ghostfolio/ui/page-tabs'; import { DataService } from '@ghostfolio/ui/services'; -import { ChangeDetectorRef, Component, DestroyRef } from '@angular/core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef +} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { addIcons } from 'ionicons'; import { @@ -21,7 +26,8 @@ import { } from 'ionicons/icons'; @Component({ - host: { class: 'page has-tabs' }, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'page' }, imports: [GfPageTabsComponent], selector: 'gf-about-page', styleUrls: ['./about-page.scss'], @@ -83,8 +89,6 @@ export class AboutPageComponent { }); this.user = state.user; - - this.changeDetectorRef.markForCheck(); } this.tabs.push({ @@ -92,6 +96,8 @@ export class AboutPageComponent { label: publicRoutes.about.subRoutes.ossFriends.title, routerLink: publicRoutes.about.subRoutes.ossFriends.routerLink }); + + this.changeDetectorRef.markForCheck(); }); addIcons({ diff --git a/apps/client/src/app/pages/about/changelog/changelog-page.component.ts b/apps/client/src/app/pages/about/changelog/changelog-page.component.ts index d7f583bd1..24265f2ef 100644 --- a/apps/client/src/app/pages/about/changelog/changelog-page.component.ts +++ b/apps/client/src/app/pages/about/changelog/changelog-page.component.ts @@ -1,8 +1,9 @@ -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MarkdownModule } from 'ngx-markdown'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, imports: [MarkdownModule, NgxSkeletonLoaderModule], selector: 'gf-changelog-page', styleUrls: ['./changelog-page.scss'], diff --git a/apps/client/src/app/pages/about/license/license-page.component.ts b/apps/client/src/app/pages/about/license/license-page.component.ts index d530d0418..80f4eb0c0 100644 --- a/apps/client/src/app/pages/about/license/license-page.component.ts +++ b/apps/client/src/app/pages/about/license/license-page.component.ts @@ -1,7 +1,8 @@ -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MarkdownModule } from 'ngx-markdown'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, imports: [MarkdownModule], selector: 'gf-license-page', styleUrls: ['./license-page.scss'], diff --git a/apps/client/src/app/pages/about/oss-friends/oss-friends-page.component.ts b/apps/client/src/app/pages/about/oss-friends/oss-friends-page.component.ts index c2e500a52..cb2aa6b07 100644 --- a/apps/client/src/app/pages/about/oss-friends/oss-friends-page.component.ts +++ b/apps/client/src/app/pages/about/oss-friends/oss-friends-page.component.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { IonIcon } from '@ionic/angular/standalone'; @@ -8,6 +8,7 @@ import { arrowForwardOutline } from 'ionicons/icons'; const ossFriends = require('../../../../assets/oss-friends.json'); @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, imports: [IonIcon, MatButtonModule, MatCardModule], selector: 'gf-oss-friends-page', styleUrls: ['./oss-friends-page.scss'], diff --git a/apps/client/src/app/pages/about/overview/about-overview-page.component.ts b/apps/client/src/app/pages/about/overview/about-overview-page.component.ts index 92a98598b..5af3c48ec 100644 --- a/apps/client/src/app/pages/about/overview/about-overview-page.component.ts +++ b/apps/client/src/app/pages/about/overview/about-overview-page.component.ts @@ -5,6 +5,7 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { DataService } from '@ghostfolio/ui/services'; import { + ChangeDetectionStrategy, ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA, @@ -25,6 +26,7 @@ import { } from 'ionicons/icons'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, imports: [IonIcon, MatButtonModule, RouterModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], selector: 'gf-about-overview-page', @@ -68,9 +70,9 @@ export class GfAboutOverviewPageComponent implements OnInit { .subscribe((state) => { if (state?.user) { this.user = state.user; - - this.changeDetectorRef.markForCheck(); } + + this.changeDetectorRef.markForCheck(); }); } } diff --git a/apps/client/src/app/pages/about/overview/about-overview-page.html b/apps/client/src/app/pages/about/overview/about-overview-page.html index c28a63be2..6e0b0323d 100644 --- a/apps/client/src/app/pages/about/overview/about-overview-page.html +++ b/apps/client/src/app/pages/about/overview/about-overview-page.html @@ -16,6 +16,7 @@ >The source code is fully available as open source software @@ -49,6 +50,7 @@ >and is driven by the efforts of its contributorsSlack community, post to @ghostfolio_or start a discussion at   GitHub this.deviceDetectorService.deviceInfo().deviceType + ); + + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly deviceDetectorService = inject(DeviceDetectorService); + private readonly dialog = inject(MatDialog); + private readonly impersonationStorageService = inject( + ImpersonationStorageService + ); + private readonly notificationService = inject(NotificationService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly userService = inject(UserService); + + public constructor() { this.route.queryParams .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((params) => { @@ -82,7 +92,9 @@ export class GfAccountsPageComponent implements OnInit { return id === params['accountId']; }); - this.openUpdateAccountDialog(account); + if (account) { + this.openUpdateAccountDialog(account); + } } else { this.router.navigate(['.'], { relativeTo: this.route }); } @@ -90,18 +102,18 @@ export class GfAccountsPageComponent implements OnInit { this.openTransferBalanceDialog(); } }); + } - addIcons({ addOutline }); + protected get hasImpersonationId() { + return !!this.impersonationId; } public ngOnInit() { - this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; - this.impersonationStorageService .onChangeHasImpersonation() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((impersonationId) => { - this.hasImpersonationId = !!impersonationId; + this.impersonationId = impersonationId; }); this.userService.stateChanged @@ -118,40 +130,15 @@ export class GfAccountsPageComponent implements OnInit { this.user.permissions, permissions.updateAccount ); - - this.changeDetectorRef.markForCheck(); } + + this.changeDetectorRef.markForCheck(); }); this.fetchAccounts(); } - public fetchAccounts() { - this.dataService - .fetchAccounts() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe( - ({ - accounts, - activitiesCount, - totalBalanceInBaseCurrency, - totalValueInBaseCurrency - }) => { - this.accounts = accounts; - this.activitiesCount = activitiesCount; - this.totalBalanceInBaseCurrency = totalBalanceInBaseCurrency; - this.totalValueInBaseCurrency = totalValueInBaseCurrency; - - if (this.accounts?.length <= 0) { - this.router.navigate([], { queryParams: { createDialog: true } }); - } - - this.changeDetectorRef.markForCheck(); - } - ); - } - - public onDeleteAccount(aId: string) { + protected onDeleteAccount(aId: string) { this.reset(); this.dataService @@ -167,27 +154,52 @@ export class GfAccountsPageComponent implements OnInit { }); } - public onTransferBalance() { + protected onTransferBalance() { this.router.navigate([], { queryParams: { transferBalanceDialog: true } }); } - public onUpdateAccount(aAccount: AccountModel) { + protected onUpdateAccount(aAccount: AccountWithValue) { this.router.navigate([], { queryParams: { accountId: aAccount.id, editDialog: true } }); } - public openUpdateAccountDialog({ + private fetchAccounts() { + this.dataService + .fetchAccounts() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe( + ({ + accounts, + activitiesCount, + totalBalanceInBaseCurrency, + totalValueInBaseCurrency + }) => { + this.accounts = accounts; + this.activitiesCount = activitiesCount; + this.totalBalanceInBaseCurrency = totalBalanceInBaseCurrency; + this.totalValueInBaseCurrency = totalValueInBaseCurrency; + + if (this.accounts?.length <= 0) { + this.router.navigate([], { queryParams: { createDialog: true } }); + } + + this.changeDetectorRef.markForCheck(); + } + ); + } + + private openUpdateAccountDialog({ balance, comment, currency, id, - isExcluded, name, - platformId - }: AccountModel) { + platformId, + tags + }: AccountWithValue & { tags?: Tag[] }) { const dialogRef = this.dialog.open< GfCreateOrUpdateAccountDialogComponent, CreateOrUpdateAccountDialogParams @@ -198,13 +210,14 @@ export class GfAccountsPageComponent implements OnInit { comment, currency, id, - isExcluded, name, - platformId - } + platformId, + tags + }, + user: this.user }, - height: this.deviceType === 'mobile' ? '98vh' : '80vh', - width: this.deviceType === 'mobile' ? '100vw' : '50rem' + height: this.deviceType() === 'mobile' ? '98vh' : '80vh', + width: this.deviceType() === 'mobile' ? '100vw' : '50rem' }); dialogRef @@ -236,26 +249,31 @@ export class GfAccountsPageComponent implements OnInit { private openAccountDetailDialog(aAccountId: string) { const dialogRef = this.dialog.open< GfAccountDetailDialogComponent, - AccountDetailDialogParams + AccountDetailDialogParams, + AccountDetailDialogResult >(GfAccountDetailDialogComponent, { autoFocus: false, data: { accountId: aAccountId, - deviceType: this.deviceType, - hasImpersonationId: this.hasImpersonationId, + deviceType: this.deviceType(), hasPermissionToCreateActivity: !this.hasImpersonationId && hasPermission(this.user?.permissions, permissions.createActivity) && - !this.user?.settings?.isRestrictedView + !this.user?.settings?.isRestrictedView, + impersonationId: this.impersonationId }, - height: this.deviceType === 'mobile' ? '98vh' : '80vh', - width: this.deviceType === 'mobile' ? '100vw' : '50rem' + height: this.deviceType() === 'mobile' ? '98vh' : '80vh', + width: this.deviceType() === 'mobile' ? '100vw' : '50rem' }); dialogRef .afterClosed() .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { + .subscribe((result) => { + if (result?.isNavigating) { + return; + } + this.fetchAccounts(); this.router.navigate(['.'], { relativeTo: this.route }); @@ -271,15 +289,16 @@ export class GfAccountsPageComponent implements OnInit { account: { balance: 0, comment: null, - currency: this.user?.settings?.baseCurrency, + currency: this.user?.settings?.baseCurrency ?? null, id: null, - isExcluded: false, name: null, - platformId: null - } - }, - height: this.deviceType === 'mobile' ? '98vh' : '80vh', - width: this.deviceType === 'mobile' ? '100vw' : '50rem' + platformId: null, + tags: [] + }, + user: this.user + } satisfies CreateOrUpdateAccountDialogParams, + height: this.deviceType() === 'mobile' ? '98vh' : '80vh', + width: this.deviceType() === 'mobile' ? '100vw' : '50rem' }); dialogRef @@ -316,7 +335,7 @@ export class GfAccountsPageComponent implements OnInit { data: { accounts: this.accounts }, - width: this.deviceType === 'mobile' ? '100vw' : '50rem' + width: this.deviceType() === 'mobile' ? '100vw' : '50rem' }); dialogRef @@ -357,7 +376,7 @@ export class GfAccountsPageComponent implements OnInit { } private reset() { - this.accounts = undefined; + this.accounts = []; this.activitiesCount = 0; this.totalBalanceInBaseCurrency = 0; this.totalValueInBaseCurrency = 0; diff --git a/apps/client/src/app/pages/accounts/accounts-page.html b/apps/client/src/app/pages/accounts/accounts-page.html index 3d9d7ee5c..1bdedbbb9 100644 --- a/apps/client/src/app/pages/accounts/accounts-page.html +++ b/apps/client/src/app/pages/accounts/accounts-page.html @@ -26,16 +26,6 @@ hasPermissionToCreateAccount && !user.settings.isRestrictedView ) { -
    - - - -
    + } diff --git a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts index 8319999eb..c1d171b6f 100644 --- a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts +++ b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts @@ -1,11 +1,22 @@ +import { UserService } from '@ghostfolio/client/services/user/user.service'; import { CreateAccountDto, UpdateAccountDto } from '@ghostfolio/common/dtos'; +import { getStringOrNull } from '@ghostfolio/common/helper'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { validateObjectForForm } from '@ghostfolio/common/utils'; import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; +import { translate } from '@ghostfolio/ui/i18n'; import { DataService } from '@ghostfolio/ui/services'; +import { GfTagsSelectorComponent } from '@ghostfolio/ui/tags-selector'; import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + inject +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { AbstractControl, FormBuilder, @@ -16,7 +27,6 @@ import { } from '@angular/forms'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatButtonModule } from '@angular/material/button'; -import { MatCheckboxModule } from '@angular/material/checkbox'; import { MAT_DIALOG_DATA, MatDialogModule, @@ -24,7 +34,7 @@ import { } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; -import { Platform } from '@prisma/client'; +import { Platform, Tag } from '@prisma/client'; import { Observable } from 'rxjs'; import { map, startWith } from 'rxjs/operators'; @@ -37,9 +47,9 @@ import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces'; CommonModule, GfCurrencySelectorComponent, GfEntityLogoComponent, + GfTagsSelectorComponent, MatAutocompleteModule, MatButtonModule, - MatCheckboxModule, MatDialogModule, MatFormFieldModule, MatInputModule, @@ -53,29 +63,90 @@ export class GfCreateOrUpdateAccountDialogComponent { protected accountForm: FormGroup; protected currencies: string[] = []; protected filteredPlatforms: Observable | undefined; + protected hasPermissionToCreateOwnTag: boolean; protected platforms: Platform[] = []; + protected tagsAvailable: Tag[] = []; protected readonly data = inject(MAT_DIALOG_DATA); private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); private readonly dialogRef = inject>(MatDialogRef); private readonly formBuilder = inject(FormBuilder); + private readonly userService = inject(UserService); + + protected get selectedPlatform() { + const platform = this.accountForm.get('platformId')?.value; + + return typeof platform === 'string' ? undefined : (platform as Platform); + } public ngOnInit() { const { currencies } = this.dataService.fetchInfo(); this.currencies = currencies; + this.hasPermissionToCreateOwnTag = hasPermission( + this.data.user?.permissions, + permissions.createOwnTag + ); + + this.tagsAvailable = + this.data.user?.tags?.map((tag) => { + return { + ...tag, + name: translate(tag.name) + }; + }) ?? []; + this.accountForm = this.formBuilder.group({ accountId: [{ disabled: true, value: this.data.account.id }], balance: [this.data.account.balance, Validators.required], comment: [this.data.account.comment], currency: [this.data.account.currency, Validators.required], - isExcluded: [this.data.account.isExcluded], name: [this.data.account.name, Validators.required], - platformId: [null, this.autocompleteObjectValidator()] + platformId: [null, this.autocompleteObjectValidator()], + tags: [ + this.data.account.tags?.map(({ id, name }) => { + return { + id, + name: translate(name) + }; + }) + ] }); + this.accountForm + .get('tags') + ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((tags: Tag[]) => { + const newTag = tags.find(({ id }) => { + return id === undefined; + }); + + if (newTag && this.hasPermissionToCreateOwnTag) { + this.dataService + .postTag({ ...newTag, userId: this.data.user.id }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((tag) => { + this.accountForm.get('tags')?.setValue( + tags.map((currentTag) => { + if (currentTag.id === undefined) { + return tag; + } + + return currentTag; + }) + ); + + this.userService + .get(true) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(); + }); + } + }); + this.dataService.fetchPlatforms().subscribe(({ platforms }) => { this.platforms = platforms; @@ -127,12 +198,20 @@ export class GfCreateOrUpdateAccountDialogComponent { protected async onSubmit() { const account: CreateAccountDto | UpdateAccountDto = { balance: this.accountForm.get('balance')?.value, - comment: this.accountForm.get('comment')?.value || null, + comment: getStringOrNull(this.accountForm.get('comment')?.value), currency: this.accountForm.get('currency')?.value, id: this.accountForm.get('accountId')?.value, - isExcluded: this.accountForm.get('isExcluded')?.value, name: this.accountForm.get('name')?.value, - platformId: this.accountForm.get('platformId')?.value?.id || null + platformId: this.accountForm.get('platformId')?.value?.id ?? null, + tags: this.accountForm + .get('tags') + ?.value?.filter(({ id }: Tag) => { + // Skip tags which have not been created yet + return !!id; + }) + .map(({ id }: Tag) => { + return id; + }) }; try { diff --git a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html index c90c9c440..86a034b53 100644 --- a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html +++ b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -46,6 +46,15 @@
    Platform + @if (selectedPlatform) { + + } @@ -85,10 +95,12 @@ >
    -
    - Exclude from Analysis +
    +
    @if (data.account.id) {
    diff --git a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/interfaces/interfaces.ts b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/interfaces/interfaces.ts index a3e6272f8..43015de00 100644 --- a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/interfaces/interfaces.ts +++ b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/interfaces/interfaces.ts @@ -1,5 +1,15 @@ -import { Account } from '@prisma/client'; +import { User } from '@ghostfolio/common/interfaces'; +import { AccountWithBalance } from '@ghostfolio/common/types'; + +import { Tag } from '@prisma/client'; export interface CreateOrUpdateAccountDialogParams { - account: Omit; + account: Omit< + AccountWithBalance, + 'createdAt' | 'id' | 'updatedAt' | 'userId' + > & { + id: string | null; + tags?: Tag[]; + }; + user: User; } diff --git a/apps/client/src/app/pages/accounts/transfer-balance/interfaces/interfaces.ts b/apps/client/src/app/pages/accounts/transfer-balance/interfaces/interfaces.ts index 3a0b921fd..51c42bc5d 100644 --- a/apps/client/src/app/pages/accounts/transfer-balance/interfaces/interfaces.ts +++ b/apps/client/src/app/pages/accounts/transfer-balance/interfaces/interfaces.ts @@ -1,5 +1,12 @@ +import { FormControl, FormGroup } from '@angular/forms'; import { Account } from '@prisma/client'; export interface TransferBalanceDialogParams { accounts: Account[]; } + +export type TransferBalanceForm = FormGroup<{ + balance: FormControl; + fromAccount: FormControl; + toAccount: FormControl; +}>; diff --git a/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts b/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts index 34a66b156..1682874dc 100644 --- a/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts +++ b/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts @@ -1,10 +1,9 @@ import { TransferBalanceDto } from '@ghostfolio/common/dtos'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; -import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { - AbstractControl, - FormBuilder, + FormControl, FormGroup, ReactiveFormsModule, ValidationErrors, @@ -21,7 +20,10 @@ import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { Account } from '@prisma/client'; -import { TransferBalanceDialogParams } from './interfaces/interfaces'; +import { + TransferBalanceDialogParams, + TransferBalanceForm +} from './interfaces/interfaces'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -40,57 +42,79 @@ import { TransferBalanceDialogParams } from './interfaces/interfaces'; templateUrl: 'transfer-balance-dialog.html' }) export class GfTransferBalanceDialogComponent { - public accounts: Account[] = []; - public currency: string; - public transferBalanceForm: FormGroup; + protected readonly accounts: Account[] = + inject(MAT_DIALOG_DATA).accounts; + + protected currency: string; - public constructor( - @Inject(MAT_DIALOG_DATA) public data: TransferBalanceDialogParams, - public dialogRef: MatDialogRef, - private formBuilder: FormBuilder - ) {} + protected readonly transferBalanceForm: TransferBalanceForm = new FormGroup( + { + balance: new FormControl('', Validators.required), + fromAccount: new FormControl('', Validators.required), + toAccount: new FormControl('', Validators.required) + }, + { + validators: this.compareAccounts + } + ); + + private readonly dialogRef = + inject>(MatDialogRef); + + protected get selectedFromAccount() { + return this.getAccountById( + this.transferBalanceForm.controls.fromAccount.value + ); + } + + protected get selectedToAccount() { + return this.getAccountById( + this.transferBalanceForm.controls.toAccount.value + ); + } public ngOnInit() { - this.accounts = this.data.accounts; - - this.transferBalanceForm = this.formBuilder.group( - { - balance: ['', Validators.required], - fromAccount: ['', Validators.required], - toAccount: ['', Validators.required] - }, - { - validators: this.compareAccounts + this.transferBalanceForm.controls.fromAccount.valueChanges.subscribe( + (id) => { + const currency = this.getAccountById(id)?.currency; + + if (currency) { + this.currency = currency; + } } ); - - this.transferBalanceForm.get('fromAccount').valueChanges.subscribe((id) => { - this.currency = this.accounts.find((account) => { - return account.id === id; - }).currency; - }); } - public onCancel() { + protected onCancel() { this.dialogRef.close(); } - public onSubmit() { + protected onSubmit() { const account: TransferBalanceDto = { - accountIdFrom: this.transferBalanceForm.get('fromAccount').value, - accountIdTo: this.transferBalanceForm.get('toAccount').value, - balance: this.transferBalanceForm.get('balance').value + accountIdFrom: this.transferBalanceForm.controls.fromAccount.value ?? '', + accountIdTo: this.transferBalanceForm.controls.toAccount.value ?? '', + balance: Number(this.transferBalanceForm.controls.balance.value) }; this.dialogRef.close({ account }); } - private compareAccounts(control: AbstractControl): ValidationErrors { - const accountFrom = control.get('fromAccount'); - const accountTo = control.get('toAccount'); + private compareAccounts( + formGroup: TransferBalanceForm + ): ValidationErrors | null { + const accountFrom = formGroup.controls.fromAccount; + const accountTo = formGroup.controls.toAccount; if (accountFrom.value === accountTo.value) { return { invalid: true }; } + + return null; + } + + private getAccountById(aId: string | null) { + return this.accounts.find(({ id }) => { + return id === aId; + }); } } diff --git a/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.html b/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.html index 50c96be86..941c9485a 100644 --- a/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.html +++ b/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.html @@ -10,16 +10,29 @@ From + +
    + @if (selectedFromAccount) { + + } + {{ selectedFromAccount?.name }} +
    +
    + @for (account of accounts; track account) {
    - @if (account.platform?.url) { - - } + {{ account.name }}
    @@ -31,16 +44,29 @@ To + +
    + @if (selectedToAccount) { + + } + {{ selectedToAccount?.name }} +
    +
    + @for (account of accounts; track account) {
    - @if (account.platform?.url) { - - } + {{ account.name }}
    diff --git a/apps/client/src/app/pages/admin/admin-page.component.ts b/apps/client/src/app/pages/admin/admin-page.component.ts index 6b653efb0..15957d346 100644 --- a/apps/client/src/app/pages/admin/admin-page.component.ts +++ b/apps/client/src/app/pages/admin/admin-page.component.ts @@ -1,10 +1,24 @@ +import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; +import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { + BULL_BOARD_COOKIE_NAME, + BULL_BOARD_ROUTE +} from '@ghostfolio/common/config'; +import { User } from '@ghostfolio/common/interfaces'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { GfPageTabsComponent, TabConfiguration } from '@ghostfolio/ui/page-tabs'; -import { Component, OnInit } from '@angular/core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + inject +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { addIcons } from 'ionicons'; import { flashOutline, @@ -15,16 +29,33 @@ import { } from 'ionicons/icons'; @Component({ - host: { class: 'page has-tabs' }, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'page' }, imports: [GfPageTabsComponent], selector: 'gf-admin-page', styleUrls: ['./admin-page.scss'], templateUrl: './admin-page.html' }) -export class AdminPageComponent implements OnInit { +export class AdminPageComponent { public tabs: TabConfiguration[] = []; + private user: User; + + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly tokenStorageService = inject(TokenStorageService); + private readonly userService = inject(UserService); + public constructor() { + this.userService.stateChanged + .pipe(takeUntilDestroyed()) + .subscribe((state) => { + this.user = state?.user; + + this.initializeTabs(); + + this.changeDetectorRef.markForCheck(); + }); + addIcons({ flashOutline, peopleOutline, @@ -34,7 +65,12 @@ export class AdminPageComponent implements OnInit { }); } - public ngOnInit() { + private initializeTabs() { + const hasPermissionToAccessBullBoard = hasPermission( + this.user?.permissions, + permissions.accessAdminControlBullBoard + ); + this.tabs = [ { iconName: 'reader-outline', @@ -51,11 +87,19 @@ export class AdminPageComponent implements OnInit { label: internalRoutes.adminControl.subRoutes.marketData.title, routerLink: internalRoutes.adminControl.subRoutes.marketData.routerLink }, - { - iconName: 'flash-outline', - label: internalRoutes.adminControl.subRoutes.jobs.title, - routerLink: internalRoutes.adminControl.subRoutes.jobs.routerLink - }, + hasPermissionToAccessBullBoard + ? { + iconName: 'flash-outline', + label: $localize`Job Queue`, + onClick: () => { + this.onOpenBullBoard(); + } + } + : { + iconName: 'flash-outline', + label: internalRoutes.adminControl.subRoutes.jobs.title, + routerLink: internalRoutes.adminControl.subRoutes.jobs.routerLink + }, { iconName: 'people-outline', label: internalRoutes.adminControl.subRoutes.users.title, @@ -63,4 +107,16 @@ export class AdminPageComponent implements OnInit { } ]; } + + private onOpenBullBoard() { + const token = this.tokenStorageService.getToken(); + + document.cookie = [ + `${BULL_BOARD_COOKIE_NAME}=${encodeURIComponent(token ?? '')}`, + 'path=/', + 'SameSite=Strict' + ].join('; '); + + window.open(BULL_BOARD_ROUTE, '_blank'); + } } diff --git a/apps/client/src/app/pages/admin/admin-page.routes.ts b/apps/client/src/app/pages/admin/admin-page.routes.ts index c5309edbb..30d6728ae 100644 --- a/apps/client/src/app/pages/admin/admin-page.routes.ts +++ b/apps/client/src/app/pages/admin/admin-page.routes.ts @@ -6,10 +6,31 @@ import { GfAdminUsersComponent } from '@ghostfolio/client/components/admin-users import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; import { internalRoutes } from '@ghostfolio/common/routes/routes'; -import { Routes } from '@angular/router'; +import { Routes, UrlMatcher, UrlSegment } from '@angular/router'; import { AdminPageComponent } from './admin-page.component'; +// Matches both the users list and the user detail dialog route within a single +// route configuration so that the component is reused (and not re-created) when +// the user detail dialog is opened or closed +const usersMatcher: UrlMatcher = (segments: UrlSegment[]) => { + if ( + segments[0]?.path !== internalRoutes.adminControl.subRoutes.users.path || + segments.length > 2 + ) { + return null; + } + + if (segments.length === 2) { + return { + consumed: segments, + posParams: { userId: segments[1] } + }; + } + + return { consumed: segments }; +}; + export const routes: Routes = [ { canActivate: [AuthGuard], @@ -35,13 +56,8 @@ export const routes: Routes = [ title: internalRoutes.adminControl.subRoutes.settings.title }, { - path: internalRoutes.adminControl.subRoutes.users.path, - component: GfAdminUsersComponent, - title: internalRoutes.adminControl.subRoutes.users.title - }, - { - path: `${internalRoutes.adminControl.subRoutes.users.path}/:userId`, component: GfAdminUsersComponent, + matcher: usersMatcher, title: internalRoutes.adminControl.subRoutes.users.title } ], diff --git a/apps/client/src/app/pages/api/api-page.component.ts b/apps/client/src/app/pages/api/api-page.component.ts index e75a51c73..8b80370e8 100644 --- a/apps/client/src/app/pages/api/api-page.component.ts +++ b/apps/client/src/app/pages/api/api-page.component.ts @@ -10,8 +10,10 @@ import { DividendsResponse, HistoricalResponse, LookupResponse, + MarketDataOfMarketsResponse, QuotesResponse } from '@ghostfolio/common/interfaces'; +import { GfFearAndGreedIndexComponent } from '@ghostfolio/ui/fear-and-greed-index'; import { CommonModule } from '@angular/common'; import { @@ -20,7 +22,13 @@ import { HttpHeaders, HttpParams } from '@angular/common/http'; -import { Component, DestroyRef, OnInit } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + inject, + OnInit +} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatCardModule } from '@angular/material/card'; import { format, startOfYear } from 'date-fns'; @@ -31,35 +39,45 @@ import { catchError, map, Observable, of, OperatorFunction } from 'rxjs'; import { FetchFailure, FetchResult } from './interfaces/interfaces'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, - imports: [CommonModule, MatCardModule, NgxSkeletonLoaderModule], + imports: [ + CommonModule, + GfFearAndGreedIndexComponent, + MatCardModule, + NgxSkeletonLoaderModule + ], selector: 'gf-api-page', styleUrls: ['./api-page.scss'], templateUrl: './api-page.html' }) export class GfApiPageComponent implements OnInit { - public aiServiceHealth$: Observable>; - public assetProfile$: Observable< + protected aiServiceHealth$: Observable>; + protected assetProfile$: Observable< FetchResult >; - public dividends$: Observable>; - public historicalData$: Observable< + protected dividends$: Observable>; + protected historicalData$: Observable< FetchResult >; - public isinLookupItems$: Observable>; - public lookupItems$: Observable>; - public quotes$: Observable>; - public status$: Observable>; + protected isinLookupItems$: Observable>; + protected lookupItems$: Observable>; + protected marketDataOfMarkets$: Observable< + FetchResult + >; + protected quotes$: Observable>; + protected status$: Observable< + FetchResult + >; private apiKey: string; - public constructor( - private destroyRef: DestroyRef, - private http: HttpClient - ) {} + private readonly destroyRef = inject(DestroyRef); + private readonly http = inject(HttpClient); public ngOnInit() { - this.apiKey = prompt($localize`Please enter your Ghostfolio API key:`); + this.apiKey = + prompt($localize`Please enter your Ghostfolio API key:`) ?? ''; this.aiServiceHealth$ = this.fetchAiServiceHealth(); this.assetProfile$ = this.fetchAssetProfile({ symbol: 'AAPL' }); @@ -67,11 +85,12 @@ export class GfApiPageComponent implements OnInit { this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL' }); this.isinLookupItems$ = this.fetchLookupItems({ query: 'US0378331005' }); this.lookupItems$ = this.fetchLookupItems({ query: 'apple' }); + this.marketDataOfMarkets$ = this.fetchMarketDataOfMarkets(); this.quotes$ = this.fetchQuotes({ symbols: ['AAPL', 'VOO'] }); this.status$ = this.fetchStatus(); } - public isFetchFailure(value: unknown): value is FetchFailure { + protected isFetchFailure(value: unknown): value is FetchFailure { return isObject(value) && value !== null && 'fetchError' in value; } @@ -94,7 +113,7 @@ export class GfApiPageComponent implements OnInit { private fetchAssetProfile({ symbol }: { symbol: string }) { return this.http .get( - `/api/v1/data-providers/ghostfolio/asset-profile/${symbol}`, + `/api/v1/data-providers/ghostfolio/asset-profile/${encodeURIComponent(symbol)}`, { headers: this.getHeaders() } ) .pipe(this.catchFetchFailure(), takeUntilDestroyed(this.destroyRef)); @@ -107,7 +126,7 @@ export class GfApiPageComponent implements OnInit { return this.http .get( - `/api/v2/data-providers/ghostfolio/dividends/${symbol}`, + `/api/v2/data-providers/ghostfolio/dividends/${encodeURIComponent(symbol)}`, { params, headers: this.getHeaders() @@ -129,7 +148,7 @@ export class GfApiPageComponent implements OnInit { return this.http .get( - `/api/v2/data-providers/ghostfolio/historical/${symbol}`, + `/api/v2/data-providers/ghostfolio/historical/${encodeURIComponent(symbol)}`, { params, headers: this.getHeaders() @@ -171,6 +190,15 @@ export class GfApiPageComponent implements OnInit { ); } + private fetchMarketDataOfMarkets() { + return this.http + .get( + '/api/v1/data-providers/ghostfolio/markets', + { headers: this.getHeaders() } + ) + .pipe(this.catchFetchFailure(), takeUntilDestroyed(this.destroyRef)); + } + private fetchQuotes({ symbols }: { symbols: string[] }) { const params = new HttpParams().set('symbols', symbols.join(',')); diff --git a/apps/client/src/app/pages/api/api-page.html b/apps/client/src/app/pages/api/api-page.html index 07f5ec981..2153fba1e 100644 --- a/apps/client/src/app/pages/api/api-page.html +++ b/apps/client/src/app/pages/api/api-page.html @@ -168,6 +168,50 @@ } +
    + @let marketDataOfMarkets = marketDataOfMarkets$ | async; +
    + + + Markets + Stocks + + + @if (isFetchFailure(marketDataOfMarkets)) { + 🔴 {{ marketDataOfMarkets.fetchError }} + } @else { + + } + + +
    +
    + + + Markets + Cryptocurrencies + + + @if (isFetchFailure(marketDataOfMarkets)) { + 🔴 {{ marketDataOfMarkets.fetchError }} + } @else { + + } + + +
    +
    diff --git a/apps/client/src/app/pages/auth/auth-page.component.ts b/apps/client/src/app/pages/auth/auth-page.component.ts index 1b0b4a67c..7c501403e 100644 --- a/apps/client/src/app/pages/auth/auth-page.component.ts +++ b/apps/client/src/app/pages/auth/auth-page.component.ts @@ -4,11 +4,17 @@ import { } from '@ghostfolio/client/services/settings-storage.service'; import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; -import { Component, DestroyRef, OnInit } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + OnInit +} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'gf-auth-page', styleUrls: ['./auth-page.scss'], templateUrl: './auth-page.html' diff --git a/apps/client/src/app/pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.component.ts b/apps/client/src/app/pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.component.ts index 597c8d998..ac6ec5c9e 100644 --- a/apps/client/src/app/pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.component.ts +++ b/apps/client/src/app/pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.component.ts @@ -1,10 +1,11 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [MatButtonModule, RouterModule], selector: 'gf-hallo-ghostfolio-page', diff --git a/apps/client/src/app/pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.component.ts b/apps/client/src/app/pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.component.ts index dd29cfd80..22fe85853 100644 --- a/apps/client/src/app/pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.component.ts +++ b/apps/client/src/app/pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.component.ts @@ -1,10 +1,11 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [MatButtonModule, RouterModule], selector: 'gf-hello-ghostfolio-page', diff --git a/apps/client/src/app/pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.component.ts b/apps/client/src/app/pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.component.ts index 91b05896d..2ab59f5ce 100644 --- a/apps/client/src/app/pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.component.ts +++ b/apps/client/src/app/pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.component.ts @@ -1,10 +1,11 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [MatButtonModule, RouterModule], selector: 'gf-first-months-in-open-source-page', diff --git a/apps/client/src/app/pages/blog/2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.component.ts b/apps/client/src/app/pages/blog/2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.component.ts index c410dd2b2..2f2409131 100644 --- a/apps/client/src/app/pages/blog/2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.component.ts +++ b/apps/client/src/app/pages/blog/2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.component.ts @@ -1,10 +1,11 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [MatButtonModule, RouterModule], selector: 'gf-ghostfolio-meets-internet-identity-page', diff --git a/apps/client/src/app/pages/blog/2022/07/how-do-i-get-my-finances-in-order/how-do-i-get-my-finances-in-order-page.component.ts b/apps/client/src/app/pages/blog/2022/07/how-do-i-get-my-finances-in-order/how-do-i-get-my-finances-in-order-page.component.ts index 812151600..f40170ba6 100644 --- a/apps/client/src/app/pages/blog/2022/07/how-do-i-get-my-finances-in-order/how-do-i-get-my-finances-in-order-page.component.ts +++ b/apps/client/src/app/pages/blog/2022/07/how-do-i-get-my-finances-in-order/how-do-i-get-my-finances-in-order-page.component.ts @@ -1,10 +1,11 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [MatButtonModule, RouterModule], selector: 'gf-how-do-i-get-my-finances-in-order-page', diff --git a/apps/client/src/app/pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.component.ts b/apps/client/src/app/pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.component.ts index 9004ac0e2..2627f4134 100644 --- a/apps/client/src/app/pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.component.ts +++ b/apps/client/src/app/pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.component.ts @@ -1,10 +1,11 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [MatButtonModule, RouterModule], selector: 'gf-500-stars-on-github-page', diff --git a/apps/client/src/app/pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.component.ts b/apps/client/src/app/pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.component.ts index fd34a6470..9e9fc2f8d 100644 --- a/apps/client/src/app/pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.component.ts +++ b/apps/client/src/app/pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.component.ts @@ -1,10 +1,11 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [MatButtonModule, RouterModule], selector: 'gf-hacktoberfest-2022-page', diff --git a/apps/client/src/app/pages/blog/2022/11/black-friday-2022/black-friday-2022-page.component.ts b/apps/client/src/app/pages/blog/2022/11/black-friday-2022/black-friday-2022-page.component.ts index dd13d5b4d..2b57cb09e 100644 --- a/apps/client/src/app/pages/blog/2022/11/black-friday-2022/black-friday-2022-page.component.ts +++ b/apps/client/src/app/pages/blog/2022/11/black-friday-2022/black-friday-2022-page.component.ts @@ -1,11 +1,12 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [GfPremiumIndicatorComponent, MatButtonModule, RouterModule], selector: 'gf-black-friday-2022-page', diff --git a/apps/client/src/app/pages/blog/2022/12/the-importance-of-tracking-your-personal-finances/the-importance-of-tracking-your-personal-finances-page.component.ts b/apps/client/src/app/pages/blog/2022/12/the-importance-of-tracking-your-personal-finances/the-importance-of-tracking-your-personal-finances-page.component.ts index 6e5a19f3c..4df232ea3 100644 --- a/apps/client/src/app/pages/blog/2022/12/the-importance-of-tracking-your-personal-finances/the-importance-of-tracking-your-personal-finances-page.component.ts +++ b/apps/client/src/app/pages/blog/2022/12/the-importance-of-tracking-your-personal-finances/the-importance-of-tracking-your-personal-finances-page.component.ts @@ -1,10 +1,11 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [MatButtonModule, RouterModule], selector: 'gf-the-importance-of-tracking-your-personal-finances-page', diff --git a/apps/client/src/app/pages/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt/ghostfolio-auf-sackgeld-vorgestellt-page.component.ts b/apps/client/src/app/pages/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt/ghostfolio-auf-sackgeld-vorgestellt-page.component.ts index 53d670184..fb3f226ca 100644 --- a/apps/client/src/app/pages/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt/ghostfolio-auf-sackgeld-vorgestellt-page.component.ts +++ b/apps/client/src/app/pages/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt/ghostfolio-auf-sackgeld-vorgestellt-page.component.ts @@ -1,10 +1,11 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [MatButtonModule, RouterModule], selector: 'gf-ghostfolio-auf-sackgeld-vorgestellt-page', diff --git a/apps/client/src/app/pages/blog/2023/02/ghostfolio-meets-umbrel/ghostfolio-meets-umbrel-page.component.ts b/apps/client/src/app/pages/blog/2023/02/ghostfolio-meets-umbrel/ghostfolio-meets-umbrel-page.component.ts index cb8dffcd3..3533af5c4 100644 --- a/apps/client/src/app/pages/blog/2023/02/ghostfolio-meets-umbrel/ghostfolio-meets-umbrel-page.component.ts +++ b/apps/client/src/app/pages/blog/2023/02/ghostfolio-meets-umbrel/ghostfolio-meets-umbrel-page.component.ts @@ -1,10 +1,11 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [MatButtonModule, RouterModule], selector: 'gf-ghostfolio-meets-umbrel-page', diff --git a/apps/client/src/app/pages/blog/2023/03/1000-stars-on-github/1000-stars-on-github-page.component.ts b/apps/client/src/app/pages/blog/2023/03/1000-stars-on-github/1000-stars-on-github-page.component.ts index 60cc4177d..7a7d0197c 100644 --- a/apps/client/src/app/pages/blog/2023/03/1000-stars-on-github/1000-stars-on-github-page.component.ts +++ b/apps/client/src/app/pages/blog/2023/03/1000-stars-on-github/1000-stars-on-github-page.component.ts @@ -1,10 +1,11 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [MatButtonModule, RouterModule], selector: 'gf-1000-stars-on-github-page', diff --git a/apps/client/src/app/pages/blog/2023/05/unlock-your-financial-potential-with-ghostfolio/unlock-your-financial-potential-with-ghostfolio-page.component.ts b/apps/client/src/app/pages/blog/2023/05/unlock-your-financial-potential-with-ghostfolio/unlock-your-financial-potential-with-ghostfolio-page.component.ts index 5781674a6..de4d9a3c2 100644 --- a/apps/client/src/app/pages/blog/2023/05/unlock-your-financial-potential-with-ghostfolio/unlock-your-financial-potential-with-ghostfolio-page.component.ts +++ b/apps/client/src/app/pages/blog/2023/05/unlock-your-financial-potential-with-ghostfolio/unlock-your-financial-potential-with-ghostfolio-page.component.ts @@ -1,10 +1,11 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [MatButtonModule, RouterModule], selector: 'gf-unlock-your-financial-potential-with-ghostfolio-page', diff --git a/apps/client/src/app/pages/blog/2023/07/exploring-the-path-to-fire/exploring-the-path-to-fire-page.component.ts b/apps/client/src/app/pages/blog/2023/07/exploring-the-path-to-fire/exploring-the-path-to-fire-page.component.ts index 6c7bb2ae2..79adc0e93 100644 --- a/apps/client/src/app/pages/blog/2023/07/exploring-the-path-to-fire/exploring-the-path-to-fire-page.component.ts +++ b/apps/client/src/app/pages/blog/2023/07/exploring-the-path-to-fire/exploring-the-path-to-fire-page.component.ts @@ -1,10 +1,11 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [MatButtonModule, RouterModule], selector: 'gf-exploring-the-path-to-fire-page-page', diff --git a/apps/client/src/app/pages/blog/2023/08/ghostfolio-joins-oss-friends/ghostfolio-joins-oss-friends-page.component.ts b/apps/client/src/app/pages/blog/2023/08/ghostfolio-joins-oss-friends/ghostfolio-joins-oss-friends-page.component.ts index c5a9cf178..937212d0c 100644 --- a/apps/client/src/app/pages/blog/2023/08/ghostfolio-joins-oss-friends/ghostfolio-joins-oss-friends-page.component.ts +++ b/apps/client/src/app/pages/blog/2023/08/ghostfolio-joins-oss-friends/ghostfolio-joins-oss-friends-page.component.ts @@ -1,10 +1,11 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [MatButtonModule, RouterModule], selector: 'gf-ghostfolio-joins-oss-friends-page', diff --git a/apps/client/src/app/pages/blog/2023/09/ghostfolio-2/ghostfolio-2-page.component.ts b/apps/client/src/app/pages/blog/2023/09/ghostfolio-2/ghostfolio-2-page.component.ts index 197bc3e6b..3971b725b 100644 --- a/apps/client/src/app/pages/blog/2023/09/ghostfolio-2/ghostfolio-2-page.component.ts +++ b/apps/client/src/app/pages/blog/2023/09/ghostfolio-2/ghostfolio-2-page.component.ts @@ -1,10 +1,11 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [MatButtonModule, RouterModule], selector: 'gf-ghostfolio-2-page', diff --git a/apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.component.ts b/apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.component.ts index 1cf3f20a5..77d8f7ee0 100644 --- a/apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.component.ts +++ b/apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.component.ts @@ -1,10 +1,11 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [MatButtonModule, RouterModule], selector: 'gf-hacktoberfest-2023-page', diff --git a/apps/client/src/app/pages/blog/2023/11/black-week-2023/black-week-2023-page.component.ts b/apps/client/src/app/pages/blog/2023/11/black-week-2023/black-week-2023-page.component.ts index 09c13cfa2..a4503c99b 100644 --- a/apps/client/src/app/pages/blog/2023/11/black-week-2023/black-week-2023-page.component.ts +++ b/apps/client/src/app/pages/blog/2023/11/black-week-2023/black-week-2023-page.component.ts @@ -1,11 +1,12 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [GfPremiumIndicatorComponent, MatButtonModule, RouterModule], selector: 'gf-black-week-2023-page', diff --git a/apps/client/src/app/pages/blog/2023/11/hacktoberfest-2023-debriefing/hacktoberfest-2023-debriefing-page.component.ts b/apps/client/src/app/pages/blog/2023/11/hacktoberfest-2023-debriefing/hacktoberfest-2023-debriefing-page.component.ts index 7c8b37931..14ff67a04 100644 --- a/apps/client/src/app/pages/blog/2023/11/hacktoberfest-2023-debriefing/hacktoberfest-2023-debriefing-page.component.ts +++ b/apps/client/src/app/pages/blog/2023/11/hacktoberfest-2023-debriefing/hacktoberfest-2023-debriefing-page.component.ts @@ -1,10 +1,11 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [MatButtonModule, RouterModule], selector: 'gf-hacktoberfest-2023-debriefing-page', diff --git a/apps/client/src/app/pages/blog/2024/09/hacktoberfest-2024/hacktoberfest-2024-page.component.ts b/apps/client/src/app/pages/blog/2024/09/hacktoberfest-2024/hacktoberfest-2024-page.component.ts index 47f61adad..abfa39ffc 100644 --- a/apps/client/src/app/pages/blog/2024/09/hacktoberfest-2024/hacktoberfest-2024-page.component.ts +++ b/apps/client/src/app/pages/blog/2024/09/hacktoberfest-2024/hacktoberfest-2024-page.component.ts @@ -1,10 +1,11 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [MatButtonModule, RouterModule], selector: 'gf-hacktoberfest-2024-page', diff --git a/apps/client/src/app/pages/blog/2024/11/black-weeks-2024/black-weeks-2024-page.component.ts b/apps/client/src/app/pages/blog/2024/11/black-weeks-2024/black-weeks-2024-page.component.ts index d15f081f8..38b7b80c4 100644 --- a/apps/client/src/app/pages/blog/2024/11/black-weeks-2024/black-weeks-2024-page.component.ts +++ b/apps/client/src/app/pages/blog/2024/11/black-weeks-2024/black-weeks-2024-page.component.ts @@ -1,11 +1,12 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [GfPremiumIndicatorComponent, MatButtonModule, RouterModule], selector: 'gf-black-weeks-2024-page', diff --git a/apps/client/src/app/pages/blog/2025/09/hacktoberfest-2025/hacktoberfest-2025-page.component.ts b/apps/client/src/app/pages/blog/2025/09/hacktoberfest-2025/hacktoberfest-2025-page.component.ts index 72990ca47..88c70723d 100644 --- a/apps/client/src/app/pages/blog/2025/09/hacktoberfest-2025/hacktoberfest-2025-page.component.ts +++ b/apps/client/src/app/pages/blog/2025/09/hacktoberfest-2025/hacktoberfest-2025-page.component.ts @@ -1,10 +1,11 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [MatButtonModule, RouterModule], selector: 'gf-hacktoberfest-2025-page', diff --git a/apps/client/src/app/pages/blog/2025/11/black-weeks-2025/black-weeks-2025-page.component.ts b/apps/client/src/app/pages/blog/2025/11/black-weeks-2025/black-weeks-2025-page.component.ts index c5947abf4..b98be9c76 100644 --- a/apps/client/src/app/pages/blog/2025/11/black-weeks-2025/black-weeks-2025-page.component.ts +++ b/apps/client/src/app/pages/blog/2025/11/black-weeks-2025/black-weeks-2025-page.component.ts @@ -1,11 +1,12 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [GfPremiumIndicatorComponent, MatButtonModule, RouterModule], selector: 'gf-black-weeks-2025-page', diff --git a/apps/client/src/app/pages/blog/2026/04/ghostfolio-3/ghostfolio-3-page.component.ts b/apps/client/src/app/pages/blog/2026/04/ghostfolio-3/ghostfolio-3-page.component.ts index 63cd09d9c..e8e0c6da9 100644 --- a/apps/client/src/app/pages/blog/2026/04/ghostfolio-3/ghostfolio-3-page.component.ts +++ b/apps/client/src/app/pages/blog/2026/04/ghostfolio-3/ghostfolio-3-page.component.ts @@ -1,10 +1,11 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [MatButtonModule, RouterModule], selector: 'gf-ghostfolio-3-page', diff --git a/apps/client/src/app/pages/blog/blog-page.component.ts b/apps/client/src/app/pages/blog/blog-page.component.ts index 7f2c56d2d..f3227e3b0 100644 --- a/apps/client/src/app/pages/blog/blog-page.component.ts +++ b/apps/client/src/app/pages/blog/blog-page.component.ts @@ -1,7 +1,11 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { DataService } from '@ghostfolio/ui/services'; -import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + CUSTOM_ELEMENTS_SCHEMA +} from '@angular/core'; import { MatCardModule } from '@angular/material/card'; import { RouterModule } from '@angular/router'; import { IonIcon } from '@ionic/angular/standalone'; @@ -9,6 +13,7 @@ import { addIcons } from 'ionicons'; import { chevronForwardOutline } from 'ionicons/icons'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [IonIcon, MatCardModule, RouterModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], diff --git a/apps/client/src/app/pages/demo/demo-page.component.ts b/apps/client/src/app/pages/demo/demo-page.component.ts index 235805dcc..871efcd73 100644 --- a/apps/client/src/app/pages/demo/demo-page.component.ts +++ b/apps/client/src/app/pages/demo/demo-page.component.ts @@ -3,13 +3,13 @@ import { InfoItem } from '@ghostfolio/common/interfaces'; import { NotificationService } from '@ghostfolio/ui/notifications'; import { DataService } from '@ghostfolio/ui/services'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { Router } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, selector: 'gf-demo-page', - standalone: true, templateUrl: './demo-page.html' }) export class GfDemoPageComponent { @@ -25,7 +25,7 @@ export class GfDemoPageComponent { } public ngOnInit() { - const hasToken = this.tokenStorageService.getToken()?.length > 0; + const hasToken = !!this.tokenStorageService.getToken(); if (hasToken) { this.notificationService.alert({ diff --git a/apps/client/src/app/pages/faq/faq-page.component.ts b/apps/client/src/app/pages/faq/faq-page.component.ts index 6dabdb5e2..17f70d138 100644 --- a/apps/client/src/app/pages/faq/faq-page.component.ts +++ b/apps/client/src/app/pages/faq/faq-page.component.ts @@ -6,12 +6,13 @@ import { } from '@ghostfolio/ui/page-tabs'; import { DataService } from '@ghostfolio/ui/services'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { addIcons } from 'ionicons'; import { cloudyOutline, readerOutline, serverOutline } from 'ionicons/icons'; @Component({ - host: { class: 'page has-tabs' }, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'page' }, imports: [GfPageTabsComponent], selector: 'gf-faq-page', styleUrls: ['./faq-page.scss'], diff --git a/apps/client/src/app/pages/faq/overview/faq-overview-page.component.ts b/apps/client/src/app/pages/faq/overview/faq-overview-page.component.ts index 1d59e67e8..17d93e795 100644 --- a/apps/client/src/app/pages/faq/overview/faq-overview-page.component.ts +++ b/apps/client/src/app/pages/faq/overview/faq-overview-page.component.ts @@ -4,6 +4,7 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { + ChangeDetectionStrategy, ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA, @@ -14,6 +15,7 @@ import { MatCardModule } from '@angular/material/card'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [GfPremiumIndicatorComponent, MatCardModule, RouterModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], @@ -38,9 +40,9 @@ export class GfFaqOverviewPageComponent { .subscribe((state) => { if (state?.user) { this.user = state.user; - - this.changeDetectorRef.markForCheck(); } + + this.changeDetectorRef.markForCheck(); }); } } diff --git a/apps/client/src/app/pages/faq/overview/faq-overview-page.html b/apps/client/src/app/pages/faq/overview/faq-overview-page.html index 3b43ac096..ee4e0ad1a 100644 --- a/apps/client/src/app/pages/faq/overview/faq-overview-page.html +++ b/apps/client/src/app/pages/faq/overview/faq-overview-page.html @@ -193,7 +193,7 @@ } or start a discussion at GitHub. { if (state?.user) { this.user = state.user; - - this.changeDetectorRef.markForCheck(); } + + this.changeDetectorRef.markForCheck(); }); } } diff --git a/apps/client/src/app/pages/faq/saas/saas-page.html b/apps/client/src/app/pages/faq/saas/saas-page.html index 8e9cdaff7..fc39acc53 100644 --- a/apps/client/src/app/pages/faq/saas/saas-page.html +++ b/apps/client/src/app/pages/faq/saas/saas-page.html @@ -96,7 +96,7 @@ Request your student discount - here with + here with your university e-mail address. @@ -186,7 +186,7 @@ } or start a discussion at GitHub. or start a discussion at GitHub. { if (state?.user) { this.user = state.user; - - this.changeDetectorRef.markForCheck(); } + + this.changeDetectorRef.markForCheck(); }); this.hasPermissionForSubscription = hasPermission( diff --git a/apps/client/src/app/pages/home/home-page.component.ts b/apps/client/src/app/pages/home/home-page.component.ts index 453a79a52..4ef7741de 100644 --- a/apps/client/src/app/pages/home/home-page.component.ts +++ b/apps/client/src/app/pages/home/home-page.component.ts @@ -1,7 +1,6 @@ import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { User } from '@ghostfolio/common/interfaces'; -import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { GfPageTabsComponent, @@ -9,6 +8,7 @@ import { } from '@ghostfolio/ui/page-tabs'; import { + ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, @@ -25,7 +25,8 @@ import { } from 'ionicons/icons'; @Component({ - host: { class: 'page has-tabs' }, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'page' }, imports: [GfPageTabsComponent], selector: 'gf-home-page', styleUrls: ['./home-page.scss'], @@ -71,23 +72,13 @@ export class GfHomePageComponent implements OnInit { }, { iconName: 'newspaper-outline', - label: hasPermission( - this.user?.permissions, - permissions.readMarketDataOfMarkets - ) - ? internalRoutes.home.subRoutes.marketsPremium.title - : internalRoutes.home.subRoutes.markets.title, - routerLink: hasPermission( - this.user?.permissions, - permissions.readMarketDataOfMarkets - ) - ? internalRoutes.home.subRoutes.marketsPremium.routerLink - : internalRoutes.home.subRoutes.markets.routerLink + label: internalRoutes.home.subRoutes.markets.title, + routerLink: internalRoutes.home.subRoutes.markets.routerLink } ]; - - this.changeDetectorRef.markForCheck(); } + + this.changeDetectorRef.markForCheck(); }); addIcons({ diff --git a/apps/client/src/app/pages/home/home-page.routes.ts b/apps/client/src/app/pages/home/home-page.routes.ts index 82ef1e521..cc444c2b0 100644 --- a/apps/client/src/app/pages/home/home-page.routes.ts +++ b/apps/client/src/app/pages/home/home-page.routes.ts @@ -1,5 +1,4 @@ import { GfHomeHoldingsComponent } from '@ghostfolio/client/components/home-holdings/home-holdings.component'; -import { GfHomeMarketComponent } from '@ghostfolio/client/components/home-market/home-market.component'; import { GfHomeOverviewComponent } from '@ghostfolio/client/components/home-overview/home-overview.component'; import { GfHomeSummaryComponent } from '@ghostfolio/client/components/home-summary/home-summary.component'; import { GfHomeWatchlistComponent } from '@ghostfolio/client/components/home-watchlist/home-watchlist.component'; @@ -31,13 +30,8 @@ export const routes: Routes = [ }, { path: internalRoutes.home.subRoutes.markets.path, - component: GfHomeMarketComponent, - title: internalRoutes.home.subRoutes.markets.title - }, - { - path: internalRoutes.home.subRoutes.marketsPremium.path, component: GfMarketsComponent, - title: internalRoutes.home.subRoutes.marketsPremium.title + title: internalRoutes.home.subRoutes.markets.title }, { path: internalRoutes.home.subRoutes.watchlist.path, diff --git a/apps/client/src/app/pages/i18n/i18n-page.component.ts b/apps/client/src/app/pages/i18n/i18n-page.component.ts index 76d123914..0cf305bf9 100644 --- a/apps/client/src/app/pages/i18n/i18n-page.component.ts +++ b/apps/client/src/app/pages/i18n/i18n-page.component.ts @@ -1,9 +1,9 @@ -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, selector: 'gf-i18n-page', - standalone: true, styleUrls: ['./i18n-page.scss'], templateUrl: './i18n-page.html' }) diff --git a/apps/client/src/app/pages/landing/landing-page.component.ts b/apps/client/src/app/pages/landing/landing-page.component.ts index 386992a6e..8b8d4dcfd 100644 --- a/apps/client/src/app/pages/landing/landing-page.component.ts +++ b/apps/client/src/app/pages/landing/landing-page.component.ts @@ -8,7 +8,7 @@ import { DataService } from '@ghostfolio/ui/services'; import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfWorldMapChartComponent } from '@ghostfolio/ui/world-map-chart'; -import { Component, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { RouterModule } from '@angular/router'; @@ -22,6 +22,7 @@ import { import { DeviceDetectorService } from 'ngx-device-detector'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [ GfCarouselComponent, diff --git a/apps/client/src/app/pages/landing/landing-page.html b/apps/client/src/app/pages/landing/landing-page.html index dc0df10d4..bdd5f422f 100644 --- a/apps/client/src/app/pages/landing/landing-page.html +++ b/apps/client/src/app/pages/landing/landing-page.html @@ -14,6 +14,7 @@
    @@ -58,6 +59,7 @@ > @@ -76,6 +78,7 @@ > @@ -94,6 +97,7 @@ > @@ -243,11 +247,11 @@ @if (testimonial.url) { {{ testimonial.author - }} + }}, } @else { - {{ testimonial.author }} + {{ testimonial.author }}, } - , {{ testimonial.country }}
    diff --git a/apps/client/src/app/pages/markets/markets-page.component.ts b/apps/client/src/app/pages/markets/markets-page.component.ts index c70cb8120..bf35c5556 100644 --- a/apps/client/src/app/pages/markets/markets-page.component.ts +++ b/apps/client/src/app/pages/markets/markets-page.component.ts @@ -1,10 +1,11 @@ -import { GfHomeMarketComponent } from '@ghostfolio/client/components/home-market/home-market.component'; +import { GfMarketsComponent } from '@ghostfolio/client/components/markets/markets.component'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, - imports: [GfHomeMarketComponent], + imports: [GfMarketsComponent], selector: 'gf-markets-page', styleUrls: ['./markets-page.scss'], templateUrl: './markets-page.html' diff --git a/apps/client/src/app/pages/markets/markets-page.html b/apps/client/src/app/pages/markets/markets-page.html index 16457b8cd..0ff0170d7 100644 --- a/apps/client/src/app/pages/markets/markets-page.html +++ b/apps/client/src/app/pages/markets/markets-page.html @@ -1,7 +1,7 @@
    - +
    diff --git a/apps/client/src/app/pages/open/open-page.component.ts b/apps/client/src/app/pages/open/open-page.component.ts index 090588d7d..93346ecaa 100644 --- a/apps/client/src/app/pages/open/open-page.component.ts +++ b/apps/client/src/app/pages/open/open-page.component.ts @@ -4,6 +4,7 @@ import { DataService } from '@ghostfolio/ui/services'; import { GfValueComponent } from '@ghostfolio/ui/value'; import { + ChangeDetectionStrategy, ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA, @@ -14,6 +15,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatCardModule } from '@angular/material/card'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [GfValueComponent, MatCardModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], @@ -42,9 +44,9 @@ export class GfOpenPageComponent implements OnInit { .subscribe((state) => { if (state?.user) { this.user = state.user; - - this.changeDetectorRef.markForCheck(); } + + this.changeDetectorRef.markForCheck(); }); } } diff --git a/apps/client/src/app/pages/open/open-page.html b/apps/client/src/app/pages/open/open-page.html index e7ff2719c..7ba6c1f4c 100644 --- a/apps/client/src/app/pages/open/open-page.html +++ b/apps/client/src/app/pages/open/open-page.html @@ -8,6 +8,7 @@ the source code as open source software diff --git a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts b/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts index e43af52c9..0ff4168ac 100644 --- a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts @@ -2,7 +2,6 @@ import { IcsService } from '@ghostfolio/client/services/ics/ics.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config'; -import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; import { downloadAsFile } from '@ghostfolio/common/helper'; import { Activity, @@ -10,42 +9,37 @@ import { User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; -import { DateRange } from '@ghostfolio/common/types'; +import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; +import { GfFabComponent } from '@ghostfolio/ui/fab'; import { DataService } from '@ghostfolio/ui/services'; import { + ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, + inject, OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; import { PageEvent } from '@angular/material/paginator'; import { MatSnackBarModule } from '@angular/material/snack-bar'; import { Sort, SortDirection } from '@angular/material/sort'; import { MatTableDataSource } from '@angular/material/table'; -import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { IonIcon } from '@ionic/angular/standalone'; +import { Router, RouterModule } from '@angular/router'; import { format, parseISO } from 'date-fns'; -import { addIcons } from 'ionicons'; -import { addOutline } from 'ionicons/icons'; import { DeviceDetectorService } from 'ngx-device-detector'; -import { Subscription } from 'rxjs'; -import { GfCreateOrUpdateActivityDialogComponent } from './create-or-update-activity-dialog/create-or-update-activity-dialog.component'; -import { CreateOrUpdateActivityDialogParams } from './create-or-update-activity-dialog/interfaces/interfaces'; import { GfImportActivitiesDialogComponent } from './import-activities-dialog/import-activities-dialog.component'; import { ImportActivitiesDialogParams } from './import-activities-dialog/interfaces/interfaces'; @Component({ - host: { class: 'has-fab' }, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ GfActivitiesTableComponent, - IonIcon, - MatButtonModule, + GfFabComponent, MatSnackBarModule, RouterModule ], @@ -54,62 +48,32 @@ import { ImportActivitiesDialogParams } from './import-activities-dialog/interfa templateUrl: './activities-page.html' }) export class GfActivitiesPageComponent implements OnInit { - public activityTypesFilter: string[] = []; - public dataSource: MatTableDataSource; - public deviceType: string; - public hasImpersonationId: boolean; - public hasPermissionToCreateActivity: boolean; - public hasPermissionToDeleteActivity: boolean; - public pageIndex = 0; - public pageSize = DEFAULT_PAGE_SIZE; - public routeQueryParams: Subscription; - public sortColumn = 'date'; - public sortDirection: SortDirection = 'desc'; - public totalItems: number | undefined; - public user: User; - - public constructor( - private changeDetectorRef: ChangeDetectorRef, - private dataService: DataService, - private destroyRef: DestroyRef, - private deviceDetectorService: DeviceDetectorService, - private dialog: MatDialog, - private icsService: IcsService, - private impersonationStorageService: ImpersonationStorageService, - private route: ActivatedRoute, - private router: Router, - private userService: UserService - ) { - this.routeQueryParams = route.queryParams - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((params) => { - if (params['createDialog']) { - if (params['activityId']) { - this.dataService - .fetchActivity(params['activityId']) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((activity) => { - this.openCreateActivityDialog(activity); - }); - } else { - this.openCreateActivityDialog(); - } - } else if (params['editDialog']) { - if (params['activityId']) { - this.dataService - .fetchActivity(params['activityId']) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((activity) => { - this.openUpdateActivityDialog(activity); - }); - } else { - this.router.navigate(['.'], { relativeTo: this.route }); - } - } - }); - - addIcons({ addOutline }); - } + protected dataSource: MatTableDataSource | undefined; + protected deviceType: string; + protected hasImpersonationId: boolean; + protected hasPermissionToCreateActivity: boolean; + protected hasPermissionToDeleteActivity: boolean; + protected readonly internalRoutes = internalRoutes; + protected pageIndex = 0; + protected readonly pageSize = DEFAULT_PAGE_SIZE; + protected sortColumn = 'date'; + protected sortDirection: SortDirection = 'desc'; + protected totalItems: number | undefined; + protected user: User; + + private activityTypesFilter: string[] = []; + + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly deviceDetectorService = inject(DeviceDetectorService); + private readonly dialog = inject(MatDialog); + private readonly icsService = inject(IcsService); + private readonly impersonationStorageService = inject( + ImpersonationStorageService + ); + private readonly router = inject(Router); + private readonly userService = inject(UserService); public ngOnInit() { this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; @@ -119,14 +83,22 @@ export class GfActivitiesPageComponent implements OnInit { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((impersonationId) => { this.hasImpersonationId = !!impersonationId; + + this.changeDetectorRef.markForCheck(); }); this.userService.stateChanged .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((state) => { if (state?.user) { + const previousDateRange = this.getDateRange(); + this.updateUser(state.user); + if (previousDateRange !== this.getDateRange()) { + this.pageIndex = 0; + } + this.fetchActivities(); this.changeDetectorRef.markForCheck(); @@ -134,49 +106,13 @@ export class GfActivitiesPageComponent implements OnInit { }); } - public fetchActivities() { - // Reset dataSource and totalItems to show loading state - this.dataSource = undefined; - this.totalItems = undefined; - - const dateRange = this.user?.settings?.dateRange; - const range = this.isCalendarYear(dateRange) ? dateRange : undefined; - - this.dataService - .fetchActivities({ - range, - activityTypes: this.activityTypesFilter.length - ? this.activityTypesFilter - : undefined, - filters: this.userService.getFilters(), - skip: this.pageIndex * this.pageSize, - sortColumn: this.sortColumn, - sortDirection: this.sortDirection, - take: this.pageSize - }) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(({ activities, count }) => { - this.dataSource = new MatTableDataSource(activities); - this.totalItems = count; - - if ( - this.hasPermissionToCreateActivity && - this.user?.activitiesCount === 0 - ) { - this.router.navigate([], { queryParams: { createDialog: true } }); - } - - this.changeDetectorRef.markForCheck(); - }); - } - - public onChangePage(page: PageEvent) { + protected onChangePage(page: PageEvent) { this.pageIndex = page.pageIndex; this.fetchActivities(); } - public onClickActivity({ dataSource, symbol }: AssetProfileIdentifier) { + protected onClickActivity({ dataSource, symbol }: AssetProfileIdentifier) { this.router.navigate([], { queryParams: { dataSource, @@ -186,14 +122,14 @@ export class GfActivitiesPageComponent implements OnInit { }); } - public onCloneActivity(aActivity: Activity) { - this.openCreateActivityDialog(aActivity); - } - - public onDeleteActivities() { + protected onDeleteActivities() { this.dataService .deleteActivities({ - filters: this.userService.getFilters() + activityTypes: this.activityTypesFilter.length + ? this.activityTypesFilter + : undefined, + filters: this.userService.getFilters(), + range: this.getDateRange() }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { @@ -208,7 +144,7 @@ export class GfActivitiesPageComponent implements OnInit { }); } - public onDeleteActivity(aId: string) { + protected onDeleteActivity(aId: string) { this.dataService .deleteActivity(aId) .pipe(takeUntilDestroyed(this.destroyRef)) @@ -224,7 +160,7 @@ export class GfActivitiesPageComponent implements OnInit { }); } - public onExport(activityIds?: string[]) { + protected onExport(activityIds?: string[]) { let fetchExportParams: any = { activityIds }; if (!activityIds) { @@ -232,7 +168,8 @@ export class GfActivitiesPageComponent implements OnInit { activityTypes: this.activityTypesFilter.length ? this.activityTypesFilter : undefined, - filters: this.userService.getFilters() + filters: this.userService.getFilters(), + range: this.getDateRange() }; } @@ -240,10 +177,6 @@ export class GfActivitiesPageComponent implements OnInit { .fetchExport(fetchExportParams) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((data) => { - for (const activity of data.activities) { - delete activity.id; - } - downloadAsFile({ content: data, fileName: `ghostfolio-export-${format( @@ -255,9 +188,9 @@ export class GfActivitiesPageComponent implements OnInit { }); } - public onExportDrafts(activityIds?: string[]) { + protected onExportDrafts(activityIds?: string[]) { this.dataService - .fetchExport({ activityIds }) + .fetchExport({ activityIds, withActivityIds: true }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((data) => { downloadAsFile({ @@ -273,7 +206,7 @@ export class GfActivitiesPageComponent implements OnInit { }); } - public onImport() { + protected onImport() { const dialogRef = this.dialog.open< GfImportActivitiesDialogComponent, ImportActivitiesDialogParams @@ -301,7 +234,7 @@ export class GfActivitiesPageComponent implements OnInit { }); } - public onImportDividends() { + protected onImportDividends() { const dialogRef = this.dialog.open< GfImportActivitiesDialogComponent, ImportActivitiesDialogParams @@ -330,7 +263,7 @@ export class GfActivitiesPageComponent implements OnInit { }); } - public onSortChanged({ active, direction }: Sort) { + protected onSortChanged({ active, direction }: Sort) { this.pageIndex = 0; this.sortColumn = active; this.sortDirection = direction; @@ -338,112 +271,59 @@ export class GfActivitiesPageComponent implements OnInit { this.fetchActivities(); } - public onTypesFilterChanged(aTypes: string[]) { + protected onTypesFilterChanged(aTypes: string[]) { this.activityTypesFilter = aTypes; this.pageIndex = 0; this.fetchActivities(); } - public onUpdateActivity(aActivity: Activity) { - this.router.navigate([], { - queryParams: { activityId: aActivity.id, editDialog: true } - }); - } - - public openUpdateActivityDialog(aActivity: Activity) { - const dialogRef = this.dialog.open< - GfCreateOrUpdateActivityDialogComponent, - CreateOrUpdateActivityDialogParams - >(GfCreateOrUpdateActivityDialogComponent, { - data: { - activity: aActivity, - accounts: this.user?.accounts, - user: this.user - }, - height: this.deviceType === 'mobile' ? '98vh' : '80vh', - width: this.deviceType === 'mobile' ? '100vw' : '50rem' - }); + private fetchActivities() { + // Reset dataSource and totalItems to show loading state + this.dataSource = undefined; + this.totalItems = undefined; - dialogRef - .afterClosed() + this.dataService + .fetchActivities({ + activityTypes: this.activityTypesFilter.length + ? this.activityTypesFilter + : undefined, + filters: this.userService.getFilters(), + range: this.getDateRange(), + skip: this.pageIndex * this.pageSize, + sortColumn: this.sortColumn, + sortDirection: this.sortDirection, + take: this.pageSize + }) .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((activity: UpdateOrderDto) => { - if (activity) { - this.dataService - .putActivity(activity) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe({ - next: () => { - this.fetchActivities(); - - this.changeDetectorRef.markForCheck(); - } - }); + .subscribe(({ activities, count }) => { + this.dataSource = new MatTableDataSource(activities); + this.totalItems = count; + + if ( + this.hasPermissionToCreateActivity && + this.user?.activitiesCount === 0 + ) { + void this.router.navigate( + internalRoutes.portfolio.subRoutes.activities.subRoutes.create + .routerLink + ); } - this.router.navigate(['.'], { relativeTo: this.route }); + this.changeDetectorRef.markForCheck(); }); } - private isCalendarYear(dateRange: DateRange) { - if (!dateRange) { - return false; - } - - return /^\d{4}$/.test(dateRange); - } + private getDateRange() { + const dateRange = this.user?.settings?.dateRange; - private openCreateActivityDialog(aActivity?: Activity) { - this.userService - .get() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((user) => { - this.updateUser(user); - - const dialogRef = this.dialog.open< - GfCreateOrUpdateActivityDialogComponent, - CreateOrUpdateActivityDialogParams - >(GfCreateOrUpdateActivityDialogComponent, { - data: { - accounts: this.user?.accounts, - activity: { - ...aActivity, - accountId: aActivity?.accountId, - date: new Date(), - id: null, - fee: 0, - type: aActivity?.type ?? 'BUY', - unitPrice: null - }, - user: this.user - }, - height: this.deviceType === 'mobile' ? '98vh' : '80vh', - width: this.deviceType === 'mobile' ? '100vw' : '50rem' - }); + // Omit the date ranges which do not apply to activities: '1d' spans today + // only, while 'max' would exclude drafts dated in the future + if (!dateRange || ['1d', 'max'].includes(dateRange)) { + return undefined; + } - dialogRef - .afterClosed() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((transaction: CreateOrderDto | null) => { - if (transaction) { - this.dataService.postActivity(transaction).subscribe({ - next: () => { - this.userService - .get(true) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(); - - this.fetchActivities(); - - this.changeDetectorRef.markForCheck(); - } - }); - } - - this.router.navigate(['.'], { relativeTo: this.route }); - }); - }); + return dateRange; } private updateUser(aUser: User) { diff --git a/apps/client/src/app/pages/portfolio/activities/activities-page.html b/apps/client/src/app/pages/portfolio/activities/activities-page.html index 2a72dcfd2..c69437742 100644 --- a/apps/client/src/app/pages/portfolio/activities/activities-page.html +++ b/apps/client/src/app/pages/portfolio/activities/activities-page.html @@ -1,5 +1,5 @@
    -
    +

    Activities

    - - - -
    + }
    + + diff --git a/apps/client/src/app/pages/portfolio/activities/activities-page.routes.ts b/apps/client/src/app/pages/portfolio/activities/activities-page.routes.ts index c96c8a558..f21f23ba4 100644 --- a/apps/client/src/app/pages/portfolio/activities/activities-page.routes.ts +++ b/apps/client/src/app/pages/portfolio/activities/activities-page.routes.ts @@ -4,10 +4,39 @@ import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { Routes } from '@angular/router'; import { GfActivitiesPageComponent } from './activities-page.component'; +import { GfActivityDialogHostComponent } from './activity-dialog-host/activity-dialog-host.component'; + +const { clone, create, update } = + internalRoutes.portfolio.subRoutes.activities.subRoutes; export const routes: Routes = [ { canActivate: [AuthGuard], + children: [ + { + component: GfActivityDialogHostComponent, + data: { mode: 'create' }, + path: create.path, + title: create.title + }, + { + children: [ + { + component: GfActivityDialogHostComponent, + data: { mode: 'clone' }, + path: clone.path, + title: clone.title + }, + { + component: GfActivityDialogHostComponent, + data: { mode: 'update' }, + path: update.path, + title: update.title + } + ], + path: ':activityId' + } + ], component: GfActivitiesPageComponent, path: '', title: internalRoutes.portfolio.subRoutes.activities.title diff --git a/apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts b/apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts new file mode 100644 index 000000000..e0e3fc950 --- /dev/null +++ b/apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts @@ -0,0 +1,169 @@ +import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; +import { Activity, User } from '@ghostfolio/common/interfaces'; +import { internalRoutes } from '@ghostfolio/common/routes/routes'; +import { DataService } from '@ghostfolio/ui/services'; + +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + OnDestroy, + OnInit, + inject +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { ActivatedRoute, Router } from '@angular/router'; +import { DeviceDetectorService } from 'ngx-device-detector'; +import { Observable, of } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; + +import { GfCreateOrUpdateActivityDialogComponent } from '../create-or-update-activity-dialog/create-or-update-activity-dialog.component'; +import { CreateOrUpdateActivityDialogParams } from '../create-or-update-activity-dialog/interfaces/interfaces'; +import { ActivityDialogMode } from './types/activity-dialog-mode.type'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'gf-activity-dialog-host', + template: '' +}) +export class GfActivityDialogHostComponent implements OnDestroy, OnInit { + private dialogRef: MatDialogRef; + + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly deviceDetectorService = inject(DeviceDetectorService); + private readonly dialog = inject(MatDialog); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly userService = inject(UserService); + + public ngOnInit() { + const mode = this.route.snapshot.data.mode as ActivityDialogMode; + const activityId = this.route.snapshot.paramMap.get('activityId'); + + const activity$: Observable = activityId + ? this.dataService.fetchActivity(activityId) + : of(undefined); + + this.userService + .get() + .pipe( + switchMap((user) => { + return activity$.pipe( + map((activity) => { + return { activity, user }; + }) + ); + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + error: () => { + this.navigateBack(); + }, + next: ({ activity, user }) => { + if (mode === 'update') { + if (!activity) { + this.navigateBack(); + + return; + } + + this.openDialog({ activity, user, isUpdate: true }); + + return; + } + + if (mode === 'clone' && !activity) { + this.navigateBack(); + + return; + } + + this.openDialog({ + user, + activity: { + ...activity, + accountId: activity?.accountId, + assetProfile: activity?.assetProfile ?? null, + date: new Date(), + fee: 0, + id: null, + type: activity?.type ?? 'BUY', + unitPrice: null + }, + isUpdate: false + }); + } + }); + } + + public ngOnDestroy() { + // The dialog lives in an overlay outside of this component, so it needs to + // be closed explicitly when leaving the route (for example via the browser + // navigation) + this.dialogRef?.close(); + } + + private navigateBack() { + void this.router.navigate( + internalRoutes.portfolio.subRoutes.activities.routerLink + ); + } + + private openDialog({ + activity, + isUpdate, + user + }: { + activity: CreateOrUpdateActivityDialogParams['activity']; + isUpdate: boolean; + user: User; + }) { + const deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; + + this.dialogRef = this.dialog.open< + GfCreateOrUpdateActivityDialogComponent, + CreateOrUpdateActivityDialogParams + >(GfCreateOrUpdateActivityDialogComponent, { + data: { + activity, + user, + accounts: user?.accounts + }, + height: deviceType === 'mobile' ? '98vh' : '80vh', + width: deviceType === 'mobile' ? '100vw' : '50rem' + }); + + this.dialogRef + .afterClosed() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((result: CreateOrderDto | UpdateOrderDto | null) => { + if (!result) { + this.navigateBack(); + + return; + } + + const request$: Observable = isUpdate + ? this.dataService.putActivity(result as UpdateOrderDto) + : this.dataService.postActivity(result as CreateOrderDto); + + request$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + error: () => { + this.navigateBack(); + }, + next: () => { + // Deliberately not bound to the destroy reference: navigating back + // destroys this component and the refreshed user is what makes the + // activities page reload its data + this.userService.get(true).subscribe(); + + this.navigateBack(); + } + }); + }); + } +} diff --git a/apps/client/src/app/pages/portfolio/activities/activity-dialog-host/types/activity-dialog-mode.type.ts b/apps/client/src/app/pages/portfolio/activities/activity-dialog-host/types/activity-dialog-mode.type.ts new file mode 100644 index 000000000..03d6305a5 --- /dev/null +++ b/apps/client/src/app/pages/portfolio/activities/activity-dialog-host/types/activity-dialog-mode.type.ts @@ -0,0 +1 @@ +export type ActivityDialogMode = 'clone' | 'create' | 'update'; diff --git a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts index 1e943824c..07f596711 100644 --- a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts @@ -1,8 +1,10 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { ASSET_CLASS_MAPPING } from '@ghostfolio/common/config'; -import { locale as defaultLocale } from '@ghostfolio/common/config'; +import { ASSET_CLASS_MAPPING, DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; -import { getDateFormatString } from '@ghostfolio/common/helper'; +import { + getDateFormatString, + getStringOrNull +} from '@ghostfolio/common/helper'; import { AssetClassSelectorOption, LookupItem @@ -21,7 +23,7 @@ import { ChangeDetectorRef, Component, DestroyRef, - Inject + inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { @@ -76,51 +78,62 @@ import { ActivityType } from './types/activity-type.type'; templateUrl: 'create-or-update-activity-dialog.html' }) export class GfCreateOrUpdateActivityDialogComponent { - public activityForm: FormGroup; - - public assetClassOptions: AssetClassSelectorOption[] = Object.keys(AssetClass) - .map((id) => { - return { id, label: translate(id) } as AssetClassSelectorOption; - }) - .sort((a, b) => { - return a.label.localeCompare(b.label); - }); + protected activityForm: FormGroup; + + protected readonly assetClassOptions: AssetClassSelectorOption[] = + Object.keys(AssetClass) + .map((id) => { + return { id, label: translate(id) } as AssetClassSelectorOption; + }) + .sort((a, b) => { + return a.label.localeCompare(b.label); + }); - public assetSubClassOptions: AssetClassSelectorOption[] = []; - public currencies: string[] = []; - public currencyOfAssetProfile: string | undefined; - public currentMarketPrice: number | null = null; - public defaultDateFormat: string; - public defaultLookupItems: LookupItem[] = []; - public hasPermissionToCreateOwnTag: boolean | undefined; - public isLoading = false; - public isToday = isToday; - public mode: 'create' | 'update'; - public tagsAvailable: Tag[] = []; - public total = 0; - public typesTranslationMap = new Map(); - public Validators = Validators; - - public constructor( - private changeDetectorRef: ChangeDetectorRef, - @Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateActivityDialogParams, - private dataService: DataService, - private dateAdapter: DateAdapter, - private destroyRef: DestroyRef, - public dialogRef: MatDialogRef, - private formBuilder: FormBuilder, - @Inject(MAT_DATE_LOCALE) private locale: string, - private userService: UserService - ) { + protected assetSubClassOptions: AssetClassSelectorOption[] = []; + protected currencies: string[] = []; + protected currencyOfAssetProfile: string | undefined; + protected currentMarketPrice: number | null = null; + protected defaultDateFormat: string; + protected defaultLookupItems: LookupItem[] = []; + protected hasPermissionToCreateOwnTag: boolean; + protected isLoading = false; + protected readonly isToday = isToday; + protected mode: 'create' | 'update'; + protected tagsAvailable: Tag[] = []; + protected total = 0; + protected readonly typesTranslationMap = new Map(); + protected readonly Validators = Validators; + + protected readonly data = + inject(MAT_DIALOG_DATA); + + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly dataService = inject(DataService); + private readonly dateAdapter = inject>(DateAdapter); + private readonly destroyRef = inject(DestroyRef); + private readonly dialogRef = + inject>(MatDialogRef); + private readonly formBuilder = inject(FormBuilder); + private locale = inject(MAT_DATE_LOCALE); + private readonly userService = inject(UserService); + + public constructor() { addIcons({ calendarClearOutline, refreshOutline }); } + protected get selectedAccount() { + return this.data.accounts.find(({ id }) => { + return id === this.activityForm.get('accountId')?.value; + }); + } + public ngOnInit() { - this.currencyOfAssetProfile = this.data.activity?.SymbolProfile?.currency; - this.hasPermissionToCreateOwnTag = - this.data.user?.settings?.isExperimentalFeatures && - hasPermission(this.data.user?.permissions, permissions.createOwnTag); - this.locale = this.data.user.settings.locale ?? defaultLocale; + this.currencyOfAssetProfile = this.data.activity?.assetProfile?.currency; + this.hasPermissionToCreateOwnTag = hasPermission( + this.data.user?.permissions, + permissions.createOwnTag + ); + this.locale = this.data.user.settings.locale ?? DEFAULT_LOCALE; this.mode = this.data.activity?.id ? 'update' : 'create'; this.dateAdapter.setLocale(this.locale); @@ -139,7 +152,9 @@ export class GfCreateOrUpdateActivityDialogComponent { return !['CASH'].includes(assetProfile.assetSubClass); }) .sort((a, b) => { - return a.name?.localeCompare(b.name); + return (a.assetProfile.name ?? '').localeCompare( + b.assetProfile.name ?? '' + ); }) .map(({ assetProfile }) => { return { @@ -180,31 +195,31 @@ export class GfCreateOrUpdateActivityDialogComponent { ? this.data.accounts[0].id : this.data.activity?.accountId ], - assetClass: [this.data.activity?.SymbolProfile?.assetClass], - assetSubClass: [this.data.activity?.SymbolProfile?.assetSubClass], + assetClass: [this.data.activity?.assetProfile?.assetClass], + assetSubClass: [this.data.activity?.assetProfile?.assetSubClass], comment: [this.data.activity?.comment], currency: [ - this.data.activity?.SymbolProfile?.currency, + this.data.activity?.assetProfile?.currency, Validators.required ], currencyOfUnitPrice: [ this.data.activity?.currency ?? - this.data.activity?.SymbolProfile?.currency, + this.data.activity?.assetProfile?.currency, Validators.required ], dataSource: [ - this.data.activity?.SymbolProfile?.dataSource, + this.data.activity?.assetProfile?.dataSource, Validators.required ], date: [this.data.activity?.date, Validators.required], fee: [this.data.activity?.fee, Validators.required], - name: [this.data.activity?.SymbolProfile?.name, Validators.required], + name: [this.data.activity?.assetProfile?.name, Validators.required], quantity: [this.data.activity?.quantity, Validators.required], searchSymbol: [ - this.data.activity?.SymbolProfile + this.data.activity?.assetProfile ? { - dataSource: this.data.activity?.SymbolProfile?.dataSource, - symbol: this.data.activity?.SymbolProfile?.symbol + dataSource: this.data.activity?.assetProfile?.dataSource, + symbol: this.data.activity?.assetProfile?.symbol } : null, Validators.required @@ -260,16 +275,9 @@ export class GfCreateOrUpdateActivityDialogComponent { this.activityForm.get('currency')?.setValue(currency); this.activityForm.get('currencyOfUnitPrice')?.setValue(currency); - - if (['FEE', 'INTEREST'].includes(type)) { - if (this.activityForm.get('accountId')?.value) { - this.activityForm.get('updateAccountBalance')?.enable(); - } else { - this.activityForm.get('updateAccountBalance')?.disable(); - this.activityForm.get('updateAccountBalance')?.setValue(false); - } - } } + + this.syncUpdateAccountBalanceControl(); }); this.activityForm @@ -293,19 +301,14 @@ export class GfCreateOrUpdateActivityDialogComponent { }); this.activityForm.get('date')?.valueChanges.subscribe(() => { - if (isToday(this.activityForm.get('date')?.value)) { - this.activityForm.get('updateAccountBalance')?.enable(); - } else { - this.activityForm.get('updateAccountBalance')?.disable(); - this.activityForm.get('updateAccountBalance')?.setValue(false); - } + this.syncUpdateAccountBalanceControl(); this.changeDetectorRef.markForCheck(); }); this.activityForm.get('searchSymbol')?.valueChanges.subscribe(() => { if (this.activityForm.get('searchSymbol')?.invalid) { - this.data.activity.SymbolProfile = null; + this.data.activity.assetProfile = null; } else if ( ['BUY', 'DIVIDEND', 'SELL'].includes( this.activityForm.get('type')?.value @@ -378,8 +381,6 @@ export class GfCreateOrUpdateActivityDialogComponent { .get('searchSymbol') ?.removeValidators(Validators.required); this.activityForm.get('searchSymbol')?.updateValueAndValidity(); - this.activityForm.get('updateAccountBalance')?.disable(); - this.activityForm.get('updateAccountBalance')?.setValue(false); } else if (['FEE', 'INTEREST', 'LIABILITY'].includes(type)) { const currency = this.data.accounts.find(({ id }) => { @@ -415,16 +416,6 @@ export class GfCreateOrUpdateActivityDialogComponent { if (type === 'FEE') { this.activityForm.get('unitPrice')?.setValue(0); } - - if ( - ['FEE', 'INTEREST'].includes(type) && - this.activityForm.get('accountId')?.value - ) { - this.activityForm.get('updateAccountBalance')?.enable(); - } else { - this.activityForm.get('updateAccountBalance')?.disable(); - this.activityForm.get('updateAccountBalance')?.setValue(false); - } } else { this.activityForm .get('dataSource') @@ -436,9 +427,10 @@ export class GfCreateOrUpdateActivityDialogComponent { .get('searchSymbol') ?.setValidators(Validators.required); this.activityForm.get('searchSymbol')?.updateValueAndValidity(); - this.activityForm.get('updateAccountBalance')?.enable(); } + this.syncUpdateAccountBalanceControl(); + this.changeDetectorRef.markForCheck(); }); @@ -449,11 +441,11 @@ export class GfCreateOrUpdateActivityDialogComponent { this.activityForm.get('type')?.disable(); } - if (this.data.activity?.SymbolProfile?.symbol) { + if (this.data.activity?.assetProfile?.symbol) { this.dataService .fetchSymbolItem({ - dataSource: this.data.activity?.SymbolProfile?.dataSource, - symbol: this.data.activity?.SymbolProfile?.symbol + dataSource: this.data.activity?.assetProfile?.dataSource, + symbol: this.data.activity?.assetProfile?.symbol }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ marketPrice }) => { @@ -464,14 +456,14 @@ export class GfCreateOrUpdateActivityDialogComponent { } } - public applyCurrentMarketPrice() { + protected applyCurrentMarketPrice() { this.activityForm.patchValue({ currencyOfUnitPrice: this.activityForm.get('currency')?.value, unitPrice: this.currentMarketPrice }); } - public dateFilter(aDate: Date) { + protected dateFilter(aDate: Date) { if (!aDate) { return true; } @@ -479,16 +471,16 @@ export class GfCreateOrUpdateActivityDialogComponent { return isAfter(aDate, new Date(0)); } - public onCancel() { + protected onCancel() { this.dialogRef.close(); } - public async onSubmit() { + protected async onSubmit() { const activity: CreateOrderDto | UpdateOrderDto = { accountId: this.activityForm.get('accountId')?.value, assetClass: this.activityForm.get('assetClass')?.value, assetSubClass: this.activityForm.get('assetSubClass')?.value, - comment: this.activityForm.get('comment')?.value || null, + comment: getStringOrNull(this.activityForm.get('comment')?.value), currency: this.activityForm.get('currency')?.value, customCurrency: this.activityForm.get('currencyOfUnitPrice')?.value, dataSource: ['FEE', 'INTEREST', 'LIABILITY', 'VALUABLE'].includes( @@ -531,7 +523,13 @@ export class GfCreateOrUpdateActivityDialogComponent { this.dialogRef.close(activity); } else { - (activity as UpdateOrderDto).id = this.data.activity?.id; + const activityId = this.data.activity?.id; + + if (!activityId) { + throw new Error('Activity ID is required for update'); + } + + (activity as UpdateOrderDto).id = activityId; await validateObjectForForm({ classDto: UpdateOrderDto, @@ -547,6 +545,27 @@ export class GfCreateOrUpdateActivityDialogComponent { } } + private syncUpdateAccountBalanceControl() { + const accountBalanceControl = this.activityForm.get('updateAccountBalance'); + const accountId = this.activityForm.get('accountId')?.value; + const dataSource = this.activityForm.get('dataSource')?.value; + const date = this.activityForm.get('date')?.value; + const type = this.activityForm.get('type')?.value; + + const isEligible = + !!accountId && + isToday(date) && + !['LIABILITY', 'VALUABLE'].includes(type) && + !(dataSource === 'MANUAL' && type === 'BUY'); + + if (isEligible) { + accountBalanceControl?.enable(); + } else { + accountBalanceControl?.disable(); + accountBalanceControl?.setValue(false); + } + } + private updateAssetProfile() { this.isLoading = true; this.changeDetectorRef.markForCheck(); @@ -558,7 +577,7 @@ export class GfCreateOrUpdateActivityDialogComponent { }) .pipe( catchError(() => { - this.data.activity.SymbolProfile = null; + this.data.activity.assetProfile = null; this.isLoading = false; diff --git a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html index 20c50d0fe..9455a9dd6 100644 --- a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html +++ b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html @@ -85,18 +85,31 @@ > Account + +
    + @if (selectedAccount) { + + } + {{ selectedAccount?.name }} +
    +
    + @for (account of data.accounts; track account) {
    - @if (account.platform?.url) { - - } + {{ account.name }}
    @@ -167,7 +180,7 @@ name="calendar-clear-outline" /> - +
    & { - SymbolProfile: Activity['SymbolProfile'] | null; + accounts: AccountWithPlatform[]; + activity: Partial> & { + assetProfile: Activity['assetProfile'] | null; + id: string | null; + unitPrice: number | null; }; user: User; } diff --git a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts index 7796939fd..48f1d5abd 100644 --- a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts @@ -1,12 +1,13 @@ import { GfFileDropDirective } from '@ghostfolio/client/directives/file-drop/file-drop.directive'; import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service'; +import { DEFAULT_DATE_RANGE } from '@ghostfolio/common/config'; import { CreateAccountWithBalancesDto, CreateAssetProfileWithMarketDataDto, + CreatePlatformDto, CreateTagDto } from '@ghostfolio/common/dtos'; import { Activity, PortfolioPosition } from '@ghostfolio/common/interfaces'; -import { GfSymbolPipe } from '@ghostfolio/common/pipes'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer'; import { GfDialogHeaderComponent } from '@ghostfolio/ui/dialog-header'; @@ -65,7 +66,6 @@ import { ImportActivitiesDialogParams } from './interfaces/interfaces'; GfDialogFooterComponent, GfDialogHeaderComponent, GfFileDropDirective, - GfSymbolPipe, IonIcon, MatButtonModule, MatDialogModule, @@ -108,6 +108,7 @@ export class GfImportActivitiesDialogComponent { private accounts: CreateAccountWithBalancesDto[] = []; private activities: Activity[] = []; private assetProfiles: CreateAssetProfileWithMarketDataDto[] = []; + private platforms: CreatePlatformDto[] = []; private tags: CreateTagDto[] = []; private readonly changeDetectorRef = inject(ChangeDetectorRef); @@ -145,12 +146,12 @@ export class GfImportActivitiesDialogComponent { type: 'ASSET_CLASS' } ], - range: 'max' + range: DEFAULT_DATE_RANGE }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ holdings }) => { this.holdings = sortBy(holdings, ({ assetProfile }) => { - return assetProfile.name.toLowerCase(); + return assetProfile.name?.toLowerCase(); }); this.assetProfileForm.controls.assetProfileIdentifier.enable(); @@ -174,6 +175,7 @@ export class GfImportActivitiesDialogComponent { accounts: this.accounts, activities: this.selectedActivities, assetProfiles: this.assetProfiles, + platforms: this.platforms, tags: this.tags }); @@ -225,7 +227,8 @@ export class GfImportActivitiesDialogComponent { this.assetProfileForm.controls.assetProfileIdentifier.disable(); const { dataSource, symbol } = - this.assetProfileForm.controls.assetProfileIdentifier.value ?? {}; + this.assetProfileForm.controls.assetProfileIdentifier.value + ?.assetProfile ?? {}; if (!dataSource || !symbol) { return; @@ -304,6 +307,7 @@ export class GfImportActivitiesDialogComponent { this.accounts = content.accounts; this.assetProfiles = content.assetProfiles; + this.platforms = content.platforms; this.tags = content.tags; if (!isArray(content.activities)) { @@ -337,6 +341,7 @@ export class GfImportActivitiesDialogComponent { activities: content.activities, assetProfiles: content.assetProfiles, isDryRun: true, + platforms: content.platforms, tags: content.tags }); diff --git a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html index 85fb73ba2..559f639b1 100644 --- a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html +++ b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html @@ -32,15 +32,18 @@ Holding {{ - assetProfileForm.get('assetProfileIdentifier')?.value?.name + assetProfileForm.get('assetProfileIdentifier')?.value + ?.assetProfile?.name }} @for (holding of holdings; track holding) {
    {{ holding.assetProfile.symbol | gfSymbol }} · + >{{ holding.assetProfile.symbol }} · {{ holding.assetProfile.currency }}
    @@ -161,12 +164,14 @@ @for (message of errorMessages; track message; let i = $index) { - -
    + +
    -
    {{ message }}
    +
    + {{ message }} +
    diff --git a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.scss b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.scss index 64f488e36..79f8f868c 100644 --- a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.scss +++ b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.scss @@ -1,9 +1,9 @@ +@use '@angular/material' as mat; + :host { display: block; .mat-mdc-dialog-content { - max-height: unset; - a { color: rgba(var(--palette-primary-500), 1); } @@ -43,8 +43,13 @@ } .mat-expansion-panel { - background: none; - box-shadow: none; + @include mat.expansion-overrides( + ( + container-background-color: transparent, + container-elevation-shadow: none, + container-shape: 0 + ) + ); .mat-expansion-panel-header { color: inherit; diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts index a7f8cd2ec..be7d49bea 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts @@ -1,9 +1,15 @@ import { GfAccountDetailDialogComponent } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component'; -import { AccountDetailDialogParams } from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces'; +import { + AccountDetailDialogParams, + AccountDetailDialogResult +} from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { MAX_TOP_HOLDINGS, UNKNOWN_KEY } from '@ghostfolio/common/config'; -import { prettifySymbol } from '@ghostfolio/common/helper'; +import { + canOpenHoldingDetail, + getCountryName +} from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, HoldingWithParents, @@ -11,8 +17,12 @@ import { PortfolioPosition, User } from '@ghostfolio/common/interfaces'; -import { hasPermission, permissions } from '@ghostfolio/common/permissions'; -import { Market, MarketAdvanced } from '@ghostfolio/common/types'; +import { + hasPermission, + hasReadRestrictedAccessPermission, + permissions +} from '@ghostfolio/common/permissions'; +import { MarketAdvanced } from '@ghostfolio/common/types'; import { translate } from '@ghostfolio/ui/i18n'; import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; @@ -22,9 +32,12 @@ import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfWorldMapChartComponent } from '@ghostfolio/ui/world-map-chart'; import { + ChangeDetectionStrategy, ChangeDetectorRef, Component, + computed, DestroyRef, + inject, OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -41,8 +54,12 @@ import { } from '@prisma/client'; import { isNumber } from 'lodash'; import { DeviceDetectorService } from 'ngx-device-detector'; +import { filter, switchMap, tap } from 'rxjs'; + +import { AllocationsPageParams } from './interfaces/interfaces'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ GfPortfolioProportionChartComponent, GfPremiumIndicatorComponent, @@ -57,159 +74,196 @@ import { DeviceDetectorService } from 'ngx-device-detector'; templateUrl: './allocations-page.html' }) export class GfAllocationsPageComponent implements OnInit { - public accounts: { + protected accounts: { [id: string]: Pick & { id: string; value: number; }; }; - public continents: { + protected continents: { [code: string]: { name: string; value: number }; }; - public countries: { + protected countries: { [code: string]: { name: string; value: number }; }; - public deviceType: string; - public hasImpersonationId: boolean; - public holdings: { + protected readonly deviceType = computed( + () => this.deviceDetectorService.deviceInfo().deviceType + ); + protected holdings: { [symbol: string]: Pick< - PortfolioPosition, + PortfolioPosition['assetProfile'], | 'assetClass' | 'assetClassLabel' | 'assetSubClass' | 'assetSubClassLabel' | 'currency' - | 'exchange' | 'name' > & { etfProvider: string; value: number }; }; - public isLoading = false; - public markets: { - [key in Market]: { id: Market; valueInPercentage: number }; - }; - public marketsAdvanced: { + protected impersonationId: string | null; + protected isLoading = false; + protected markets: PortfolioDetails['markets']; + protected marketsAdvanced: { [key in MarketAdvanced]: { id: MarketAdvanced; name: string; value: number; }; }; - public platforms: { + protected platforms: { [id: string]: Pick & { id: string; value: number; }; }; - public portfolioDetails: PortfolioDetails; - public sectors: { + protected portfolioDetails: PortfolioDetails; + protected sectors: { [name: string]: { name: string; value: number }; }; - public symbols: { + protected symbols: { [name: string]: { dataSource?: DataSource; + isClickable?: boolean; name: string; symbol: string; value: number; }; }; - public topHoldings: HoldingWithParents[]; - public topHoldingsMap: { + protected topHoldings: HoldingWithParents[]; + protected readonly UNKNOWN_KEY = UNKNOWN_KEY; + protected user: User; + + private topHoldingsMap: { [name: string]: { name: string; value: number }; }; - public totalValueInEtf = 0; - public UNKNOWN_KEY = UNKNOWN_KEY; - public user: User; - public worldMapChartFormat: string; - - public constructor( - private changeDetectorRef: ChangeDetectorRef, - private dataService: DataService, - private destroyRef: DestroyRef, - private deviceDetectorService: DeviceDetectorService, - private dialog: MatDialog, - private impersonationStorageService: ImpersonationStorageService, - private route: ActivatedRoute, - private router: Router, - private userService: UserService - ) { + private totalValueInEtf = 0; + + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly deviceDetectorService = inject(DeviceDetectorService); + private readonly dialog = inject(MatDialog); + private readonly impersonationStorageService = inject( + ImpersonationStorageService + ); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly userService = inject(UserService); + + public constructor() { this.route.queryParams .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((params) => { - if (params['accountId'] && params['accountDetailDialog']) { - this.openAccountDetailDialog(params['accountId']); + .subscribe( + ({ accountId, accountDetailDialog }: AllocationsPageParams) => { + if (accountId && accountDetailDialog) { + this.openAccountDetailDialog(accountId); + } } - }); + ); } - public ngOnInit() { - this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; + protected get worldMapChartFormat(): string { + return this.showValuesInPercentage() + ? '{0}%' + : `{0} ${this.user?.settings?.baseCurrency}`; + } + public ngOnInit() { this.impersonationStorageService .onChangeHasImpersonation() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((impersonationId) => { - this.hasImpersonationId = !!impersonationId; + this.impersonationId = impersonationId; + + this.changeDetectorRef.markForCheck(); }); this.userService.stateChanged - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((state) => { - if (state?.user) { + .pipe( + filter((state) => !!state?.user), + tap((state) => { this.user = state.user; - this.worldMapChartFormat = this.showValuesInPercentage() - ? `{0}%` - : `{0} ${this.user?.settings?.baseCurrency}`; - this.isLoading = true; this.initialize(); - this.fetchPortfolioDetails() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((portfolioDetails) => { - this.initialize(); - - this.portfolioDetails = portfolioDetails; + this.changeDetectorRef.markForCheck(); + }), + switchMap(() => this.fetchPortfolioDetails()), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((portfolioDetails) => { + this.initialize(); - this.initializeAllocationsData(); + this.portfolioDetails = portfolioDetails; - this.isLoading = false; + this.initializeAllocationsData(); - this.changeDetectorRef.markForCheck(); - }); + this.isLoading = false; - this.changeDetectorRef.markForCheck(); - } + this.changeDetectorRef.markForCheck(); }); this.initialize(); } - public onAccountChartClicked({ symbol }: AssetProfileIdentifier) { - if (symbol && symbol !== UNKNOWN_KEY) { - this.router.navigate([], { - queryParams: { accountId: symbol, accountDetailDialog: true } + protected onAccountChartClicked({ accountId }: { accountId: string }) { + if (accountId && accountId !== UNKNOWN_KEY) { + void this.router.navigate([], { + queryParams: { accountId, accountDetailDialog: true } }); } } - public onSymbolChartClicked({ dataSource, symbol }: AssetProfileIdentifier) { + protected onSymbolChartClicked({ + dataSource, + symbol + }: AssetProfileIdentifier) { if (dataSource && symbol) { - this.router.navigate([], { + void this.router.navigate([], { queryParams: { dataSource, symbol, holdingDetailDialog: true } }); } } + protected showValuesInPercentage() { + return ( + hasReadRestrictedAccessPermission({ + accesses: this.user?.access, + impersonationId: this.impersonationId + }) || this.user?.settings?.isRestrictedView + ); + } + + private extractCurrency({ + assetClass, + assetSubClass, + currency + }: { + assetClass: PortfolioPosition['assetProfile']['assetClass']; + assetSubClass: PortfolioPosition['assetProfile']['assetSubClass']; + currency?: PortfolioPosition['assetProfile']['currency']; + }) { + if ( + assetClass === AssetClass.COMMODITY || + assetSubClass === AssetSubClass.CRYPTOCURRENCY + ) { + // Commodities and cryptocurrencies have no meaningful currency exposure + return UNKNOWN_KEY; + } + + return currency; + } + private extractEtfProvider({ assetSubClass, name }: { - assetSubClass: PortfolioPosition['assetSubClass']; - name: string; + assetSubClass: PortfolioPosition['assetProfile']['assetSubClass']; + name?: string; }) { - if (assetSubClass === 'ETF') { + if (assetSubClass === 'ETF' && name) { const [firstWord] = name.split(' '); return firstWord; } @@ -279,7 +333,7 @@ export class GfAllocationsPageComponent implements OnInit { this.platforms = {}; this.portfolioDetails = { accounts: {}, - createdAt: undefined, + createdAt: new Date(), holdings: {}, platforms: {}, summary: undefined @@ -308,7 +362,7 @@ export class GfAllocationsPageComponent implements OnInit { let value = 0; if (this.showValuesInPercentage()) { - value = valueInPercentage; + value = valueInPercentage ?? 0; } else { value = valueInBaseCurrency; } @@ -323,138 +377,129 @@ export class GfAllocationsPageComponent implements OnInit { for (const [symbol, position] of Object.entries( this.portfolioDetails.holdings )) { - let value = 0; - - if (this.showValuesInPercentage()) { - value = position.allocationInPercentage; - } else { - value = position.valueInBaseCurrency; - } - this.holdings[symbol] = { - value, - assetClass: position.assetClass || (UNKNOWN_KEY as AssetClass), - assetClassLabel: position.assetClassLabel || UNKNOWN_KEY, - assetSubClass: position.assetSubClass || (UNKNOWN_KEY as AssetSubClass), - assetSubClassLabel: position.assetSubClassLabel || UNKNOWN_KEY, - currency: position.currency, + assetClass: + position.assetProfile.assetClass || (UNKNOWN_KEY as AssetClass), + assetClassLabel: position.assetProfile.assetClassLabel ?? UNKNOWN_KEY, + assetSubClass: + position.assetProfile.assetSubClass || (UNKNOWN_KEY as AssetSubClass), + assetSubClassLabel: + position.assetProfile.assetSubClassLabel ?? UNKNOWN_KEY, + currency: this.extractCurrency(position.assetProfile), etfProvider: this.extractEtfProvider({ - assetSubClass: position.assetSubClass, - name: position.name + assetSubClass: position.assetProfile.assetSubClass, + name: position.assetProfile.name }), - exchange: position.exchange, - name: position.name + name: position.assetProfile.name, + value: this.showValuesInPercentage() + ? position.allocationInPercentage + : (position.valueInBaseCurrency ?? 0) }; - if (position.assetClass !== AssetClass.LIQUIDITY) { - // Prepare analysis data by continents, countries, holdings and sectors except for liquidity - - if (position.countries.length > 0) { - for (const country of position.countries) { - const { code, continent, name, weight } = country; - - if (this.continents[continent]?.value) { - this.continents[continent].value += - weight * - (isNumber(position.valueInBaseCurrency) - ? position.valueInBaseCurrency - : position.valueInPercentage); - } else { - this.continents[continent] = { - name: continent, - value: - weight * - (isNumber(position.valueInBaseCurrency) - ? this.portfolioDetails.holdings[symbol].valueInBaseCurrency - : this.portfolioDetails.holdings[symbol].valueInPercentage) - }; - } - - if (this.countries[code]?.value) { - this.countries[code].value += - weight * - (isNumber(position.valueInBaseCurrency) - ? position.valueInBaseCurrency - : position.valueInPercentage); - } else { - this.countries[code] = { - name, - value: - weight * - (isNumber(position.valueInBaseCurrency) - ? this.portfolioDetails.holdings[symbol].valueInBaseCurrency - : this.portfolioDetails.holdings[symbol].valueInPercentage) - }; - } + // Prepare analysis data by continents, countries, holdings and sectors + + if (position.assetProfile.countries.length > 0) { + for (const country of position.assetProfile.countries) { + const { code, continent, weight } = country; + const value = + (isNumber(position.valueInBaseCurrency) + ? position.valueInBaseCurrency + : position.valueInPercentage) ?? 0; + + const continentData = this.continents[continent]; + + if (continentData) { + continentData.value += weight * value; + } else { + this.continents[continent] = { + name: translate(continent), + value: weight * value + }; + } + + const countryData = this.countries[code]; + + if (countryData) { + countryData.value += weight * value; + } else { + this.countries[code] = { + name: getCountryName({ code }), + value: weight * value + }; } - } else { - this.continents[UNKNOWN_KEY].value += isNumber( - position.valueInBaseCurrency - ) - ? this.portfolioDetails.holdings[symbol].valueInBaseCurrency - : this.portfolioDetails.holdings[symbol].valueInPercentage; - - this.countries[UNKNOWN_KEY].value += isNumber( - position.valueInBaseCurrency - ) - ? this.portfolioDetails.holdings[symbol].valueInBaseCurrency - : this.portfolioDetails.holdings[symbol].valueInPercentage; } + } else { + const value = + (isNumber(position.valueInBaseCurrency) + ? position.valueInBaseCurrency + : position.valueInPercentage) ?? 0; - if (position.holdings.length > 0) { - for (const { - allocationInPercentage, - name, - valueInBaseCurrency - } of position.holdings) { - const normalizedAssetName = this.normalizeAssetName(name); - - if (this.topHoldingsMap[normalizedAssetName]?.value) { - this.topHoldingsMap[normalizedAssetName].value += isNumber( - valueInBaseCurrency - ) - ? valueInBaseCurrency - : allocationInPercentage * - this.portfolioDetails.holdings[symbol].valueInPercentage; - } else { - this.topHoldingsMap[normalizedAssetName] = { - name, - value: isNumber(valueInBaseCurrency) - ? valueInBaseCurrency - : allocationInPercentage * - this.portfolioDetails.holdings[symbol].valueInPercentage - }; - } + const continentData = this.continents[UNKNOWN_KEY]; + + if (continentData) { + continentData.value += value; + } + + const countryData = this.countries[UNKNOWN_KEY]; + + if (countryData) { + countryData.value += value; + } + } + + if (position.assetProfile.holdings.length > 0) { + for (const { + allocationInPercentage, + name, + valueInBaseCurrency + } of position.assetProfile.holdings) { + const normalizedAssetName = this.normalizeAssetName(name); + const value = isNumber(valueInBaseCurrency) + ? valueInBaseCurrency + : allocationInPercentage * (position.valueInPercentage ?? 0); + + const holdingData = this.topHoldingsMap[normalizedAssetName]; + + if (holdingData) { + holdingData.value += value; + } else { + this.topHoldingsMap[normalizedAssetName] = { + name, + value + }; } } + } - if (position.sectors.length > 0) { - for (const sector of position.sectors) { - const { name, weight } = sector; - - if (this.sectors[name]?.value) { - this.sectors[name].value += - weight * - (isNumber(position.valueInBaseCurrency) - ? position.valueInBaseCurrency - : position.valueInPercentage); - } else { - this.sectors[name] = { - name, - value: - weight * - (isNumber(position.valueInBaseCurrency) - ? this.portfolioDetails.holdings[symbol].valueInBaseCurrency - : this.portfolioDetails.holdings[symbol].valueInPercentage) - }; - } + if (position.assetProfile.sectors.length > 0) { + for (const sector of position.assetProfile.sectors) { + const { name, weight } = sector; + const value = + (isNumber(position.valueInBaseCurrency) + ? position.valueInBaseCurrency + : position.valueInPercentage) ?? 0; + + const sectorData = this.sectors[name]; + + if (sectorData) { + sectorData.value += weight * value; + } else { + this.sectors[name] = { + name: translate(name), + value: weight * value + }; } - } else { - this.sectors[UNKNOWN_KEY].value += isNumber( - position.valueInBaseCurrency - ) - ? this.portfolioDetails.holdings[symbol].valueInBaseCurrency - : this.portfolioDetails.holdings[symbol].valueInPercentage; + } + } else { + const value = + (isNumber(position.valueInBaseCurrency) + ? position.valueInBaseCurrency + : position.valueInPercentage) ?? 0; + + const sectorData = this.sectors[UNKNOWN_KEY]; + + if (sectorData) { + sectorData.value += value; } } @@ -462,25 +507,29 @@ export class GfAllocationsPageComponent implements OnInit { this.totalValueInEtf += this.holdings[symbol].value; } - this.symbols[prettifySymbol(symbol)] = { - dataSource: position.dataSource, - name: position.name, - symbol: prettifySymbol(symbol), - value: isNumber(position.valueInBaseCurrency) - ? position.valueInBaseCurrency - : position.valueInPercentage + this.symbols[symbol] = { + symbol, + dataSource: position.assetProfile.dataSource, + isClickable: canOpenHoldingDetail(position), + name: position.assetProfile.name ?? '', + value: + (isNumber(position.valueInBaseCurrency) + ? position.valueInBaseCurrency + : position.valueInPercentage) ?? 0 }; } this.markets = this.portfolioDetails.markets; - Object.values(this.portfolioDetails.marketsAdvanced).forEach( - ({ id, valueInBaseCurrency, valueInPercentage }) => { - this.marketsAdvanced[id].value = isNumber(valueInBaseCurrency) - ? valueInBaseCurrency - : valueInPercentage; - } - ); + if (this.portfolioDetails.marketsAdvanced) { + Object.values(this.portfolioDetails.marketsAdvanced).forEach( + ({ id, valueInBaseCurrency, valueInPercentage }) => { + this.marketsAdvanced[id].value = isNumber(valueInBaseCurrency) + ? valueInBaseCurrency + : valueInPercentage; + } + ); + } for (const [ id, @@ -489,7 +538,7 @@ export class GfAllocationsPageComponent implements OnInit { let value = 0; if (this.showValuesInPercentage()) { - value = valueInPercentage; + value = valueInPercentage ?? 0; } else { value = valueInBaseCurrency; } @@ -502,12 +551,11 @@ export class GfAllocationsPageComponent implements OnInit { } this.topHoldings = Object.values(this.topHoldingsMap) - .map(({ name, value }) => { + .map(({ name, value }): HoldingWithParents => { if (this.showValuesInPercentage()) { return { name, - allocationInPercentage: value, - valueInBaseCurrency: null + allocationInPercentage: value }; } @@ -517,8 +565,8 @@ export class GfAllocationsPageComponent implements OnInit { this.totalValueInEtf > 0 ? value / this.totalValueInEtf : 0, parents: Object.entries(this.portfolioDetails.holdings) .map(([symbol, holding]) => { - if (holding.holdings.length > 0) { - const currentParentHolding = holding.holdings.find( + if (holding.assetProfile.holdings.length > 0) { + const currentParentHolding = holding.assetProfile.holdings.find( (parentHolding) => { return ( this.normalizeAssetName(parentHolding.name) === @@ -527,13 +575,14 @@ export class GfAllocationsPageComponent implements OnInit { } ); - return currentParentHolding + return currentParentHolding && + isNumber(currentParentHolding.valueInBaseCurrency) ? { + symbol, allocationInPercentage: currentParentHolding.valueInBaseCurrency / value, - name: holding.name, + name: holding.assetProfile.name ?? '', position: holding, - symbol: prettifySymbol(symbol), valueInBaseCurrency: currentParentHolding.valueInBaseCurrency } @@ -571,31 +620,32 @@ export class GfAllocationsPageComponent implements OnInit { private openAccountDetailDialog(aAccountId: string) { const dialogRef = this.dialog.open< GfAccountDetailDialogComponent, - AccountDetailDialogParams + AccountDetailDialogParams, + AccountDetailDialogResult >(GfAccountDetailDialogComponent, { autoFocus: false, data: { accountId: aAccountId, - deviceType: this.deviceType, - hasImpersonationId: this.hasImpersonationId, + deviceType: this.deviceType(), hasPermissionToCreateActivity: - !this.hasImpersonationId && + !this.impersonationId && hasPermission(this.user?.permissions, permissions.createActivity) && - !this.user?.settings?.isRestrictedView + !this.user?.settings?.isRestrictedView, + impersonationId: this.impersonationId }, - height: this.deviceType === 'mobile' ? '98vh' : '80vh', - width: this.deviceType === 'mobile' ? '100vw' : '50rem' + height: this.deviceType() === 'mobile' ? '98vh' : '80vh', + width: this.deviceType() === 'mobile' ? '100vw' : '50rem' }); dialogRef .afterClosed() .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { - this.router.navigate(['.'], { relativeTo: this.route }); - }); - } + .subscribe((result) => { + if (result?.isNavigating) { + return; + } - public showValuesInPercentage() { - return this.hasImpersonationId || this.user?.settings?.isRestrictedView; + void this.router.navigate(['.'], { relativeTo: this.route }); + }); } } diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.html b/apps/client/src/app/pages/portfolio/allocations/allocations-page.html index a1000189b..902d04cde 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.html +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.html @@ -14,12 +14,9 @@ @@ -115,7 +112,7 @@ [isInPercentage]="showValuesInPercentage()" [keys]="['symbol']" [locale]="user?.settings?.locale" - [showLabels]="deviceType !== 'mobile'" + [showLabels]="deviceType() !== 'mobile'" (proportionChartClicked)="onSymbolChartClicked($event)" /> @@ -213,6 +210,7 @@ Developed MarketsEmerging MarketsOther MarketsNo data available; - public benchmarkDataItems: HistoricalDataItem[] = []; - public benchmarks: Partial[]; - public bottom3: PortfolioPosition[]; - public deviceType: string; - public dividendsByGroup: InvestmentItem[]; - public dividendTimelineDataLabel = $localize`Dividend`; - public firstOrderDate: Date; - public hasImpersonationId: boolean; - public hasPermissionToReadAiPrompt: boolean; - public investments: InvestmentItem[]; - public investmentTimelineDataLabel = $localize`Investment`; - public investmentsByGroup: InvestmentItem[]; - public isLoadingAnalysisPrompt: boolean; - public isLoadingBenchmarkComparator: boolean; - public isLoadingDividendTimelineChart: boolean; - public isLoadingInvestmentChart: boolean; - public isLoadingInvestmentTimelineChart: boolean; - public isLoadingPortfolioPrompt: boolean; - public mode: GroupBy = 'month'; - public modeOptions: ToggleOption[] = [ + protected benchmark?: Partial; + protected benchmarkDataItems: HistoricalDataItem[] = []; + protected readonly benchmarks: Partial[]; + protected bottom3: PortfolioPosition[]; + protected dividendsByGroup: InvestmentItem[]; + protected readonly dividendTimelineDataLabel = $localize`Dividend`; + protected hasPermissionToReadAiPrompt: boolean; + protected impersonationId: string | null; + protected investments: InvestmentItem[]; + protected readonly investmentTimelineDataLabel = $localize`Invested Capital`; + protected investmentsByGroup: InvestmentItem[]; + protected isLoadingAnalysisPrompt: boolean; + protected isLoadingBenchmarkComparator: boolean; + protected isLoadingDividendTimelineChart: boolean; + protected isLoadingInvestmentChart: boolean; + protected isLoadingInvestmentTimelineChart: boolean; + protected isLoadingPortfolioPrompt: boolean; + protected readonly mode = signal('month'); + protected readonly modeOptions: ToggleOption[] = [ { label: $localize`Monthly`, value: 'month' }, { label: $localize`Yearly`, value: 'year' } ]; - public performance: PortfolioPerformance; - public performanceDataItems: HistoricalDataItem[]; - public performanceDataItemsInPercentage: HistoricalDataItem[]; - public portfolioEvolutionDataLabel = $localize`Investment`; - public precision = 2; - public streaks: PortfolioInvestmentsResponse['streaks']; - public top3: PortfolioPosition[]; - public unitCurrentStreak: string; - public unitLongestStreak: string; - public user: User; - - public constructor( - private changeDetectorRef: ChangeDetectorRef, - private clipboard: Clipboard, - private dataService: DataService, - private destroyRef: DestroyRef, - private deviceDetectorService: DeviceDetectorService, - private impersonationStorageService: ImpersonationStorageService, - private snackBar: MatSnackBar, - private userService: UserService - ) { + protected performance: PortfolioPerformance; + protected performanceDataItems: HistoricalDataItem[]; + protected performanceDataItemsInPercentage: HistoricalDataItem[]; + protected readonly portfolioEvolutionDataLabel = $localize`Investment`; + protected precision = 2; + protected savingsRatePerMonth: number | undefined; + protected streaks: PortfolioInvestmentsResponse['streaks']; + protected top3: PortfolioPosition[]; + protected unitCurrentStreak: string; + protected unitLongestStreak: string; + protected user: User; + + private readonly actionsMenuButton = viewChild.required(MatMenuTrigger); + private readonly deviceType = computed( + () => this.deviceDetectorService.deviceInfo().deviceType + ); + private dateOfFirstActivity: Date; + + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly clipboard = inject(Clipboard); + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly deviceDetectorService = inject(DeviceDetectorService); + private readonly impersonationStorageService = inject( + ImpersonationStorageService + ); + private readonly snackBar = inject(MatSnackBar); + private readonly userService = inject(UserService); + + public constructor() { const { benchmarks } = this.dataService.fetchInfo(); this.benchmarks = benchmarks; @@ -118,24 +136,23 @@ export class GfAnalysisPageComponent implements OnInit { } get savingsRate() { - const savingsRatePerMonth = - this.hasImpersonationId || this.user.settings.isRestrictedView - ? undefined - : this.user?.settings?.savingsRate; - - return this.mode === 'year' - ? savingsRatePerMonth * 12 - : savingsRatePerMonth; + if (!this.savingsRatePerMonth) { + return undefined; + } + + return this.mode() === 'year' + ? this.savingsRatePerMonth * 12 + : this.savingsRatePerMonth; } public ngOnInit() { - this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; - this.impersonationStorageService .onChangeHasImpersonation() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((impersonationId) => { - this.hasImpersonationId = !!impersonationId; + this.impersonationId = impersonationId; + + this.changeDetectorRef.markForCheck(); }); this.userService.stateChanged @@ -155,10 +172,12 @@ export class GfAnalysisPageComponent implements OnInit { this.update(); } + + this.changeDetectorRef.markForCheck(); }); } - public onChangeBenchmark(symbolProfileId: string) { + protected onChangeBenchmark(symbolProfileId: string) { this.dataService .putUserSetting({ benchmark: symbolProfileId }) .pipe(takeUntilDestroyed(this.destroyRef)) @@ -174,12 +193,12 @@ export class GfAnalysisPageComponent implements OnInit { }); } - public onChangeGroupBy(aMode: GroupBy) { - this.mode = aMode; + protected onChangeGroupBy(aMode: GroupBy) { + this.mode.set(aMode); this.fetchDividendsAndInvestments(); } - public onCopyPromptToClipboard(mode: AiPromptMode) { + protected onCopyPromptToClipboard(mode: AiPromptMode) { if (mode === 'analysis') { this.isLoadingAnalysisPrompt = true; } else if (mode === 'portfolio') { @@ -210,16 +229,27 @@ export class GfAnalysisPageComponent implements OnInit { window.open('https://duck.ai', '_blank'); }); - this.actionsMenuButton.closeMenu(); + this.actionsMenuButton().closeMenu(); if (mode === 'analysis') { this.isLoadingAnalysisPrompt = false; } else if (mode === 'portfolio') { this.isLoadingPortfolioPrompt = false; } + + this.changeDetectorRef.markForCheck(); }); } + protected showValuesInPercentage() { + return ( + hasReadRestrictedAccessPermission({ + accesses: this.user?.access, + impersonationId: this.impersonationId + }) || this.user?.settings?.isRestrictedView + ); + } + private fetchDividendsAndInvestments() { this.isLoadingDividendTimelineChart = true; this.isLoadingInvestmentTimelineChart = true; @@ -227,8 +257,8 @@ export class GfAnalysisPageComponent implements OnInit { this.dataService .fetchDividends({ filters: this.userService.getFilters(), - groupBy: this.mode, - range: this.user?.settings?.dateRange + groupBy: this.mode(), + range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ dividends }) => { @@ -242,15 +272,16 @@ export class GfAnalysisPageComponent implements OnInit { this.dataService .fetchInvestments({ filters: this.userService.getFilters(), - groupBy: this.mode, - range: this.user?.settings?.dateRange + groupBy: this.mode(), + range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE }) .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(({ investments, streaks }) => { + .subscribe(({ investments, savingsRate, streaks }) => { this.investmentsByGroup = investments; + this.savingsRatePerMonth = savingsRate; this.streaks = streaks; this.unitCurrentStreak = - this.mode === 'year' + this.mode() === 'year' ? this.streaks?.currentStreak === 1 ? translate('YEAR') : translate('YEARS') @@ -258,7 +289,7 @@ export class GfAnalysisPageComponent implements OnInit { ? translate('MONTH') : translate('MONTHS'); this.unitLongestStreak = - this.mode === 'year' + this.mode() === 'year' ? this.streaks?.longestStreak === 1 ? translate('YEAR') : translate('YEARS') @@ -278,11 +309,11 @@ export class GfAnalysisPageComponent implements OnInit { this.dataService .fetchPortfolioPerformance({ filters: this.userService.getFilters(), - range: this.user?.settings?.dateRange + range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE }) .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(({ chart, firstOrderDate, performance }) => { - this.firstOrderDate = firstOrderDate ?? new Date(); + .subscribe(({ chart, dateOfFirstActivity, performance }) => { + this.dateOfFirstActivity = dateOfFirstActivity ?? new Date(); this.investments = []; this.performance = performance; @@ -298,13 +329,16 @@ export class GfAnalysisPageComponent implements OnInit { valueInPercentage, valueWithCurrencyEffect } - ] of chart.entries()) { + ] of (chart ?? []).entries()) { + // Ignore first item where value is 0 if (index > 0 || this.user?.settings?.dateRange === 'max') { - // Ignore first item where value is 0 - this.investments.push({ - date, - investment: totalInvestmentValueWithCurrencyEffect - }); + if (totalInvestmentValueWithCurrencyEffect !== undefined) { + this.investments.push({ + date, + investment: totalInvestmentValueWithCurrencyEffect + }); + } + this.performanceDataItems.push({ date, value: isNumber(valueWithCurrencyEffect) @@ -320,7 +354,7 @@ export class GfAnalysisPageComponent implements OnInit { } if ( - this.deviceType === 'mobile' && + this.deviceType() === 'mobile' && this.performance.currentValueInBaseCurrency >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES ) { @@ -342,8 +376,11 @@ export class GfAnalysisPageComponent implements OnInit { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ holdings }) => { const holdingsSorted = sortBy( - holdings.filter(({ netPerformancePercentWithCurrencyEffect }) => { - return isNumber(netPerformancePercentWithCurrencyEffect); + holdings.filter((holding) => { + return ( + canOpenHoldingDetail(holding) && + isNumber(holding.netPerformancePercentWithCurrencyEffect) + ); }), 'netPerformancePercentWithCurrencyEffect' ).reverse(); @@ -367,6 +404,7 @@ export class GfAnalysisPageComponent implements OnInit { }); this.fetchDividendsAndInvestments(); + this.changeDetectorRef.markForCheck(); } @@ -387,8 +425,8 @@ export class GfAnalysisPageComponent implements OnInit { dataSource, symbol, filters: this.userService.getFilters(), - range: this.user?.settings?.dateRange, - startDate: this.firstOrderDate + range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE, + startDate: this.dateOfFirstActivity }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ marketData }) => { diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html index 4c5c61bd8..82751b882 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -83,14 +83,11 @@ i18n size="large" [isCurrency]="true" + [isLoading]="isLoadingInvestmentChart" [locale]="user?.settings?.locale" [precision]="precision" [unit]="user?.settings?.baseCurrency" - [value]=" - isLoadingInvestmentChart - ? undefined - : performance?.currentValueInBaseCurrency - " + [value]="performance?.currentValueInBaseCurrency" >Total amount @@ -104,14 +101,11 @@ size="large" [colorizeSign]="true" [isCurrency]="true" + [isLoading]="isLoadingInvestmentChart" [locale]="user?.settings?.locale" [precision]="precision" [unit]="user?.settings?.baseCurrency" - [value]=" - isLoadingInvestmentChart - ? undefined - : performance?.netPerformanceWithCurrencyEffect - " + [value]="performance?.netPerformanceWithCurrencyEffect" >Change with currency effect @@ -124,13 +118,10 @@ i18n size="large" [colorizeSign]="true" + [isLoading]="isLoadingInvestmentChart" [isPercent]="true" [locale]="user?.settings?.locale" - [value]=" - isLoadingInvestmentChart - ? undefined - : performance?.netPerformancePercentageWithCurrencyEffect - " + [value]="performance?.netPerformancePercentageWithCurrencyEffect" >Performance with currency effect @@ -173,13 +164,10 @@ class="justify-content-end" position="end" [isCurrency]="true" + [isLoading]="isLoadingInvestmentChart" [locale]="user?.settings?.locale" [unit]="user?.settings?.baseCurrency" - [value]=" - isLoadingInvestmentChart - ? undefined - : performance?.netPerformance - " + [value]="performance?.netPerformance" />
    @@ -192,13 +180,10 @@ class="justify-content-end" position="end" [colorizeSign]="true" + [isLoading]="isLoadingInvestmentChart" [isPercent]="true" [locale]="user?.settings?.locale" - [value]=" - isLoadingInvestmentChart - ? undefined - : performance?.netPerformancePercentage - " + [value]="performance?.netPerformancePercentage" />
    @@ -216,15 +201,14 @@ class="justify-content-end" position="end" [isCurrency]="true" + [isLoading]="isLoadingInvestmentChart" [locale]="user?.settings?.locale" [unit]="user?.settings?.baseCurrency" [value]=" - isLoadingInvestmentChart - ? undefined - : performance?.netPerformance === null - ? null - : performance?.netPerformanceWithCurrencyEffect - - performance?.netPerformance + performance?.netPerformance === null + ? null + : performance?.netPerformanceWithCurrencyEffect - + performance?.netPerformance " /> @@ -238,15 +222,14 @@ class="justify-content-end" position="end" [colorizeSign]="true" + [isLoading]="isLoadingInvestmentChart" [isPercent]="true" [locale]="user?.settings?.locale" [value]=" - isLoadingInvestmentChart - ? undefined - : performance?.netPerformancePercentage === null - ? null - : performance?.netPerformancePercentageWithCurrencyEffect - - performance?.netPerformancePercentage + performance?.netPerformancePercentage === null + ? null + : performance?.netPerformancePercentageWithCurrencyEffect - + performance?.netPerformancePercentage " /> @@ -261,13 +244,10 @@ class="justify-content-end" position="end" [isCurrency]="true" + [isLoading]="isLoadingInvestmentChart" [locale]="user?.settings?.locale" [unit]="user?.settings?.baseCurrency" - [value]=" - isLoadingInvestmentChart - ? undefined - : performance?.netPerformanceWithCurrencyEffect - " + [value]="performance?.netPerformanceWithCurrencyEffect" /> @@ -280,12 +260,11 @@ class="justify-content-end" position="end" [colorizeSign]="true" + [isLoading]="isLoadingInvestmentChart" [isPercent]="true" [locale]="user?.settings?.locale" [value]=" - isLoadingInvestmentChart - ? undefined - : performance?.netPerformancePercentageWithCurrencyEffect + performance?.netPerformancePercentageWithCurrencyEffect " /> @@ -310,13 +289,15 @@ -
    {{ holding.name }}
    +
    + {{ holding.assetProfile.name }} +
    -
    {{ holding.name }}
    +
    + {{ holding.assetProfile.name }} +
    @@ -438,7 +419,7 @@
    diff --git a/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts b/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts index dc0a1d776..b7ef8b302 100644 --- a/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts +++ b/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts @@ -1,6 +1,7 @@ import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { SubscriptionType } from '@ghostfolio/common/enums'; +import { formatMonthAndYear } from '@ghostfolio/common/helper'; import { FireCalculationCompleteEvent, FireWealth, @@ -12,8 +13,9 @@ import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { DataService } from '@ghostfolio/ui/services'; import { GfValueComponent } from '@ghostfolio/ui/value'; -import { CommonModule, NgStyle } from '@angular/common'; +import { CommonModule } from '@angular/common'; import { + ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, @@ -29,13 +31,13 @@ import { DeviceDetectorService } from 'ngx-device-detector'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CommonModule, FormsModule, GfFireCalculatorComponent, GfPremiumIndicatorComponent, GfValueComponent, - NgStyle, NgxSkeletonLoaderModule, ReactiveFormsModule ], @@ -76,6 +78,20 @@ export class GfFirePageComponent implements OnInit { ); private readonly userService = inject(UserService); + protected get retirementDateLabel(): string { + const retirementDate = + this.user?.settings?.retirementDate ?? this.retirementDate; + + if (!retirementDate) { + return ''; + } + + return formatMonthAndYear({ + date: new Date(retirementDate), + locale: this.user?.settings?.locale + }); + } + public ngOnInit() { this.isLoading = true; @@ -90,6 +106,7 @@ export class GfFirePageComponent implements OnInit { : 0 } }; + if (this.user.subscription?.type === SubscriptionType.Basic) { this.fireWealth = { today: { @@ -108,6 +125,8 @@ export class GfFirePageComponent implements OnInit { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((impersonationId) => { this.hasImpersonationId = !!impersonationId; + + this.changeDetectorRef.markForCheck(); }); this.safeWithdrawalRateControl.valueChanges @@ -136,9 +155,9 @@ export class GfFirePageComponent implements OnInit { ); this.calculateWithdrawalRates(); - - this.changeDetectorRef.markForCheck(); } + + this.changeDetectorRef.markForCheck(); }); } diff --git a/apps/client/src/app/pages/portfolio/fire/fire-page.html b/apps/client/src/app/pages/portfolio/fire/fire-page.html index 76ad6cbf6..13693a15a 100644 --- a/apps/client/src/app/pages/portfolio/fire/fire-page.html +++ b/apps/client/src/app/pages/portfolio/fire/fire-page.html @@ -19,14 +19,15 @@ !hasImpersonationId && hasPermissionToUpdateUserSettings " [locale]="user?.settings?.locale" - [ngStyle]="{ - opacity: user?.subscription?.type === 'Basic' ? '0.67' : 'initial', - 'pointer-events': - user?.subscription?.type === 'Basic' ? 'none' : 'initial' - }" [projectedTotalAmount]="user?.settings?.projectedTotalAmount" [retirementDate]="user?.settings?.retirementDate" - [savingsRate]="user?.settings?.savingsRate" + [savingsRate]="hasImpersonationId ? 0 : user?.settings?.savingsRate" + [style.opacity]=" + user?.subscription?.type === 'Basic' ? '0.67' : 'initial' + " + [style.pointer-events]=" + user?.subscription?.type === 'Basic' ? 'none' : 'initial' + " (annualInterestRateChanged)="onAnnualInterestRateChange($event)" (calculationCompleted)="onCalculationComplete($event)" (projectedTotalAmountChanged)="onProjectedTotalAmountChange($event)" @@ -65,47 +66,38 @@
    If you retire today, you would be able to withdraw -   - -   - per year -   - or -   - -   - per monthIf you retire today, you would be able to withdraw + + per year + or + + per month, based on your total assets of + + and a safe withdrawal rate (SWR) of - , based on your total assets of -   - - -   - and a safe withdrawal rate (SWR) of @if ( !hasImpersonationId && hasPermissionToUpdateUserSettings && @@ -117,7 +109,7 @@ > @for (rate of safeWithdrawalRateOptions; track rate) { }. @@ -136,53 +128,40 @@ @if (user?.settings?.isExperimentalFeatures) {
    - By -   - {{ - user?.settings?.retirementDate ?? retirementDate - | date: 'MMMM yyyy' - }} - , -   - this is projected to increase to -   - -   - per year -   - or -   - -   - per monthBy {{ retirementDateLabel }}, this is projected to increase to + + per year + or + + per month, assuming a + + annual interest rate. - , assuming a -   - -   - annual interest rate.
    }
    diff --git a/apps/client/src/app/pages/portfolio/fire/fire-page.scss b/apps/client/src/app/pages/portfolio/fire/fire-page.scss index 3a0618ed6..6a5f037e2 100644 --- a/apps/client/src/app/pages/portfolio/fire/fire-page.scss +++ b/apps/client/src/app/pages/portfolio/fire/fire-page.scss @@ -29,5 +29,9 @@ @include select-arrow(variables.$light-primary-text); color: rgb(var(--light-primary-text)); + + option { + color: rgb(var(--dark-primary-text)); + } } } diff --git a/apps/client/src/app/pages/portfolio/portfolio-page.component.ts b/apps/client/src/app/pages/portfolio/portfolio-page.component.ts index 00fb3242b..f92165487 100644 --- a/apps/client/src/app/pages/portfolio/portfolio-page.component.ts +++ b/apps/client/src/app/pages/portfolio/portfolio-page.component.ts @@ -6,7 +6,12 @@ import { TabConfiguration } from '@ghostfolio/ui/page-tabs'; -import { ChangeDetectorRef, Component, DestroyRef } from '@angular/core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef +} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { addIcons } from 'ionicons'; import { @@ -18,7 +23,8 @@ import { } from 'ionicons/icons'; @Component({ - host: { class: 'page has-tabs' }, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'page' }, imports: [GfPageTabsComponent], selector: 'gf-portfolio-page', styleUrls: ['./portfolio-page.scss'], @@ -67,9 +73,9 @@ export class PortfolioPageComponent { } ]; this.user = state.user; - - this.changeDetectorRef.markForCheck(); } + + this.changeDetectorRef.markForCheck(); }); addIcons({ diff --git a/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts b/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts index c5c4fc979..6e162e11f 100644 --- a/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts +++ b/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts @@ -12,7 +12,12 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { DataService } from '@ghostfolio/ui/services'; -import { ChangeDetectorRef, Component, DestroyRef } from '@angular/core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef +} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { IonIcon } from '@ionic/angular/standalone'; import { addIcons } from 'ionicons'; @@ -24,6 +29,7 @@ import { import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ GfPremiumIndicatorComponent, GfRulesComponent, @@ -63,6 +69,8 @@ export class GfXRayPageComponent { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((impersonationId) => { this.hasImpersonationId = !!impersonationId; + + this.changeDetectorRef.markForCheck(); }); this.userService.stateChanged @@ -103,6 +111,8 @@ export class GfXRayPageComponent { private initializePortfolioReport() { this.isLoading = true; + this.changeDetectorRef.markForCheck(); + this.dataService .fetchPortfolioReport() .pipe(takeUntilDestroyed(this.destroyRef)) diff --git a/apps/client/src/app/pages/pricing/pricing-page.component.ts b/apps/client/src/app/pages/pricing/pricing-page.component.ts index a1fe0c0b5..8b4e07ffb 100644 --- a/apps/client/src/app/pages/pricing/pricing-page.component.ts +++ b/apps/client/src/app/pages/pricing/pricing-page.component.ts @@ -8,6 +8,7 @@ import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { DataService } from '@ghostfolio/ui/services'; import { + ChangeDetectionStrategy, ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA, @@ -32,6 +33,7 @@ import { EMPTY } from 'rxjs'; import { catchError } from 'rxjs/operators'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [ GfPremiumIndicatorComponent, @@ -68,20 +70,6 @@ export class GfPricingPageComponent implements OnInit { 'PROFESSIONAL_DATA_PROVIDER_TOOLTIP_PREMIUM' ); - protected readonly referralBrokers = [ - 'Alpian', - 'DEGIRO', - 'finpension', - 'frankly', - 'Interactive Brokers', - 'Mintos', - 'Monefit SmartSaver', - 'Revolut', - 'Swissquote', - 'VIAC', - 'Zak' - ] as const; - protected readonly routerLinkFeatures = publicRoutes.features.routerLink; protected readonly routerLinkRegister = publicRoutes.register.routerLink; protected user: User; @@ -137,9 +125,9 @@ export class GfPricingPageComponent implements OnInit { this.label = this.user?.subscription?.offer?.label; this.price = this.user?.subscription?.offer?.price; this.priceId = this.user?.subscription?.offer?.priceId; - - this.changeDetectorRef.markForCheck(); } + + this.changeDetectorRef.markForCheck(); }); } diff --git a/apps/client/src/app/pages/pricing/pricing-page.html b/apps/client/src/app/pages/pricing/pricing-page.html index b951baa98..ddd92892a 100644 --- a/apps/client/src/app/pages/pricing/pricing-page.html +++ b/apps/client/src/app/pages/pricing/pricing-page.html @@ -305,23 +305,23 @@
    - @if (user?.subscription?.type === 'Basic') { + @if (user?.referralPartners?.length) {

    If you plan to open an account at   @for ( - broker of referralBrokers; - track broker; + partner of user.referralPartners; + track partner.name; let i = $index; let last = $last ) { - {{ broker }} + {{ partner.name }} @if (last) { , } @else { - @if (i === referralBrokers.length - 2) { + @if (i === user.referralPartners.length - 2) {   or   @@ -332,7 +332,7 @@ } please   - contact us   @@ -343,7 +343,7 @@   Request it   - here + here   with your university e-mail address.

    diff --git a/apps/client/src/app/pages/public/public-page.component.ts b/apps/client/src/app/pages/public/public-page.component.ts index c12a2d5de..e354f8a1b 100644 --- a/apps/client/src/app/pages/public/public-page.component.ts +++ b/apps/client/src/app/pages/public/public-page.component.ts @@ -1,5 +1,5 @@ import { UNKNOWN_KEY } from '@ghostfolio/common/config'; -import { prettifySymbol } from '@ghostfolio/common/helper'; +import { getCountryName } from '@ghostfolio/common/helper'; import { InfoItem, PortfolioPosition, @@ -9,6 +9,7 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { Market } from '@ghostfolio/common/types'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table/activities-table.component'; import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table/holdings-table.component'; +import { translate } from '@ghostfolio/ui/i18n'; import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.component'; import { DataService } from '@ghostfolio/ui/services'; import { GfValueComponent } from '@ghostfolio/ui/value'; @@ -16,6 +17,7 @@ import { GfWorldMapChartComponent } from '@ghostfolio/ui/world-map-chart'; import { HttpErrorResponse } from '@angular/common/http'; import { + ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, @@ -37,6 +39,7 @@ import { EMPTY } from 'rxjs'; import { catchError } from 'rxjs/operators'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [ GfActivitiesTableComponent, @@ -66,6 +69,7 @@ export class GfPublicPageComponent implements OnInit { protected hasPermissionForSubscription: boolean; protected holdings: PublicPortfolioResponse['holdings'][string][]; protected info: InfoItem; + protected isLoading = true; protected latestActivitiesDataSource: MatTableDataSource< PublicPortfolioResponse['latestActivities'][0] >; @@ -74,7 +78,10 @@ export class GfPublicPageComponent implements OnInit { }; protected readonly pageSize = Number.MAX_SAFE_INTEGER; protected positions: { - [symbol: string]: Pick & { + [symbol: string]: Pick< + PortfolioPosition['assetProfile'], + 'currency' | 'name' + > & { value: number; }; }; @@ -132,6 +139,8 @@ export class GfPublicPageComponent implements OnInit { this.publicPortfolioDetails.latestActivities ); + this.isLoading = false; + this.changeDetectorRef.markForCheck(); }); } @@ -172,24 +181,24 @@ export class GfPublicPageComponent implements OnInit { this.holdings.push(position); this.positions[symbol] = { - currency: position.currency, - name: position.name, + currency: position.assetProfile.currency, + name: position.assetProfile.name, value: position.allocationInPercentage }; - if (position.assetClass !== AssetClass.LIQUIDITY) { + if (position.assetProfile.assetClass !== AssetClass.LIQUIDITY) { // Prepare analysis data by continents, countries, holdings and sectors except for liquidity - if (position.countries.length > 0) { - for (const country of position.countries) { - const { code, continent, name, weight } = country; + if (position.assetProfile.countries.length > 0) { + for (const country of position.assetProfile.countries) { + const { code, continent, weight } = country; if (this.continents[continent]?.value) { this.continents[continent].value += weight * (position.valueInBaseCurrency ?? 0); } else { this.continents[continent] = { - name: continent, + name: translate(continent), value: weight * (this.publicPortfolioDetails.holdings[symbol] @@ -202,7 +211,7 @@ export class GfPublicPageComponent implements OnInit { weight * (position.valueInBaseCurrency ?? 0); } else { this.countries[code] = { - name, + name: getCountryName({ code }), value: weight * (this.publicPortfolioDetails.holdings[symbol] @@ -220,8 +229,8 @@ export class GfPublicPageComponent implements OnInit { 0; } - if (position.sectors.length > 0) { - for (const sector of position.sectors) { + if (position.assetProfile.sectors.length > 0) { + for (const sector of position.assetProfile.sectors) { const { name, weight } = sector; if (this.sectors[name]?.value) { @@ -229,7 +238,7 @@ export class GfPublicPageComponent implements OnInit { weight * (position.valueInBaseCurrency ?? 0); } else { this.sectors[name] = { - name, + name: translate(name), value: weight * (this.publicPortfolioDetails.holdings[symbol] @@ -244,9 +253,9 @@ export class GfPublicPageComponent implements OnInit { } } - this.symbols[prettifySymbol(symbol)] = { - name: position.name, - symbol: prettifySymbol(symbol), + this.symbols[symbol] = { + symbol, + name: position.assetProfile.name ?? symbol, value: isNumber(position.valueInBaseCurrency) ? position.valueInBaseCurrency : (position.valueInPercentage ?? 0) diff --git a/apps/client/src/app/pages/public/public-page.html b/apps/client/src/app/pages/public/public-page.html index 57bc1e95f..e0e855585 100644 --- a/apps/client/src/app/pages/public/public-page.html +++ b/apps/client/src/app/pages/public/public-page.html @@ -15,11 +15,11 @@ i18n size="large" [colorizeSign]="true" + [isLoading]="isLoading" [isPercent]="true" [precision]="2" [value]=" - publicPortfolioDetails?.performance?.['1d']?.relativeChange ?? - undefined + publicPortfolioDetails?.performance?.['1d']?.relativeChange " >Today @@ -33,11 +33,11 @@ i18n size="large" [colorizeSign]="true" + [isLoading]="isLoading" [isPercent]="true" [precision]="2" [value]=" - publicPortfolioDetails?.performance?.['ytd']?.relativeChange ?? - undefined + publicPortfolioDetails?.performance?.['ytd']?.relativeChange " >This year @@ -51,11 +51,11 @@ i18n size="large" [colorizeSign]="true" + [isLoading]="isLoading" [isPercent]="true" [precision]="2" [value]=" - publicPortfolioDetails?.performance?.['max']?.relativeChange ?? - undefined + publicPortfolioDetails?.performance?.['max']?.relativeChange " >From the beginning diff --git a/apps/client/src/app/pages/register/register-page.component.ts b/apps/client/src/app/pages/register/register-page.component.ts index ecc83d8f3..6a4825c4d 100644 --- a/apps/client/src/app/pages/register/register-page.component.ts +++ b/apps/client/src/app/pages/register/register-page.component.ts @@ -6,6 +6,7 @@ import { GfLogoComponent } from '@ghostfolio/ui/logo'; import { DataService } from '@ghostfolio/ui/services'; import { + ChangeDetectionStrategy, Component, CUSTOM_ELEMENTS_SCHEMA, DestroyRef, @@ -21,6 +22,7 @@ import { UserAccountRegistrationDialogParams } from './user-account-registration import { GfUserAccountRegistrationDialogComponent } from './user-account-registration-dialog/user-account-registration-dialog.component'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [GfLogoComponent, MatButtonModule, RouterModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], diff --git a/apps/client/src/app/pages/register/user-account-registration-dialog/user-account-registration-dialog.component.ts b/apps/client/src/app/pages/register/user-account-registration-dialog/user-account-registration-dialog.component.ts index cbbe2d29c..0265357bf 100644 --- a/apps/client/src/app/pages/register/user-account-registration-dialog/user-account-registration-dialog.component.ts +++ b/apps/client/src/app/pages/register/user-account-registration-dialog/user-account-registration-dialog.component.ts @@ -9,8 +9,8 @@ import { Component, CUSTOM_ELEMENTS_SCHEMA, DestroyRef, - Inject, - ViewChild + inject, + viewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; @@ -53,26 +53,28 @@ import { UserAccountRegistrationDialogParams } from './interfaces/interfaces'; templateUrl: 'user-account-registration-dialog.html' }) export class GfUserAccountRegistrationDialogComponent { - @ViewChild(MatStepper) stepper!: MatStepper; + protected readonly stepper = viewChild.required(MatStepper); - public accessToken: string; - public authToken: string; - public isCreateAccountButtonDisabled = true; - public isDisclaimerChecked = false; - public role: string; - public routerLinkAboutTermsOfService = + protected accessToken: string | undefined; + protected authToken: string; + protected isCreateAccountButtonDisabled = true; + protected isDisclaimerChecked = false; + protected role: string; + protected readonly routerLinkAboutTermsOfService = publicRoutes.about.subRoutes.termsOfService.routerLink; - public constructor( - private changeDetectorRef: ChangeDetectorRef, - @Inject(MAT_DIALOG_DATA) public data: UserAccountRegistrationDialogParams, - private dataService: DataService, - private destroyRef: DestroyRef - ) { + protected readonly data = + inject(MAT_DIALOG_DATA); + + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + + public constructor() { addIcons({ arrowForwardOutline, checkmarkOutline, copyOutline }); } - public createAccount() { + protected createAccount() { this.dataService .postUser() .pipe(takeUntilDestroyed(this.destroyRef)) @@ -81,17 +83,17 @@ export class GfUserAccountRegistrationDialogComponent { this.authToken = authToken; this.role = role; - this.stepper.next(); + this.stepper().next(); this.changeDetectorRef.markForCheck(); }); } - public enableCreateAccountButton() { + protected enableCreateAccountButton() { this.isCreateAccountButtonDisabled = false; } - public onChangeDislaimerChecked() { + protected onChangeDislaimerChecked() { this.isDisclaimerChecked = !this.isDisclaimerChecked; } } diff --git a/apps/client/src/app/pages/resources/glossary/resources-glossary.component.ts b/apps/client/src/app/pages/resources/glossary/resources-glossary.component.ts index 112619239..d0aa5e923 100644 --- a/apps/client/src/app/pages/resources/glossary/resources-glossary.component.ts +++ b/apps/client/src/app/pages/resources/glossary/resources-glossary.component.ts @@ -3,10 +3,11 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { DataService } from '@ghostfolio/ui/services'; -import { Component, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, imports: [RouterModule], selector: 'gf-resources-glossary', styleUrls: ['./resources-glossary.component.scss'], diff --git a/apps/client/src/app/pages/resources/guides/resources-guides.component.ts b/apps/client/src/app/pages/resources/guides/resources-guides.component.ts index 52c317cea..9f2556195 100644 --- a/apps/client/src/app/pages/resources/guides/resources-guides.component.ts +++ b/apps/client/src/app/pages/resources/guides/resources-guides.component.ts @@ -1,7 +1,8 @@ -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, imports: [RouterModule], selector: 'gf-resources-guides', styleUrls: ['./resources-guides.component.scss'], diff --git a/apps/client/src/app/pages/resources/markets/resources-markets.component.ts b/apps/client/src/app/pages/resources/markets/resources-markets.component.ts index 79c185959..71660504a 100644 --- a/apps/client/src/app/pages/resources/markets/resources-markets.component.ts +++ b/apps/client/src/app/pages/resources/markets/resources-markets.component.ts @@ -1,6 +1,7 @@ -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'gf-resources-markets', styleUrls: ['./resources-markets.component.scss'], templateUrl: './resources-markets.component.html' diff --git a/apps/client/src/app/pages/resources/overview/resources-overview.component.ts b/apps/client/src/app/pages/resources/overview/resources-overview.component.ts index 81338200f..7830b2eec 100644 --- a/apps/client/src/app/pages/resources/overview/resources-overview.component.ts +++ b/apps/client/src/app/pages/resources/overview/resources-overview.component.ts @@ -1,9 +1,10 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, imports: [RouterModule], selector: 'gf-resources-overview', styleUrls: ['./resources-overview.component.scss'], diff --git a/apps/client/src/app/pages/resources/personal-finance-tools/interfaces/interfaces.ts b/apps/client/src/app/pages/resources/personal-finance-tools/interfaces/interfaces.ts new file mode 100644 index 000000000..738719f0a --- /dev/null +++ b/apps/client/src/app/pages/resources/personal-finance-tools/interfaces/interfaces.ts @@ -0,0 +1,6 @@ +import type { Product } from '@ghostfolio/common/interfaces'; + +export type ResolvedProduct = Omit & { + categories?: string[]; + platforms?: string[]; +}; diff --git a/apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page.component.ts b/apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page.component.ts index bb4ae3889..27a4f3265 100644 --- a/apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page.component.ts +++ b/apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page.component.ts @@ -1,7 +1,7 @@ import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools'; import { publicRoutes } from '@ghostfolio/common/routes/routes'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatCardModule } from '@angular/material/card'; import { RouterModule } from '@angular/router'; import { IonIcon } from '@ionic/angular/standalone'; @@ -9,6 +9,7 @@ import { addIcons } from 'ionicons'; import { chevronForwardOutline } from 'ionicons/icons'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [IonIcon, MatCardModule, RouterModule], selector: 'gf-personal-finance-tools-page', diff --git a/apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page.html b/apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page.html index 0e9bb8785..505b94dd7 100644 --- a/apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page.html +++ b/apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page.html @@ -28,6 +28,7 @@
    { const { subscriptionOffer } = this.dataService.fetchInfo(); + return subscriptionOffer?.price; + }); - this.price = subscriptionOffer?.price; + protected readonly product1 = computed(() => ({ + categories: this.getSortedTranslations([ + 'FINANCIAL_PLANNING', + 'NET_WORTH_TRACKING', + 'STOCK_TRACKING' + ]), + founded: 2021, + hasFreePlan: true, + hasSelfHostingAbility: true, + isOpenSource: true, + key: 'ghostfolio', + languages: [ + 'Chinese (简体中文)', + 'Deutsch', + 'English', + 'Español', + 'Français', + 'Italiano', + // 'Japanese (日本語)', + 'Korean (한국어)', + 'Nederlands', + 'Português', + 'Türkçe' + ], + name: 'Ghostfolio', + origin: getCountryName({ code: 'CH' }), + platforms: this.getSortedTranslations(['ANDROID', 'WEB']), + regions: [$localize`Global`], + slogan: 'Open Source Wealth Management', + useAnonymously: true + })); - this.product1 = { - founded: 2021, - hasFreePlan: true, - hasSelfHostingAbility: true, - isOpenSource: true, - key: 'ghostfolio', - languages: [ - 'Chinese (简体中文)', - 'Deutsch', - 'English', - 'Español', - 'Français', - 'Italiano', - 'Korean (한국어)', - 'Nederlands', - 'Português', - 'Türkçe' - ], - name: 'Ghostfolio', - origin: $localize`Switzerland`, - regions: [$localize`Global`], - slogan: 'Open Source Wealth Management', - useAnonymously: true - }; - - this.product2 = personalFinanceTools.find(({ key }) => { + protected readonly product2 = computed(() => { + const product = personalFinanceTools.find(({ key }) => { return key === this.route.snapshot.data['key']; }); - if (this.product2.origin) { - this.product2.origin = translate(this.product2.origin); + const mappedProduct: ResolvedProduct = { + key: product?.key ?? '', + name: product?.name ?? '', + ...product, + categories: this.getSortedTranslations(product?.categories), + platforms: this.getSortedTranslations(product?.platforms) + }; + + if (mappedProduct.origin) { + mappedProduct.origin = getCountryName({ code: mappedProduct.origin }); } - if (this.product2.regions) { - this.product2.regions = this.product2.regions.map((region) => { - return translate(region); + if (mappedProduct.regions) { + mappedProduct.regions = mappedProduct.regions.map((region) => { + return region === 'Global' + ? translate(region) + : getCountryName({ code: region }); }); } - this.tags = [ - this.product1.name, - this.product1.origin, - this.product2.name, - this.product2.origin, - $localize`Alternative`, - $localize`App`, - $localize`Budgeting`, - $localize`Community`, - $localize`Family Office`, - `Fintech`, - $localize`Investment`, - $localize`Investor`, - $localize`Open Source`, - `OSS`, - $localize`Personal Finance`, - $localize`Privacy`, - $localize`Portfolio`, - $localize`Software`, - $localize`Tool`, - $localize`User Experience`, - $localize`Wealth`, - $localize`Wealth Management`, - `WealthTech` - ] - .filter((item) => { - return !!item; + return mappedProduct; + }); + + protected readonly routerLinkAbout = publicRoutes.about.routerLink; + protected readonly routerLinkFeatures = publicRoutes.features.routerLink; + protected readonly routerLinkResourcesPersonalFinanceTools = + publicRoutes.resources.subRoutes.personalFinanceTools.routerLink; + + protected readonly tags = computed(() => { + const product1 = this.product1(); + const product2 = this.product2(); + + return Array.from( + new Set( + [ + ...[product1, product2].flatMap( + ({ categories, name, origin, platforms }) => { + return [ + ...(categories ?? []), + ...(platforms ?? []), + name, + origin + ]; + } + ), + $localize`Alternative`, + $localize`App`, + $localize`Community`, + `Fintech`, + $localize`Investment`, + $localize`Investor`, + $localize`Open Source`, + `OSS`, + $localize`Personal Finance`, + $localize`Portfolio`, + $localize`Privacy`, + $localize`Software`, + $localize`Tool`, + $localize`User Experience`, + $localize`Wealth`, + `WealthTech` + ].filter((item): item is string => { + return !!item; + }) + ) + ).sort((a, b) => { + return a.localeCompare(b, undefined, { sensitivity: 'base' }); + }); + }); + + private readonly dataService = inject(DataService); + private readonly route = inject(ActivatedRoute); + + private getSortedTranslations(values?: string[]) { + return values + ?.map((value) => { + return translate(value); }) .sort((a, b) => { return a.localeCompare(b, undefined, { sensitivity: 'base' }); diff --git a/apps/client/src/app/pages/resources/personal-finance-tools/product-page.html b/apps/client/src/app/pages/resources/personal-finance-tools/product-page.html index a71ca0038..192b3cadb 100644 --- a/apps/client/src/app/pages/resources/personal-finance-tools/product-page.html +++ b/apps/client/src/app/pages/resources/personal-finance-tools/product-page.html @@ -6,10 +6,10 @@

    Ghostfolio: The Open Source Alternative to {{ product2.name }} + > {{ product2().name }}

    - @if (product2.isArchived) { + @if (product2().isArchived) {
    This page has been archived.
    @@ -17,7 +17,7 @@

    Are you looking for an open source alternative to - {{ product2.name }}? + {{ product2().name }}? Ghostfolio is a powerful portfolio management tool that provides individuals with a comprehensive platform to track, analyze, and optimize their @@ -31,7 +31,7 @@

    Ghostfolio is an open source software (OSS), providing a - cost-effective alternative to {{ product2.name }} making it + cost-effective alternative to {{ product2().name }} making it particularly suitable for individuals on a tight budget, such as those

    Let’s dive deeper into the detailed Ghostfolio vs - {{ product2.name }} comparison table below to gain a thorough + {{ product2().name }} comparison table below to gain a thorough understanding of how Ghostfolio positions itself relative to - {{ product2.name }}. We will explore various aspects such as + {{ product2().name }}. We will explore various aspects such as features, data privacy, pricing, and more, allowing you to make a well-informed choice for your personal requirements.

    @@ -54,7 +54,7 @@ Ghostfolio vs {{ - product2.name + product2().name }} comparison table @@ -63,31 +63,52 @@ Ghostfolio - {{ product2.name }} + {{ product2().name }} - {{ product1.slogan }} - {{ product2.slogan }} + {{ product1().slogan }} + {{ product2().slogan }} + + + Category + + @for ( + category of product1().categories; + track category; + let isLast = $last + ) { + {{ category }}{{ isLast ? '' : ', ' }} + } + + + @for ( + category of product2().categories; + track category; + let isLast = $last + ) { + {{ category }}{{ isLast ? '' : ', ' }} + } + Founded - {{ product1.founded }} - {{ product2.founded }} + {{ product1().founded }} + {{ product2().founded }} Origin - {{ product1.origin }} - {{ product2.origin }} + {{ product1().origin }} + {{ product2().origin }} Region @for ( - region of product1.regions; + region of product1().regions; track region; let isLast = $last ) { @@ -96,7 +117,7 @@ @for ( - region of product2.regions; + region of product2().regions; track region; let isLast = $last ) { @@ -104,13 +125,36 @@ } + + + Available on + + + @for ( + platform of product1().platforms; + track platform; + let isLast = $last + ) { + {{ platform }}{{ isLast ? '' : ', ' }} + } + + + @for ( + platform of product2().platforms; + track platform; + let isLast = $last + ) { + {{ platform }}{{ isLast ? '' : ', ' }} + } + + Available in @for ( - language of product1.languages; + language of product1().languages; track language; let isLast = $last ) { @@ -119,7 +163,7 @@ @for ( - language of product2.languages; + language of product2().languages; track language; let isLast = $last ) { @@ -132,35 +176,35 @@ Open Source Software - @if (product1.isOpenSource) { + @if (product1().isOpenSource) { ✅ Yes } @else { ❌ No } - @if (product2.isOpenSource) { + @if (product2().isOpenSource) { ✅ Yes } @else { ❌ No } @@ -171,35 +215,35 @@ Self-Hosting - @if (product1.hasSelfHostingAbility === true) { + @if (product1().hasSelfHostingAbility === true) { ✅ Yes - } @else if (product1.hasSelfHostingAbility === false) { + } @else if (product1().hasSelfHostingAbility === false) { ❌ No } - @if (product2.hasSelfHostingAbility === true) { + @if (product2().hasSelfHostingAbility === true) { ✅ Yes - } @else if (product2.hasSelfHostingAbility === false) { + } @else if (product2().hasSelfHostingAbility === false) { ❌ No } @@ -210,35 +254,35 @@ Use anonymously - @if (product1.useAnonymously === true) { + @if (product1().useAnonymously === true) { ✅ Yes - } @else if (product1.useAnonymously === false) { + } @else if (product1().useAnonymously === false) { ❌ No } - @if (product2.useAnonymously === true) { + @if (product2().useAnonymously === true) { ✅ Yes - } @else if (product2.useAnonymously === false) { + } @else if (product2().useAnonymously === false) { ❌ No } @@ -249,35 +293,35 @@ Free Plan - @if (product1.hasFreePlan === true) { + @if (product1().hasFreePlan === true) { ✅ Yes - } @else if (product1.hasFreePlan === false) { + } @else if (product1().hasFreePlan === false) { ❌ No } - @if (product2.hasFreePlan === true) { + @if (product2().hasFreePlan === true) { ✅ Yes - } @else if (product2.hasFreePlan === false) { + } @else if (product2().hasFreePlan === false) { ❌ No } @@ -286,22 +330,22 @@ Pricing - Starting from ${{ price }} / + Starting from ${{ price() }} / year - @if (product2.pricingPerYear) { + @if (product2().pricingPerYear) { Starting from - {{ product2.pricingPerYear }} / + {{ product2().pricingPerYear }} / year } - @if (product1.note || product2.note) { + @if (product1().note || product2().note) { Notes - {{ product1.note }} - {{ product2.note }} + {{ product1().note }} + {{ product2().note }} } @@ -310,9 +354,9 @@

    Please note that the information provided in the Ghostfolio vs - {{ product2.name }} comparison table is based on our independent + {{ product2().name }} comparison table is based on our independent research and analysis. This website is not affiliated with - {{ product2.name }} or any other product mentioned in the + {{ product2().name }} or any other product mentioned in the comparison. As the landscape of personal finance tools evolves, it is essential to verify any specific details or changes directly from the respective product page. Data needs a refresh? Help us maintain @@ -337,7 +381,7 @@