diff --git a/.admin.cred b/.admin.cred new file mode 100644 index 000000000..53cce50db --- /dev/null +++ b/.admin.cred @@ -0,0 +1 @@ +14d4daf73eefed7da7c32ec19bc37e678be0244fb46c8f4965bfe9ece7384706ed58222ad9b96323893c1d845bc33a308e7524c2c79636062cbb095e0780cb51 \ No newline at end of file diff --git a/.env b/.env.example similarity index 84% rename from .env rename to .env.example index 4f6dc7cd1..8df547e37 100644 --- a/.env +++ b/.env.example @@ -11,6 +11,5 @@ POSTGRES_USER=user POSTGRES_PASSWORD= ACCESS_TOKEN_SALT= -ALPHA_VANTAGE_API_KEY= -DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer +DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer JWT_SECRET_KEY= diff --git a/.eslintrc.json b/.eslintrc.json index 6dd74ced7..33e0aafa1 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,12 +1,12 @@ { "root": true, "ignorePatterns": ["**/*"], - "plugins": ["@nrwl/nx"], + "plugins": ["@nx"], "overrides": [ { "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], "rules": { - "@nrwl/nx/enforce-module-boundaries": [ + "@nx/enforce-module-boundaries": [ "error", { "enforceBuildableLibDependency": true, @@ -23,12 +23,12 @@ }, { "files": ["*.ts", "*.tsx"], - "extends": ["plugin:@nrwl/nx/typescript"], + "extends": ["plugin:@nx/typescript"], "rules": {} }, { "files": ["*.js", "*.jsx"], - "extends": ["plugin:@nrwl/nx/javascript"], + "extends": ["plugin:@nx/javascript"], "rules": {} }, { @@ -113,5 +113,6 @@ "radix": "error" } } - ] + ], + "extends": [null, "plugin:storybook/recommended"] } diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index ffee37bee..0d93138f6 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -6,7 +6,7 @@ labels: '' assignees: '' --- -The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed on our [Slack channel](https://ghostfolio.slack.com) or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions). +The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions). **Bug Description** @@ -36,6 +36,7 @@ The Issue tracker is **ONLY** used for reporting bugs. New features should be di +- Cloud or Self-hosted - Ghostfolio Version X.Y.Z - Browser - OS diff --git a/.github/workflows/build-code.yml b/.github/workflows/build-code.yml index c1e950ba7..26ac7226b 100644 --- a/.github/workflows/build-code.yml +++ b/.github/workflows/build-code.yml @@ -10,7 +10,7 @@ jobs: strategy: matrix: node_version: - - 16 + - 18 steps: - name: Checkout code uses: actions/checkout@v3 @@ -33,4 +33,4 @@ jobs: run: yarn test - name: Build application - run: yarn build:all + run: yarn build:production diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 6b85f639b..bf533d836 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -6,7 +6,7 @@ on: - '*.*.*' pull_request: branches: - - 'main' + - 'dockerpush' jobs: build_and_push: @@ -19,7 +19,7 @@ jobs: id: meta uses: docker/metadata-action@v4 with: - images: ghostfolio/ghostfolio + images: dandevaud/ghostfolio tags: | type=semver,pattern={{version}} @@ -41,7 +41,7 @@ jobs: uses: docker/build-push-action@v3 with: context: . - platforms: linux/amd64,linux/arm64 + platforms: linux/amd64,linux/arm/v7,linux/arm64 push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.output.labels }} diff --git a/.gitignore b/.gitignore index b91474588..307d7e9e2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ /tmp # dependencies +/.yarn /node_modules # IDEs and editors @@ -24,6 +25,7 @@ # misc /.angular/cache +.env .env.prod /.sass-cache /connect.lock diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..3f430af82 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v18 diff --git a/.prettierrc b/.prettierrc index 30f191d91..6a8ad9afa 100644 --- a/.prettierrc +++ b/.prettierrc @@ -9,6 +9,7 @@ ], "attributeSort": "ASC", "endOfLine": "auto", + "plugins": ["prettier-plugin-organize-attributes"], "printWidth": 80, "singleQuote": true, "tabWidth": 2, diff --git a/.storybook/main.js b/.storybook/main.js deleted file mode 100644 index 9b1beed01..000000000 --- a/.storybook/main.js +++ /dev/null @@ -1,11 +0,0 @@ -module.exports = { - stories: [], - addons: ['@storybook/addon-essentials'] - // uncomment the property below if you want to apply some webpack config globally - // webpackFinal: async (config, { configType }) => { - // // Make whatever fine-grained changes you need that should apply to all storybook configs - - // // Return the altered config - // return config; - // }, -}; diff --git a/.storybook/tsconfig.json b/.storybook/tsconfig.json deleted file mode 100644 index 4b1101543..000000000 --- a/.storybook/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../tsconfig.base.json", - "exclude": [ - "../**/*.spec.js", - "../**/*.spec.ts", - "../**/*.spec.tsx", - "../**/*.spec.jsx" - ], - "include": ["../**/*"] -} diff --git a/.vscode/launch.json b/.vscode/launch.json index fa7c688b8..c1f19e7f0 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,32 +2,33 @@ "version": "0.2.0", "configurations": [ { - "name": "Debug Jest File", - "type": "node", - "request": "launch", - "program": "${workspaceFolder}/node_modules/@angular/cli/bin/ng", "args": [ "test", "--codeCoverage=false", - "--testFile=${workspaceFolder}/apps/api/src/models/portfolio.spec.ts" + "--testFile=${workspaceFolder}/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts" ], + "console": "internalConsole", "cwd": "${workspaceFolder}", - "console": "internalConsole" + "name": "Debug Jest", + "program": "${workspaceFolder}/node_modules/@nrwl/cli/bin/nx", + "request": "launch", + "type": "node" }, { + "autoAttachChildProcesses": true, + "console": "integratedTerminal", + "cwd": "${workspaceFolder}/apps/api", "envFile": "${workspaceFolder}/.env", - "type": "node", - "request": "launch", - "name": "Launch Program", + "name": "Debug API", + "outFiles": ["${workspaceFolder}/dist/apps/api/**/*.js"], "program": "${workspaceFolder}/apps/api/src/main.ts", + "request": "launch", "runtimeArgs": ["--nolazy", "-r", "ts-node/register"], - "outFiles": ["${workspaceFolder}/dist/apps/api/**/*.js"], - "autoAttachChildProcesses": true, "skipFiles": [ "${workspaceFolder}/node_modules/**/*.js", "/**/*.js" ], - "console": "integratedTerminal" + "type": "node" } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 79169e6a0..07b89aea6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,1516 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## 2.9.0 - 2023-10-08 + +### Added + +- Added support to search for a holding by `isin`, `name` and `symbol` (experimental) +- Added support for notes in the activities import +- Added support to search in the platform selector of the create or update account dialog +- Added support for a search query in the portfolio position endpoint +- Added the application version to the endpoint `GET api/v1/admin` +- Introduced a carousel component for the testimonial section on the landing page + +### Changed + +- Displayed the link to the markets overview on the home page without any permission + +### Fixed + +- Fixed the style of the active features page in the navigation on desktop + +## 2.8.0 - 2023-10-03 + +### Added + +- Supported enter key press to submit the form of the create or update account dialog +- Added the application version to the admin control panel +- Added pagination parameters (`skip`, `take`) to the endpoint `GET api/v1/order` + +### Changed + +- Harmonized the settings icon of the user account page +- Improved the usability to set an asset profile as a benchmark +- Reload platforms after making a change in the admin control panel +- Reload tags after making a change in the admin control panel + +### Fixed + +- Fixed the sidebar navigation on the user account page + +## 2.7.0 - 2023-09-30 + +### Added + +- Added a new static portfolio analysis rule: Emergency fund setup +- Added tabs to the user account page + +### Changed + +- Set up the _Inter_ font family +- Upgraded `yahoo-finance2` from version `2.7.0` to `2.8.0` + +### Fixed + +- Fixed a link on the features page + +## 2.6.0 - 2023-09-26 + +### Added + +- Added the management of tags in the admin control panel +- Added a blog post: _Hacktoberfest 2023_ + +### Changed + +- Upgraded `prettier` from version `3.0.2` to `3.0.3` +- Upgraded `yahoo-finance2` from version `2.5.0` to `2.7.0` + +## 2.5.0 - 2023-09-23 + +### Added + +- Added support for translated activity types in the activities table +- Added support for dates in `DD.MM.YYYY` format in the activities import +- Set up the language localization for Türkçe (`tr`) + +### Changed + +- Skipped creating queue jobs for asset profiles with `MANUAL` data source on creating a new activity + +### Fixed + +- Fixed an issue with the cash position in the holdings table + +## 2.4.0 - 2023-09-19 + +### Added + +- Added support for interest on account level (experimental) + +### Changed + +- Improved the preselected currency based on the account's currency in the create or edit activity dialog +- Unlocked the experimental features setting for all users +- Upgraded `prisma` from version `5.2.0` to `5.3.1` + +### Fixed + +- Fixed a memory leak related to the server's timezone (behind UTC) in the data gathering + +## 2.3.0 - 2023-09-17 + +### Added + +- Added support for fees on account level (experimental) + +### Fixed + +- Fixed the export functionality for liabilities + +## 2.2.0 - 2023-09-17 + +### Added + +- Introduced a sidebar navigation on desktop + +### Changed + +- Improved the style of the system message +- Upgraded _Postgres_ from version `12` to `15` in the `docker-compose` files + +## 2.1.0 - 2023-09-15 + +### Added + +- Added support to drop a file in the import activities dialog +- Added a timeout to all data source requests + +### Changed + +- Harmonized the style of the user interface for granting and revoking public access to share the portfolio +- Removed the account type from the user interface as a preparation to remove it from the `Account` database schema +- Improved the logger output of the info service +- Harmonized the logger output: ` ()` +- Improved the language localization for German (`de`) +- Improved the language localization for Italian (`it`) +- Improved the language localization for Dutch (`nl`) +- Improved the read-only mode + +### Fixed + +- Fixed the timeout in _EOD Historical Data_ requests +- Fixed an issue with the portfolio summary caused by the language localization for Dutch (`nl`) + +## 2.0.0 - 2023-09-09 + +### Added + +- Added support for the cryptocurrency _CyberConnect_ +- Added a blog post: _Announcing Ghostfolio 2.0_ + +### Changed + +- **Breaking Change**: Removed the deprecated environment variable `BASE_CURRENCY` +- Improved the validation in the activities import +- Deactivated _Internet Identity_ as a social login provider for the account registration +- Improved the language localization for German (`de`) +- Refreshed the cryptocurrencies list +- Changed the version in the `docker-compose` files from `3.7` to `3.9` +- Upgraded `yahoo-finance2` from version `2.4.4` to `2.5.0` + +### Fixed + +- Fixed an issue in the _Yahoo Finance_ data enhancer where countries and sectors have been removed + +## 1.305.0 - 2023-09-03 + +### Added + +- Added _Hacker News_ to the _As seen in_ section on the landing page + +### Changed + +- Shortened the page titles +- Improved the language localization for German (`de`) +- Upgraded `prisma` from version `4.16.2` to `5.2.0` +- Upgraded `replace-in-file` from version `6.3.5` to `7.0.1` +- Upgraded `yahoo-finance2` from version `2.4.3` to `2.4.4` + +### Fixed + +- Fixed the alignment in the header navigation +- Fixed the alignment in the menu of the impersonation mode + +## 1.304.0 - 2023-08-27 + +### Added + +- Added health check endpoints for data enhancers + +### Changed + +- Upgraded `Nx` from version `16.7.2` to `16.7.4` +- Upgraded `prettier` from version `2.8.4` to `3.0.2` + +## 1.303.0 - 2023-08-23 + +### Added + +- Added a blog post: _Ghostfolio joins OSS Friends_ + +### Changed + +- Refreshed the cryptocurrencies list +- Improved the _OSS Friends_ page + +### Fixed + +- Fixed an issue with the _Trackinsight_ data enhancer for asset profile data + +## 1.302.0 - 2023-08-20 + +### Changed + +- Improved the language localization for German (`de`) +- Upgraded `angular` from version `16.1.8` to `16.2.1` +- Upgraded `Nx` from version `16.6.0` to `16.7.2` + +## 1.301.1 - 2023-08-19 + +### Added + +- Added the data export feature to the user account page +- Added a currencies preset to the historical market data table of the admin control panel +- Added the _OSS Friends_ page + +### Changed + +- Improved the localized meta data in `html` files + +### Fixed + +- Fixed the rows with cash positions in the holdings table +- Fixed an issue with the date parsing in the historical market data editor of the admin control panel + +## 1.300.0 - 2023-08-11 + +### Added + +- Added more durations in the coupon system + +### Changed + +- Migrated the remaining requests from `bent` to `got` + +## 1.299.1 - 2023-08-10 + +### Changed + +- Optimized the activities import by allowing a different currency than the asset's official one +- Added a timeout to the _EOD Historical Data_ requests +- Migrated the requests from `bent` to `got` in the _EOD Historical Data_ service + +### Fixed + +- Fixed the editing of the emergency fund +- Fixed the historical data gathering interval for asset profiles used as benchmarks having activities + +## 1.298.0 - 2023-08-06 + +### Changed + +- Improved the language localization for German (`de`) +- Upgraded `ng-extract-i18n-merge` from version `2.6.0` to `2.7.0` +- Upgraded `Nx` from version `16.5.5` to `16.6.0` + +### Fixed + +- Fixed the styles of various components (card, progress, tab) after the upgrade to `@angular/material` `16` + +## 1.297.4 - 2023-08-05 + +### Added + +- Added the footer to the public page +- Added a `copy-assets` `Nx` target to the client build + +### Changed + +- Improved the alignment of the region percentages on the allocations page +- Improved the alignment of the region percentages on the public page +- Improved the redirection of the home page to the localized home page +- Improved the language localization for German (`de`) +- Upgraded `angular` from version `15.2.5` to `16.1.8` +- Upgraded `nestjs` from version `9.1.4` to `10.1.3` +- Upgraded `Nx` from version `16.0.3` to `16.5.5` + +## 1.296.0 - 2023-08-01 + +### Changed + +- Optimized the validation in the activities import by reducing the list to unique asset profiles +- Optimized the data gathering in the activities import + +## 1.295.0 - 2023-07-30 + +### Added + +- Added a step by step introduction for new users + +### Fixed + +- Removed the _Stay signed in_ setting on _Sign in with fingerprint_ activation + +## 1.294.0 - 2023-07-29 + +### Changed + +- Extended the allocations by market chart on the allocations page by unavailable data + +### Fixed + +- Considered liabilities in the total account value calculation + +## 1.293.0 - 2023-07-26 + +### Added + +- Added error handling for the _Redis_ connections to keep the app running if the connection fails + +### Changed + +- Set the `lastmod` dates of `sitemap.xml` dynamically + +### Fixed + +- Fixed the missing values in the holdings table +- Fixed the `no such file or directory` error caused by the missing `favicon.ico` file + +## 1.292.0 - 2023-07-24 + +### Added + +- Introduced the allocations by market chart on the allocations page + +### Changed + +- Upgraded `yahoo-finance2` from version `2.4.2` to `2.4.3` + +### Fixed + +- Fixed an issue in the public page + +## 1.291.0 - 2023-07-23 + +### Added + +- Broken down the emergency fund by cash and assets +- Added support for account balance time series + +### Changed + +- Renamed queries to presets in the historical market data table of the admin control panel + +## 1.290.0 - 2023-07-16 + +### Added + +- Added hints to the activity types in the create or edit activity dialog +- Added queries to the historical market data table of the admin control panel + +### Changed + +- Improved the usability of the login dialog +- Disabled the caching in the health check endpoints for data providers +- Improved the content of the Frequently Asked Questions (FAQ) page +- Upgraded `prisma` from version `4.15.0` to `4.16.2` + +## 1.289.0 - 2023-07-14 + +### Changed + +- Upgraded `yahoo-finance2` from version `2.4.1` to `2.4.2` + +## 1.288.0 - 2023-07-12 + +### Changed + +- Improved the loading state during filtering on the allocations page +- Beautified the names with ampersand (`&`) in the asset profile +- Improved the language localization for German (`de`) + +## 1.287.0 - 2023-07-09 + +### Changed + +- Hid the average buy price in the position detail chart if there is no holding +- Improved the language localization for French (`fr`) +- Refactored the blog articles to standalone components + +### Fixed + +- Fixed the sorting by currency in the activities table + +## 1.286.0 - 2023-07-03 + +### Fixed + +- Fixed the creation of (wealth) items and liabilities + +## 1.285.0 - 2023-07-01 + +### Added + +- Added a blog post: _Exploring the Path to Financial Independence and Retiring Early (FIRE)_ +- Added pagination to the historical market data table of the admin control panel +- Added the attribute `headers` to the scraper configuration + +### Changed + +- Extended the asset profile details dialog in the admin control panel by the scraper configuration +- Improved the language localization for German (`de`) + +## 1.284.0 - 2023-06-27 + +### Added + +- Added the currency to the cash balance in the create or update account dialog +- Added the ability to add an index for benchmarks as an asset profile in the admin control panel + +### Changed + +- Upgraded the _Internet Identity_ dependencies from version `0.15.1` to `0.15.7` + +### Fixed + +- Fixed an issue with the clone functionality of a transaction caused by the symbol search component + +## 1.283.5 - 2023-06-25 + +### Added + +- Added the caching for current market prices +- Added a loading indicator to the import dividends dialog +- Set up the `helmet` middleware to protect the app from web vulnerabilities by setting HTTP headers + +### Changed + +- Improved the selected item of the holding selector in the import dividends dialog +- Extended the symbol search component by asset sub classes + +## 1.282.0 - 2023-06-19 + +### Added + +- Added an icon to the external links in the footer navigation +- Added the ability to add an asset profile in the admin control panel + +### Changed + +- Harmonized the use of permissions on the about page +- Harmonized the use of permissions on the landing page +- Improved the language localization for German (`de`) +- Improved the language localization for Portuguese (`pt`) +- Updated the binary targets of `linux-arm64-openssl` for `prisma` + +## 1.281.0 - 2023-06-17 + +### Added + +- Extended the feature overview page by liabilities +- Set up the language localization for Portuguese (`pt`) + +### Changed + +- Extracted the symbol search to a dedicated component +- Improved the column headers in the holdings table for mobile +- Upgraded `prisma` from version `4.14.1` to `4.15.0` + +## 1.280.1 - 2023-06-10 + +### Added + +- Added support for liabilities + +## 1.279.0 - 2023-06-10 + +### Added + +- Supported a note for accounts + +### Changed + +- Improved the language localization for French (`fr`) + +### Fixed + +- Fixed an issue with the value nullification related to the investment streaks +- Fixed an issue in the public page related to the impersonation service + +## 1.278.0 - 2023-06-09 + +### Changed + +- Extended the clone functionality of a transaction by the quantity +- Changed the direction of the ellipsis icon in various tables +- Extracted the license to a dedicated tab on the about page +- Displayed the link to the markets overview in the footer based on a permission +- Improved the spacing in the benchmark comparator +- Refreshed the cryptocurrencies list +- Upgraded `Node.js` from version `16` to `18` (`Dockerfile`) + +## 1.277.0 - 2023-06-07 + +### Added + +- Added the investment streaks to the analysis page +- Added support for a unit in the value component +- Added a semantic list structure to the header navigation +- Added a default value for the `includeHistoricalData` attribute in the symbol data endpoint + +### Fixed + +- Fixed an issue with the date format parsing in the activities import + +## 1.276.0 - 2023-06-03 + +### Added + +- Added tabs to the about page +- Added the `changefreq` attribute to the sitemap + +### Changed + +- Improved the routes of the tabs +- Enforced a stricter date format in the activities import: `dd-MM-yyyy` instead of `dd-MM-yy` +- Updated the URL of the Ghostfolio Slack channel +- Removed the _Ghostfolio in Numbers_ section from the about page + +### Fixed + +- Fixed an issue with the price when creating a `Subscription` + +## 1.275.0 - 2023-05-30 + +### Changed + +- Extended the footer navigation by the localized Ghostfolio versions +- Improved the language localization for German (`de`) + +### Fixed + +- Fixed the exchange rate service for a specific date (indirect calculation via base currency) used in activities with a manual currency + +## 1.274.0 - 2023-05-29 + +### Added + +- Extended the footer by a navigation +- Extended the testimonial section on the landing page +- Added localized meta descriptions +- Added support for localized routes in Spanish (`es`) + +### Changed + +- Improved the activities import dialog +- Improved the language localization for German (`de`) + +## 1.273.0 - 2023-05-28 + +### Added + +- Added a stepper to the activities import dialog +- Added a link to manage the benchmarks to the benchmark comparator +- Added support for localized routes + +### Fixed + +- Fixed an issue in the data source transformation + +## 1.272.0 - 2023-05-26 + +### Added + +- Added support to set an asset profile as a benchmark + +### Changed + +- Decreased the density of the `@angular/material` tables +- Improved the portfolio proportion chart component by supporting case insensitive names +- Improved the breadcrumb navigation style in the blog post pages for mobile +- Improved the error handling in the delete user endpoint +- Improved the style of the _Changelog & License_ button on the about page +- Upgraded `ionicons` from version `6.1.2` to `7.1.0` + +## 1.271.0 - 2023-05-20 + +### Added + +- Added the historical data and search functionality for the `FINANCIAL_MODELING_PREP` data source type +- Added a blog post: _Unlock your Financial Potential with Ghostfolio_ + +### Changed + +- Improved the local number formatting in the value component +- Changed the uptime to the last 90 days on the _Open Startup_ (`/open`) page + +### Fixed + +- Fixed the vertical alignment in the toggle component + +## 1.270.1 - 2023-05-19 + +### Added + +- Added the cash balance and the value of equity to the account detail dialog +- Added a check for duplicates to the preview step of the import dividends dialog +- Added an error message for duplicates to the preview step of the activities import +- Added a connection timeout to the environment variable `DATABASE_URL` +- Introduced the _Open Startup_ (`/open`) page with aggregated key metrics including uptime + +### Changed + +- Improved the mobile layout of the portfolio summary tab on the home page +- Improved the language localization for German (`de`) +- Upgraded `prisma` from version `4.13.0` to `4.14.1` + +### Fixed + +- Improved the _Select all_ activities checkbox state after importing activities including a duplicate +- Fixed an issue with the data source transformation in the import dividends dialog +- Fixed the _Storybook_ setup + +## 1.269.0 - 2023-05-11 + +### Added + +- Added `FINANCIAL_MODELING_PREP` as a new data source type + +### Changed + +- Improved the market price on the first buy date in the chart of the position detail dialog +- Restructured the admin control panel with a new settings tab + +### Fixed + +- Fixed an error that occurred while editing an activity caused by the cash balance update + +## 1.268.0 - 2023-05-08 + +### Added + +- Added `depends_on` and `healthcheck` for the _Postgres_ and _Redis_ services to the `docker-compose` files (`docker-compose.yml` and `docker-compose.build.yml`) + +### Changed + +- Improved the preview step of the activities import by unchecking duplicates +- Upgraded `yahoo-finance2` from version `2.3.10` to `2.4.1` + +## 1.267.0 - 2023-05-07 + +### Added + +- Added support for the _Stripe_ checkout to the pricing page + +### Changed + +- Improved the management of platforms in the admin control panel +- Improved the style of the interstitial for the subscription +- Improved the language localization for German (`de`) +- Upgraded `Nx` from version `15.9.2` to `16.0.3` + +## 1.266.0 - 2023-05-06 + +### Added + +- Introduced the option to update the cash balance of an account when adding an activity +- Added support for the management of platforms in the admin control panel +- Added _DEV Community_ to the _As seen in_ section on the landing page + +### Changed + +- Upgraded `class-transformer` from version `0.3.2` to `0.5.1` +- Upgraded `class-validator` from version `0.13.1` to `0.14.0` +- Upgraded `prisma` from version `4.12.0` to `4.13.0` + +### Fixed + +- Added a fallback to use `quoteSummary(symbol)` if `quote(symbols)` fails in the _Yahoo Finance_ service +- Added the missing `dataSource` attribute to the activities import + +## 1.265.0 - 2023-05-01 + +### Changed + +- Improved the tooltip of the portfolio proportion chart component + +### Fixed + +- Fixed the missing platform name in the allocations by platform chart on the allocations page + +## 1.264.0 - 2023-05-01 + +### Added + +- Introduced the allocations by platform chart on the allocations page + +### Changed + +- Deprecated the use of the environment variable `BASE_CURRENCY` +- Cleaned up initial values from the _X-ray_ section + +## 1.263.0 - 2023-04-30 + +### Changed + +- Split the environment variable `DATA_SOURCE_PRIMARY` in `DATA_SOURCE_EXCHANGE_RATES` and `DATA_SOURCE_IMPORT` + +### Fixed + +- Fixed the exception on the accounts page + +## 1.262.0 - 2023-04-29 + +### Added + +- Added the labels to the tabs to increase the usability +- Extended the support of the impersonation mode for local development + +### Changed + +- Improved the queue jobs implementation by adding / updating historical market data in bulk +- Improved the language localization for German (`de`) + +### Fixed + +- Improved the holdings table by showing the cash position also when the filter contains the accounts, so that we can see the total allocation for that account + +## 1.261.0 - 2023-04-25 + +### Added + +- Introduced a new button to delete all activities from the portfolio activities page +- Added `state` to the `MarketData` database schema to distinguish `CLOSE` and `INTRADAY` in the data gathering +- Added the distance to now to the subscription expiration date in the users table of the admin control panel + +## 1.260.0 - 2023-04-23 + +### Added + +- Added `dataSource` as a unique constraint to the `MarketData` database schema + +### Fixed + +- Removed the unnecessary sort header of the comment column in the historical market data table of the admin control panel + +## 1.259.0 - 2023-04-22 + +### Added + +- Added a fallback to historical market data if a data provider does not provide live data +- Added a general health check endpoint +- Added health check endpoints for data providers + +### Changed + +- Persisted today's market data continuously + +### Fixed + +- Fixed the alignment of the performance column header in the holdings table +- Removed the unnecessary sort header of the comment column in the activities table +- Fixed the targets in `proxy.conf.json` from `http://localhost:3333` to `http://0.0.0.0:3333` for local development + +## 1.258.0 - 2023-04-20 + +### Added + +- Introduced a data source mapping + +## 1.257.0 - 2023-04-18 + +### Added + +- Introduced the allocations by ETF provider chart on the allocations page + +### Fixed + +- Fixed an issue in the global heat map component caused by manipulating an input property +- Fixed an issue with the currency inconsistency in the _EOD Historical Data_ service (convert from `GBX` to `GBp`) + +## 1.256.0 - 2023-04-17 + +### Added + +- Added the _Yahoo Finance_ data enhancer for countries, sectors and urls + +### Changed + +- Enabled the configuration to immediately remove queue jobs on complete +- Refactored the implementation of removing queue jobs + +### Fixed + +- Fixed the unique job ids of the gather asset profile process +- Fixed the style of the button to fetch the current market price + +## 1.255.0 - 2023-04-15 + +### Added + +- Made the system message expandable + +### Changed + +- Skipped creating queue jobs for asset profiles with `MANUAL` data source not having a scraper configuration +- Reduced the execution interval of the data gathering to every hour +- Upgraded `prisma` from version `4.11.0` to `4.12.0` + +### Fixed + +- Improved the style of the system message + +## 1.254.0 - 2023-04-14 + +### Changed + +- Improved the queue jobs implementation by adding in bulk +- Improved the queue jobs implementation by introducing unique job ids +- Reverted the execution interval of the data gathering from every 12 hours to every 4 hours + +## 1.253.0 - 2023-04-14 + +### Changed + +- Reduced the execution interval of the data gathering to every 12 hours + +### Fixed + +- Fixed the background color of dialogs in dark mode + +## 1.252.2 - 2023-04-11 + +### Changed + +- Deprecated the `auth` endpoint of the login with _Security Token_ (`GET`) + +## 1.252.1 - 2023-04-10 + +### Changed + +- Changed the slide toggles to checkboxes on the user account page +- Changed the slide toggles to checkboxes in the admin control panel +- Increased the density of the theme +- Migrated the style of various components to `@angular/material` `15` (mdc) +- Upgraded `@angular/cdk` and `@angular/material` from version `15.2.5` to `15.2.6` +- Upgraded `bull` from version `4.10.2` to `4.10.4` + +## 1.251.0 - 2023-04-07 + +### Changed + +- Improved the activities import for `csv` files exported by _Interactive Brokers_ +- Improved the rendering of the chart ticks (`0.5K` → `500`) +- Increased the historical market data gathering of currency pairs to 10+ years +- Improved the content of the Frequently Asked Questions (FAQ) page +- Improved the content of the pricing page +- Changed the `auth` endpoint of the login with _Security Token_ from `GET` to `POST` +- Changed the `auth` endpoint of the _Internet Identity_ login provider from `GET` to `POST` +- Migrated the style of the `libs` components to `@angular/material` `15` (mdc) + - `ActivitiesFilterComponent` + - `ActivitiesTableComponent` + - `BenchmarkComponent` + - `HoldingsTableComponent` +- Upgraded `angular` from version `15.1.5` to `15.2.5` +- Upgraded `Nx` from version `15.7.2` to `15.9.2` + +## 1.250.0 - 2023-04-02 + +### Added + +- Added support for multiple subscription offers + +### Changed + +- Improved the portfolio evolution chart (ignore first item) +- Improved the accounts import by handling the platform + +### Fixed + +- Fixed an issue with more than 50 activities in the activities import (`dryRun`) + +## 1.249.0 - 2023-03-27 + +### Added + +- Extended the testimonial section on the landing page + +### Changed + +- Improved the loading state of the value component on the allocations page +- Improved the value component by always showing the label (also while loading) +- Improved the language localization for German (`de`) + +### Fixed + +- Fixed an issue with the algebraic sign in the value component + +## 1.248.0 - 2023-03-25 + +### Added + +- Added a blog post: _Ghostfolio reaches 1’000 Stars on GitHub_ +- Added a breadcrumb navigation to the blog post pages + +### Changed + +- Refactored the calculation of the chart +- Hid the platform selector if no platforms are available in the create or update account dialog +- Upgraded `ng-extract-i18n-merge` from version `2.5.0` to `2.6.0` + +## 1.247.0 - 2023-03-23 + +### Added + +- Added the asset and asset sub class to the search functionality +- Added the subscription expiration date to the users table of the admin control panel + +### Changed + +- Updated the URL of the Ghostfolio Slack channel +- Upgraded `prisma` from version `4.10.1` to `4.11.0` + +### Fixed + +- Fixed the total amount calculation in the portfolio evolution chart + +## 1.246.0 - 2023-03-18 + +### Added + +- Added support for asset and asset sub class to the `EOD_HISTORICAL_DATA` data source type +- Added `isin` to the asset profile model + +### Changed + +- Extended the _Trackinsight_ data enhancer for asset profile data by `isin` +- Improved the language localization for _Gather Data_ + +### Fixed + +- Fixed the border color in the _FIRE_ calculator (dark mode) + +## 1.245.0 - 2023-03-12 + +### Added + +- Added the search functionality for the `EOD_HISTORICAL_DATA` data source type + +### Changed + +- Improved the usability of the _FIRE_ calculator +- Improved the exchange rate service for a specific date used in activities with a manual currency +- Upgraded `ngx-device-detector` from version `3.0.0` to `5.0.1` + +## 1.244.0 - 2023-03-09 + +### Added + +- Extended the _FIRE_ calculator by a retirement date setting + +## 1.243.0 - 2023-03-08 + +### Added + +- Added `COINGECKO` as a default to `DATA_SOURCES` + +### Changed + +- Improved the validation of the manual currency for the activity fee and unit price +- Harmonized the axis style of charts +- Made setting `NODE_ENV: production` optional (to avoid `ENOENT: no such file or directory` errors on startup) +- Removed the environment variable `ENABLE_FEATURE_CUSTOM_SYMBOLS` + +## 1.242.0 - 2023-03-04 + +### Changed + +- Simplified the database seeding +- Upgraded `ngx-skeleton-loader` from version `5.0.0` to `7.0.0` + +### Fixed + +- Downgraded `Node.js` from version `18` to `16` (Dockerfile) to resolve `SIGSEGV` (segmentation fault) during the `prisma` database migrations (see https://github.com/prisma/prisma/issues/10649) + +## 1.241.0 - 2023-03-01 + +### Changed + +- Filtered activities with type `ITEM` from search results +- Considered the user's language in the _Stripe_ checkout +- Upgraded the _Stripe_ dependencies +- Upgraded `twitter-api-v2` from version `1.10.3` to `1.14.2` + +## 1.240.0 - 2023-02-26 + +### Added + +- Supported a manual currency for the activity unit price + +### Fixed + +- Fixed the feature graphic of the _Ghostfolio meets Umbrel_ blog post + +## 1.239.0 - 2023-02-25 + +### Added + +- Added a blog post: _Ghostfolio meets Umbrel_ + +### Changed + +- Removed the dependency `rimraf` + +## 1.238.0 - 2023-02-25 + +### Added + +- Added `COINGECKO` as a new data source type +- Added support for data provider information to the position detail dialog +- Added the configuration to publish a `linux/arm/v7` docker image +- Added _Reddit_ to the _As seen in_ section on the landing page +- Added _Umbrel_ to the _As seen in_ section on the landing page + +### Changed + +- Renamed the example environment variable file from `.env` to `.env.example` +- Upgraded `zone.js` from version `0.11.8` to `0.12.0` + +### Fixed + +- Fixed `RangeError: Maximum call stack size exceeded` for values of type `Big` in the value redaction interceptor for the impersonation mode +- Reset the letter spacing in buttons + +### Todo + +- Ensure that you still have a `.env` file in your project + +## 1.237.0 - 2023-02-19 + +### Added + +- Added the support details to the pricing page + +### Changed + +- Increased the file size limit for the activities import +- Improved the style of the search results for symbols +- Migrated the style of `GfHeaderModule` to `@angular/material` `15` (mdc) +- Upgraded `angular` from version `15.1.2` to `15.1.5` +- Upgraded `Nx` from version `15.6.3` to `15.7.2` + +### Fixed + +- Fixed an issue with exact matches in the activities table filter (`VT` vs. `VTI`) +- Fixed an issue in the data gathering service (do not skip `MANUAL` data source) + +## 1.236.0 - 2023-02-17 + +### Changed + +- Beautified the ETF names in the asset profile +- Removed the data source type `GHOSTFOLIO` + +### Fixed + +- Fixed an issue in the data gathering service (do not skip `MANUAL` data source) +- Fixed the buying power calculation if no emergency fund is set but an activity is tagged as _Emergency Fund_ +- Fixed the url on logout during the local development + +## 1.235.0 - 2023-02-16 + +### Changed + +- Improved the styles on the about page +- Eliminated the `GhostfolioScraperApiService` + +## 1.234.0 - 2023-02-15 + +### Added + +- Added the data import and export feature to the pricing page + +### Changed + +- Copy the logic of `GhostfolioScraperApiService` to `ManualService` +- Improved the content of the landing page +- Improved the content of the Frequently Asked Questions (FAQ) page +- Improved the usability of the _Import Activities..._ action +- Eliminated the permission `enableImport` +- Set the exposed port as an environment variable (`PORT`) in `Dockerfile` +- Migrated the style of `AboutPageModule` to `@angular/material` `15` (mdc) +- Migrated the style of `BlogPageModule` to `@angular/material` `15` (mdc) +- Migrated the style of `ChangelogPageModule` to `@angular/material` `15` (mdc) +- Migrated the style of `ResourcesPageModule` to `@angular/material` `15` (mdc) +- Upgraded `chart.js` from version `4.0.1` to `4.2.0` +- Upgraded `ionicons` from version `6.0.4` to `6.1.2` +- Upgraded `prettier` from version `2.8.1` to `2.8.4` +- Upgraded `prisma` from version `4.9.0` to `4.10.1` + +### Fixed + +- Fixed an issue on the landing page caused by the global heat map of subscribers +- Fixed the links in the interstitial for the subscription + +### Todo + +- Remove the environment variable `ENABLE_FEATURE_IMPORT` +- Rename the `dataSource` from `GHOSTFOLIO` to `MANUAL` +- Eliminate `GhostfolioScraperApiService` + +## 1.233.0 - 2023-02-09 + +### Added + +- Added support to export accounts +- Added support to import accounts + +### Changed + +- Improved the styling in the admin control panel +- Removed the _Google Play_ badge from the landing page +- Upgraded `eslint` dependencies + +## 1.232.0 - 2023-02-05 + +### Changed + +- Improved the language localization for German (`de`) +- Migrated the style of `ActivitiesPageModule` to `@angular/material` `15` (mdc) +- Migrated the style of `GfCreateOrUpdateActivityDialogModule` to `@angular/material` `15` (mdc) +- Migrated the style of `GfMarketDataDetailDialogModule` to `@angular/material` `15` (mdc) +- Upgraded `ng-extract-i18n-merge` from version `2.1.2` to `2.5.0` +- Upgraded `ngx-markdown` from version `14.0.1` to `15.1.0` + +### Fixed + +- Fixed the `Upgrade Plan` button of the interstitial for the subscription + +## 1.231.0 - 2023-02-04 + +### Added + +- Added the dividend and fees to the position detail dialog +- Added support to link a (wealth) item to an account + +### Changed + +- Relaxed the validation rule of the _Redis_ host environment variable (`REDIS_HOST`) +- Improved the language localization for German (`de`) +- Eliminated `angular-material-css-vars` +- Upgraded `angular` from version `14.2.0` to `15.1.2` +- Upgraded `Nx` from version `15.0.13` to `15.6.3` + +## 1.230.0 - 2023-01-29 + +### Added + +- Added an interstitial for the subscription +- Added _SourceForge_ to the _As seen in_ section on the landing page +- Added a quote to the blog post _Ghostfolio auf Sackgeld.com vorgestellt_ + +### Changed + +- Improved the unit format (`%`) in the global heat map component of the public page +- Improved the pricing page +- Upgraded `Node.js` from version `16` to `18` (`Dockerfile`) +- Upgraded `prisma` from version `4.8.0` to `4.9.0` + +### Fixed + +- Fixed the click of unknown accounts in the portfolio proportion chart component +- Fixed an issue with `value` in the value redaction interceptor for the impersonation mode + +## 1.229.0 - 2023-01-21 + +### Added + +- Added a blog post: _Ghostfolio auf Sackgeld.com vorgestellt_ +- Added _Sackgeld.com_ to the _As seen in_ section on the landing page + +### Changed + +- Removed the toggle _Original Shares_ vs. _Current Shares_ on the allocations page +- Hid error messages related to no current investment in the client +- Refactored the value redaction interceptor for the impersonation mode + +### Fixed + +- Fixed the value of the active (emergency fund) filter in percentage on the allocations page + +## 1.228.1 - 2023-01-18 + +### Added + +- Extended the hints in user settings + +### Changed + +- Improved the date formatting in the tooltip of the dividend timeline grouped by month / year +- Improved the date formatting in the tooltip of the investment timeline grouped by month / year +- Reduced the execution interval of the data gathering to every 4 hours +- Removed emergency fund as an asset class + +## 1.227.1 - 2023-01-14 + +### Changed + +- Improved the language localization for German (`de`) + +### Fixed + +- Fixed the create or edit activity dialog + +## 1.227.0 - 2023-01-14 + +### Added + +- Added support for assets other than cash in emergency fund (affecting buying power) +- Added support for translated tags + +### Changed + +- Improved the logo alignment + +### Fixed + +- Fixed the grouping by month / year of the dividend and investment timeline + +## 1.226.0 - 2023-01-11 + +### Added + +- Added the language localization for Français (`fr`) +- Extended the landing page by a global heat map of subscribers +- Added support for the thousand separator in the global heat map component + +### Changed + +- Improved the form of the import dividends dialog (disable while loading) +- Removed the deprecated `~` in _Sass_ imports + +### Fixed + +- Fixed an exception in the _X-ray_ section + +## 1.225.0 - 2023-01-07 + +### Added + +- Added support for importing dividends from a data provider + +### Changed + +- Extended the Frequently Asked Questions (FAQ) page + +## 1.224.0 - 2023-01-04 + +### Added + +- Added support for the dividend timeline grouped by year +- Added support for the investment timeline grouped by year +- Set up the language localization for Français (`fr`) + +### Changed + +- Improved the language localization for Dutch (`nl`) + +## 1.223.0 - 2023-01-01 + +### Added + +- Added a student discount to the pricing page +- Added a prefix to the codes of the coupon system + +### Changed + +- Optimized the page titles in the header for mobile +- Extended the asset profile details dialog in the admin control panel + +## 1.222.0 - 2022-12-29 + +### Added + +- Added support for filtering on the analysis page +- Added the price to the `Subscription` database schema + +### Changed + +- Changed the execution time of the asset profile data gathering to every Sunday at lunch time +- Improved the activities import by providing asset profile details +- Upgraded `@codewithdan/observable-store` from version `2.2.11` to `2.2.15` +- Upgraded `bull` from version `4.8.5` to `4.10.2` +- Upgraded `countup.js` from version `2.0.7` to `2.3.2` +- Upgraded the _Internet Identity_ dependencies from version `0.12.1` to `0.15.1` +- Upgraded `prisma` from version `4.7.1` to `4.8.0` + +### Fixed + +- Fixed the language localization of the account type + +## 1.221.0 - 2022-12-26 + +### Added + +- Added support to manage the tags in the create or edit activity dialog +- Added the tags to the admin control panel +- Added a blog post: _The importance of tracking your personal finances_ +- Resolved the title of the blog post + +### Changed + +- Improved the activities import by a preview step +- Improved the labels based on the type in the create or edit activity dialog +- Refreshed the cryptocurrencies list +- Removed the data source type `RAKUTEN` + +### Fixed + +- Fixed the date conversion for years with only two digits + +## 1.220.0 - 2022-12-23 + +### Added + +- Added the position detail dialog to the _Top 3_ and _Bottom 3_ performers of the analysis page +- Added the `dryRun` option to the import activities endpoint + +### Changed + +- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 365 days +- Upgraded `color` from version `4.0.1` to `4.2.3` +- Upgraded `prettier` from version `2.7.1` to `2.8.1` + +### Fixed + +- Fixed the rounding of the y-axis ticks in the benchmark comparator + +## 1.219.0 - 2022-12-17 + +### Added + +- Added support to disable user sign up in the admin control panel +- Extended the glossary of the resources page by _Deflation_, _Inflation_ and _Stagflation_ + +### Changed + +- Added the name to the symbol column in the activities table +- Combined the name and symbol column in the holdings table (former positions table) + +## 1.218.0 - 2022-12-12 + +### Added + +- Added the date of the first activity to the positions table +- Added an endpoint to fetch the logo of an asset or a platform + +### Changed + +- Improved the asset profile details dialog in the admin control panel +- Upgraded `chart.js` from version `3.8.0` to `4.0.1` + +## 1.217.0 - 2022-12-10 + +### Added + +- Added the dividend timeline grouped by month + +### Changed + +- Improved the value redaction interceptor (including `comment`) +- Improved the language localization for Español (`es`) +- Upgraded `cheerio` from version `1.0.0-rc.6` to `1.0.0-rc.12` +- Upgraded `prisma` from version `4.6.1` to `4.7.1` + +### Fixed + +- Fixed the activities sorting in the account detail dialog + +## 1.216.0 - 2022-12-03 + +### Added + +- Supported a note for asset profiles +- Supported a manual currency for the activity fee +- Extended the support for column sorting in the accounts table (name, platform, transactions) +- Extended the support for column sorting in the activities table (name, symbol) +- Extended the support for column sorting in the positions table (performance) + +### Changed + +- Upgraded `big.js` from version `6.1.1` to `6.2.1` +- Upgraded `date-fns` from version `2.28.0` to `2.29.3` +- Upgraded `replace-in-file` from version `6.2.0` to `6.3.5` + +### Fixed + +- Fixed the filter by asset sub class for the asset profiles in the admin control + +## 1.215.0 - 2022-11-27 + +### Changed + +- Improved the language selector on the user account page +- Improved the wording in the _X-ray_ section (net worth instead of investment) +- Extended the asset profile details dialog in the admin control panel +- Updated the browserslist database +- Upgraded `ionicons` from version `5.5.1` to `6.0.4` +- Upgraded `uuid` from version `8.3.2` to `9.0.0` + +## 1.214.0 - 19.11.2022 + +### Added + +- Added support for sorting in the accounts table + +### Changed + +- Improved the support for the `MANUAL` data source +- Improved the _Activities_ tab icon +- Improved the _Activities_ icons for `BUY`, `DIVIDEND` and `SELL` +- Upgraded `prisma` from version `4.4.0` to `4.6.1` +- Upgraded `yahoo-finance2` from version `2.3.6` to `2.3.10` + +### Fixed + +- Fixed the activities sorting in the position detail dialog +- Fixed the dynamic number of decimal places for cryptocurrencies in the position detail dialog +- Fixed a division by zero error in the cash positions calculation + +## 1.213.0 - 14.11.2022 + +### Added + +- Added an indicator for excluded accounts in the accounts table +- Added a blog post: _Black Friday 2022_ + +### Fixed + +- Fixed an issue with the currency inconsistency in the _Yahoo Finance_ service (convert from `ZAc` to `ZAR`) + +## 1.212.0 - 11.11.2022 + +### Changed + +- Changed the view mode selector to a slide toggle +- Upgraded `Nx` from version `15.0.0` to `15.0.13` + +## 1.211.0 - 11.11.2022 + +### Changed + +- Converted the client into a _Progressive Web App_ (PWA) with `@angular/pwa` +- Removed the bottom margin from the body element +- Improved the pricing page + +## 1.210.0 - 08.11.2022 + +### Added + +- Added tabs to the portfolio page + +### Changed + +- Merged the _FIRE_ calculator and the _X-ray_ section to a single page +- Tightened the validation rule of the base currency environment variable (`BASE_CURRENCY`) + +### Fixed + +- Fixed an issue in the cash positions calculation + +## 1.209.0 - 05.11.2022 + +### Added + +- Added the _Buy me a coffee_ button to the about page + +### Changed + +- Improved the usability of the activities import +- Improved the usage of the premium indicator component +- Removed the intro image in dark mode +- Refactored the `TransactionsPageComponent` to `ActivitiesPageComponent` + +## 1.208.0 - 03.11.2022 + +### Added + +- Added pagination to the activities table + +### Changed + +- Restructured the actions in the admin control panel + +### Fixed + +- Fixed the calculation in the portfolio evolution chart + +## 1.207.0 - 31.10.2022 ### Added - Added support for translated labels of asset and asset sub class +- Added support for dates in _ISO 8601_ date format (`YYYY-MM-DD`) in the activities import + +### Changed + +- Darkened the background color of the dark mode ### Fixed +- Fixed the public page - Improved the loading indicator of the portfolio evolution chart ## 1.206.2 - 20.10.2022 @@ -43,7 +1545,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added support to change the appearance (dark mode) in user settings - Added the total amount chart to the investment timeline -- Setup the `prettier` plugin `prettier-plugin-organize-attributes` +- Set up the `prettier` plugin `prettier-plugin-organize-attributes` ### Changed @@ -149,7 +1651,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Set up the language localization for Italiano (`it`) +- Set up the language localization for Italian (`it`) - Extended the landing page ## 1.195.0 - 20.09.2022 @@ -290,7 +1792,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added the alias to the `Access` database schema - Added support for translated time distances -- Added a _GitHub Action_ to create an `arm64` docker image +- Added a _GitHub Action_ to create an `linux/arm64` docker image ### Changed @@ -322,7 +1824,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added a language selector to the account page +- Added a language selector to the user account page - Added support for translated labels in the value component ### Changed @@ -433,7 +1935,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Support a note for activities +- Supported a note for activities ### Todo @@ -651,7 +2153,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added the user id to the account page +- Added the user id to the user account page - Added a new view with jobs of the queue to the admin control panel ### Changed @@ -903,7 +2405,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Beautified the ETF names in the symbol profile +- Beautified the ETF names in the asset profile ### Fixed @@ -1328,7 +2830,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Extended the historical data view in the admin control panel -- Upgraded _Stripe_ dependencies +- Upgraded the _Stripe_ dependencies - Upgraded `prisma` from version `3.7.0` to `3.8.1` ### Fixed @@ -1572,7 +3074,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Supported the management of additional currencies in the admin control panel - Introduced the system message -- Introduced the read only mode +- Introduced the read-only mode ### Changed @@ -2306,7 +3808,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Respected the cash balance on the analysis page -- Improved the settings selectors on the account page +- Improved the settings selectors on the user account page - Harmonized the slogan to "Open Source Wealth Management Software" ### Fixed @@ -2772,7 +4274,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added a gradient to the line charts -- Added a selector to set the base currency on the account page +- Added a selector to set the base currency on the user account page ## 0.81.0 - 06.04.2021 @@ -3086,7 +4588,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Added the membership status to the account page +- Added the membership status to the user account page ### Fixed diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 000000000..a950e5672 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,37 @@ +# Ghostfolio Development Guide + +## Git + +### Rebase + +`git rebase -i --autosquash main` + +## Dependencies + +### Nx + +#### Upgrade + +1. Run `yarn nx migrate latest` +1. Make sure `package.json` changes make sense and then run `yarn install` +1. Run `yarn nx migrate --run-migrations` + +### Prisma + +#### Access database via GUI + +Run `yarn database:gui` + +https://www.prisma.io/studio + +#### Synchronize schema with database for prototyping + +Run `yarn database:push` + +https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push + +#### Create schema migration + +Run `yarn prisma migrate dev --name added_job_title` + +https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate diff --git a/Dockerfile b/Dockerfile index 5bc79b61a..91b2cd3e6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=$BUILDPLATFORM node:16-slim as builder +FROM --platform=$BUILDPLATFORM node:18-slim as builder # Build application and add additional files WORKDIR /ghostfolio @@ -33,7 +33,7 @@ COPY ./tsconfig.base.json tsconfig.base.json COPY ./libs libs COPY ./apps apps -RUN yarn build:all +RUN yarn build:production # Prepare the dist image with additional node_modules WORKDIR /ghostfolio/dist/apps/api @@ -50,12 +50,12 @@ COPY package.json /ghostfolio/dist/apps/api RUN yarn database:generate-typings # Image to run, copy everything needed from builder -FROM node:16-slim +FROM node:18-slim RUN apt update && apt install -y \ openssl \ && rm -rf /var/lib/apt/lists/* COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps WORKDIR /ghostfolio/apps/api -EXPOSE 3333 -CMD [ "yarn", "start:prod" ] +EXPOSE ${PORT:-3333} +CMD [ "yarn", "start:production" ] diff --git a/README.md b/README.md index 3343ca7b8..87e6c7d1b 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,33 @@
- - Ghostfolio Logo - - -

Ghostfolio

-

- Open Source Wealth Management Software -

-

- Ghostfol.ioLive Demo | Ghostfolio Premium | FAQ | Blog | Slack | Twitter -

-

- - - - License: AGPL v3 -

+ +[Ghostfolio logo](https://ghostfol.io) + +# Ghostfolio + +**Open Source Wealth Management Software** + +[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) | +[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**Twitter**](https://twitter.com/ghostfolio_) + +[![Shield: Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee)](https://www.buymeacoffee.com/ghostfolio) +[![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-orange.svg)](#contributing) +[![Shield: License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) + +New: [Ghostfolio 2.0](https://ghostfol.io/en/blog/2023/09/ghostfolio-2) +
-**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. +**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation. + +
+ +[Preview image of the Ghostfolio video trailer](https://www.youtube.com/watch?v=yY6ObSQVJZk) -
- -
## Ghostfolio Premium -Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs. +Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. Revenue is used to cover the costs of the hosting infrastructure and to fund ongoing development. If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section. @@ -46,23 +42,25 @@ Ghostfolio is for you if you are... - 🧘 into minimalism - 🧺 caring about diversifying your financial resources - 🆓 interested in financial independence -- 🙅 saying no to spreadsheets in 2022 +- 🙅 saying no to spreadsheets - 😎 still reading this list ## Features - ✅ Create, update and delete transactions - ✅ Multi account management -- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max` +- ✅ Portfolio performance for `Today`, `YTD`, `1Y`, `5Y`, `Max` - ✅ Various charts - ✅ Static analysis to identify potential risks in your portfolio - ✅ Import and export transactions - ✅ Dark Mode - ✅ Zen Mode -- ✅ Mobile-first design +- ✅ Progressive Web App (PWA) with a mobile-first design + +
+ +Image of a phone showing the Ghostfolio app open -
-
## Technology Stack @@ -79,14 +77,19 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater ## Self-hosting -We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64` and `linux/arm64`. +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`. + +
+ +[Buy me a coffee button](https://www.buymeacoffee.com/ghostfolio) + +
### Supported Environment Variables | Name | Default Value | Description | | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens | -| `BASE_CURRENCY` | `USD` | The base currency of the Ghostfolio application. Caution: This cannot be changed later! | | `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` | | `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on | | `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) | @@ -104,7 +107,8 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c - Basic knowledge of Docker - Installation of [Docker](https://www.docker.com/products/docker-desktop) -- Local copy of this Git repository (clone) +- Create a local copy of this Git repository (clone) +- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`) #### a. Run environment @@ -123,13 +127,10 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml build docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d ``` -#### Fetch Historical Data - -Open http://localhost:3333 in your browser and accomplish these steps: +#### Setup +1. Open http://localhost:3333 in your browser 1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`) -1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data -1. Click _Sign out_ and check out the _Live Demo_ #### Upgrade Version @@ -137,40 +138,42 @@ Open http://localhost:3333 in your browser and accomplish these steps: 1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d` At each start, the container will automatically apply the database schema migrations if needed. -### Run with _Unraid_ (Community) +### Home Server Systems (Community) -Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio). +Ghostfolio is available for various home server systems, including [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio). ## Development ### Prerequisites - [Docker](https://www.docker.com/products/docker-desktop) -- [Node.js](https://nodejs.org/en/download) (version 16+) +- [Node.js](https://nodejs.org/en/download) (version 18+) - [Yarn](https://yarnpkg.com/en/docs/install) -- A local copy of this Git repository (clone) +- Create a local copy of this Git repository (clone) +- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`) ### Setup 1. Run `yarn install` -1. Run `yarn build:dev` to build the source code including the assets 1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io) -1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data +1. Run `yarn database:setup` to initialize the database schema 1. Start the server and the client (see [_Development_](#Development)) +1. Open http://localhost:4200/en in your browser 1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`) -1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data -1. Click _Sign out_ and check out the _Live Demo_ ### Start Server -
    -
  1. Debug: Run yarn watch:server and click "Launch Program" in Visual Studio Code
  2. -
  3. Serve: Run yarn start:server
  4. -
+#### Debug + +Run `yarn watch:server` and click _Launch Program_ in [Visual Studio Code](https://code.visualstudio.com) + +#### Serve + +Run `yarn start:server` ### Start Client -Run `yarn start:client` +Run `yarn start:client` and open http://localhost:4200/en in your browser ### Start _Storybook_ @@ -190,20 +193,24 @@ Run `yarn test` ## Public API -### Import Activities - -#### Request - -`POST http://localhost:3333/api/v1/import` - -#### Authorization: Bearer Token +### Authorization: Bearer Token -Set the header as follows: +Set the header for each request as follows: ``` "Authorization": "Bearer eyJh..." ``` +You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ accessToken: }`) + +Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/` or `curl -s http://localhost:3333/api/v1/auth/anonymous/`. + +### Import Activities + +#### Request + +`POST http://localhost:3333/api/v1/import` + #### Body ``` @@ -215,7 +222,7 @@ Set the header as follows: "date": "2021-09-15T00:00:00.000Z", "fee": 19, "quantity": 5, - "symbol": "MSFT" + "symbol": "MSFT", "type": "BUY", "unitPrice": 298.58 } @@ -226,6 +233,7 @@ Set the header as follows: | Field | Type | Description | | ---------- | ------------------- | -------------------------------------------------- | | accountId | string (`optional`) | Id of the account | +| comment | string (`optional`) | Comment of the activity | | currency | string | `CHF` \| `EUR` \| `USD` etc. | | dataSource | string | `MANUAL` (for type `ITEM`) \| `YAHOO` | | date | string | Date in the format `ISO-8601` | @@ -254,16 +262,22 @@ Set the header as follows: } ``` +## Community Projects + +Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio + +Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ repository to get listed as well. [Learn more →](https://docs.github.com/en/articles/classifying-your-repository-with-topics) + ## 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. -Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you. +Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_). We would love to hear from you. -If you like to support this project, get **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** or **[Buy me a coffee](https://www.buymeacoffee.com/ghostfolio)**. +If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio). ## License -© 2022 [Ghostfolio](https://ghostfol.io) +© 2021 - 2023 [Ghostfolio](https://ghostfol.io) Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html). diff --git a/apps/api/jest.config.ts b/apps/api/jest.config.ts index b21073ff3..8152c3f2a 100644 --- a/apps/api/jest.config.ts +++ b/apps/api/jest.config.ts @@ -2,13 +2,14 @@ export default { displayName: 'api', - globals: { - 'ts-jest': { - tsconfig: '/tsconfig.spec.json' - } - }, + globals: {}, transform: { - '^.+\\.[tj]s$': 'ts-jest' + '^.+\\.[tj]s$': [ + 'ts-jest', + { + tsconfig: '/tsconfig.spec.json' + } + ] }, moduleFileExtensions: ['ts', 'js', 'html'], coverageDirectory: '../../coverage/apps/api', diff --git a/apps/api/project.json b/apps/api/project.json index 21474bcb1..80f924672 100644 --- a/apps/api/project.json +++ b/apps/api/project.json @@ -1,4 +1,5 @@ { + "name": "api", "$schema": "../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "apps/api/src", "projectType": "application", @@ -32,7 +33,7 @@ "outputs": ["{options.outputPath}"] }, "serve": { - "executor": "@nrwl/node:node", + "executor": "@nx/js:node", "options": { "buildTarget": "api:build" } @@ -44,7 +45,7 @@ } }, "test": { - "executor": "@nrwl/jest:jest", + "executor": "@nx/jest:jest", "options": { "jestConfig": "apps/api/jest.config.ts", "passWithNoTests": true diff --git a/apps/api/src/app/access/access.module.ts b/apps/api/src/app/access/access.module.ts index d70388038..b9813d173 100644 --- a/apps/api/src/app/access/access.module.ts +++ b/apps/api/src/app/access/access.module.ts @@ -1,4 +1,4 @@ -import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { Module } from '@nestjs/common'; import { AccessController } from './access.controller'; diff --git a/apps/api/src/app/access/access.service.ts b/apps/api/src/app/access/access.service.ts index f1dc4c6ec..bbaef5f73 100644 --- a/apps/api/src/app/access/access.service.ts +++ b/apps/api/src/app/access/access.service.ts @@ -1,4 +1,4 @@ -import { PrismaService } from '@ghostfolio/api/services/prisma.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { AccessWithGranteeUser } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; import { Access, Prisma } from '@prisma/client'; diff --git a/apps/api/src/app/account/account.controller.ts b/apps/api/src/app/account/account.controller.ts index 9e170b7fd..bf15d998f 100644 --- a/apps/api/src/app/account/account.controller.ts +++ b/apps/api/src/app/account/account.controller.ts @@ -1,10 +1,7 @@ import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; -import { UserService } from '@ghostfolio/api/app/user/user.service'; -import { - nullifyValuesInObject, - nullifyValuesInObjects -} from '@ghostfolio/api/helper/object.helper'; -import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; +import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor'; +import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; +import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { Accounts } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import type { @@ -22,7 +19,8 @@ import { Param, Post, Put, - UseGuards + UseGuards, + UseInterceptors } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; @@ -39,8 +37,7 @@ export class AccountController { private readonly accountService: AccountService, private readonly impersonationService: ImpersonationService, private readonly portfolioService: PortfolioService, - @Inject(REQUEST) private readonly request: RequestWithUser, - private readonly userService: UserService + @Inject(REQUEST) private readonly request: RequestWithUser ) {} @Delete(':id') @@ -85,87 +82,36 @@ export class AccountController { @Get() @UseGuards(AuthGuard('jwt')) + @UseInterceptors(RedactValuesInResponseInterceptor) public async getAllAccounts( - @Headers('impersonation-id') impersonationId + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId ): Promise { const impersonationUserId = - await this.impersonationService.validateImpersonationId( - impersonationId, - this.request.user.id - ); - - let accountsWithAggregations = - await this.portfolioService.getAccountsWithAggregations({ - userId: impersonationUserId || this.request.user.id, - withExcludedAccounts: true - }); + await this.impersonationService.validateImpersonationId(impersonationId); - if ( - impersonationUserId || - this.userService.isRestrictedView(this.request.user) - ) { - accountsWithAggregations = { - ...nullifyValuesInObject(accountsWithAggregations, [ - 'totalBalanceInBaseCurrency', - 'totalValueInBaseCurrency' - ]), - accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [ - 'balance', - 'balanceInBaseCurrency', - 'convertedBalance', - 'fee', - 'quantity', - 'unitPrice', - 'value', - 'valueInBaseCurrency' - ]) - }; - } - - return accountsWithAggregations; + return this.portfolioService.getAccountsWithAggregations({ + userId: impersonationUserId || this.request.user.id, + withExcludedAccounts: true + }); } @Get(':id') @UseGuards(AuthGuard('jwt')) + @UseInterceptors(RedactValuesInResponseInterceptor) public async getAccountById( - @Headers('impersonation-id') impersonationId, + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, @Param('id') id: string ): Promise { const impersonationUserId = - await this.impersonationService.validateImpersonationId( - impersonationId, - this.request.user.id - ); + await this.impersonationService.validateImpersonationId(impersonationId); - let accountsWithAggregations = + const accountsWithAggregations = await this.portfolioService.getAccountsWithAggregations({ filters: [{ id, type: 'ACCOUNT' }], userId: impersonationUserId || this.request.user.id, withExcludedAccounts: true }); - if ( - impersonationUserId || - this.userService.isRestrictedView(this.request.user) - ) { - accountsWithAggregations = { - ...nullifyValuesInObject(accountsWithAggregations, [ - 'totalBalanceInBaseCurrency', - 'totalValueInBaseCurrency' - ]), - accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [ - 'balance', - 'balanceInBaseCurrency', - 'convertedBalance', - 'fee', - 'quantity', - 'unitPrice', - 'value', - 'valueInBaseCurrency' - ]) - }; - } - return accountsWithAggregations.accounts[0]; } diff --git a/apps/api/src/app/account/account.module.ts b/apps/api/src/app/account/account.module.ts index 90bf909fc..26ace47c2 100644 --- a/apps/api/src/app/account/account.module.ts +++ b/apps/api/src/app/account/account.module.ts @@ -1,11 +1,12 @@ import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module'; -import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; +import { AccountBalanceModule } from '@ghostfolio/api/services/account-balance/account-balance.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.module'; -import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module'; -import { PrismaModule } from '@ghostfolio/api/services/prisma.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 { Module } from '@nestjs/common'; import { AccountController } from './account.controller'; @@ -15,6 +16,7 @@ import { AccountService } from './account.service'; controllers: [AccountController], exports: [AccountService], imports: [ + AccountBalanceModule, ConfigurationModule, DataProviderModule, ExchangeRateDataModule, diff --git a/apps/api/src/app/account/account.service.ts b/apps/api/src/app/account/account.service.ts index 7c10fc31f..dc049108c 100644 --- a/apps/api/src/app/account/account.service.ts +++ b/apps/api/src/app/account/account.service.ts @@ -1,5 +1,6 @@ -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; -import { PrismaService } from '@ghostfolio/api/services/prisma.service'; +import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { Filter } from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; import { Account, Order, Platform, Prisma } from '@prisma/client'; @@ -11,16 +12,21 @@ import { CashDetails } from './interfaces/cash-details.interface'; @Injectable() export class AccountService { public constructor( + private readonly accountBalanceService: AccountBalanceService, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly prismaService: PrismaService ) {} - public async account( - accountWhereUniqueInput: Prisma.AccountWhereUniqueInput - ): Promise { - return this.prismaService.account.findUnique({ - where: accountWhereUniqueInput + public async account({ + id_userId + }: Prisma.AccountWhereUniqueInput): Promise { + const { id, userId } = id_userId; + + const [account] = await this.accounts({ + where: { id, userId } }); + + return account; } public async accountWithOrders( @@ -50,9 +56,11 @@ export class AccountService { Platform?: Platform; })[] > { - const { include, skip, take, cursor, where, orderBy } = params; + const { include = {}, skip, take, cursor, where, orderBy } = params; - return this.prismaService.account.findMany({ + include.balances = { orderBy: { date: 'desc' }, take: 1 }; + + const accounts = await this.prismaService.account.findMany({ cursor, include, orderBy, @@ -60,15 +68,36 @@ export class AccountService { take, where }); + + return accounts.map((account) => { + account = { ...account, balance: account.balances[0]?.value ?? 0 }; + + delete account.balances; + + return account; + }); } public async createAccount( data: Prisma.AccountCreateInput, aUserId: string ): Promise { - return this.prismaService.account.create({ + const account = await this.prismaService.account.create({ data }); + + await this.prismaService.accountBalance.create({ + data: { + Account: { + connect: { + id_userId: { id: account.id, userId: aUserId } + } + }, + value: data.balance + } + }); + + return account; } public async deleteAccount( @@ -167,9 +196,65 @@ export class AccountService { aUserId: string ): Promise { const { data, where } = params; + + await this.prismaService.accountBalance.create({ + data: { + Account: { + connect: { + id_userId: where.id_userId + } + }, + value: data.balance + } + }); + return this.prismaService.account.update({ data, where }); } + + public async updateAccountBalance({ + accountId, + amount, + currency, + date, + userId + }: { + accountId: string; + amount: number; + currency: string; + date: Date; + userId: string; + }) { + const { balance, currency: currencyOfAccount } = await this.account({ + id_userId: { + userId, + id: accountId + } + }); + + const amountInCurrencyOfAccount = + await this.exchangeRateDataService.toCurrencyAtDate( + amount, + currency, + currencyOfAccount, + date + ); + + if (amountInCurrencyOfAccount) { + await this.accountBalanceService.createAccountBalance({ + date, + Account: { + connect: { + id_userId: { + userId, + id: accountId + } + } + }, + value: new Big(balance).plus(amountInCurrencyOfAccount).toNumber() + }); + } + } } diff --git a/apps/api/src/app/account/create-account.dto.ts b/apps/api/src/app/account/create-account.dto.ts index 3ea13e20a..fff982ecf 100644 --- a/apps/api/src/app/account/create-account.dto.ts +++ b/apps/api/src/app/account/create-account.dto.ts @@ -1,4 +1,5 @@ import { AccountType } from '@prisma/client'; +import { Transform, TransformFnParams } from 'class-transformer'; import { IsBoolean, IsNumber, @@ -6,17 +7,30 @@ import { IsString, ValidateIf } from 'class-validator'; +import { isString } from 'lodash'; export class CreateAccountDto { + @IsOptional() @IsString() - accountType: AccountType; + accountType?: AccountType; @IsNumber() balance: number; + @IsOptional() + @IsString() + @Transform(({ value }: TransformFnParams) => + isString(value) ? value.trim() : value + ) + comment?: string; + @IsString() currency: string; + @IsOptional() + @IsString() + id?: string; + @IsBoolean() @IsOptional() isExcluded?: boolean; diff --git a/apps/api/src/app/account/transfer-balance.dto.ts b/apps/api/src/app/account/transfer-balance.dto.ts new file mode 100644 index 000000000..fb602033e --- /dev/null +++ b/apps/api/src/app/account/transfer-balance.dto.ts @@ -0,0 +1,12 @@ +import { IsNumber, IsString } from 'class-validator'; + +export class TransferBalanceDto { + @IsString() + accountIdFrom: string; + + @IsString() + accountIdTo: string; + + @IsNumber() + balance: number; +} diff --git a/apps/api/src/app/account/update-account.dto.ts b/apps/api/src/app/account/update-account.dto.ts index 0b5737607..7ab829454 100644 --- a/apps/api/src/app/account/update-account.dto.ts +++ b/apps/api/src/app/account/update-account.dto.ts @@ -1,4 +1,5 @@ import { AccountType } from '@prisma/client'; +import { Transform, TransformFnParams } from 'class-transformer'; import { IsBoolean, IsNumber, @@ -6,14 +7,23 @@ import { IsString, ValidateIf } from 'class-validator'; +import { isString } from 'lodash'; export class UpdateAccountDto { + @IsOptional() @IsString() - accountType: AccountType; + accountType?: AccountType; @IsNumber() balance: number; + @IsOptional() + @IsString() + @Transform(({ value }: TransformFnParams) => + isString(value) ? value.trim() : value + ) + comment?: string; + @IsString() currency: string; diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index e7209fa01..67e106ff8 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -1,18 +1,25 @@ -import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; -import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; +import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { + DEFAULT_PAGE_SIZE, GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS_OPTIONS } from '@ghostfolio/common/config'; +import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { AdminData, AdminMarketData, AdminMarketDataDetails, + EnhancedSymbolProfile, Filter } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; -import type { RequestWithUser } from '@ghostfolio/common/types'; +import type { + MarketDataPreset, + RequestWithUser +} from '@ghostfolio/common/types'; import { Body, Controller, @@ -21,18 +28,21 @@ import { HttpException, Inject, Param, + Patch, Post, Put, Query, - UseGuards + UseGuards, + UseInterceptors } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; -import { DataSource, MarketData } from '@prisma/client'; -import { isDate } from 'date-fns'; +import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client'; +import { isDate, parseISO } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { AdminService } from './admin.service'; +import { UpdateAssetProfileDto } from './update-asset-profile.dto'; import { UpdateMarketDataDto } from './update-market-data.dto'; @Controller('admin') @@ -97,16 +107,21 @@ export class AdminController { const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); - for (const { dataSource, symbol } of uniqueAssets) { - await this.dataGatheringService.addJobToQueue( - GATHER_ASSET_PROFILE_PROCESS, - { - dataSource, - symbol - }, - GATHER_ASSET_PROFILE_PROCESS_OPTIONS - ); - } + await this.dataGatheringService.addJobsToQueue( + uniqueAssets.map(({ dataSource, symbol }) => { + return { + data: { + dataSource, + symbol + }, + name: GATHER_ASSET_PROFILE_PROCESS, + opts: { + ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, + jobId: getAssetProfileIdentifier({ dataSource, symbol }) + } + }; + }) + ); this.dataGatheringService.gatherMax(); } @@ -128,16 +143,21 @@ export class AdminController { const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); - for (const { dataSource, symbol } of uniqueAssets) { - await this.dataGatheringService.addJobToQueue( - GATHER_ASSET_PROFILE_PROCESS, - { - dataSource, - symbol - }, - GATHER_ASSET_PROFILE_PROCESS_OPTIONS - ); - } + await this.dataGatheringService.addJobsToQueue( + uniqueAssets.map(({ dataSource, symbol }) => { + return { + data: { + dataSource, + symbol + }, + name: GATHER_ASSET_PROFILE_PROCESS, + opts: { + ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, + jobId: getAssetProfileIdentifier({ dataSource, symbol }) + } + }; + }) + ); } @Post('gather/profile-data/:dataSource/:symbol') @@ -158,14 +178,17 @@ export class AdminController { ); } - await this.dataGatheringService.addJobToQueue( - GATHER_ASSET_PROFILE_PROCESS, - { + await this.dataGatheringService.addJobToQueue({ + data: { dataSource, symbol }, - GATHER_ASSET_PROFILE_PROCESS_OPTIONS - ); + name: GATHER_ASSET_PROFILE_PROCESS, + opts: { + ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, + jobId: getAssetProfileIdentifier({ dataSource, symbol }) + } + }); } @Post('gather/:dataSource/:symbol') @@ -210,7 +233,7 @@ export class AdminController { ); } - const date = new Date(dateString); + const date = parseISO(dateString); if (!isDate(date)) { throw new HttpException( @@ -229,7 +252,12 @@ export class AdminController { @Get('market-data') @UseGuards(AuthGuard('jwt')) public async getMarketData( - @Query('assetSubClasses') filterByAssetSubClasses?: string + @Query('assetSubClasses') filterByAssetSubClasses?: string, + @Query('presetId') presetId?: MarketDataPreset, + @Query('skip') skip?: number, + @Query('sortColumn') sortColumn?: string, + @Query('sortDirection') sortDirection?: Prisma.SortOrder, + @Query('take') take?: number ): Promise { if ( !hasPermission( @@ -254,7 +282,14 @@ export class AdminController { }) ]; - return this.adminService.getMarketData(filters); + return this.adminService.getMarketData({ + filters, + presetId, + sortColumn, + sortDirection, + skip: isNaN(skip) ? undefined : skip, + take: isNaN(take) ? undefined : take + }); } @Get('market-data/:dataSource/:symbol') @@ -298,12 +333,13 @@ export class AdminController { ); } - const date = new Date(dateString); + const date = parseISO(dateString); return this.marketDataService.updateMarketData({ - data: { ...data, dataSource }, + data: { marketPrice: data.marketPrice, state: 'CLOSE' }, where: { - date_symbol: { + dataSource_date_symbol: { + dataSource, date, symbol } @@ -311,6 +347,28 @@ export class AdminController { }); } + @Post('profile-data/:dataSource/:symbol') + @UseGuards(AuthGuard('jwt')) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + public async addProfileData( + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + if ( + !hasPermission( + this.request.user.permissions, + permissions.accessAdminControl + ) + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.adminService.addAssetProfile({ dataSource, symbol }); + } + @Delete('profile-data/:dataSource/:symbol') @UseGuards(AuthGuard('jwt')) public async deleteProfileData( @@ -332,6 +390,32 @@ export class AdminController { return this.adminService.deleteProfileData({ dataSource, symbol }); } + @Patch('profile-data/:dataSource/:symbol') + @UseGuards(AuthGuard('jwt')) + public async patchAssetProfileData( + @Body() assetProfileData: UpdateAssetProfileDto, + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + if ( + !hasPermission( + this.request.user.permissions, + permissions.accessAdminControl + ) + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.adminService.patchAssetProfileData({ + ...assetProfileData, + dataSource, + symbol + }); + } + @Put('settings/:key') @UseGuards(AuthGuard('jwt')) public async updateProperty( diff --git a/apps/api/src/app/admin/admin.module.ts b/apps/api/src/app/admin/admin.module.ts index 464ba7069..500af69db 100644 --- a/apps/api/src/app/admin/admin.module.ts +++ b/apps/api/src/app/admin/admin.module.ts @@ -1,12 +1,12 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; -import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; -import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; -import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; -import { MarketDataModule } from '@ghostfolio/api/services/market-data.module'; -import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; -import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { Module } from '@nestjs/common'; import { AdminController } from './admin.controller'; diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index 5c7f8698a..dd9e3f9ce 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -1,11 +1,18 @@ import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; -import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; -import { PrismaService } from '@ghostfolio/api/services/prisma.service'; +import { environment } from '@ghostfolio/api/environments/environment'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; -import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; -import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { + DEFAULT_CURRENCY, + PROPERTY_CURRENCIES, + PROPERTY_IS_READ_ONLY_MODE, + PROPERTY_IS_USER_SIGNUP_ENABLED +} from '@ghostfolio/common/config'; import { AdminData, AdminMarketData, @@ -14,25 +21,55 @@ import { Filter, UniqueAsset } from '@ghostfolio/common/interfaces'; -import { Injectable } from '@nestjs/common'; -import { AssetSubClass, Prisma, Property } from '@prisma/client'; +import { MarketDataPreset } from '@ghostfolio/common/types'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import { AssetSubClass, Prisma, Property, SymbolProfile } from '@prisma/client'; import { differenceInDays } from 'date-fns'; import { groupBy } from 'lodash'; @Injectable() export class AdminService { - private baseCurrency: string; - public constructor( private readonly configurationService: ConfigurationService, + private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly marketDataService: MarketDataService, private readonly prismaService: PrismaService, private readonly propertyService: PropertyService, private readonly subscriptionService: SubscriptionService, private readonly symbolProfileService: SymbolProfileService - ) { - this.baseCurrency = this.configurationService.get('BASE_CURRENCY'); + ) {} + + public async addAssetProfile({ + dataSource, + symbol + }: UniqueAsset): Promise { + try { + const assetProfiles = await this.dataProviderService.getAssetProfiles([ + { dataSource, symbol } + ]); + + if (!assetProfiles[symbol]?.currency) { + throw new BadRequestException( + `Asset profile not found for ${symbol} (${dataSource})` + ); + } + + return await this.symbolProfileService.add( + assetProfiles[symbol] as Prisma.SymbolProfileCreateInput + ); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + throw new BadRequestException( + `Asset profile of ${symbol} (${dataSource}) already exists` + ); + } + + throw error; + } } public async deleteProfileData({ dataSource, symbol }: UniqueAsset) { @@ -45,15 +82,15 @@ export class AdminService { exchangeRates: this.exchangeRateDataService .getCurrencies() .filter((currency) => { - return currency !== this.baseCurrency; + return currency !== DEFAULT_CURRENCY; }) .map((currency) => { return { - label1: this.baseCurrency, + label1: DEFAULT_CURRENCY, label2: currency, value: this.exchangeRateDataService.toCurrency( 1, - this.baseCurrency, + DEFAULT_CURRENCY, currency ) }; @@ -61,13 +98,39 @@ export class AdminService { settings: await this.propertyService.get(), transactionCount: await this.prismaService.order.count(), userCount: await this.prismaService.user.count(), - users: await this.getUsersWithAnalytics() + users: await this.getUsersWithAnalytics(), + version: environment.version }; } - public async getMarketData(filters?: Filter[]): Promise { + public async getMarketData({ + filters, + presetId, + sortColumn, + sortDirection, + 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 === 'CURRENCIES') { + return this.getMarketDataForCurrencies(); + } else if ( + presetId === 'ETF_WITHOUT_COUNTRIES' || + presetId === 'ETF_WITHOUT_SECTORS' + ) { + filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }]; + } + const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy( filters, (filter) => { @@ -75,47 +138,40 @@ export class AdminService { } ); - const marketData = await this.prismaService.marketData.groupBy({ + const marketDataItems = await this.prismaService.marketData.groupBy({ _count: true, by: ['dataSource', 'symbol'] }); - let currencyPairsToGather: AdminMarketDataItem[] = []; - if (filtersByAssetSubClass) { where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id]; - } else { - currencyPairsToGather = this.exchangeRateDataService - .getCurrencyPairs() - .map(({ dataSource, symbol }) => { - const marketDataItemCount = - marketData.find((marketDataItem) => { - return ( - marketDataItem.dataSource === dataSource && - marketDataItem.symbol === symbol - ); - })?._count ?? 0; + } - return { - dataSource, - marketDataItemCount, - symbol, - countriesCount: 0, - sectorsCount: 0 - }; - }); + if (sortColumn) { + orderBy = [{ [sortColumn]: sortDirection }]; + + if (sortColumn === 'activitiesCount') { + orderBy = { + Order: { + _count: sortDirection + } + }; + } } - const symbolProfilesToGather: AdminMarketDataItem[] = ( - await this.prismaService.symbolProfile.findMany({ + let [assetProfiles, count] = await Promise.all([ + this.prismaService.symbolProfile.findMany({ + orderBy, + skip, + take, where, - orderBy: [{ symbol: 'asc' }], select: { _count: { select: { Order: true } }, assetClass: true, assetSubClass: true, + comment: true, countries: true, dataSource: true, Order: { @@ -127,37 +183,64 @@ export class AdminService { sectors: true, symbol: true } - }) - ).map((symbolProfile) => { - const countriesCount = symbolProfile.countries - ? Object.keys(symbolProfile.countries).length - : 0; - const marketDataItemCount = - marketData.find((marketDataItem) => { - return ( - marketDataItem.dataSource === symbolProfile.dataSource && - marketDataItem.symbol === symbolProfile.symbol - ); - })?._count ?? 0; - const sectorsCount = symbolProfile.sectors - ? Object.keys(symbolProfile.sectors).length - : 0; - - return { - countriesCount, - marketDataItemCount, - sectorsCount, - activityCount: symbolProfile._count.Order, - assetClass: symbolProfile.assetClass, - assetSubClass: symbolProfile.assetSubClass, - dataSource: symbolProfile.dataSource, - date: symbolProfile.Order?.[0]?.date, - symbol: symbolProfile.symbol - }; - }); + }), + this.prismaService.symbolProfile.count({ where }) + ]); + + let marketData = assetProfiles.map( + ({ + _count, + assetClass, + assetSubClass, + comment, + countries, + dataSource, + Order, + sectors, + symbol + }) => { + const countriesCount = countries ? Object.keys(countries).length : 0; + const marketDataItemCount = + marketDataItems.find((marketDataItem) => { + return ( + marketDataItem.dataSource === dataSource && + marketDataItem.symbol === symbol + ); + })?._count ?? 0; + const sectorsCount = sectors ? Object.keys(sectors).length : 0; + + return { + assetClass, + assetSubClass, + comment, + countriesCount, + dataSource, + symbol, + marketDataItemCount, + sectorsCount, + activitiesCount: _count.Order, + date: Order?.[0]?.date + }; + } + ); + + if (presetId) { + if (presetId === 'ETF_WITHOUT_COUNTRIES') { + marketData = marketData.filter(({ countriesCount }) => { + return countriesCount === 0; + }); + } else if (presetId === 'ETF_WITHOUT_SECTORS') { + marketData = marketData.filter(({ sectorsCount }) => { + return sectorsCount === 0; + }); + } + + count = marketData.length; + } return { - marketData: [...currencyPairsToGather, ...symbolProfilesToGather] + count, + marketData }; } @@ -165,8 +248,14 @@ export class AdminService { dataSource, symbol }: UniqueAsset): Promise { - return { - marketData: await this.marketDataService.marketDataItems({ + const [[assetProfile], marketData] = await Promise.all([ + this.symbolProfileService.getSymbolProfiles([ + { + dataSource, + symbol + } + ]), + this.marketDataService.marketDataItems({ orderBy: { date: 'asc' }, @@ -175,9 +264,42 @@ export class AdminService { symbol } }) + ]); + + return { + marketData, + assetProfile: assetProfile ?? { + symbol, + currency: '-' + } }; } + public async patchAssetProfileData({ + comment, + dataSource, + scraperConfiguration, + symbol, + symbolMapping + }: Prisma.SymbolProfileUpdateInput & UniqueAsset) { + await this.symbolProfileService.updateSymbolProfile({ + comment, + dataSource, + scraperConfiguration, + symbol, + symbolMapping + }); + + const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([ + { + dataSource, + symbol + } + ]); + + return symbolProfile; + } + public async putSetting(key: string, value: string) { let response: Property; @@ -187,20 +309,67 @@ export class AdminService { response = await this.propertyService.delete({ key }); } - if (key === PROPERTY_CURRENCIES) { + if (key === PROPERTY_IS_READ_ONLY_MODE && value === 'true') { + await this.putSetting(PROPERTY_IS_USER_SIGNUP_ENABLED, 'false'); + } else if (key === PROPERTY_CURRENCIES) { await this.exchangeRateDataService.initialize(); } return response; } + private async getMarketDataForCurrencies(): Promise { + const marketDataItems = await this.prismaService.marketData.groupBy({ + _count: true, + by: ['dataSource', 'symbol'] + }); + + const marketData: AdminMarketDataItem[] = this.exchangeRateDataService + .getCurrencyPairs() + .map(({ dataSource, symbol }) => { + const marketDataItemCount = + marketDataItems.find((marketDataItem) => { + return ( + marketDataItem.dataSource === dataSource && + marketDataItem.symbol === symbol + ); + })?._count ?? 0; + + return { + dataSource, + marketDataItemCount, + symbol, + assetClass: 'CASH', + countriesCount: 0, + sectorsCount: 0 + }; + }); + + return { marketData, count: marketData.length }; + } + private async getUsersWithAnalytics(): Promise { - const usersWithAnalytics = await this.prismaService.user.findMany({ - orderBy: { + let orderBy: any = { + createdAt: 'desc' + }; + let where; + + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + orderBy = { Analytics: { updatedAt: 'desc' } - }, + }; + where = { + NOT: { + Analytics: null + } + }; + } + + const usersWithAnalytics = await this.prismaService.user.findMany({ + orderBy, + where, select: { _count: { select: { Account: true, Order: true } @@ -208,6 +377,7 @@ export class AdminService { Analytics: { select: { activityCount: true, + country: true, updatedAt: true } }, @@ -215,19 +385,16 @@ export class AdminService { id: true, Subscription: true }, - take: 30, - where: { - NOT: { - Analytics: null - } - } + take: 30 }); return usersWithAnalytics.map( ({ _count, Analytics, createdAt, id, Subscription }) => { const daysSinceRegistration = differenceInDays(new Date(), createdAt) + 1; - const engagement = Analytics.activityCount / daysSinceRegistration; + const engagement = Analytics + ? Analytics.activityCount / daysSinceRegistration + : undefined; const subscription = this.configurationService.get( 'ENABLE_FEATURE_SUBSCRIPTION' @@ -241,7 +408,8 @@ export class AdminService { id, subscription, accountCount: _count.Account || 0, - lastActivity: Analytics.updatedAt, + country: Analytics?.country, + lastActivity: Analytics?.updatedAt, transactionCount: _count.Order || 0 }; } diff --git a/apps/api/src/app/admin/queue/queue.module.ts b/apps/api/src/app/admin/queue/queue.module.ts index 62091f34b..3c1be5128 100644 --- a/apps/api/src/app/admin/queue/queue.module.ts +++ b/apps/api/src/app/admin/queue/queue.module.ts @@ -1,4 +1,4 @@ -import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; +import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { Module } from '@nestjs/common'; import { QueueController } from './queue.controller'; diff --git a/apps/api/src/app/admin/queue/queue.service.ts b/apps/api/src/app/admin/queue/queue.service.ts index ebaab6d94..81b32ddbb 100644 --- a/apps/api/src/app/admin/queue/queue.service.ts +++ b/apps/api/src/app/admin/queue/queue.service.ts @@ -4,7 +4,7 @@ import { } from '@ghostfolio/common/config'; import { AdminJobs } from '@ghostfolio/common/interfaces'; import { InjectQueue } from '@nestjs/bull'; -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { JobStatus, Queue } from 'bull'; @Injectable() @@ -23,14 +23,11 @@ export class QueueService { }: { status?: JobStatus[]; }) { - const jobs = await this.dataGatheringQueue.getJobs(status); - - for (const job of jobs) { - try { - await job.remove(); - } catch (error) { - Logger.warn(error, 'QueueService'); - } + for (const statusItem of status) { + await this.dataGatheringQueue.clean( + 300, + statusItem === 'waiting' ? 'wait' : statusItem + ); } } @@ -44,18 +41,23 @@ export class QueueService { const jobs = await this.dataGatheringQueue.getJobs(status); const jobsWithState = await Promise.all( - jobs.slice(0, limit).map(async (job) => { - return { - attemptsMade: job.attemptsMade + 1, - data: job.data, - finishedOn: job.finishedOn, - id: job.id, - name: job.name, - stacktrace: job.stacktrace, - state: await job.getState(), - timestamp: job.timestamp - }; - }) + jobs + .filter((job) => { + return job; + }) + .slice(0, limit) + .map(async (job) => { + return { + attemptsMade: job.attemptsMade + 1, + data: job.data, + finishedOn: job.finishedOn, + id: job.id, + name: job.name, + stacktrace: job.stacktrace, + state: await job.getState(), + timestamp: job.timestamp + }; + }) ); return { diff --git a/apps/api/src/app/admin/update-asset-profile.dto.ts b/apps/api/src/app/admin/update-asset-profile.dto.ts new file mode 100644 index 000000000..54f2d8f25 --- /dev/null +++ b/apps/api/src/app/admin/update-asset-profile.dto.ts @@ -0,0 +1,18 @@ +import { Prisma } from '@prisma/client'; +import { IsObject, IsOptional, IsString } from 'class-validator'; + +export class UpdateAssetProfileDto { + @IsString() + @IsOptional() + comment?: string; + + @IsObject() + @IsOptional() + scraperConfiguration?: Prisma.InputJsonObject; + + @IsObject() + @IsOptional() + symbolMapping?: { + [dataProvider: string]: string; + }; +} diff --git a/apps/api/src/app/app.controller.ts b/apps/api/src/app/app.controller.ts index d92a7a3ce..33e9ae56a 100644 --- a/apps/api/src/app/app.controller.ts +++ b/apps/api/src/app/app.controller.ts @@ -1,4 +1,4 @@ -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { Controller } from '@nestjs/common'; @Controller() diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index e41b60b0e..03c6a4aaa 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -1,35 +1,45 @@ import { join } from 'path'; -import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module'; -import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; -import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { CronService } from '@ghostfolio/api/services/cron.service'; -import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; +import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; -import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; -import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module'; +import { + DEFAULT_LANGUAGE_CODE, + SUPPORTED_LANGUAGE_CODES +} from '@ghostfolio/common/config'; import { BullModule } from '@nestjs/bull'; -import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { ScheduleModule } from '@nestjs/schedule'; import { ServeStaticModule } from '@nestjs/serve-static'; +import { StatusCodes } from 'http-status-codes'; import { AccessModule } from './access/access.module'; import { AccountModule } from './account/account.module'; import { AdminModule } from './admin/admin.module'; import { AppController } from './app.controller'; +import { AuthDeviceModule } from './auth-device/auth-device.module'; import { AuthModule } from './auth/auth.module'; import { BenchmarkModule } from './benchmark/benchmark.module'; import { CacheModule } from './cache/cache.module'; +import { ExchangeRateModule } from './exchange-rate/exchange-rate.module'; import { ExportModule } from './export/export.module'; -import { FrontendMiddleware } from './frontend.middleware'; +import { HealthModule } from './health/health.module'; import { ImportModule } from './import/import.module'; import { InfoModule } from './info/info.module'; +import { LogoModule } from './logo/logo.module'; import { OrderModule } from './order/order.module'; +import { PlatformModule } from './platform/platform.module'; import { PortfolioModule } from './portfolio/portfolio.module'; +import { RedisCacheModule } from './redis-cache/redis-cache.module'; +import { SitemapModule } from './sitemap/sitemap.module'; import { SubscriptionModule } from './subscription/subscription.module'; import { SymbolModule } from './symbol/symbol.module'; +import { TagModule } from './tag/tag.module'; import { UserModule } from './user/user.module'; @Module({ @@ -43,7 +53,7 @@ import { UserModule } from './user/user.module'; BullModule.forRoot({ redis: { host: process.env.REDIS_HOST, - port: parseInt(process.env.REDIS_PORT, 10), + port: parseInt(process.env.REDIS_PORT ?? '6379', 10), password: process.env.REDIS_PASSWORD } }), @@ -52,41 +62,51 @@ import { UserModule } from './user/user.module'; ConfigurationModule, DataGatheringModule, DataProviderModule, + ExchangeRateModule, ExchangeRateDataModule, ExportModule, + HealthModule, ImportModule, InfoModule, + LogoModule, OrderModule, + PlatformModule, PortfolioModule, PrismaModule, RedisCacheModule, ScheduleModule.forRoot(), ServeStaticModule.forRoot({ + exclude: ['/api*', '/sitemap.xml'], + rootPath: join(__dirname, '..', 'client'), serveStaticOptions: { - /*etag: false // Disable etag header to fix PWA - setHeaders: (res, path) => { - if (path.includes('ngsw.json')) { - // Disable cache (https://stackoverflow.com/questions/22632593/how-to-disable-webpage-caching-in-expressjs-nodejs/39775595) - // https://gertjans.home.xs4all.nl/javascript/cache-control.html#no-cache - res.set('Cache-Control', 'no-cache, no-store, must-revalidate'); + setHeaders: (res) => { + if (res.req?.path === '/') { + let languageCode = DEFAULT_LANGUAGE_CODE; + + try { + const code = res.req.headers['accept-language'] + .split(',')[0] + .split('-')[0]; + + if (SUPPORTED_LANGUAGE_CODES.includes(code)) { + languageCode = code; + } + } catch {} + + res.set('Location', `/${languageCode}`); + res.statusCode = StatusCodes.MOVED_PERMANENTLY; } - }*/ - }, - rootPath: join(__dirname, '..', 'client'), - exclude: ['/api*'] + } + } }), + SitemapModule, SubscriptionModule, SymbolModule, + TagModule, TwitterBotModule, UserModule ], controllers: [AppController], providers: [CronService] }) -export class AppModule { - configure(consumer: MiddlewareConsumer) { - consumer - .apply(FrontendMiddleware) - .forRoutes({ path: '*', method: RequestMethod.ALL }); - } -} +export class AppModule {} diff --git a/apps/api/src/app/auth-device/auth-device.module.ts b/apps/api/src/app/auth-device/auth-device.module.ts index de6cd1cbc..11ddd58ec 100644 --- a/apps/api/src/app/auth-device/auth-device.module.ts +++ b/apps/api/src/app/auth-device/auth-device.module.ts @@ -1,7 +1,7 @@ import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; -import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; -import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; diff --git a/apps/api/src/app/auth-device/auth-device.service.ts b/apps/api/src/app/auth-device/auth-device.service.ts index 048aca758..19dad8876 100644 --- a/apps/api/src/app/auth-device/auth-device.service.ts +++ b/apps/api/src/app/auth-device/auth-device.service.ts @@ -1,5 +1,5 @@ -import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; -import { PrismaService } from '@ghostfolio/api/services/prisma.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { Injectable } from '@nestjs/common'; import { AuthDevice, Prisma } from '@prisma/client'; diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index 749f6f037..376109b8d 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -1,5 +1,5 @@ import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { OAuthResponse } from '@ghostfolio/common/interfaces'; import { @@ -16,6 +16,7 @@ import { Version } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; +import { Request, Response } from 'express'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { AuthService } from './auth.service'; @@ -32,13 +33,32 @@ export class AuthController { private readonly webAuthService: WebAuthService ) {} + /** + * @deprecated + */ @Get('anonymous/:accessToken') - public async accessTokenLogin( + 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') + public async accessTokenLogin( + @Body() body: { accessToken: string } ): Promise { try { const authToken = await this.authService.validateAnonymousLogin( - accessToken + body.accessToken ); return { authToken }; } catch { @@ -58,18 +78,21 @@ export class AuthController { @Get('google/callback') @UseGuards(AuthGuard('google')) @Version(VERSION_NEUTRAL) - public googleLoginCallback(@Req() req, @Res() res) { + public googleLoginCallback( + @Req() request: Request, + @Res() response: Response + ) { // Handles the Google OAuth2 callback - const jwt: string = req.user.jwt; + const jwt: string = (request.user).jwt; if (jwt) { - res.redirect( + response.redirect( `${this.configurationService.get( 'ROOT_URL' )}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}` ); } else { - res.redirect( + response.redirect( `${this.configurationService.get( 'ROOT_URL' )}/${DEFAULT_LANGUAGE_CODE}/auth` @@ -77,13 +100,13 @@ export class AuthController { } } - @Get('internet-identity/:principalId') + @Post('internet-identity') public async internetIdentityLogin( - @Param('principalId') principalId: string + @Body() body: { principalId: string } ): Promise { try { const authToken = await this.authService.validateInternetIdentityLogin( - principalId + body.principalId ); return { authToken }; } catch { diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index b25e4c18b..458493051 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -2,8 +2,9 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module'; -import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; -import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; @@ -21,6 +22,7 @@ import { JwtStrategy } from './jwt.strategy'; signOptions: { expiresIn: '180 days' } }), PrismaModule, + PropertyModule, SubscriptionModule, UserModule ], diff --git a/apps/api/src/app/auth/auth.service.ts b/apps/api/src/app/auth/auth.service.ts index 3178ce9ac..c7270f8c3 100644 --- a/apps/api/src/app/auth/auth.service.ts +++ b/apps/api/src/app/auth/auth.service.ts @@ -1,5 +1,6 @@ import { UserService } from '@ghostfolio/api/app/user/user.service'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Provider } from '@prisma/client'; @@ -11,6 +12,7 @@ export class AuthService { public constructor( private readonly configurationService: ConfigurationService, private readonly jwtService: JwtService, + private readonly propertyService: PropertyService, private readonly userService: UserService ) {} @@ -50,10 +52,19 @@ export class AuthService { }); if (!user) { + const isUserSignupEnabled = + await this.propertyService.isUserSignupEnabled(); + + if (!isUserSignupEnabled || true) { + throw new Error('Sign up forbidden'); + } + // Create new user if not found user = await this.userService.createUser({ - provider, - thirdPartyId: principalId + data: { + provider, + thirdPartyId: principalId + } }); } @@ -78,10 +89,19 @@ export class AuthService { }); if (!user) { + const isUserSignupEnabled = + await this.propertyService.isUserSignupEnabled(); + + if (!isUserSignupEnabled) { + throw new Error('Sign up forbidden'); + } + // Create new user if not found user = await this.userService.createUser({ - provider, - thirdPartyId + data: { + provider, + thirdPartyId + } }); } diff --git a/apps/api/src/app/auth/google.strategy.ts b/apps/api/src/app/auth/google.strategy.ts index c8fb260b7..7e43f5817 100644 --- a/apps/api/src/app/auth/google.strategy.ts +++ b/apps/api/src/app/auth/google.strategy.ts @@ -1,4 +1,4 @@ -import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { Injectable, Logger } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Provider } from '@prisma/client'; diff --git a/apps/api/src/app/auth/jwt.strategy.ts b/apps/api/src/app/auth/jwt.strategy.ts index ee50e3b72..6d7e2ecdc 100644 --- a/apps/api/src/app/auth/jwt.strategy.ts +++ b/apps/api/src/app/auth/jwt.strategy.ts @@ -1,33 +1,46 @@ import { UserService } from '@ghostfolio/api/app/user/user.service'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; -import { PrismaService } from '@ghostfolio/api/services/prisma.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; +import * as countriesAndTimezones from 'countries-and-timezones'; import { ExtractJwt, Strategy } from 'passport-jwt'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { public constructor( - readonly configurationService: ConfigurationService, + private readonly configurationService: ConfigurationService, private readonly prismaService: PrismaService, private readonly userService: UserService ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + passReqToCallback: true, secretOrKey: configurationService.get('JWT_SECRET_KEY') }); } - public async validate({ id }: { id: string }) { + public async validate(request: Request, { id }: { id: string }) { try { + const timezone = request.headers[HEADER_KEY_TIMEZONE.toLowerCase()]; const user = await this.userService.user({ id }); if (user) { - await this.prismaService.analytics.upsert({ - create: { User: { connect: { id: user.id } } }, - update: { activityCount: { increment: 1 }, updatedAt: new Date() }, - where: { userId: user.id } - }); + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + const country = + countriesAndTimezones.getCountryForTimezone(timezone)?.id; + + await this.prismaService.analytics.upsert({ + create: { country, User: { connect: { id: user.id } } }, + update: { + country, + activityCount: { increment: 1 }, + updatedAt: new Date() + }, + where: { userId: user.id } + }); + } return user; } else { diff --git a/apps/api/src/app/auth/web-auth.service.ts b/apps/api/src/app/auth/web-auth.service.ts index bbfddb673..471b77709 100644 --- a/apps/api/src/app/auth/web-auth.service.ts +++ b/apps/api/src/app/auth/web-auth.service.ts @@ -1,7 +1,7 @@ import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import type { RequestWithUser } from '@ghostfolio/common/types'; import { Inject, diff --git a/apps/api/src/app/benchmark/benchmark.controller.ts b/apps/api/src/app/benchmark/benchmark.controller.ts index b5562bf86..2230ff42b 100644 --- a/apps/api/src/app/benchmark/benchmark.controller.ts +++ b/apps/api/src/app/benchmark/benchmark.controller.ts @@ -1,24 +1,114 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; -import { +import type { BenchmarkMarketDataDetails, - BenchmarkResponse + BenchmarkResponse, + UniqueAsset } from '@ghostfolio/common/interfaces'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import type { RequestWithUser } from '@ghostfolio/common/types'; import { + Body, Controller, + Delete, Get, + HttpException, + Inject, Param, + Post, UseGuards, UseInterceptors } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; import { DataSource } from '@prisma/client'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { BenchmarkService } from './benchmark.service'; @Controller('benchmark') export class BenchmarkController { - public constructor(private readonly benchmarkService: BenchmarkService) {} + public constructor( + private readonly benchmarkService: BenchmarkService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} + + @Post() + @UseGuards(AuthGuard('jwt')) + public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) { + if ( + !hasPermission( + this.request.user.permissions, + permissions.accessAdminControl + ) + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + try { + const benchmark = await this.benchmarkService.addBenchmark({ + dataSource, + symbol + }); + + if (!benchmark) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + return benchmark; + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), + StatusCodes.INTERNAL_SERVER_ERROR + ); + } + } + + @Delete(':dataSource/:symbol') + @UseGuards(AuthGuard('jwt')) + public async deleteBenchmark( + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ) { + if ( + !hasPermission( + this.request.user.permissions, + permissions.accessAdminControl + ) + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + try { + const benchmark = await this.benchmarkService.deleteBenchmark({ + dataSource, + symbol + }); + + if (!benchmark) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + return benchmark; + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), + StatusCodes.INTERNAL_SERVER_ERROR + ); + } + } @Get() @UseInterceptors(TransformDataSourceInRequestInterceptor) @@ -30,8 +120,8 @@ export class BenchmarkController { } @Get(':dataSource/:symbol/:startDateString') - @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseGuards(AuthGuard('jwt')) + @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getBenchmarkMarketDataBySymbol( @Param('dataSource') dataSource: DataSource, @Param('startDateString') startDateString: string, diff --git a/apps/api/src/app/benchmark/benchmark.module.ts b/apps/api/src/app/benchmark/benchmark.module.ts index 4c20e61aa..c2cc3fbb5 100644 --- a/apps/api/src/app/benchmark/benchmark.module.ts +++ b/apps/api/src/app/benchmark/benchmark.module.ts @@ -1,10 +1,11 @@ import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; -import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; -import { MarketDataModule } from '@ghostfolio/api/services/market-data.module'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; -import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { Module } from '@nestjs/common'; import { BenchmarkController } from './benchmark.controller'; @@ -17,6 +18,7 @@ import { BenchmarkService } from './benchmark.service'; ConfigurationModule, DataProviderModule, MarketDataModule, + PrismaModule, PropertyModule, RedisCacheModule, SymbolModule, diff --git a/apps/api/src/app/benchmark/benchmark.service.spec.ts b/apps/api/src/app/benchmark/benchmark.service.spec.ts index 833dbcdfc..5fa2c3e7b 100644 --- a/apps/api/src/app/benchmark/benchmark.service.spec.ts +++ b/apps/api/src/app/benchmark/benchmark.service.spec.ts @@ -4,7 +4,15 @@ describe('BenchmarkService', () => { let benchmarkService: BenchmarkService; beforeAll(async () => { - benchmarkService = new BenchmarkService(null, null, null, null, null, null); + benchmarkService = new BenchmarkService( + null, + null, + null, + null, + null, + null, + null + ); }); it('calculateChangeInPercentage', async () => { diff --git a/apps/api/src/app/benchmark/benchmark.service.ts b/apps/api/src/app/benchmark/benchmark.service.ts index 015fc7ee7..7fe1911a4 100644 --- a/apps/api/src/app/benchmark/benchmark.service.ts +++ b/apps/api/src/app/benchmark/benchmark.service.ts @@ -1,9 +1,10 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; -import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; -import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { MAX_CHART_ITEMS, PROPERTY_BENCHMARKS @@ -11,6 +12,7 @@ import { import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { BenchmarkMarketDataDetails, + BenchmarkProperty, BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces'; @@ -18,6 +20,7 @@ import { Injectable } from '@nestjs/common'; import { SymbolProfile } from '@prisma/client'; import Big from 'big.js'; import { format } from 'date-fns'; +import { uniqBy } from 'lodash'; import ms from 'ms'; @Injectable() @@ -27,6 +30,7 @@ export class BenchmarkService { public constructor( private readonly dataProviderService: DataProviderService, private readonly marketDataService: MarketDataService, + private readonly prismaService: PrismaService, private readonly propertyService: PropertyService, private readonly redisCacheService: RedisCacheService, private readonly symbolProfileService: SymbolProfileService, @@ -62,11 +66,11 @@ export class BenchmarkService { const promises: Promise[] = []; - const quotes = await this.dataProviderService.getQuotes( - benchmarkAssetProfiles.map(({ dataSource, symbol }) => { + const quotes = await this.dataProviderService.getQuotes({ + items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => { return { dataSource, symbol }; }) - ); + }); for (const { dataSource, symbol } of benchmarkAssetProfiles) { promises.push(this.marketDataService.getMax({ dataSource, symbol })); @@ -116,9 +120,9 @@ export class BenchmarkService { public async getBenchmarkAssetProfiles(): Promise[]> { const symbolProfileIds: string[] = ( - ((await this.propertyService.getByKey(PROPERTY_BENCHMARKS)) as { - symbolProfileId: string; - }[]) ?? [] + ((await this.propertyService.getByKey( + PROPERTY_BENCHMARKS + )) as BenchmarkProperty[]) ?? [] ).map(({ symbolProfileId }) => { return symbolProfileId; }); @@ -204,6 +208,80 @@ export class BenchmarkService { return response; } + public async addBenchmark({ + dataSource, + symbol + }: UniqueAsset): Promise> { + const assetProfile = await this.prismaService.symbolProfile.findFirst({ + where: { + dataSource, + symbol + } + }); + + if (!assetProfile) { + return; + } + + let benchmarks = + ((await this.propertyService.getByKey( + PROPERTY_BENCHMARKS + )) as BenchmarkProperty[]) ?? []; + + benchmarks.push({ symbolProfileId: assetProfile.id }); + + benchmarks = uniqBy(benchmarks, 'symbolProfileId'); + + await this.propertyService.put({ + key: PROPERTY_BENCHMARKS, + value: JSON.stringify(benchmarks) + }); + + return { + dataSource, + symbol, + id: assetProfile.id, + name: assetProfile.name + }; + } + + public async deleteBenchmark({ + dataSource, + symbol + }: UniqueAsset): Promise> { + const assetProfile = await this.prismaService.symbolProfile.findFirst({ + where: { + dataSource, + symbol + } + }); + + if (!assetProfile) { + return null; + } + + let benchmarks = + ((await this.propertyService.getByKey( + PROPERTY_BENCHMARKS + )) as BenchmarkProperty[]) ?? []; + + benchmarks = benchmarks.filter(({ symbolProfileId }) => { + return symbolProfileId !== assetProfile.id; + }); + + await this.propertyService.put({ + key: PROPERTY_BENCHMARKS, + value: JSON.stringify(benchmarks) + }); + + return { + dataSource, + symbol, + id: assetProfile.id, + name: assetProfile.name + }; + } + private getMarketCondition(aPerformanceInPercent: number) { return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET'; } diff --git a/apps/api/src/app/cache/cache.module.ts b/apps/api/src/app/cache/cache.module.ts index c079c7942..23b3ea2bb 100644 --- a/apps/api/src/app/cache/cache.module.ts +++ b/apps/api/src/app/cache/cache.module.ts @@ -1,10 +1,10 @@ import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; -import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; -import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; -import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; -import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; -import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module'; +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { Module } from '@nestjs/common'; import { CacheController } from './cache.controller'; diff --git a/apps/api/src/app/exchange-rate/exchange-rate.controller.ts b/apps/api/src/app/exchange-rate/exchange-rate.controller.ts new file mode 100644 index 000000000..8e01c4ca9 --- /dev/null +++ b/apps/api/src/app/exchange-rate/exchange-rate.controller.ts @@ -0,0 +1,43 @@ +import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; +import { + Controller, + Get, + HttpException, + Param, + UseGuards +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { parseISO } from 'date-fns'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +import { ExchangeRateService } from './exchange-rate.service'; + +@Controller('exchange-rate') +export class ExchangeRateController { + public constructor( + private readonly exchangeRateService: ExchangeRateService + ) {} + + @Get(':symbol/:dateString') + @UseGuards(AuthGuard('jwt')) + public async getExchangeRate( + @Param('dateString') dateString: string, + @Param('symbol') symbol: string + ): Promise { + const date = parseISO(dateString); + + const exchangeRate = await this.exchangeRateService.getExchangeRate({ + date, + symbol + }); + + if (exchangeRate) { + return { marketPrice: exchangeRate }; + } + + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } +} diff --git a/apps/api/src/app/exchange-rate/exchange-rate.module.ts b/apps/api/src/app/exchange-rate/exchange-rate.module.ts new file mode 100644 index 000000000..d94f047f3 --- /dev/null +++ b/apps/api/src/app/exchange-rate/exchange-rate.module.ts @@ -0,0 +1,13 @@ +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; +import { Module } from '@nestjs/common'; + +import { ExchangeRateController } from './exchange-rate.controller'; +import { ExchangeRateService } from './exchange-rate.service'; + +@Module({ + controllers: [ExchangeRateController], + exports: [ExchangeRateService], + imports: [ExchangeRateDataModule], + providers: [ExchangeRateService] +}) +export class ExchangeRateModule {} diff --git a/apps/api/src/app/exchange-rate/exchange-rate.service.ts b/apps/api/src/app/exchange-rate/exchange-rate.service.ts new file mode 100644 index 000000000..44a8d0568 --- /dev/null +++ b/apps/api/src/app/exchange-rate/exchange-rate.service.ts @@ -0,0 +1,26 @@ +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ExchangeRateService { + public constructor( + private readonly exchangeRateDataService: ExchangeRateDataService + ) {} + + public async getExchangeRate({ + date, + symbol + }: { + date: Date; + symbol: string; + }): Promise { + const [currency1, currency2] = symbol.split('-'); + + return this.exchangeRateDataService.toCurrencyAtDate( + 1, + currency1, + currency2, + date + ); + } +} diff --git a/apps/api/src/app/export/export.module.ts b/apps/api/src/app/export/export.module.ts index dd273c717..ca4588925 100644 --- a/apps/api/src/app/export/export.module.ts +++ b/apps/api/src/app/export/export.module.ts @@ -1,8 +1,9 @@ +import { AccountModule } from '@ghostfolio/api/app/account/account.module'; +import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; -import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; -import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; -import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { Module } from '@nestjs/common'; import { ExportController } from './export.controller'; @@ -10,10 +11,11 @@ import { ExportService } from './export.service'; @Module({ imports: [ + AccountModule, ConfigurationModule, DataGatheringModule, DataProviderModule, - PrismaModule, + OrderModule, RedisCacheModule ], controllers: [ExportController], diff --git a/apps/api/src/app/export/export.service.ts b/apps/api/src/app/export/export.service.ts index c4655e7d8..2134a6520 100644 --- a/apps/api/src/app/export/export.service.ts +++ b/apps/api/src/app/export/export.service.ts @@ -1,11 +1,15 @@ +import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { environment } from '@ghostfolio/api/environments/environment'; -import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { Export } from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; @Injectable() export class ExportService { - public constructor(private readonly prismaService: PrismaService) {} + public constructor( + private readonly accountService: AccountService, + private readonly orderService: OrderService + ) {} public async export({ activityIds, @@ -14,19 +18,30 @@ export class ExportService { activityIds?: string[]; userId: string; }): Promise { - let activities = await this.prismaService.order.findMany({ + const accounts = ( + await this.accountService.accounts({ + orderBy: { + name: 'asc' + }, + where: { userId } + }) + ).map( + ({ balance, comment, currency, id, isExcluded, name, platformId }) => { + return { + balance, + comment, + currency, + id, + isExcluded, + name, + platformId + }; + } + ); + + let activities = await this.orderService.orders({ + include: { SymbolProfile: true }, orderBy: { date: 'desc' }, - select: { - accountId: true, - comment: true, - date: true, - fee: true, - id: true, - quantity: true, - SymbolProfile: true, - type: true, - unitPrice: true - }, where: { userId } }); @@ -38,6 +53,7 @@ export class ExportService { return { meta: { date: new Date().toISOString(), version: environment.version }, + accounts, activities: activities.map( ({ accountId, @@ -61,7 +77,13 @@ export class ExportService { currency: SymbolProfile.currency, dataSource: SymbolProfile.dataSource, date: date.toISOString(), - symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol + symbol: + type === 'FEE' || + type === 'INTEREST' || + type === 'ITEM' || + type === 'LIABILITY' + ? SymbolProfile.name + : SymbolProfile.symbol }; } ) diff --git a/apps/api/src/app/frontend.middleware.ts b/apps/api/src/app/frontend.middleware.ts deleted file mode 100644 index a1ab6452c..000000000 --- a/apps/api/src/app/frontend.middleware.ts +++ /dev/null @@ -1,146 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; -import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; -import { Injectable, NestMiddleware } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { NextFunction, Request, Response } from 'express'; - -@Injectable() -export class FrontendMiddleware implements NestMiddleware { - public indexHtmlDe = ''; - public indexHtmlEn = ''; - public indexHtmlEs = ''; - public indexHtmlIt = ''; - public indexHtmlNl = ''; - public isProduction: boolean; - - public constructor( - private readonly configService: ConfigService, - private readonly configurationService: ConfigurationService - ) { - const NODE_ENV = - this.configService.get<'development' | 'production'>('NODE_ENV') ?? - 'development'; - - this.isProduction = NODE_ENV === 'production'; - - try { - this.indexHtmlDe = fs.readFileSync( - this.getPathOfIndexHtmlFile('de'), - 'utf8' - ); - this.indexHtmlEn = fs.readFileSync( - this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE), - 'utf8' - ); - this.indexHtmlEs = fs.readFileSync( - this.getPathOfIndexHtmlFile('es'), - 'utf8' - ); - this.indexHtmlIt = fs.readFileSync( - this.getPathOfIndexHtmlFile('it'), - 'utf8' - ); - this.indexHtmlNl = fs.readFileSync( - this.getPathOfIndexHtmlFile('nl'), - 'utf8' - ); - } catch {} - } - - public use(req: Request, res: Response, next: NextFunction) { - let featureGraphicPath = 'assets/cover.png'; - - if ( - req.path === '/en/blog/2022/08/500-stars-on-github' || - req.path === '/en/blog/2022/08/500-stars-on-github/' - ) { - featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg'; - } else if ( - req.path === '/en/blog/2022/10/hacktoberfest-2022' || - req.path === '/en/blog/2022/10/hacktoberfest-2022/' - ) { - featureGraphicPath = 'assets/images/blog/hacktoberfest-2022.png'; - } - - if ( - req.path.startsWith('/api/') || - this.isFileRequest(req.url) || - !this.isProduction - ) { - // Skip - next(); - } else if (req.path === '/de' || req.path.startsWith('/de/')) { - res.send( - this.interpolate(this.indexHtmlDe, { - featureGraphicPath, - languageCode: 'de', - path: req.path, - rootUrl: this.configurationService.get('ROOT_URL') - }) - ); - } else if (req.path === '/es' || req.path.startsWith('/es/')) { - res.send( - this.interpolate(this.indexHtmlEs, { - featureGraphicPath, - languageCode: 'es', - path: req.path, - rootUrl: this.configurationService.get('ROOT_URL') - }) - ); - } else if (req.path === '/it' || req.path.startsWith('/it/')) { - res.send( - this.interpolate(this.indexHtmlIt, { - featureGraphicPath, - languageCode: 'it', - path: req.path, - rootUrl: this.configurationService.get('ROOT_URL') - }) - ); - } else if (req.path === '/nl' || req.path.startsWith('/nl/')) { - res.send( - this.interpolate(this.indexHtmlNl, { - featureGraphicPath, - languageCode: 'nl', - path: req.path, - rootUrl: this.configurationService.get('ROOT_URL') - }) - ); - } else { - res.send( - this.interpolate(this.indexHtmlEn, { - featureGraphicPath, - languageCode: DEFAULT_LANGUAGE_CODE, - path: req.path, - rootUrl: this.configurationService.get('ROOT_URL') - }) - ); - } - } - - private getPathOfIndexHtmlFile(aLocale: string) { - return path.join(__dirname, '..', 'client', aLocale, 'index.html'); - } - - private interpolate(template: string, context: any) { - return template.replace(/[$]{([^}]+)}/g, (_, objectPath) => { - const properties = objectPath.split('.'); - return properties.reduce( - (previous, current) => previous?.[current], - context - ); - }); - } - - private isFileRequest(filename: string) { - if (filename === '/assets/LICENSE') { - return true; - } else if (filename.includes('auth/ey')) { - return false; - } - - return filename.split('.').pop() !== filename; - } -} diff --git a/apps/api/src/app/health/health.controller.ts b/apps/api/src/app/health/health.controller.ts new file mode 100644 index 000000000..cc430c0dc --- /dev/null +++ b/apps/api/src/app/health/health.controller.ts @@ -0,0 +1,56 @@ +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; +import { + Controller, + Get, + HttpException, + Param, + UseInterceptors +} from '@nestjs/common'; +import { DataSource } from '@prisma/client'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +import { HealthService } from './health.service'; + +@Controller('health') +export class HealthController { + public constructor(private readonly healthService: HealthService) {} + + @Get() + public async getHealth() {} + + @Get('data-enhancer/:name') + public async getHealthOfDataEnhancer(@Param('name') name: string) { + const hasResponse = + await this.healthService.hasResponseFromDataEnhancer(name); + + if (hasResponse !== true) { + throw new HttpException( + getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE), + StatusCodes.SERVICE_UNAVAILABLE + ); + } + } + + @Get('data-provider/:dataSource') + @UseInterceptors(TransformDataSourceInRequestInterceptor) + public async getHealthOfDataProvider( + @Param('dataSource') dataSource: DataSource + ) { + if (!DataSource[dataSource]) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + const hasResponse = + await this.healthService.hasResponseFromDataProvider(dataSource); + + if (hasResponse !== true) { + throw new HttpException( + getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE), + StatusCodes.SERVICE_UNAVAILABLE + ); + } + } +} diff --git a/apps/api/src/app/health/health.module.ts b/apps/api/src/app/health/health.module.ts new file mode 100644 index 000000000..b6952c3b5 --- /dev/null +++ b/apps/api/src/app/health/health.module.ts @@ -0,0 +1,14 @@ +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module'; +import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; +import { Module } from '@nestjs/common'; + +import { HealthController } from './health.controller'; +import { HealthService } from './health.service'; + +@Module({ + controllers: [HealthController], + imports: [ConfigurationModule, DataEnhancerModule, DataProviderModule], + providers: [HealthService] +}) +export class HealthModule {} diff --git a/apps/api/src/app/health/health.service.ts b/apps/api/src/app/health/health.service.ts new file mode 100644 index 000000000..8fac2dde9 --- /dev/null +++ b/apps/api/src/app/health/health.service.ts @@ -0,0 +1,20 @@ +import { DataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.service'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { Injectable } from '@nestjs/common'; +import { DataSource } from '@prisma/client'; + +@Injectable() +export class HealthService { + public constructor( + private readonly dataEnhancerService: DataEnhancerService, + private readonly dataProviderService: DataProviderService + ) {} + + public async hasResponseFromDataEnhancer(aName: string) { + return this.dataEnhancerService.enhance(aName); + } + + public async hasResponseFromDataProvider(aDataSource: DataSource) { + return this.dataProviderService.checkQuote(aDataSource); + } +} diff --git a/apps/api/src/app/import/import-data.dto.ts b/apps/api/src/app/import/import-data.dto.ts index f3a0ba8fe..fa954a2c9 100644 --- a/apps/api/src/app/import/import-data.dto.ts +++ b/apps/api/src/app/import/import-data.dto.ts @@ -1,8 +1,15 @@ +import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { Type } from 'class-transformer'; -import { IsArray, ValidateNested } from 'class-validator'; +import { IsArray, IsOptional, ValidateNested } from 'class-validator'; export class ImportDataDto { + @IsOptional() + @IsArray() + @Type(() => CreateAccountDto) + @ValidateNested({ each: true }) + accounts: CreateAccountDto[]; + @IsArray() @Type(() => CreateOrderDto) @ValidateNested({ each: true }) diff --git a/apps/api/src/app/import/import.controller.ts b/apps/api/src/app/import/import.controller.ts index 422e1cb9f..9fbc8075c 100644 --- a/apps/api/src/app/import/import.controller.ts +++ b/apps/api/src/app/import/import.controller.ts @@ -1,16 +1,25 @@ -import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; +import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ImportResponse } from '@ghostfolio/common/interfaces'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import type { RequestWithUser } from '@ghostfolio/common/types'; import { Body, Controller, + Get, HttpException, Inject, Logger, + Param, Post, - UseGuards + Query, + UseGuards, + UseInterceptors } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; +import { DataSource } from '@prisma/client'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { ImportDataDto } from './import-data.dto'; @@ -26,8 +35,19 @@ export class ImportController { @Post() @UseGuards(AuthGuard('jwt')) - public async import(@Body() importData: ImportDataDto): Promise { - if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) { + @UseInterceptors(TransformDataSourceInRequestInterceptor) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async import( + @Body() importData: ImportDataDto, + @Query('dryRun') isDryRun?: boolean + ): Promise { + if ( + !hasPermission( + this.request.user.permissions, + permissions.createAccount + ) || + !hasPermission(this.request.user.permissions, permissions.createOrder) + ) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), StatusCodes.FORBIDDEN @@ -45,12 +65,19 @@ export class ImportController { maxActivitiesToImport = Number.MAX_SAFE_INTEGER; } + const userCurrency = this.request.user.Settings.settings.baseCurrency; + try { - return await this.importService.import({ + const activities = await this.importService.import({ + isDryRun, maxActivitiesToImport, - activities: importData.activities, + userCurrency, + accountsDto: importData.accounts ?? [], + activitiesDto: importData.activities, userId: this.request.user.id }); + + return { activities }; } catch (error) { Logger.error(error, ImportController); @@ -63,4 +90,23 @@ export class ImportController { ); } } + + @Get('dividends/:dataSource/:symbol') + @UseGuards(AuthGuard('jwt')) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async gatherDividends( + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + const userCurrency = this.request.user.Settings.settings.baseCurrency; + + const activities = await this.importService.getDividends({ + dataSource, + symbol, + userCurrency + }); + + return { activities }; + } } diff --git a/apps/api/src/app/import/import.module.ts b/apps/api/src/app/import/import.module.ts index 62d227bf5..8b5668860 100644 --- a/apps/api/src/app/import/import.module.ts +++ b/apps/api/src/app/import/import.module.ts @@ -1,11 +1,15 @@ import { AccountModule } from '@ghostfolio/api/app/account/account.module'; import { CacheModule } from '@ghostfolio/api/app/cache/cache.module'; import { OrderModule } from '@ghostfolio/api/app/order/order.module'; +import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module'; +import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; -import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; -import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; -import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { Module } from '@nestjs/common'; import { ImportController } from './import.controller'; @@ -19,9 +23,13 @@ import { ImportService } from './import.service'; ConfigurationModule, DataGatheringModule, DataProviderModule, + ExchangeRateDataModule, OrderModule, + PlatformModule, + PortfolioModule, PrismaModule, - RedisCacheModule + RedisCacheModule, + SymbolProfileModule ], providers: [ImportService] }) diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 7617b8cb3..83d062b83 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -1,149 +1,594 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; +import { + Activity, + ActivityError +} from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { OrderService } from '@ghostfolio/api/app/order/order.service'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; +import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { + DATE_FORMAT, + getAssetProfileIdentifier, + parseDate +} from '@ghostfolio/common/helper'; +import { UniqueAsset } from '@ghostfolio/common/interfaces'; +import { + AccountWithPlatform, + OrderWithAccount +} from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; -import { isSameDay, parseISO } from 'date-fns'; +import { DataSource, Prisma, SymbolProfile } from '@prisma/client'; +import Big from 'big.js'; +import { endOfToday, format, isAfter, isSameDay, parseISO } from 'date-fns'; +import { uniqBy } from 'lodash'; +import { v4 as uuidv4 } from 'uuid'; @Injectable() export class ImportService { public constructor( private readonly accountService: AccountService, - private readonly configurationService: ConfigurationService, + private readonly dataGatheringService: DataGatheringService, private readonly dataProviderService: DataProviderService, - private readonly orderService: OrderService + private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly orderService: OrderService, + private readonly platformService: PlatformService, + private readonly portfolioService: PortfolioService, + private readonly symbolProfileService: SymbolProfileService ) {} + public async getDividends({ + dataSource, + symbol, + userCurrency + }: UniqueAsset & { userCurrency: string }): Promise { + try { + const { firstBuyDate, historicalData, orders } = + await this.portfolioService.getPosition(dataSource, undefined, symbol); + + const [[assetProfile], dividends] = await Promise.all([ + this.symbolProfileService.getSymbolProfiles([ + { + dataSource, + symbol + } + ]), + await this.dataProviderService.getDividends({ + dataSource, + symbol, + from: parseDate(firstBuyDate), + granularity: 'day', + to: new Date() + }) + ]); + + const accounts = orders.map((order) => { + return order.Account; + }); + + const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined; + + return Object.entries(dividends).map(([dateString, { marketPrice }]) => { + const quantity = + historicalData.find((historicalDataItem) => { + return historicalDataItem.date === dateString; + })?.quantity ?? 0; + + const value = new Big(quantity).mul(marketPrice).toNumber(); + + const isDuplicate = orders.some((activity) => { + return ( + activity.SymbolProfile.currency === assetProfile.currency && + activity.SymbolProfile.dataSource === assetProfile.dataSource && + isSameDay(activity.date, parseDate(dateString)) && + activity.quantity === quantity && + activity.SymbolProfile.symbol === assetProfile.symbol && + activity.type === 'DIVIDEND' && + activity.unitPrice === marketPrice + ); + }); + + const error: ActivityError = isDuplicate + ? { code: 'IS_DUPLICATE' } + : undefined; + + return { + Account, + error, + quantity, + value, + accountId: Account?.id, + accountUserId: undefined, + comment: undefined, + createdAt: undefined, + date: parseDate(dateString), + fee: 0, + feeInBaseCurrency: 0, + id: assetProfile.id, + isDraft: false, + SymbolProfile: (assetProfile), + symbolProfileId: assetProfile.id, + type: 'DIVIDEND', + unitPrice: marketPrice, + updatedAt: undefined, + userId: Account?.userId, + valueInBaseCurrency: this.exchangeRateDataService.toCurrency( + value, + assetProfile.currency, + userCurrency + ) + }; + }); + } catch { + return []; + } + } + public async import({ - activities, + accountsDto, + activitiesDto, + isDryRun = false, maxActivitiesToImport, + userCurrency, userId }: { - activities: Partial[]; + accountsDto: Partial[]; + activitiesDto: Partial[]; + isDryRun?: boolean; maxActivitiesToImport: number; + userCurrency: string; userId: string; - }): Promise { - for (const activity of activities) { + }): Promise { + const accountIdMapping: { [oldAccountId: string]: string } = {}; + + if (!isDryRun && accountsDto?.length) { + const [existingAccounts, existingPlatforms] = await Promise.all([ + this.accountService.accounts({ + where: { + id: { + in: accountsDto.map(({ id }) => { + return id; + }) + } + } + }), + this.platformService.getPlatforms() + ]); + + for (const account of accountsDto) { + // Check if there is any existing account with the same ID + const accountWithSameId = existingAccounts.find( + (existingAccount) => existingAccount.id === account.id + ); + + // If there is no account or if the account belongs to a different user then create a new account + if (!accountWithSameId || accountWithSameId.userId !== userId) { + let oldAccountId: string; + const platformId = account.platformId; + + delete account.platformId; + + if (accountWithSameId) { + oldAccountId = account.id; + delete account.id; + } + + let accountObject: Prisma.AccountCreateInput = { + ...account, + User: { connect: { id: userId } } + }; + + if ( + existingPlatforms.some(({ id }) => { + return id === platformId; + }) + ) { + accountObject = { + ...accountObject, + Platform: { connect: { id: platformId } } + }; + } + + const newAccount = await this.accountService.createAccount( + accountObject, + userId + ); + + // Store the new to old account ID mappings for updating activities + if (accountWithSameId && oldAccountId) { + accountIdMapping[oldAccountId] = newAccount.id; + } + } + } + } + + for (const activity of activitiesDto) { if (!activity.dataSource) { - if (activity.type === 'ITEM') { - activity.dataSource = 'MANUAL'; + if (activity.type === 'ITEM' || activity.type === 'LIABILITY') { + activity.dataSource = DataSource.MANUAL; } else { - activity.dataSource = this.dataProviderService.getPrimaryDataSource(); + activity.dataSource = + this.dataProviderService.getDataSourceForImport(); + } + } + + // If a new account is created, then update the accountId in all activities + if (!isDryRun) { + if (Object.keys(accountIdMapping).includes(activity.accountId)) { + activity.accountId = accountIdMapping[activity.accountId]; } } } - await this.validateActivities({ - activities, - maxActivitiesToImport, + const assetProfiles = await this.validateActivities({ + activitiesDto, + maxActivitiesToImport + }); + + const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({ + activitiesDto, userId }); - const accountIds = (await this.accountService.getAccounts(userId)).map( - (account) => { - return account.id; + const accounts = (await this.accountService.getAccounts(userId)).map( + ({ id, name }) => { + return { id, name }; } ); - for (const { - accountId, - comment, - currency, - dataSource, - date, - fee, - quantity, - symbol, - type, - unitPrice - } of activities) { - await this.orderService.createOrder({ + if (isDryRun) { + accountsDto.forEach(({ id, name }) => { + accounts.push({ id, name }); + }); + } + + const activities: Activity[] = []; + + for (let [ + index, + { + accountId, comment, + date, + error, fee, quantity, + SymbolProfile, type, - unitPrice, - userId, - accountId: accountIds.includes(accountId) ? accountId : undefined, - date: parseISO((date)), - SymbolProfile: { - connectOrCreate: { - create: { - currency, - dataSource, - symbol - }, - where: { - dataSource_symbol: { + unitPrice + } + ] of activitiesExtendedWithErrors.entries()) { + const assetProfile = assetProfiles[ + getAssetProfileIdentifier({ + dataSource: SymbolProfile.dataSource, + symbol: SymbolProfile.symbol + }) + ] ?? { + currency: SymbolProfile.currency, + dataSource: SymbolProfile.dataSource, + symbol: SymbolProfile.symbol + }; + const { + assetClass, + assetSubClass, + countries, + createdAt, + currency, + dataSource, + id, + isin, + name, + scraperConfiguration, + sectors, + symbol, + symbolMapping, + url, + updatedAt + } = assetProfile; + const validatedAccount = accounts.find(({ id }) => { + return id === accountId; + }); + + let order: + | OrderWithAccount + | (Omit & { + Account?: { id: string; name: string }; + }); + + if (SymbolProfile.currency !== assetProfile.currency) { + // Convert the unit price and fee to the asset currency if the imported + // activity is in a different currency + unitPrice = await this.exchangeRateDataService.toCurrencyAtDate( + unitPrice, + SymbolProfile.currency, + assetProfile.currency, + date + ); + + if (!unitPrice) { + throw new Error( + `activities.${index} historical exchange rate at ${format( + date, + DATE_FORMAT + )} is not available from "${SymbolProfile.currency}" to "${ + assetProfile.currency + }"` + ); + } + + fee = await this.exchangeRateDataService.toCurrencyAtDate( + fee, + SymbolProfile.currency, + assetProfile.currency, + date + ); + } + + if (isDryRun) { + order = { + comment, + date, + fee, + quantity, + type, + unitPrice, + userId, + accountId: validatedAccount?.id, + accountUserId: undefined, + createdAt: new Date(), + id: uuidv4(), + isDraft: isAfter(date, endOfToday()), + SymbolProfile: { + assetClass, + assetSubClass, + countries, + createdAt, + currency, + dataSource, + id, + isin, + name, + scraperConfiguration, + sectors, + symbol, + symbolMapping, + updatedAt, + url, + comment: assetProfile.comment + }, + Account: validatedAccount, + symbolProfileId: undefined, + updatedAt: new Date() + }; + } else { + if (error) { + continue; + } + + order = await this.orderService.createOrder({ + comment, + date, + fee, + quantity, + type, + unitPrice, + userId, + accountId: validatedAccount?.id, + SymbolProfile: { + connectOrCreate: { + create: { + currency, dataSource, symbol + }, + where: { + dataSource_symbol: { + dataSource, + symbol + } } } - } - }, - User: { connect: { id: userId } } + }, + updateAccountBalance: false, + User: { connect: { id: userId } } + }); + } + + const value = new Big(quantity).mul(unitPrice).toNumber(); + + activities.push({ + ...order, + error, + value, + feeInBaseCurrency: this.exchangeRateDataService.toCurrency( + fee, + currency, + userCurrency + ), + // @ts-ignore + SymbolProfile: assetProfile, + valueInBaseCurrency: this.exchangeRateDataService.toCurrency( + value, + currency, + userCurrency + ) }); } + + activities.sort((activity1, activity2) => { + return Number(activity1.date) - Number(activity2.date); + }); + + if (!isDryRun) { + // Gather symbol data in the background, if not dry run + const uniqueActivities = uniqBy(activities, ({ SymbolProfile }) => { + return getAssetProfileIdentifier({ + dataSource: SymbolProfile.dataSource, + symbol: SymbolProfile.symbol + }); + }); + + this.dataGatheringService.gatherSymbols( + uniqueActivities.map(({ date, SymbolProfile }) => { + return { + date, + dataSource: SymbolProfile.dataSource, + symbol: SymbolProfile.symbol + }; + }) + ); + } + + return activities; } - private async validateActivities({ - activities, - maxActivitiesToImport, + private async extendActivitiesWithErrors({ + activitiesDto, userId }: { - activities: Partial[]; - maxActivitiesToImport: number; + activitiesDto: Partial[]; userId: string; - }) { - if (activities?.length > maxActivitiesToImport) { - throw new Error(`Too many activities (${maxActivitiesToImport} at most)`); - } - + }): Promise[]> { const existingActivities = await this.orderService.orders({ include: { SymbolProfile: true }, orderBy: { date: 'desc' }, where: { userId } }); - for (const [ - index, - { currency, dataSource, date, fee, quantity, symbol, type, unitPrice } - ] of activities.entries()) { - const duplicateActivity = existingActivities.find((activity) => { - return ( - activity.SymbolProfile.currency === currency && - activity.SymbolProfile.dataSource === dataSource && - isSameDay(activity.date, parseISO((date))) && - activity.fee === fee && - activity.quantity === quantity && - activity.SymbolProfile.symbol === symbol && - activity.type === type && - activity.unitPrice === unitPrice - ); - }); + return activitiesDto.map( + ({ + accountId, + comment, + currency, + dataSource, + date: dateString, + fee, + quantity, + symbol, + type, + unitPrice + }) => { + const date = parseISO((dateString)); + const isDuplicate = existingActivities.some((activity) => { + return ( + activity.SymbolProfile.currency === currency && + activity.SymbolProfile.dataSource === dataSource && + isSameDay(activity.date, date) && + activity.fee === fee && + activity.quantity === quantity && + activity.SymbolProfile.symbol === symbol && + activity.type === type && + activity.unitPrice === unitPrice + ); + }); + + const error: ActivityError = isDuplicate + ? { code: 'IS_DUPLICATE' } + : undefined; - if (duplicateActivity) { - throw new Error(`activities.${index} is a duplicate activity`); + return { + accountId, + comment, + date, + error, + fee, + quantity, + type, + unitPrice, + SymbolProfile: { + currency, + dataSource, + symbol, + assetClass: null, + assetSubClass: null, + comment: null, + countries: null, + createdAt: undefined, + id: undefined, + isin: null, + name: null, + scraperConfiguration: null, + sectors: null, + symbolMapping: null, + updatedAt: undefined, + url: null + } + }; } + ); + } + private isUniqueAccount(accounts: AccountWithPlatform[]) { + const uniqueAccountIds = new Set(); + + for (const account of accounts) { + uniqueAccountIds.add(account.id); + } + + return uniqueAccountIds.size === 1; + } + + private async validateActivities({ + activitiesDto, + maxActivitiesToImport + }: { + activitiesDto: Partial[]; + maxActivitiesToImport: number; + }) { + if (activitiesDto?.length > maxActivitiesToImport) { + throw new Error(`Too many activities (${maxActivitiesToImport} at most)`); + } + + const assetProfiles: { + [assetProfileIdentifier: string]: Partial; + } = {}; + + const uniqueActivitiesDto = uniqBy( + activitiesDto, + ({ dataSource, symbol }) => { + return getAssetProfileIdentifier({ dataSource, symbol }); + } + ); + + for (const [ + index, + { currency, dataSource, symbol } + ] of uniqueActivitiesDto.entries()) { if (dataSource !== 'MANUAL') { - const quotes = await this.dataProviderService.getQuotes([ - { dataSource, symbol } - ]); + const assetProfile = ( + await this.dataProviderService.getAssetProfiles([ + { dataSource, symbol } + ]) + )?.[symbol]; - if (quotes[symbol] === undefined) { + if (!assetProfile?.name) { throw new Error( `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` ); } - if (quotes[symbol].currency !== currency) { + if ( + assetProfile.currency !== currency && + !this.exchangeRateDataService.hasCurrencyPair( + currency, + assetProfile.currency + ) + ) { throw new Error( - `activities.${index}.currency ("${currency}") does not match with "${quotes[symbol].currency}"` + `activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}" and no exchange rate is available from "${currency}" to "${assetProfile.currency}"` ); } + + assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] = + assetProfile; } } + + return assetProfiles; } } diff --git a/apps/api/src/app/info/info.module.ts b/apps/api/src/app/info/info.module.ts index 4c9c9f9d9..a6b5b5b0b 100644 --- a/apps/api/src/app/info/info.module.ts +++ b/apps/api/src/app/info/info.module.ts @@ -1,12 +1,14 @@ import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module'; +import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; -import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; -import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; +import { UserModule } from '@ghostfolio/api/app/user/user.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; -import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; -import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; -import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.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'; import { JwtModule } from '@nestjs/jwt'; @@ -26,11 +28,12 @@ import { InfoService } from './info.service'; secret: process.env.JWT_SECRET_KEY, signOptions: { expiresIn: '30 days' } }), - PrismaModule, + PlatformModule, PropertyModule, RedisCacheModule, SymbolProfileModule, - TagModule + TagModule, + UserModule ], providers: [InfoService] }) diff --git a/apps/api/src/app/info/info.service.ts b/apps/api/src/app/info/info.service.ts index 8ed589cb5..4fc4aec4e 100644 --- a/apps/api/src/app/info/info.service.ts +++ b/apps/api/src/app/info/info.service.ts @@ -1,12 +1,17 @@ import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service'; +import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; -import { PrismaService } from '@ghostfolio/api/services/prisma.service'; +import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { - DEMO_USER_ID, + DEFAULT_CURRENCY, + DEFAULT_REQUEST_TIMEOUT, + PROPERTY_BETTER_UPTIME_MONITOR_ID, + PROPERTY_COUNTRIES_OF_SUBSCRIBERS, + PROPERTY_DEMO_USER_ID, PROPERTY_IS_READ_ONLY_MODE, PROPERTY_SLACK_COMMUNITY_USERS, PROPERTY_STRIPE_CONFIG, @@ -14,18 +19,22 @@ import { ghostfolioFearAndGreedIndexDataSource } from '@ghostfolio/common/config'; import { + DATE_FORMAT, encodeDataSource, extractNumberFromString } from '@ghostfolio/common/helper'; -import { InfoItem } from '@ghostfolio/common/interfaces'; -import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface'; -import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface'; +import { + InfoItem, + Statistics, + Subscription +} from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; +import { SubscriptionOffer } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; -import * as bent from 'bent'; import * as cheerio from 'cheerio'; -import { subDays } from 'date-fns'; +import { format, subDays } from 'date-fns'; +import got from 'got'; @Injectable() export class InfoService { @@ -36,18 +45,18 @@ export class InfoService { private readonly configurationService: ConfigurationService, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly jwtService: JwtService, - private readonly prismaService: PrismaService, + private readonly platformService: PlatformService, private readonly propertyService: PropertyService, private readonly redisCacheService: RedisCacheService, - private readonly tagService: TagService + private readonly tagService: TagService, + private readonly userService: UserService ) {} public async get(): Promise { const info: Partial = {}; let isReadOnlyMode: boolean; - const platforms = await this.prismaService.platform.findMany({ - orderBy: { name: 'asc' }, - select: { id: true, name: true } + const platforms = await this.platformService.getPlatforms({ + orderBy: { name: 'asc' } }); let systemMessage: string; @@ -58,9 +67,7 @@ export class InfoService { } if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { - if ( - this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true - ) { + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { info.fearAndGreedDataSource = encodeDataSource( ghostfolioFearAndGreedIndexDataSource ); @@ -71,10 +78,6 @@ export class InfoService { globalPermissions.push(permissions.enableFearAndGreedIndex); } - if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) { - globalPermissions.push(permissions.enableImport); - } - if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) { isReadOnlyMode = (await this.propertyService.getByKey( PROPERTY_IS_READ_ONLY_MODE @@ -92,6 +95,10 @@ export class InfoService { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { globalPermissions.push(permissions.enableSubscription); + info.countriesOfSubscribers = + ((await this.propertyService.getByKey( + PROPERTY_COUNTRIES_OF_SUBSCRIBERS + )) as string[]) ?? []; info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY'); } @@ -103,29 +110,40 @@ export class InfoService { )) as string; } + const isUserSignupEnabled = + await this.propertyService.isUserSignupEnabled(); + + if (isUserSignupEnabled) { + globalPermissions.push(permissions.createUserAccount); + } + + const [benchmarks, demoAuthToken, statistics, subscriptions, tags] = + await Promise.all([ + this.benchmarkService.getBenchmarkAssetProfiles(), + this.getDemoAuthToken(), + this.getStatistics(), + this.getSubscriptions(), + this.tagService.get() + ]); + return { ...info, + benchmarks, + demoAuthToken, globalPermissions, isReadOnlyMode, platforms, + statistics, + subscriptions, systemMessage, - baseCurrency: this.configurationService.get('BASE_CURRENCY'), - benchmarks: await this.benchmarkService.getBenchmarkAssetProfiles(), - currencies: this.exchangeRateDataService.getCurrencies(), - demoAuthToken: this.getDemoAuthToken(), - statistics: await this.getStatistics(), - subscriptions: await this.getSubscriptions(), - tags: await this.tagService.get() + tags, + baseCurrency: DEFAULT_CURRENCY, + currencies: this.exchangeRateDataService.getCurrencies() }; } private async countActiveUsers(aDays: number) { - return await this.prismaService.user.count({ - orderBy: { - Analytics: { - updatedAt: 'desc' - } - }, + return this.userService.count({ where: { AND: [ { @@ -147,20 +165,24 @@ export class InfoService { private async countDockerHubPulls(): Promise { try { - const get = bent( + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const { pull_count } = await got( `https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`, - 'GET', - 'json', - 200, { - 'User-Agent': 'request' + headers: { 'User-Agent': 'request' }, + // @ts-ignore + signal: abortController.signal } - ); + ).json(); - const { pull_count } = await get(); return pull_count; } catch (error) { - Logger.error(error, 'InfoService'); + Logger.error(error, 'InfoService - DockerHub'); return undefined; } @@ -168,16 +190,18 @@ export class InfoService { private async countGitHubContributors(): Promise { try { - const get = bent( - 'https://github.com/ghostfolio/ghostfolio', - 'GET', - 'string', - 200, - {} - ); + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); - const html = await get(); - const $ = cheerio.load(html); + const { body } = await got('https://github.com/ghostfolio/ghostfolio', { + // @ts-ignore + signal: abortController.signal + }); + + const $ = cheerio.load(body); return extractNumberFromString( $( @@ -185,7 +209,7 @@ export class InfoService { ).text() ); } catch (error) { - Logger.error(error, 'InfoService'); + Logger.error(error, 'InfoService - GitHub'); return undefined; } @@ -193,30 +217,31 @@ export class InfoService { private async countGitHubStargazers(): Promise { try { - const get = bent( + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const { stargazers_count } = await got( `https://api.github.com/repos/ghostfolio/ghostfolio`, - 'GET', - 'json', - 200, { - 'User-Agent': 'request' + headers: { 'User-Agent': 'request' }, + // @ts-ignore + signal: abortController.signal } - ); + ).json(); - const { stargazers_count } = await get(); return stargazers_count; } catch (error) { - Logger.error(error, 'InfoService'); + Logger.error(error, 'InfoService - GitHub'); return undefined; } } private async countNewUsers(aDays: number) { - return await this.prismaService.user.count({ - orderBy: { - createdAt: 'desc' - }, + return this.userService.count({ where: { AND: [ { @@ -240,10 +265,18 @@ export class InfoService { )) as string; } - private getDemoAuthToken() { - return this.jwtService.sign({ - id: DEMO_USER_ID - }); + private async getDemoAuthToken() { + const demoUserId = (await this.propertyService.getByKey( + PROPERTY_DEMO_USER_ID + )) as string; + + if (demoUserId) { + return this.jwtService.sign({ + id: demoUserId + }); + } + + return undefined; } private async getStatistics() { @@ -271,6 +304,7 @@ export class InfoService { const gitHubContributors = await this.countGitHubContributors(); const gitHubStargazers = await this.countGitHubStargazers(); const slackCommunityUsers = await this.countSlackCommunityUsers(); + const uptime = await this.getUptime(); statistics = { activeUsers1d, @@ -279,7 +313,8 @@ export class InfoService { gitHubContributors, gitHubStargazers, newUsers30d, - slackCommunityUsers + slackCommunityUsers, + uptime }; await this.redisCacheService.set( @@ -290,19 +325,54 @@ export class InfoService { return statistics; } - private async getSubscriptions(): Promise { + private async getSubscriptions(): Promise<{ + [offer in SubscriptionOffer]: Subscription; + }> { if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { return undefined; } - const stripeConfig = await this.prismaService.property.findUnique({ - where: { key: PROPERTY_STRIPE_CONFIG } - }); + return ( + ((await this.propertyService.getByKey(PROPERTY_STRIPE_CONFIG)) as any) ?? + {} + ); + } - if (stripeConfig) { - return [JSON.parse(stripeConfig.value)]; - } + private async getUptime(): Promise { + { + try { + const monitorId = (await this.propertyService.getByKey( + PROPERTY_BETTER_UPTIME_MONITOR_ID + )) as string; - return []; + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const { data } = await got( + `https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format( + subDays(new Date(), 90), + DATE_FORMAT + )}&to${format(new Date(), DATE_FORMAT)}`, + { + headers: { + Authorization: `Bearer ${this.configurationService.get( + 'BETTER_UPTIME_API_KEY' + )}` + }, + // @ts-ignore + signal: abortController.signal + } + ).json(); + + return data.attributes.availability / 100; + } catch (error) { + Logger.error(error, 'InfoService - Better Stack'); + + return undefined; + } + } } } diff --git a/apps/api/src/app/logo/logo.controller.ts b/apps/api/src/app/logo/logo.controller.ts new file mode 100644 index 000000000..22bafc061 --- /dev/null +++ b/apps/api/src/app/logo/logo.controller.ts @@ -0,0 +1,54 @@ +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; +import { + Controller, + Get, + HttpStatus, + Param, + Query, + Res, + UseInterceptors +} from '@nestjs/common'; +import { DataSource } from '@prisma/client'; +import { Response } from 'express'; + +import { LogoService } from './logo.service'; + +@Controller('logo') +export class LogoController { + public constructor(private readonly logoService: LogoService) {} + + @Get(':dataSource/:symbol') + @UseInterceptors(TransformDataSourceInRequestInterceptor) + public async getLogoByDataSourceAndSymbol( + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string, + @Res() response: Response + ) { + try { + const buffer = await this.logoService.getLogoByDataSourceAndSymbol({ + dataSource, + symbol + }); + + response.contentType('image/png'); + response.send(buffer); + } catch { + response.status(HttpStatus.NOT_FOUND).send(); + } + } + + @Get() + public async getLogoByUrl( + @Query('url') url: string, + @Res() response: Response + ) { + try { + const buffer = await this.logoService.getLogoByUrl(url); + + response.contentType('image/png'); + response.send(buffer); + } catch { + response.status(HttpStatus.NOT_FOUND).send(); + } + } +} diff --git a/apps/api/src/app/logo/logo.module.ts b/apps/api/src/app/logo/logo.module.ts new file mode 100644 index 000000000..43052a14f --- /dev/null +++ b/apps/api/src/app/logo/logo.module.ts @@ -0,0 +1,13 @@ +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; +import { Module } from '@nestjs/common'; + +import { LogoController } from './logo.controller'; +import { LogoService } from './logo.service'; + +@Module({ + controllers: [LogoController], + imports: [ConfigurationModule, SymbolProfileModule], + providers: [LogoService] +}) +export class LogoModule {} diff --git a/apps/api/src/app/logo/logo.service.ts b/apps/api/src/app/logo/logo.service.ts new file mode 100644 index 000000000..80ae1d6a9 --- /dev/null +++ b/apps/api/src/app/logo/logo.service.ts @@ -0,0 +1,60 @@ +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config'; +import { UniqueAsset } from '@ghostfolio/common/interfaces'; +import { HttpException, Injectable } from '@nestjs/common'; +import { DataSource } from '@prisma/client'; +import got from 'got'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +@Injectable() +export class LogoService { + public constructor( + private readonly symbolProfileService: SymbolProfileService + ) {} + + public async getLogoByDataSourceAndSymbol({ + dataSource, + symbol + }: UniqueAsset) { + if (!DataSource[dataSource]) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ + { dataSource, symbol } + ]); + + if (!assetProfile) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + return this.getBuffer(assetProfile.url); + } + + public async getLogoByUrl(aUrl: string) { + return this.getBuffer(aUrl); + } + + private getBuffer(aUrl: string) { + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + return got( + `https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`, + { + headers: { 'User-Agent': 'request' }, + // @ts-ignore + signal: abortController.signal + } + ).buffer(); + } +} diff --git a/apps/api/src/app/order/create-order.dto.ts b/apps/api/src/app/order/create-order.dto.ts index 33e6f9cc8..49b193ca5 100644 --- a/apps/api/src/app/order/create-order.dto.ts +++ b/apps/api/src/app/order/create-order.dto.ts @@ -8,6 +8,7 @@ import { import { Transform, TransformFnParams } from 'class-transformer'; import { IsArray, + IsBoolean, IsEnum, IsISO8601, IsNumber, @@ -64,4 +65,8 @@ export class CreateOrderDto { @IsNumber() unitPrice: number; + + @IsBoolean() + @IsOptional() + updateAccountBalance?: boolean; } diff --git a/apps/api/src/app/order/interfaces/activities.interface.ts b/apps/api/src/app/order/interfaces/activities.interface.ts index e14adce0b..bc2c35a50 100644 --- a/apps/api/src/app/order/interfaces/activities.interface.ts +++ b/apps/api/src/app/order/interfaces/activities.interface.ts @@ -5,6 +5,14 @@ export interface Activities { } export interface Activity extends OrderWithAccount { + error?: ActivityError; feeInBaseCurrency: number; + updateAccountBalance?: boolean; + value: number; valueInBaseCurrency: number; } + +export interface ActivityError { + code: 'IS_DUPLICATE'; + message?: string; +} diff --git a/apps/api/src/app/order/order.controller.ts b/apps/api/src/app/order/order.controller.ts index 5f9e1522d..8c8e3e27a 100644 --- a/apps/api/src/app/order/order.controller.ts +++ b/apps/api/src/app/order/order.controller.ts @@ -1,10 +1,10 @@ -import { UserService } from '@ghostfolio/api/app/user/user.service'; -import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; -import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; +import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; +import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; +import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import type { RequestWithUser } from '@ghostfolio/common/types'; import { @@ -37,12 +37,29 @@ import { UpdateOrderDto } from './update-order.dto'; export class OrderController { public constructor( private readonly apiService: ApiService, + private readonly dataGatheringService: DataGatheringService, private readonly impersonationService: ImpersonationService, private readonly orderService: OrderService, - @Inject(REQUEST) private readonly request: RequestWithUser, - private readonly userService: UserService + @Inject(REQUEST) private readonly request: RequestWithUser ) {} + @Delete() + @UseGuards(AuthGuard('jwt')) + public async deleteOrders(): Promise { + if ( + !hasPermission(this.request.user.permissions, permissions.deleteOrder) + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.orderService.deleteOrders({ + userId: this.request.user.id + }); + } + @Delete(':id') @UseGuards(AuthGuard('jwt')) public async deleteOrder(@Param('id') id: string): Promise { @@ -69,10 +86,12 @@ export class OrderController { @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getAllOrders( - @Headers('impersonation-id') impersonationId, + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, @Query('accounts') filterByAccounts?: string, @Query('assetClasses') filterByAssetClasses?: string, - @Query('tags') filterByTags?: string + @Query('skip') skip?: number, + @Query('tags') filterByTags?: string, + @Query('take') take?: number ): Promise { const filters = this.apiService.buildFiltersFromQueryParams({ filterByAccounts, @@ -81,34 +100,19 @@ export class OrderController { }); const impersonationUserId = - await this.impersonationService.validateImpersonationId( - impersonationId, - this.request.user.id - ); + await this.impersonationService.validateImpersonationId(impersonationId); const userCurrency = this.request.user.Settings.settings.baseCurrency; - let activities = await this.orderService.getOrders({ + const activities = await this.orderService.getOrders({ filters, userCurrency, includeDrafts: true, + skip: isNaN(skip) ? undefined : skip, + take: isNaN(take) ? undefined : take, userId: impersonationUserId || this.request.user.id, withExcludedAccounts: true }); - if ( - impersonationUserId || - this.userService.isRestrictedView(this.request.user) - ) { - activities = nullifyValuesInObjects(activities, [ - 'fee', - 'feeInBaseCurrency', - 'quantity', - 'unitPrice', - 'value', - 'valueInBaseCurrency' - ]); - } - return { activities }; } @@ -125,7 +129,7 @@ export class OrderController { ); } - return this.orderService.createOrder({ + const order = await this.orderService.createOrder({ ...data, date: parseISO(data.date), SymbolProfile: { @@ -146,6 +150,20 @@ export class OrderController { User: { connect: { id: this.request.user.id } }, userId: this.request.user.id }); + + if (data.dataSource && !order.isDraft) { + // Gather symbol data in the background, if data source is set + // (not MANUAL) and not draft + this.dataGatheringService.gatherSymbols([ + { + dataSource: data.dataSource, + date: order.date, + symbol: data.symbol + } + ]); + } + + return order; } @Put(':id') diff --git a/apps/api/src/app/order/order.module.ts b/apps/api/src/app/order/order.module.ts index 7ecc577a5..8f033058d 100644 --- a/apps/api/src/app/order/order.module.ts +++ b/apps/api/src/app/order/order.module.ts @@ -2,14 +2,15 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { CacheModule } from '@ghostfolio/api/app/cache/cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module'; +import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; -import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; -import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; -import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; -import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module'; -import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; -import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.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 { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { Module } from '@nestjs/common'; import { OrderController } from './order.controller'; @@ -31,6 +32,6 @@ import { OrderService } from './order.service'; SymbolProfileModule, UserModule ], - providers: [AccountService, OrderService] + providers: [AccountBalanceService, AccountService, OrderService] }) export class OrderModule {} diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index b95c96975..10515018c 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -1,12 +1,13 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service'; -import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; -import { PrismaService } from '@ghostfolio/api/services/prisma.service'; -import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; +import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS_OPTIONS } from '@ghostfolio/common/config'; +import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { Filter } from '@ghostfolio/common/interfaces'; import { OrderWithAccount } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; @@ -73,35 +74,42 @@ export class OrderService { dataSource?: DataSource; symbol?: string; tags?: Tag[]; + updateAccountBalance?: boolean; userId: string; } ): Promise { - const defaultAccount = ( - await this.accountService.getAccounts(data.userId) - ).find((account) => { - return account.isDefault === true; - }); - - const tags = data.tags ?? []; - - let Account = { - connect: { - id_userId: { - userId: data.userId, - id: data.accountId ?? defaultAccount?.id + let Account; + + if (data.accountId) { + Account = { + connect: { + id_userId: { + userId: data.userId, + id: data.accountId + } } - } - }; + }; + } - if (data.type === 'ITEM') { + const accountId = data.accountId; + let currency = data.currency; + const tags = data.tags ?? []; + const updateAccountBalance = data.updateAccountBalance ?? false; + const userId = data.userId; + + if ( + data.type === 'FEE' || + data.type === 'INTEREST' || + data.type === 'ITEM' || + data.type === 'LIABILITY' + ) { const assetClass = data.assetClass; const assetSubClass = data.assetSubClass; - const currency = data.SymbolProfile.connectOrCreate.create.currency; + currency = data.SymbolProfile.connectOrCreate.create.currency; const dataSource: DataSource = 'MANUAL'; const id = uuidv4(); const name = data.SymbolProfile.connectOrCreate.create.symbol; - Account = undefined; data.id = id; data.SymbolProfile.connectOrCreate.create.assetClass = assetClass; data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass; @@ -113,31 +121,23 @@ export class OrderService { dataSource, symbol: id }; - } else { - data.SymbolProfile.connectOrCreate.create.symbol = - data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase(); } - await this.dataGatheringService.addJobToQueue( - GATHER_ASSET_PROFILE_PROCESS, - { - dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, - symbol: data.SymbolProfile.connectOrCreate.create.symbol - }, - GATHER_ASSET_PROFILE_PROCESS_OPTIONS - ); - - const isDraft = isAfter(data.date as Date, endOfToday()); - - if (!isDraft) { - // Gather symbol data of order in the background, if not draft - this.dataGatheringService.gatherSymbols([ - { + if (data.SymbolProfile.connectOrCreate.create.dataSource !== 'MANUAL') { + this.dataGatheringService.addJobToQueue({ + data: { dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, - date: data.date, symbol: data.SymbolProfile.connectOrCreate.create.symbol + }, + name: GATHER_ASSET_PROFILE_PROCESS, + opts: { + ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, + jobId: getAssetProfileIdentifier({ + dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, + symbol: data.SymbolProfile.connectOrCreate.create.symbol + }) } - ]); + }); } delete data.accountId; @@ -152,11 +152,20 @@ export class OrderService { delete data.dataSource; delete data.symbol; delete data.tags; + delete data.updateAccountBalance; delete data.userId; const orderData: Prisma.OrderCreateInput = data; - return this.prismaService.order.create({ + const isDraft = + data.type === 'FEE' || + data.type === 'INTEREST' || + data.type === 'ITEM' || + data.type === 'LIABILITY' + ? false + : isAfter(data.date as Date, endOfToday()); + + const order = await this.prismaService.order.create({ data: { ...orderData, Account, @@ -168,6 +177,27 @@ export class OrderService { } } }); + + if (updateAccountBalance === true) { + let amount = new Big(data.unitPrice) + .mul(data.quantity) + .plus(data.fee) + .toNumber(); + + if (data.type === 'BUY') { + amount = new Big(amount).mul(-1).toNumber(); + } + + await this.accountService.updateAccountBalance({ + accountId, + amount, + currency, + userId, + date: data.date as Date + }); + } + + return order; } public async deleteOrder( @@ -177,16 +207,31 @@ export class OrderService { where }); - if (order.type === 'ITEM') { + if ( + order.type === 'FEE' || + order.type === 'INTEREST' || + order.type === 'ITEM' || + order.type === 'LIABILITY' + ) { await this.symbolProfileService.deleteById(order.symbolProfileId); } return order; } + public async deleteOrders(where: Prisma.OrderWhereInput): Promise { + const { count } = await this.prismaService.order.deleteMany({ + where + }); + + return count; + } + public async getOrders({ filters, includeDrafts = false, + skip, + take = Number.MAX_SAFE_INTEGER, types, userCurrency, userId, @@ -194,6 +239,8 @@ export class OrderService { }: { filters?: Filter[]; includeDrafts?: boolean; + skip?: number; + take?: number; types?: TypeOfOrder[]; userCurrency: string; userId: string; @@ -272,6 +319,8 @@ export class OrderService { return ( await this.orders({ + skip, + take, where, include: { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -288,7 +337,11 @@ export class OrderService { }) ) .filter((order) => { - return withExcludedAccounts || order.Account?.isExcluded === false; + return ( + withExcludedAccounts || + !order.Account || + order.Account?.isExcluded === false + ); }) .map((order) => { const value = new Big(order.quantity).mul(order.unitPrice).toNumber(); @@ -336,7 +389,12 @@ export class OrderService { let isDraft = false; - if (data.type === 'ITEM') { + if ( + data.type === 'FEE' || + data.type === 'INTEREST' || + data.type === 'ITEM' || + data.type === 'LIABILITY' + ) { delete data.SymbolProfile.connect; } else { delete data.SymbolProfile.update; @@ -362,6 +420,12 @@ export class OrderService { delete data.symbol; delete data.tags; + // Remove existing tags + await this.prismaService.order.update({ + data: { tags: { set: [] } }, + where + }); + return this.prismaService.order.update({ data: { ...data, diff --git a/apps/api/src/app/order/update-order.dto.ts b/apps/api/src/app/order/update-order.dto.ts index 7c709ea7c..a8c33c40e 100644 --- a/apps/api/src/app/order/update-order.dto.ts +++ b/apps/api/src/app/order/update-order.dto.ts @@ -8,6 +8,7 @@ import { import { Transform, TransformFnParams } from 'class-transformer'; import { IsArray, + IsBoolean, IsEnum, IsISO8601, IsNumber, diff --git a/apps/api/src/app/platform/create-platform.dto.ts b/apps/api/src/app/platform/create-platform.dto.ts new file mode 100644 index 000000000..a61f21743 --- /dev/null +++ b/apps/api/src/app/platform/create-platform.dto.ts @@ -0,0 +1,9 @@ +import { IsString } from 'class-validator'; + +export class CreatePlatformDto { + @IsString() + name: string; + + @IsString() + url: string; +} diff --git a/apps/api/src/app/platform/platform.controller.ts b/apps/api/src/app/platform/platform.controller.ts new file mode 100644 index 000000000..0369da8a3 --- /dev/null +++ b/apps/api/src/app/platform/platform.controller.ts @@ -0,0 +1,115 @@ +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import type { RequestWithUser } from '@ghostfolio/common/types'; +import { + Body, + Controller, + Delete, + Get, + HttpException, + Inject, + Param, + Post, + Put, + UseGuards +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { Platform } from '@prisma/client'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +import { CreatePlatformDto } from './create-platform.dto'; +import { PlatformService } from './platform.service'; +import { UpdatePlatformDto } from './update-platform.dto'; + +@Controller('platform') +export class PlatformController { + public constructor( + private readonly platformService: PlatformService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} + + @Get() + @UseGuards(AuthGuard('jwt')) + public async getPlatforms() { + return this.platformService.getPlatformsWithAccountCount(); + } + + @Post() + @UseGuards(AuthGuard('jwt')) + public async createPlatform( + @Body() data: CreatePlatformDto + ): Promise { + if ( + !hasPermission(this.request.user.permissions, permissions.createPlatform) + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.platformService.createPlatform(data); + } + + @Put(':id') + @UseGuards(AuthGuard('jwt')) + public async updatePlatform( + @Param('id') id: string, + @Body() data: UpdatePlatformDto + ) { + if ( + !hasPermission(this.request.user.permissions, permissions.updatePlatform) + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + const originalPlatform = await this.platformService.getPlatform({ + id + }); + + if (!originalPlatform) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.platformService.updatePlatform({ + data: { + ...data + }, + where: { + id + } + }); + } + + @Delete(':id') + @UseGuards(AuthGuard('jwt')) + public async deletePlatform(@Param('id') id: string) { + if ( + !hasPermission(this.request.user.permissions, permissions.deletePlatform) + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + const originalPlatform = await this.platformService.getPlatform({ + id + }); + + if (!originalPlatform) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.platformService.deletePlatform({ id }); + } +} diff --git a/apps/api/src/app/platform/platform.module.ts b/apps/api/src/app/platform/platform.module.ts new file mode 100644 index 000000000..04ccdf4d6 --- /dev/null +++ b/apps/api/src/app/platform/platform.module.ts @@ -0,0 +1,13 @@ +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { Module } from '@nestjs/common'; + +import { PlatformController } from './platform.controller'; +import { PlatformService } from './platform.service'; + +@Module({ + controllers: [PlatformController], + exports: [PlatformService], + imports: [PrismaModule], + providers: [PlatformService] +}) +export class PlatformModule {} diff --git a/apps/api/src/app/platform/platform.service.ts b/apps/api/src/app/platform/platform.service.ts new file mode 100644 index 000000000..35730d041 --- /dev/null +++ b/apps/api/src/app/platform/platform.service.ts @@ -0,0 +1,83 @@ +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { Injectable } from '@nestjs/common'; +import { Platform, Prisma } from '@prisma/client'; + +@Injectable() +export class PlatformService { + public constructor(private readonly prismaService: PrismaService) {} + + public async createPlatform(data: Prisma.PlatformCreateInput) { + return this.prismaService.platform.create({ + data + }); + } + + public async deletePlatform( + where: Prisma.PlatformWhereUniqueInput + ): Promise { + return this.prismaService.platform.delete({ where }); + } + + public async getPlatform( + platformWhereUniqueInput: Prisma.PlatformWhereUniqueInput + ): Promise { + return this.prismaService.platform.findUnique({ + where: platformWhereUniqueInput + }); + } + + public async getPlatforms({ + cursor, + orderBy, + skip, + take, + where + }: { + cursor?: Prisma.PlatformWhereUniqueInput; + orderBy?: Prisma.PlatformOrderByWithRelationInput; + skip?: number; + take?: number; + where?: Prisma.PlatformWhereInput; + } = {}) { + return this.prismaService.platform.findMany({ + cursor, + orderBy, + skip, + take, + where + }); + } + + public async getPlatformsWithAccountCount() { + const platformsWithAccountCount = + await this.prismaService.platform.findMany({ + include: { + _count: { + select: { Account: true } + } + } + }); + + return platformsWithAccountCount.map(({ _count, id, name, url }) => { + return { + id, + name, + url, + accountCount: _count.Account + }; + }); + } + + public async updatePlatform({ + data, + where + }: { + data: Prisma.PlatformUpdateInput; + where: Prisma.PlatformWhereUniqueInput; + }): Promise { + return this.prismaService.platform.update({ + data, + where + }); + } +} diff --git a/apps/api/src/app/platform/update-platform.dto.ts b/apps/api/src/app/platform/update-platform.dto.ts new file mode 100644 index 000000000..ec6f2687c --- /dev/null +++ b/apps/api/src/app/platform/update-platform.dto.ts @@ -0,0 +1,12 @@ +import { IsString } from 'class-validator'; + +export class UpdatePlatformDto { + @IsString() + id: string; + + @IsString() + name: string; + + @IsString() + url: string; +} diff --git a/apps/api/src/app/portfolio/current-rate.service.mock.ts b/apps/api/src/app/portfolio/current-rate.service.mock.ts index 004f642bf..a9dc03acf 100644 --- a/apps/api/src/app/portfolio/current-rate.service.mock.ts +++ b/apps/api/src/app/portfolio/current-rate.service.mock.ts @@ -2,6 +2,7 @@ import { parseDate, resetHours } from '@ghostfolio/common/helper'; import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns'; import { GetValueObject } from './interfaces/get-value-object.interface'; +import { GetValuesObject } from './interfaces/get-values-object.interface'; import { GetValuesParams } from './interfaces/get-values-params.interface'; function mockGetValue(symbol: string, date: Date) { @@ -48,8 +49,9 @@ export const CurrentRateServiceMock = { getValues: ({ dataGatheringItems, dateQuery - }: GetValuesParams): Promise => { - const result: GetValueObject[] = []; + }: GetValuesParams): Promise => { + const values: GetValueObject[] = []; + if (dateQuery.lt) { for ( let date = resetHours(dateQuery.gte); @@ -57,7 +59,7 @@ export const CurrentRateServiceMock = { date = addDays(date, 1) ) { for (const dataGatheringItem of dataGatheringItems) { - result.push({ + values.push({ date, marketPriceInBaseCurrency: mockGetValue( dataGatheringItem.symbol, @@ -70,7 +72,7 @@ export const CurrentRateServiceMock = { } else { for (const date of dateQuery.in) { for (const dataGatheringItem of dataGatheringItems) { - result.push({ + values.push({ date, marketPriceInBaseCurrency: mockGetValue( dataGatheringItem.symbol, @@ -81,6 +83,7 @@ export const CurrentRateServiceMock = { } } } - return Promise.resolve(result); + + return Promise.resolve({ values, dataProviderInfos: [], errors: [] }); } }; diff --git a/apps/api/src/app/portfolio/current-rate.service.spec.ts b/apps/api/src/app/portfolio/current-rate.service.spec.ts index 2ef8ad5fa..88790d2be 100644 --- a/apps/api/src/app/portfolio/current-rate.service.spec.ts +++ b/apps/api/src/app/portfolio/current-rate.service.spec.ts @@ -1,12 +1,13 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; -import { MarketDataService } from '@ghostfolio/api/services/market-data.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 { DataSource, MarketData } from '@prisma/client'; import { CurrentRateService } from './current-rate.service'; -import { GetValueObject } from './interfaces/get-value-object.interface'; +import { GetValuesObject } from './interfaces/get-values-object.interface'; -jest.mock('@ghostfolio/api/services/market-data.service', () => { +jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => { return { MarketDataService: jest.fn().mockImplementation(() => { return { @@ -17,7 +18,8 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => { createdAt: date, dataSource: DataSource.YAHOO, id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584', - marketPrice: 1847.839966 + marketPrice: 1847.839966, + state: 'CLOSE' }); }, getRange: ({ @@ -36,6 +38,7 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => { date: dateRangeStart, id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d', marketPrice: 1841.823902, + state: 'CLOSE', symbol: symbols[0] }, { @@ -44,6 +47,7 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => { date: dateRangeEnd, id: '082d6893-df27-4c91-8a5d-092e84315b56', marketPrice: 1847.839966, + state: 'CLOSE', symbol: symbols[0] } ]); @@ -53,14 +57,27 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => { }; }); -jest.mock('@ghostfolio/api/services/exchange-rate-data.service', () => { +jest.mock( + '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', + () => { + return { + ExchangeRateDataService: jest.fn().mockImplementation(() => { + return { + initialize: () => Promise.resolve(), + toCurrency: (value: number) => { + return 1 * value; + } + }; + }) + }; + } +); + +jest.mock('@ghostfolio/api/services/property/property.service', () => { return { - ExchangeRateDataService: jest.fn().mockImplementation(() => { + PropertyService: jest.fn().mockImplementation(() => { return { - initialize: () => Promise.resolve(), - toCurrency: (value: number) => { - return 1 * value; - } + getByKey: (key: string) => Promise.resolve({}) }; }) }; @@ -71,9 +88,19 @@ describe('CurrentRateService', () => { let dataProviderService: DataProviderService; let exchangeRateDataService: ExchangeRateDataService; let marketDataService: MarketDataService; + let propertyService: PropertyService; beforeAll(async () => { - dataProviderService = new DataProviderService(null, [], null); + propertyService = new PropertyService(null); + + dataProviderService = new DataProviderService( + null, + [], + null, + null, + propertyService, + null + ); exchangeRateDataService = new ExchangeRateDataService( null, null, @@ -102,17 +129,16 @@ describe('CurrentRateService', () => { }, userCurrency: 'CHF' }) - ).toMatchObject([ - { - date: undefined, - marketPriceInBaseCurrency: 1841.823902, - symbol: 'AMZN' - }, - { - date: undefined, - marketPriceInBaseCurrency: 1847.839966, - symbol: 'AMZN' - } - ]); + ).toMatchObject({ + dataProviderInfos: [], + errors: [], + values: [ + { + date: undefined, + marketPriceInBaseCurrency: 1841.823902, + symbol: 'AMZN' + } + ] + }); }); }); diff --git a/apps/api/src/app/portfolio/current-rate.service.ts b/apps/api/src/app/portfolio/current-rate.service.ts index 86020ce2e..a4ee317bb 100644 --- a/apps/api/src/app/portfolio/current-rate.service.ts +++ b/apps/api/src/app/portfolio/current-rate.service.ts @@ -1,12 +1,14 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; -import { MarketDataService } from '@ghostfolio/api/services/market-data.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 { resetHours } from '@ghostfolio/common/helper'; +import { DataProviderInfo, ResponseError } from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; import { isBefore, isToday } from 'date-fns'; -import { flatten } from 'lodash'; +import { flatten, isEmpty, uniqBy } from 'lodash'; import { GetValueObject } from './interfaces/get-value-object.interface'; +import { GetValuesObject } from './interfaces/get-values-object.interface'; import { GetValuesParams } from './interfaces/get-values-params.interface'; @Injectable() @@ -22,34 +24,52 @@ export class CurrentRateService { dataGatheringItems, dateQuery, userCurrency - }: GetValuesParams): Promise { + }: GetValuesParams): Promise { + const dataProviderInfos: DataProviderInfo[] = []; const includeToday = (!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) && (!dateQuery.gte || isBefore(dateQuery.gte, new Date())) && (!dateQuery.in || this.containsToday(dateQuery.in)); const promises: Promise[] = []; + const quoteErrors: ResponseError['errors'] = []; + const today = resetHours(new Date()); if (includeToday) { - const today = resetHours(new Date()); promises.push( this.dataProviderService - .getQuotes(dataGatheringItems) + .getQuotes({ items: dataGatheringItems }) .then((dataResultProvider) => { const result: GetValueObject[] = []; for (const dataGatheringItem of dataGatheringItems) { - result.push({ - date: today, - marketPriceInBaseCurrency: - this.exchangeRateDataService.toCurrency( - dataResultProvider?.[dataGatheringItem.symbol] - ?.marketPrice ?? 0, - dataResultProvider?.[dataGatheringItem.symbol]?.currency, - userCurrency - ), - symbol: dataGatheringItem.symbol - }); + if ( + dataResultProvider?.[dataGatheringItem.symbol]?.dataProviderInfo + ) { + dataProviderInfos.push( + dataResultProvider[dataGatheringItem.symbol].dataProviderInfo + ); + } + + if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) { + result.push({ + date: today, + marketPriceInBaseCurrency: + this.exchangeRateDataService.toCurrency( + dataResultProvider?.[dataGatheringItem.symbol] + ?.marketPrice, + dataResultProvider?.[dataGatheringItem.symbol]?.currency, + userCurrency + ), + symbol: dataGatheringItem.symbol + }); + } else { + quoteErrors.push({ + dataSource: dataGatheringItem.dataSource, + symbol: dataGatheringItem.symbol + }); + } } + return result; }) ); @@ -81,7 +101,60 @@ export class CurrentRateService { }) ); - return flatten(await Promise.all(promises)); + const values = flatten(await Promise.all(promises)); + + const response: GetValuesObject = { + dataProviderInfos, + errors: quoteErrors.map(({ dataSource, symbol }) => { + return { dataSource, symbol }; + }), + values: uniqBy(values, ({ date, symbol }) => `${date}-${symbol}`) + }; + + if (!isEmpty(quoteErrors)) { + for (const { symbol } of quoteErrors) { + try { + // If missing quote, fallback to the latest available historical market price + let value: GetValueObject = response.values.find((currentValue) => { + return currentValue.symbol === symbol && isToday(currentValue.date); + }); + + if (!value) { + value = { + symbol, + date: today, + marketPriceInBaseCurrency: 0 + }; + + response.values.push(value); + } + + const [latestValue] = response.values + .filter((currentValue) => { + return ( + currentValue.symbol === symbol && + currentValue.marketPriceInBaseCurrency + ); + }) + .sort((a, b) => { + if (a.date < b.date) { + return 1; + } + + if (a.date > b.date) { + return -1; + } + + return 0; + }); + + value.marketPriceInBaseCurrency = + latestValue.marketPriceInBaseCurrency; + } catch {} + } + } + + return response; } private containsToday(dates: Date[]): boolean { diff --git a/apps/api/src/app/portfolio/interfaces/get-values-object.interface.ts b/apps/api/src/app/portfolio/interfaces/get-values-object.interface.ts new file mode 100644 index 000000000..ef6cb8f96 --- /dev/null +++ b/apps/api/src/app/portfolio/interfaces/get-values-object.interface.ts @@ -0,0 +1,9 @@ +import { DataProviderInfo, ResponseError } from '@ghostfolio/common/interfaces'; + +import { GetValueObject } from './get-value-object.interface'; + +export interface GetValuesObject { + dataProviderInfos: DataProviderInfo[]; + errors: ResponseError['errors']; + values: GetValueObject[]; +} diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts index 2466e81af..cc3a97752 100644 --- a/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts @@ -1,4 +1,4 @@ -import { DataSource, Type as TypeOfOrder } from '@prisma/client'; +import { DataSource, Tag, Type as TypeOfOrder } from '@prisma/client'; import Big from 'big.js'; export interface PortfolioOrder { @@ -9,6 +9,7 @@ export interface PortfolioOrder { name: string; quantity: Big; symbol: string; + tags?: Tag[]; type: TypeOfOrder; unitPrice: Big; } diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts index 4d13a1ae3..df4760bb0 100644 --- a/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts @@ -1,4 +1,5 @@ import { + DataProviderInfo, EnhancedSymbolProfile, HistoricalDataItem } from '@ghostfolio/common/interfaces'; @@ -7,6 +8,10 @@ import { Tag } from '@prisma/client'; export interface PortfolioPositionDetail { averagePrice: number; + dataProviderInfo: DataProviderInfo; + dividendInBaseCurrency: number; + stakeRewards: number; + feeInBaseCurrency: number; firstBuyDate: string; grossPerformance: number; grossPerformancePercent: number; diff --git a/apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts b/apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts index cc199119e..5350adccc 100644 --- a/apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts @@ -1,4 +1,4 @@ -import { DataSource } from '@prisma/client'; +import { DataSource, Tag } from '@prisma/client'; import Big from 'big.js'; export interface TransactionPointSymbol { @@ -9,5 +9,6 @@ export interface TransactionPointSymbol { investment: Big; quantity: Big; symbol: string; + tags?: Tag[]; transactionCount: number; } diff --git a/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts index d826e1d0e..c66323c72 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts @@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => { const investments = portfolioCalculator.getInvestments(); - const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth(); + const investmentsByMonth = + portfolioCalculator.getInvestmentsByGroup('month'); spy.mockRestore(); @@ -81,6 +82,7 @@ describe('PortfolioCalculator', () => { averagePrice: new Big('0'), currency: 'CHF', dataSource: 'YAHOO', + fee: new Big('3.2'), firstBuyDate: '2021-11-22', grossPerformance: new Big('-12.6'), grossPerformancePercentage: new Big('-0.0440867739678096571'), diff --git a/apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts index b8cc6050a..9f49c13e0 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts @@ -53,7 +53,8 @@ describe('PortfolioCalculator', () => { const investments = portfolioCalculator.getInvestments(); - const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth(); + const investmentsByMonth = + portfolioCalculator.getInvestmentsByGroup('month'); spy.mockRestore(); @@ -70,6 +71,7 @@ describe('PortfolioCalculator', () => { averagePrice: new Big('136.6'), currency: 'CHF', dataSource: 'YAHOO', + fee: new Big('1.55'), firstBuyDate: '2021-11-30', grossPerformance: new Big('24.6'), grossPerformancePercentage: new Big('0.09004392386530014641'), diff --git a/apps/api/src/app/portfolio/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts index ddb46c9a3..e0761ebe5 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts @@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => { const investments = portfolioCalculator.getInvestments(); - const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth(); + const investmentsByMonth = + portfolioCalculator.getInvestmentsByGroup('month'); spy.mockRestore(); @@ -81,6 +82,7 @@ describe('PortfolioCalculator', () => { averagePrice: new Big('320.43'), currency: 'CHF', dataSource: 'YAHOO', + fee: new Big('0'), firstBuyDate: '2015-01-01', grossPerformance: new Big('27172.74'), grossPerformancePercentage: new Big('42.40043067128546016291'), @@ -103,6 +105,40 @@ describe('PortfolioCalculator', () => { expect(investmentsByMonth).toEqual([ { date: '2015-01-01', investment: new Big('640.86') }, + { date: '2015-02-01', investment: new Big('0') }, + { date: '2015-03-01', investment: new Big('0') }, + { date: '2015-04-01', investment: new Big('0') }, + { date: '2015-05-01', investment: new Big('0') }, + { date: '2015-06-01', investment: new Big('0') }, + { date: '2015-07-01', investment: new Big('0') }, + { date: '2015-08-01', investment: new Big('0') }, + { date: '2015-09-01', investment: new Big('0') }, + { date: '2015-10-01', investment: new Big('0') }, + { date: '2015-11-01', investment: new Big('0') }, + { date: '2015-12-01', investment: new Big('0') }, + { date: '2016-01-01', investment: new Big('0') }, + { date: '2016-02-01', investment: new Big('0') }, + { date: '2016-03-01', investment: new Big('0') }, + { date: '2016-04-01', investment: new Big('0') }, + { date: '2016-05-01', investment: new Big('0') }, + { date: '2016-06-01', investment: new Big('0') }, + { date: '2016-07-01', investment: new Big('0') }, + { date: '2016-08-01', investment: new Big('0') }, + { date: '2016-09-01', investment: new Big('0') }, + { date: '2016-10-01', investment: new Big('0') }, + { date: '2016-11-01', investment: new Big('0') }, + { date: '2016-12-01', investment: new Big('0') }, + { date: '2017-01-01', investment: new Big('0') }, + { date: '2017-02-01', investment: new Big('0') }, + { date: '2017-03-01', investment: new Big('0') }, + { date: '2017-04-01', investment: new Big('0') }, + { date: '2017-05-01', investment: new Big('0') }, + { date: '2017-06-01', investment: new Big('0') }, + { date: '2017-07-01', investment: new Big('0') }, + { date: '2017-08-01', investment: new Big('0') }, + { date: '2017-09-01', investment: new Big('0') }, + { date: '2017-10-01', investment: new Big('0') }, + { date: '2017-11-01', investment: new Big('0') }, { date: '2017-12-01', investment: new Big('-14156.4') } ]); }); diff --git a/apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts index 32935a20e..a26426017 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts @@ -41,7 +41,8 @@ describe('PortfolioCalculator', () => { const investments = portfolioCalculator.getInvestments(); - const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth(); + const investmentsByMonth = + portfolioCalculator.getInvestmentsByGroup('month'); spy.mockRestore(); diff --git a/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts index f3b3e5881..6adfc9347 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts @@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => { const investments = portfolioCalculator.getInvestments(); - const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth(); + const investmentsByMonth = + portfolioCalculator.getInvestmentsByGroup('month'); spy.mockRestore(); @@ -81,6 +82,7 @@ describe('PortfolioCalculator', () => { averagePrice: new Big('75.80'), currency: 'CHF', dataSource: 'YAHOO', + fee: new Big('4.25'), firstBuyDate: '2022-03-07', grossPerformance: new Big('21.93'), grossPerformancePercentage: new Big('0.14465699208443271768'), diff --git a/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts new file mode 100644 index 000000000..feed5923b --- /dev/null +++ b/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts @@ -0,0 +1,132 @@ +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { parseDate } from '@ghostfolio/common/helper'; +import Big from 'big.js'; + +import { CurrentRateServiceMock } from './current-rate.service.mock'; +import { PortfolioCalculator } from './portfolio-calculator'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let currentRateService: CurrentRateService; + + beforeEach(() => { + currentRateService = new CurrentRateService(null, null, null); + }); + + describe('get current positions', () => { + it.only('with NOVN.SW buy and sell', async () => { + const portfolioCalculator = new PortfolioCalculator({ + currentRateService, + currency: 'CHF', + orders: [ + { + currency: 'CHF', + date: '2022-03-07', + dataSource: 'YAHOO', + fee: new Big(0), + name: 'Novartis AG', + quantity: new Big(2), + symbol: 'NOVN.SW', + type: 'BUY', + unitPrice: new Big(75.8) + }, + { + currency: 'CHF', + date: '2022-04-08', + dataSource: 'YAHOO', + fee: new Big(0), + name: 'Novartis AG', + quantity: new Big(2), + symbol: 'NOVN.SW', + type: 'SELL', + unitPrice: new Big(85.73) + } + ] + }); + + portfolioCalculator.computeTransactionPoints(); + + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => parseDate('2022-04-11').getTime()); + + const chartData = await portfolioCalculator.getChartData( + parseDate('2022-03-07') + ); + + const currentPositions = await portfolioCalculator.getCurrentPositions( + parseDate('2022-03-07') + ); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = + portfolioCalculator.getInvestmentsByGroup('month'); + + spy.mockRestore(); + + expect(chartData[0]).toEqual({ + date: '2022-03-07', + netPerformanceInPercentage: 0, + netPerformance: 0, + totalInvestment: 151.6, + value: 151.6 + }); + + expect(chartData[chartData.length - 1]).toEqual({ + date: '2022-04-11', + netPerformanceInPercentage: 13.100263852242744, + netPerformance: 19.86, + totalInvestment: 0, + value: 0 + }); + + expect(currentPositions).toEqual({ + currentValue: new Big('0'), + errors: [], + grossPerformance: new Big('19.86'), + grossPerformancePercentage: new Big('0.13100263852242744063'), + hasErrors: false, + netPerformance: new Big('19.86'), + netPerformancePercentage: new Big('0.13100263852242744063'), + positions: [ + { + averagePrice: new Big('0'), + currency: 'CHF', + dataSource: 'YAHOO', + fee: new Big('0'), + firstBuyDate: '2022-03-07', + grossPerformance: new Big('19.86'), + grossPerformancePercentage: new Big('0.13100263852242744063'), + investment: new Big('0'), + netPerformance: new Big('19.86'), + netPerformancePercentage: new Big('0.13100263852242744063'), + marketPrice: 87.8, + quantity: new Big('0'), + symbol: 'NOVN.SW', + transactionCount: 2 + } + ], + totalInvestment: new Big('0') + }); + + expect(investments).toEqual([ + { date: '2022-03-07', investment: new Big('151.6') }, + { date: '2022-04-08', investment: new Big('0') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2022-03-01', investment: new Big('151.6') }, + { date: '2022-04-01', investment: new Big('-171.46') } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/portfolio-calculator.ts b/apps/api/src/app/portfolio/portfolio-calculator.ts index ea1f8e10c..626faf0c9 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator.ts @@ -1,7 +1,13 @@ import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; -import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces'; +import { + DataProviderInfo, + HistoricalDataItem, + ResponseError, + TimelinePosition +} from '@ghostfolio/common/interfaces'; +import { GroupBy } from '@ghostfolio/common/types'; import { Logger } from '@nestjs/common'; import { Type as TypeOfOrder } from '@prisma/client'; import Big from 'big.js'; @@ -19,9 +25,10 @@ import { isSameYear, max, min, - set + set, + subDays } from 'date-fns'; -import { first, flatten, isNumber, last, sortBy } from 'lodash'; +import { first, flatten, isNumber, last, sortBy, uniq } from 'lodash'; import { CurrentRateService } from './current-rate.service'; import { CurrentPositions } from './interfaces/current-positions.interface'; @@ -44,6 +51,7 @@ export class PortfolioCalculator { private currency: string; private currentRateService: CurrentRateService; + private dataProviderInfos: DataProviderInfo[]; private orders: PortfolioOrder[]; private transactionPoints: TransactionPoint[]; @@ -85,7 +93,7 @@ export class PortfolioCalculator { let investment = new Big(0); if (newQuantity.gt(0)) { - if (order.type === 'BUY') { + if (order.type === 'BUY' || order.type === 'STAKE') { investment = oldAccumulatedSymbol.investment.plus( order.quantity.mul(unitPrice) ); @@ -107,6 +115,7 @@ export class PortfolioCalculator { firstBuyDate: oldAccumulatedSymbol.firstBuyDate, quantity: newQuantity, symbol: order.symbol, + tags: order.tags, transactionCount: oldAccumulatedSymbol.transactionCount + 1 }; } else { @@ -118,6 +127,7 @@ export class PortfolioCalculator { investment: unitPrice.mul(order.quantity).mul(factor), quantity: order.quantity.mul(factor), symbol: order.symbol, + tags: order.tags, transactionCount: 1 }; } @@ -176,10 +186,10 @@ export class PortfolioCalculator { return isBefore(parseDate(transactionPoint.date), end); }) ?? []; - const firstIndex = transactionPointsBeforeEndDate.length; + const currencies: { [symbol: string]: string } = {}; const dates: Date[] = []; const dataGatheringItems: IDataGatheringItem[] = []; - const currencies: { [symbol: string]: string } = {}; + const firstIndex = transactionPointsBeforeEndDate.length; let day = start; @@ -201,14 +211,17 @@ export class PortfolioCalculator { symbols[item.symbol] = true; } - const marketSymbols = await this.currentRateService.getValues({ - currencies, - dataGatheringItems, - dateQuery: { - in: dates - }, - userCurrency: this.currency - }); + const { dataProviderInfos, values: marketSymbols } = + await this.currentRateService.getValues({ + currencies, + dataGatheringItems, + dateQuery: { + in: dates + }, + userCurrency: this.currency + }); + + this.dataProviderInfos = dataProviderInfos; const marketSymbolMap: { [date: string]: { [symbol: string]: Big }; @@ -226,19 +239,31 @@ export class PortfolioCalculator { } } - const netPerformanceValuesBySymbol: { - [symbol: string]: { [date: string]: Big }; + const valuesByDate: { + [date: string]: { + maxTotalInvestmentValue: Big; + totalCurrentValue: Big; + totalInvestmentValue: Big; + totalNetPerformanceValue: Big; + }; } = {}; - const investmentValuesBySymbol: { - [symbol: string]: { [date: string]: Big }; + const valuesBySymbol: { + [symbol: string]: { + currentValues: { [date: string]: Big }; + investmentValues: { [date: string]: Big }; + maxInvestmentValues: { [date: string]: Big }; + netPerformanceValues: { [date: string]: Big }; + }; } = {}; - const totalNetPerformanceValues: { [date: string]: Big } = {}; - const totalInvestmentValues: { [date: string]: Big } = {}; - for (const symbol of Object.keys(symbols)) { - const { netPerformanceValues, investmentValues } = this.getSymbolMetrics({ + const { + currentValues, + investmentValues, + maxInvestmentValues, + netPerformanceValues + } = this.getSymbolMetrics({ end, marketSymbolMap, start, @@ -247,50 +272,50 @@ export class PortfolioCalculator { isChartMode: true }); - netPerformanceValuesBySymbol[symbol] = netPerformanceValues; - investmentValuesBySymbol[symbol] = investmentValues; + valuesBySymbol[symbol] = { + currentValues, + investmentValues, + maxInvestmentValues, + netPerformanceValues + }; } - for (const currentDate of dates) { - const dateString = format(currentDate, DATE_FORMAT); + return dates.map((date) => { + const dateString = format(date, DATE_FORMAT); + let totalCurrentValue = new Big(0); + let totalInvestmentValue = new Big(0); + let maxTotalInvestmentValue = new Big(0); + let totalNetPerformanceValue = new Big(0); - for (const symbol of Object.keys(netPerformanceValuesBySymbol)) { - totalNetPerformanceValues[dateString] = - totalNetPerformanceValues[dateString] ?? new Big(0); + for (const symbol of Object.keys(valuesBySymbol)) { + const symbolValues = valuesBySymbol[symbol]; - if (netPerformanceValuesBySymbol[symbol]?.[dateString]) { - totalNetPerformanceValues[dateString] = totalNetPerformanceValues[ - dateString - ].add(netPerformanceValuesBySymbol[symbol][dateString]); - } - - totalInvestmentValues[dateString] = - totalInvestmentValues[dateString] ?? new Big(0); - - if (investmentValuesBySymbol[symbol]?.[dateString]) { - totalInvestmentValues[dateString] = totalInvestmentValues[ - dateString - ].add(investmentValuesBySymbol[symbol][dateString]); - } + totalCurrentValue = totalCurrentValue.plus( + symbolValues.currentValues?.[dateString] ?? new Big(0) + ); + totalInvestmentValue = totalInvestmentValue.plus( + symbolValues.investmentValues?.[dateString] ?? new Big(0) + ); + maxTotalInvestmentValue = maxTotalInvestmentValue.plus( + symbolValues.maxInvestmentValues?.[dateString] ?? new Big(0) + ); + totalNetPerformanceValue = totalNetPerformanceValue.plus( + symbolValues.netPerformanceValues?.[dateString] ?? new Big(0) + ); } - } - - return Object.keys(totalNetPerformanceValues).map((date) => { - const netPerformanceInPercentage = totalInvestmentValues[date].eq(0) + const netPerformanceInPercentage = maxTotalInvestmentValue.eq(0) ? 0 - : totalNetPerformanceValues[date] - .div(totalInvestmentValues[date]) + : totalNetPerformanceValue + .div(maxTotalInvestmentValue) .mul(100) .toNumber(); return { - date, + date: dateString, netPerformanceInPercentage, - netPerformance: totalNetPerformanceValues[date].toNumber(), - totalInvestment: totalInvestmentValues[date].toNumber(), - value: totalInvestmentValues[date] - .plus(totalNetPerformanceValues[date]) - .toNumber() + netPerformance: totalNetPerformanceValue.toNumber(), + totalInvestment: totalInvestmentValue.toNumber(), + value: totalCurrentValue.toNumber() }; }); } @@ -322,7 +347,7 @@ export class PortfolioCalculator { let firstTransactionPoint: TransactionPoint = null; let firstIndex = transactionPointsBeforeEndDate.length; - const dates = []; + let dates = []; const dataGatheringItems: IDataGatheringItem[] = []; const currencies: { [symbol: string]: string } = {}; @@ -351,7 +376,30 @@ export class PortfolioCalculator { dates.push(resetHours(end)); - const marketSymbols = await this.currentRateService.getValues({ + // Add dates of last week for fallback + dates.push(subDays(resetHours(new Date()), 7)); + dates.push(subDays(resetHours(new Date()), 6)); + dates.push(subDays(resetHours(new Date()), 5)); + dates.push(subDays(resetHours(new Date()), 4)); + dates.push(subDays(resetHours(new Date()), 3)); + dates.push(subDays(resetHours(new Date()), 2)); + dates.push(subDays(resetHours(new Date()), 1)); + dates.push(resetHours(new Date())); + + dates = uniq( + dates.map((date) => { + return date.getTime(); + }) + ).map((timestamp) => { + return new Date(timestamp); + }); + dates.sort((a, b) => a.getTime() - b.getTime()); + + const { + dataProviderInfos, + errors: currentRateErrors, + values: marketSymbols + } = await this.currentRateService.getValues({ currencies, dataGatheringItems, dateQuery: { @@ -360,6 +408,8 @@ export class PortfolioCalculator { userCurrency: this.currency }); + this.dataProviderInfos = dataProviderInfos; + const marketSymbolMap: { [date: string]: { [symbol: string]: Big }; } = {}; @@ -414,6 +464,7 @@ export class PortfolioCalculator { : item.investment.div(item.quantity), currency: item.currency, dataSource: item.dataSource, + fee: item.fee, firstBuyDate: item.firstBuyDate, grossPerformance: !hasErrors ? grossPerformance ?? null : null, grossPerformancePercentage: !hasErrors @@ -427,10 +478,17 @@ export class PortfolioCalculator { : null, quantity: item.quantity, symbol: item.symbol, + tags: item.tags, transactionCount: item.transactionCount }); - if (hasErrors) { + if ( + (hasErrors || + currentRateErrors.find(({ dataSource, symbol }) => { + return dataSource === item.dataSource && symbol === item.symbol; + })) && + item.investment.gt(0) + ) { errors.push({ dataSource: item.dataSource, symbol: item.symbol }); } } @@ -445,6 +503,10 @@ export class PortfolioCalculator { }; } + public getDataProviderInfos() { + return this.dataProviderInfos; + } + public getInvestments(): { date: string; investment: Big }[] { if (this.transactionPoints.length === 0) { return []; @@ -462,51 +524,95 @@ export class PortfolioCalculator { }); } - public getInvestmentsByMonth(): { date: string; investment: Big }[] { + public getInvestmentsByGroup( + groupBy: GroupBy + ): { date: string; investment: Big }[] { if (this.orders.length === 0) { return []; } - const investments = []; + const investments: { date: string; investment: Big }[] = []; let currentDate: Date; - let investmentByMonth = new Big(0); + let investmentByGroup = new Big(0); for (const [index, order] of this.orders.entries()) { if ( - isSameMonth(parseDate(order.date), currentDate) && - isSameYear(parseDate(order.date), currentDate) + isSameYear(parseDate(order.date), currentDate) && + (groupBy === 'year' || isSameMonth(parseDate(order.date), currentDate)) ) { - // Same month: Add up investments - - investmentByMonth = investmentByMonth.plus( + // Same group: Add up investments + investmentByGroup = investmentByGroup.plus( order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type)) ); } else { - // New month: Store previous month and reset - + // New group: Store previous group and reset if (currentDate) { investments.push({ - date: format(set(currentDate, { date: 1 }), DATE_FORMAT), - investment: investmentByMonth + date: format( + set(currentDate, { + date: 1, + month: groupBy === 'year' ? 0 : currentDate.getMonth() + }), + DATE_FORMAT + ), + investment: investmentByGroup }); } currentDate = parseDate(order.date); - investmentByMonth = order.quantity + investmentByGroup = order.quantity .mul(order.unitPrice) .mul(this.getFactor(order.type)); } if (index === this.orders.length - 1) { - // Store current month (latest order) + // Store current group (latest order) investments.push({ - date: format(set(currentDate, { date: 1 }), DATE_FORMAT), - investment: investmentByMonth + date: format( + set(currentDate, { + date: 1, + month: groupBy === 'year' ? 0 : currentDate.getMonth() + }), + DATE_FORMAT + ), + investment: investmentByGroup }); } } - return investments; + // Fill in the missing dates with investment = 0 + const startDate = parseDate(first(this.orders).date); + const endDate = parseDate(last(this.orders).date); + + const allDates: string[] = []; + currentDate = startDate; + + while (currentDate <= endDate) { + allDates.push( + format( + set(currentDate, { + date: 1, + month: groupBy === 'year' ? 0 : currentDate.getMonth() + }), + DATE_FORMAT + ) + ); + currentDate.setMonth(currentDate.getMonth() + 1); + } + + for (const date of allDates) { + const existingInvestment = investments.find((investment) => { + return investment.date === date; + }); + + if (!existingInvestment) { + investments.push({ date, investment: new Big(0) }); + } + } + + return sortBy(investments, (investment) => { + return investment.date; + }); } public async calculateTimeline( @@ -662,7 +768,7 @@ export class PortfolioCalculator { ); } else if (!currentPosition.quantity.eq(0)) { Logger.warn( - `Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`, + `Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`, 'PortfolioCalculator' ); hasErrors = true; @@ -716,7 +822,7 @@ export class PortfolioCalculator { let marketSymbols: GetValueObject[] = []; if (dataGatheringItems.length > 0) { try { - marketSymbols = await this.currentRateService.getValues({ + const { values } = await this.currentRateService.getValues({ currencies, dataGatheringItems, dateQuery: { @@ -725,6 +831,7 @@ export class PortfolioCalculator { }, userCurrency: this.currency }); + marketSymbols = values; } catch (error) { Logger.error( `Failed to fetch info for date ${startDate} with exception`, @@ -811,6 +918,7 @@ export class PortfolioCalculator { switch (type) { case 'BUY': + case 'STAKE': factor = 1; break; case 'SELL': @@ -858,12 +966,16 @@ export class PortfolioCalculator { if (orders.length <= 0) { return { + currentValues: {}, + grossPerformance: new Big(0), + grossPerformancePercentage: new Big(0), hasErrors: false, initialValue: new Big(0), + investmentValues: {}, + maxInvestmentValues: {}, netPerformance: new Big(0), netPerformancePercentage: new Big(0), - grossPerformance: new Big(0), - grossPerformancePercentage: new Big(0) + netPerformanceValues: {} }; } @@ -898,14 +1010,12 @@ export class PortfolioCalculator { let grossPerformanceFromSells = new Big(0); let initialValue: Big; let investmentAtStartDate: Big; + const currentValues: { [date: string]: Big } = {}; const investmentValues: { [date: string]: Big } = {}; + const maxInvestmentValues: { [date: string]: Big } = {}; let lastAveragePrice = new Big(0); - // let lastTransactionInvestment = new Big(0); - // let lastValueOfInvestmentBeforeTransaction = new Big(0); let maxTotalInvestment = new Big(0); const netPerformanceValues: { [date: string]: Big } = {}; - // let timeWeightedGrossPerformancePercentage = new Big(1); - // let timeWeightedNetPerformancePercentage = new Big(1); let totalInvestment = new Big(0); let totalInvestmentWithGrossPerformanceFromSell = new Big(0); let totalUnits = new Big(0); @@ -965,6 +1075,20 @@ export class PortfolioCalculator { marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ?? lastUnitPrice }); + } else { + let orderIndex = orders.findIndex( + (o) => o.date === format(day, DATE_FORMAT) && o.type === 'STAKE' + ); + if (orderIndex >= 0) { + let order = orders[orderIndex]; + orders.splice(orderIndex, 1); + orders.push({ + ...order, + unitPrice: + marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ?? + lastUnitPrice + }); + } } lastUnitPrice = last(orders).unitPrice; @@ -1034,12 +1158,14 @@ export class PortfolioCalculator { } const transactionInvestment = - order.type === 'BUY' + order.type === 'BUY' || order.type === 'STAKE' ? order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type)) - : totalInvestment + : totalUnits.gt(0) + ? totalInvestment .div(totalUnits) .mul(order.quantity) - .mul(this.getFactor(order.type)); + .mul(this.getFactor(order.type)) + : new Big(0); if (PortfolioCalculator.ENABLE_LOGGING) { console.log('totalInvestment', totalInvestment.toNumber()); @@ -1109,66 +1235,21 @@ export class PortfolioCalculator { .minus(totalInvestment) .plus(grossPerformanceFromSells); - // if ( - // i > indexOfStartOrder && - // !lastValueOfInvestmentBeforeTransaction - // .plus(lastTransactionInvestment) - // .eq(0) - // ) { - // const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction - // .minus( - // lastValueOfInvestmentBeforeTransaction.plus( - // lastTransactionInvestment - // ) - // ) - // .div( - // lastValueOfInvestmentBeforeTransaction.plus( - // lastTransactionInvestment - // ) - // ); - - // timeWeightedGrossPerformancePercentage = - // timeWeightedGrossPerformancePercentage.mul( - // new Big(1).plus(grossHoldingPeriodReturn) - // ); - - // const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction - // .minus(fees.minus(feesAtStartDate)) - // .minus( - // lastValueOfInvestmentBeforeTransaction.plus( - // lastTransactionInvestment - // ) - // ) - // .div( - // lastValueOfInvestmentBeforeTransaction.plus( - // lastTransactionInvestment - // ) - // ); - - // timeWeightedNetPerformancePercentage = - // timeWeightedNetPerformancePercentage.mul( - // new Big(1).plus(netHoldingPeriodReturn) - // ); - // } - grossPerformance = newGrossPerformance; - // lastTransactionInvestment = transactionInvestment; - - // lastValueOfInvestmentBeforeTransaction = - // valueOfInvestmentBeforeTransaction; - if (order.itemType === 'start') { feesAtStartDate = fees; grossPerformanceAtStartDate = grossPerformance; } if (isChartMode && i > indexOfStartOrder) { + currentValues[order.date] = valueOfInvestment; netPerformanceValues[order.date] = grossPerformance .minus(grossPerformanceAtStartDate) .minus(fees.minus(feesAtStartDate)); - investmentValues[order.date] = maxTotalInvestment; + investmentValues[order.date] = totalInvestment; + maxInvestmentValues[order.date] = maxTotalInvestment; } if (PortfolioCalculator.ENABLE_LOGGING) { @@ -1184,12 +1265,6 @@ export class PortfolioCalculator { } } - // timeWeightedGrossPerformancePercentage = - // timeWeightedGrossPerformancePercentage.minus(1); - - // timeWeightedNetPerformancePercentage = - // timeWeightedNetPerformancePercentage.minus(1); - const totalGrossPerformance = grossPerformance.minus( grossPerformanceAtStartDate ); @@ -1253,6 +1328,7 @@ export class PortfolioCalculator { Average price: ${averagePriceAtStartDate.toFixed( 2 )} -> ${averagePriceAtEndDate.toFixed(2)} + Total investment: ${totalInvestment.toFixed(2)} Max. total investment: ${maxTotalInvestment.toFixed(2)} Gross performance: ${totalGrossPerformance.toFixed( 2 @@ -1265,14 +1341,16 @@ export class PortfolioCalculator { } return { - initialValue, + currentValues, grossPerformancePercentage, + initialValue, investmentValues, + maxInvestmentValues, netPerformancePercentage, netPerformanceValues, + grossPerformance: totalGrossPerformance, hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate), - netPerformance: totalNetPerformance, - grossPerformance: totalGrossPerformance + netPerformance: totalNetPerformance }; } diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index ef0b586e5..7ec32d59c 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -8,17 +8,20 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; -import { parseDate } from '@ghostfolio/common/helper'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { + DEFAULT_CURRENCY, + HEADER_KEY_IMPERSONATION +} from '@ghostfolio/common/config'; import { PortfolioDetails, + PortfolioDividends, PortfolioInvestments, PortfolioPerformanceResponse, PortfolioPublicDetails, PortfolioReport } from '@ghostfolio/common/interfaces'; -import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import type { DateRange, GroupBy, @@ -47,8 +50,6 @@ import { PortfolioService } from './portfolio.service'; @Controller('portfolio') export class PortfolioController { - private baseCurrency: string; - public constructor( private readonly accessService: AccessService, private readonly apiService: ApiService, @@ -57,23 +58,26 @@ export class PortfolioController { private readonly portfolioService: PortfolioService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly userService: UserService - ) { - this.baseCurrency = this.configurationService.get('BASE_CURRENCY'); - } + ) {} @Get('details') @UseGuards(AuthGuard('jwt')) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getDetails( - @Headers('impersonation-id') impersonationId: string, + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Query('accounts') filterByAccounts?: string, @Query('assetClasses') filterByAssetClasses?: string, @Query('range') dateRange: DateRange = 'max', @Query('tags') filterByTags?: string ): Promise { + let hasDetails = true; let hasError = false; + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + hasDetails = this.request.user.subscription.type === 'Premium'; + } + const filters = this.apiService.buildFiltersFromQueryParams({ filterByAccounts, filterByAssetClasses, @@ -86,6 +90,7 @@ export class PortfolioController { filteredValueInPercentage, hasErrors, holdings, + platforms, summary, totalValueInBaseCurrency } = await this.portfolioService.getDetails({ @@ -105,21 +110,38 @@ export class PortfolioController { impersonationId || this.userService.isRestrictedView(this.request.user) ) { - const totalInvestment = Object.values(holdings) - .map((portfolioPosition) => { - return portfolioPosition.investment; - }) - .reduce((a, b) => a + b, 0); - - const totalValue = Object.values(holdings) - .map((portfolioPosition) => { - return this.exchangeRateDataService.toCurrency( - portfolioPosition.quantity * portfolioPosition.marketPrice, - portfolioPosition.currency, - this.request.user.Settings.settings.baseCurrency - ); - }) - .reduce((a, b) => a + b, 0); + let investmentTuple: [number, number] = [0, 0]; + for (let holding of Object.entries(holdings)) { + var portfolioPosition = holding[1]; + investmentTuple[0] += portfolioPosition.investment; + investmentTuple[1] += this.exchangeRateDataService.toCurrency( + portfolioPosition.quantity * portfolioPosition.marketPrice, + portfolioPosition.currency, + this.request.user.Settings.settings.baseCurrency + ); + } + const totalInvestment = investmentTuple[0]; + + const totalValue = investmentTuple[1]; + + if (hasDetails === false) { + portfolioSummary = nullifyValuesInObject(summary, [ + 'cash', + 'committedFunds', + 'currentGrossPerformance', + 'currentNetPerformance', + 'currentValue', + 'dividend', + 'emergencyFund', + 'excludedAccountsAndActivities', + 'fees', + 'items', + 'liabilities', + 'netWorth', + 'totalBuy', + 'totalSell' + ]); + } for (const [symbol, portfolioPosition] of Object.entries(holdings)) { portfolioPosition.grossPerformance = null; @@ -127,14 +149,42 @@ export class PortfolioController { portfolioPosition.investment / totalInvestment; portfolioPosition.netPerformance = null; portfolioPosition.quantity = null; - portfolioPosition.value = portfolioPosition.value / totalValue; + portfolioPosition.valueInPercentage = + portfolioPosition.valueInBaseCurrency / totalValue; + (portfolioPosition.assetClass = hasDetails + ? portfolioPosition.assetClass + : undefined), + (portfolioPosition.assetSubClass = hasDetails + ? portfolioPosition.assetSubClass + : undefined), + (portfolioPosition.countries = hasDetails + ? portfolioPosition.countries + : []), + (portfolioPosition.currency = hasDetails + ? portfolioPosition.currency + : undefined), + (portfolioPosition.markets = hasDetails + ? portfolioPosition.markets + : undefined), + (portfolioPosition.sectors = hasDetails + ? portfolioPosition.sectors + : []); } - for (const [name, { current, original }] of Object.entries(accounts)) { - accounts[name].current = current / totalValue; - accounts[name].original = original / totalInvestment; + for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) { + accounts[name].valueInPercentage = valueInBaseCurrency / totalValue; } + for (const [name, { valueInBaseCurrency }] of Object.entries(platforms)) { + platforms[name].valueInPercentage = valueInBaseCurrency / totalValue; + } + } + + if ( + hasDetails === false || + impersonationId || + this.userService.isRestrictedView(this.request.user) + ) { portfolioSummary = nullifyValuesInObject(summary, [ 'cash', 'committedFunds', @@ -145,26 +195,33 @@ export class PortfolioController { 'emergencyFund', 'excludedAccountsAndActivities', 'fees', + 'fireWealth', 'items', + 'liabilities', 'netWorth', 'totalBuy', + 'totalInvestment', 'totalSell' ]); } - let hasDetails = true; - if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { - hasDetails = this.request.user.subscription.type === 'Premium'; - } - for (const [symbol, portfolioPosition] of Object.entries(holdings)) { holdings[symbol] = { ...portfolioPosition, - assetClass: hasDetails ? portfolioPosition.assetClass : undefined, - assetSubClass: hasDetails ? portfolioPosition.assetSubClass : undefined, + assetClass: + hasDetails || portfolioPosition.assetClass === 'CASH' + ? portfolioPosition.assetClass + : undefined, + assetSubClass: + hasDetails || portfolioPosition.assetSubClass === 'CASH' + ? portfolioPosition.assetSubClass + : undefined, countries: hasDetails ? portfolioPosition.countries : [], currency: hasDetails ? portfolioPosition.currency : undefined, markets: hasDetails ? portfolioPosition.markets : undefined, + marketsAdvanced: hasDetails + ? portfolioPosition.marketsAdvanced + : undefined, sectors: hasDetails ? portfolioPosition.sectors : [] }; } @@ -175,43 +232,86 @@ export class PortfolioController { filteredValueInPercentage, hasError, holdings, + platforms, totalValueInBaseCurrency, - summary: hasDetails ? portfolioSummary : undefined + summary: portfolioSummary }; } - @Get('investments') + @Get('dividends') @UseGuards(AuthGuard('jwt')) - public async getInvestments( - @Headers('impersonation-id') impersonationId: string, + public async getDividends( + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Query('accounts') filterByAccounts?: string, + @Query('assetClasses') filterByAssetClasses?: string, + @Query('groupBy') groupBy?: GroupBy, @Query('range') dateRange: DateRange = 'max', - @Query('groupBy') groupBy?: GroupBy - ): Promise { + @Query('tags') filterByTags?: string + ): Promise { + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAccounts, + filterByAssetClasses, + filterByTags + }); + + let dividends = await this.portfolioService.getDividends({ + dateRange, + filters, + groupBy, + impersonationId + }); + if ( - this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - this.request.user.subscription.type === 'Basic' + impersonationId || + this.userService.isRestrictedView(this.request.user) ) { - throw new HttpException( - getReasonPhrase(StatusCodes.FORBIDDEN), - StatusCodes.FORBIDDEN + const maxDividend = dividends.reduce( + (investment, item) => Math.max(investment, item.investment), + 1 ); - } - let investments: InvestmentItem[]; + dividends = dividends.map((item) => ({ + date: item.date, + investment: item.investment / maxDividend + })); + } - if (groupBy === 'month') { - investments = await this.portfolioService.getInvestments({ - dateRange, - impersonationId, - groupBy: 'month' - }); - } else { - investments = await this.portfolioService.getInvestments({ - dateRange, - impersonationId + if ( + this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + this.request.user.subscription.type === 'Basic' + ) { + dividends = dividends.map((item) => { + return nullifyValuesInObject(item, ['investment']); }); } + return { dividends }; + } + + @Get('investments') + @UseGuards(AuthGuard('jwt')) + public async getInvestments( + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Query('accounts') filterByAccounts?: string, + @Query('assetClasses') filterByAssetClasses?: string, + @Query('groupBy') groupBy?: GroupBy, + @Query('range') dateRange: DateRange = 'max', + @Query('tags') filterByTags?: string + ): Promise { + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAccounts, + filterByAssetClasses, + filterByTags + }); + + let { investments, streaks } = await this.portfolioService.getInvestments({ + dateRange, + filters, + groupBy, + impersonationId, + savingsRate: this.request.user?.Settings?.settings.savingsRate + }); + if ( impersonationId || this.userService.isRestrictedView(this.request.user) @@ -225,9 +325,28 @@ export class PortfolioController { date: item.date, investment: item.investment / maxInvestment })); + + streaks = nullifyValuesInObject(streaks, [ + 'currentStreak', + 'longestStreak' + ]); + } + + if ( + this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + this.request.user.subscription.type === 'Basic' + ) { + investments = investments.map((item) => { + return nullifyValuesInObject(item, ['investment']); + }); + + streaks = nullifyValuesInObject(streaks, [ + 'currentStreak', + 'longestStreak' + ]); } - return { investments }; + return { investments, streaks }; } @Get('performance') @@ -235,12 +354,23 @@ export class PortfolioController { @UseInterceptors(TransformDataSourceInResponseInterceptor) @Version('2') public async getPerformanceV2( - @Headers('impersonation-id') impersonationId: string, - @Query('range') dateRange: DateRange = 'max' + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Query('accounts') filterByAccounts?: string, + @Query('assetClasses') filterByAssetClasses?: string, + @Query('range') dateRange: DateRange = 'max', + @Query('tags') filterByTags?: string ): Promise { + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAccounts, + filterByAssetClasses, + filterByTags + }); + const performanceInformation = await this.portfolioService.getPerformance({ dateRange, - impersonationId + filters, + impersonationId, + userId: this.request.user.id }); if ( @@ -256,7 +386,7 @@ export class PortfolioController { totalInvestment: new Big(totalInvestment) .div(performanceInformation.performance.totalInvestment) .toNumber(), - value: new Big(value) + valueInPercentage: new Big(value) .div(performanceInformation.performance.currentValue) .toNumber() }; @@ -274,39 +404,48 @@ export class PortfolioController { ); } + if ( + this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + this.request.user.subscription.type === 'Basic' + ) { + performanceInformation.chart = performanceInformation.chart.map( + (item) => { + return nullifyValuesInObject(item, ['totalInvestment', 'value']); + } + ); + } + return performanceInformation; } @Get('positions') @UseGuards(AuthGuard('jwt')) + @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getPositions( - @Headers('impersonation-id') impersonationId: string, - @Query('range') dateRange: DateRange = 'max' + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Query('accounts') filterByAccounts?: string, + @Query('assetClasses') filterByAssetClasses?: string, + @Query('query') filterBySearchQuery?: string, + @Query('range') dateRange: DateRange = 'max', + @Query('tags') filterByTags?: string ): Promise { - const result = await this.portfolioService.getPositions( - impersonationId, - dateRange - ); - - if ( - impersonationId || - this.userService.isRestrictedView(this.request.user) - ) { - result.positions = result.positions.map((position) => { - return nullifyValuesInObject(position, [ - 'grossPerformance', - 'investment', - 'netPerformance', - 'quantity' - ]); - }); - } + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAccounts, + filterByAssetClasses, + filterBySearchQuery, + filterByTags + }); - return result; + return this.portfolioService.getPositions({ + dateRange, + filters, + impersonationId + }); } @Get('public/:accessId') + @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getPublic( @Param('accessId') accessId ): Promise { @@ -331,7 +470,7 @@ export class PortfolioController { dateRange: 'max', filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }], impersonationId: access.userId, - userId: access.userId + userId: user.id }); const portfolioPublicDetails: PortfolioPublicDetails = { @@ -345,24 +484,26 @@ export class PortfolioController { return this.exchangeRateDataService.toCurrency( portfolioPosition.quantity * portfolioPosition.marketPrice, portfolioPosition.currency, - this.request.user?.Settings?.settings.baseCurrency ?? - this.baseCurrency + this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY ); }) .reduce((a, b) => a + b, 0); for (const [symbol, portfolioPosition] of Object.entries(holdings)) { portfolioPublicDetails.holdings[symbol] = { - allocationCurrent: portfolioPosition.value / totalValue, + allocationInPercentage: + portfolioPosition.valueInBaseCurrency / totalValue, countries: hasDetails ? portfolioPosition.countries : [], currency: hasDetails ? portfolioPosition.currency : undefined, + dataSource: portfolioPosition.dataSource, + dateOfFirstActivity: portfolioPosition.dateOfFirstActivity, markets: hasDetails ? portfolioPosition.markets : undefined, name: portfolioPosition.name, netPerformancePercent: portfolioPosition.netPerformancePercent, sectors: hasDetails ? portfolioPosition.sectors : [], symbol: portfolioPosition.symbol, url: portfolioPosition.url, - value: portfolioPosition.value / totalValue + valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue }; } @@ -370,35 +511,22 @@ export class PortfolioController { } @Get('position/:dataSource/:symbol') + @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) @UseGuards(AuthGuard('jwt')) public async getPosition( - @Headers('impersonation-id') impersonationId: string, + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Param('dataSource') dataSource, @Param('symbol') symbol ): Promise { - let position = await this.portfolioService.getPosition( + const position = await this.portfolioService.getPosition( dataSource, impersonationId, symbol ); if (position) { - if ( - impersonationId || - this.userService.isRestrictedView(this.request.user) - ) { - position = nullifyValuesInObject(position, [ - 'grossPerformance', - 'investment', - 'netPerformance', - 'orders', - 'quantity', - 'value' - ]); - } - return position; } @@ -411,18 +539,21 @@ export class PortfolioController { @Get('report') @UseGuards(AuthGuard('jwt')) public async getReport( - @Headers('impersonation-id') impersonationId: string + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string ): Promise { + const report = await this.portfolioService.getReport(impersonationId); + if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.request.user.subscription.type === 'Basic' ) { - throw new HttpException( - getReasonPhrase(StatusCodes.FORBIDDEN), - StatusCodes.FORBIDDEN - ); + for (const rule in report.rules) { + if (report.rules[rule]) { + report.rules[rule] = []; + } + } } - return await this.portfolioService.getReport(impersonationId); + return report; } } diff --git a/apps/api/src/app/portfolio/portfolio.module.ts b/apps/api/src/app/portfolio/portfolio.module.ts index bf5829833..3b4ee5d76 100644 --- a/apps/api/src/app/portfolio/portfolio.module.ts +++ b/apps/api/src/app/portfolio/portfolio.module.ts @@ -2,15 +2,16 @@ import { AccessModule } from '@ghostfolio/api/app/access/access.module'; import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module'; +import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; -import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; -import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; -import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; -import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module'; -import { MarketDataModule } from '@ghostfolio/api/services/market-data.module'; -import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; -import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.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 { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { Module } from '@nestjs/common'; import { CurrentRateService } from './current-rate.service'; @@ -36,6 +37,7 @@ import { RulesService } from './rules.service'; UserModule ], providers: [ + AccountBalanceService, AccountService, CurrentRateService, PortfolioService, diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index a34d1e385..623085cd4 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -1,37 +1,37 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface'; -import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface'; import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface'; import { UserService } from '@ghostfolio/api/app/user/user.service'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; -import { AccountClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/initial-investment'; import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account'; import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment'; -import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-initial-investment'; import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment'; -import { CurrencyClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/initial-investment'; +import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup'; import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; -import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; -import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.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 { - ASSET_SUB_CLASS_EMERGENCY_FUND, + DEFAULT_CURRENCY, + EMERGENCY_FUND_TAG_ID, MAX_CHART_ITEMS, UNKNOWN_KEY } from '@ghostfolio/common/config'; -import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; +import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper'; import { Accounts, EnhancedSymbolProfile, Filter, HistoricalDataItem, PortfolioDetails, + PortfolioInvestments, PortfolioPerformanceResponse, + PortfolioPosition, PortfolioReport, PortfolioSummary, Position, @@ -43,35 +43,34 @@ import type { AccountWithValue, DateRange, GroupBy, - Market, OrderWithAccount, - RequestWithUser + RequestWithUser, + UserWithSettings } from '@ghostfolio/common/types'; import { Inject, Injectable } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { Account, + Type as ActivityType, AssetClass, DataSource, Order, Platform, Prisma, - Tag, - Type as TypeOfOrder + Tag } from '@prisma/client'; import Big from 'big.js'; import { differenceInDays, - endOfToday, format, isAfter, isBefore, + isSameMonth, + isSameYear, max, - parse, parseISO, set, setDayOfYear, - startOfDay, subDays, subYears } from 'date-fns'; @@ -84,16 +83,15 @@ import { import { PortfolioCalculator } from './portfolio-calculator'; import { RulesService } from './rules.service'; +const asiaPacificMarkets = require('../../assets/countries/asia-pacific-markets.json'); const developedMarkets = require('../../assets/countries/developed-markets.json'); const emergingMarkets = require('../../assets/countries/emerging-markets.json'); +const europeMarkets = require('../../assets/countries/europe-markets.json'); @Injectable() export class PortfolioService { - private baseCurrency: string; - public constructor( private readonly accountService: AccountService, - private readonly configurationService: ConfigurationService, private readonly currentRateService: CurrentRateService, private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, @@ -103,9 +101,7 @@ export class PortfolioService { private readonly rulesService: RulesService, private readonly symbolProfileService: SymbolProfileService, private readonly userService: UserService - ) { - this.baseCurrency = this.configurationService.get('BASE_CURRENCY'); - } + ) {} public async getAccounts({ filters, @@ -130,9 +126,9 @@ export class PortfolioService { }), this.getDetails({ filters, - userId, withExcludedAccounts, - impersonationId: userId + impersonationId: userId, + userId: this.request.user.id }) ]); @@ -147,7 +143,8 @@ export class PortfolioService { } } - const valueInBaseCurrency = details.accounts[account.id]?.current ?? 0; + const valueInBaseCurrency = + details.accounts[account.id]?.valueInBaseCurrency ?? 0; const result = { ...account, @@ -207,19 +204,65 @@ export class PortfolioService { }; } + public async getDividends({ + dateRange, + filters, + groupBy, + impersonationId + }: { + dateRange: DateRange; + filters?: Filter[]; + groupBy?: GroupBy; + impersonationId: string; + }): Promise { + const userId = await this.getUserId(impersonationId, this.request.user.id); + + const activities = await this.orderService.getOrders({ + filters, + userId, + types: ['DIVIDEND'], + userCurrency: this.request.user.Settings.settings.baseCurrency + }); + + let dividends = activities.map((dividend) => { + return { + date: format(dividend.date, DATE_FORMAT), + investment: dividend.valueInBaseCurrency + }; + }); + + if (groupBy) { + dividends = this.getDividendsByGroup({ dividends, groupBy }); + } + + const startDate = this.getStartDate( + dateRange, + parseDate(dividends[0]?.date) + ); + + return dividends.filter(({ date }) => { + return !isBefore(parseDate(date), startDate); + }); + } + public async getInvestments({ dateRange, + filters, + groupBy, impersonationId, - groupBy + savingsRate }: { dateRange: DateRange; - impersonationId: string; + filters?: Filter[]; groupBy?: GroupBy; - }): Promise { + impersonationId: string; + savingsRate: number; + }): Promise { const userId = await this.getUserId(impersonationId, this.request.user.id); const { portfolioOrders, transactionPoints } = await this.getTransactionPoints({ + filters, userId, includeDrafts: true }); @@ -232,31 +275,39 @@ export class PortfolioService { portfolioCalculator.setTransactionPoints(transactionPoints); if (transactionPoints.length === 0) { - return []; + return { + investments: [], + streaks: { currentStreak: 0, longestStreak: 0 } + }; } let investments: InvestmentItem[]; - if (groupBy === 'month') { - investments = portfolioCalculator.getInvestmentsByMonth().map((item) => { - return { - date: item.date, - investment: item.investment.toNumber() - }; - }); + if (groupBy) { + investments = portfolioCalculator + .getInvestmentsByGroup(groupBy) + .map((item) => { + return { + date: item.date, + investment: item.investment.toNumber() + }; + }); - // Add investment of current month - const dateOfCurrentMonth = format( - set(new Date(), { date: 1 }), + // Add investment of current group + const dateOfCurrentGroup = format( + set(new Date(), { + date: 1, + month: groupBy === 'year' ? 0 : new Date().getMonth() + }), DATE_FORMAT ); - const investmentOfCurrentMonth = investments.filter(({ date }) => { - return date === dateOfCurrentMonth; + const investmentOfCurrentGroup = investments.filter(({ date }) => { + return date === dateOfCurrentGroup; }); - if (investmentOfCurrentMonth.length <= 0) { + if (investmentOfCurrentGroup.length <= 0) { investments.push({ - date: dateOfCurrentMonth, + date: dateOfCurrentGroup, investment: 0 }); } @@ -297,27 +348,48 @@ export class PortfolioService { parseDate(investments[0]?.date) ); - return investments.filter(({ date }) => { + investments = investments.filter(({ date }) => { return !isBefore(parseDate(date), startDate); }); + + let streaks: PortfolioInvestments['streaks']; + + if (savingsRate) { + streaks = this.getStreaks({ + investments, + savingsRate: groupBy === 'year' ? 12 * savingsRate : savingsRate + }); + } + + return { + investments, + streaks + }; } public async getChart({ dateRange = 'max', - impersonationId + filters, + impersonationId, + userCurrency, + userId }: { dateRange?: DateRange; + filters?: Filter[]; impersonationId: string; + userCurrency: string; + userId: string; }): Promise { - const userId = await this.getUserId(impersonationId, this.request.user.id); + userId = await this.getUserId(impersonationId, userId); const { portfolioOrders, transactionPoints } = await this.getTransactionPoints({ + filters, userId }); const portfolioCalculator = new PortfolioCalculator({ - currency: this.request.user.Settings.settings.baseCurrency, + currency: userCurrency, currentRateService: this.currentRateService, orders: portfolioOrders }); @@ -354,29 +426,25 @@ export class PortfolioService { } public async getDetails({ - impersonationId, - userId, dateRange = 'max', filters, + impersonationId, + userId, withExcludedAccounts = false }: { - impersonationId: string; - userId: string; dateRange?: DateRange; filters?: Filter[]; + impersonationId: string; + userId: string; withExcludedAccounts?: boolean; }): Promise { - // TODO userId = await this.getUserId(impersonationId, userId); const user = await this.userService.user({ id: userId }); + const userCurrency = this.getUserCurrency(user); const emergencyFund = new Big( (user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 ); - const userCurrency = - user.Settings?.settings.baseCurrency ?? - this.request.user?.Settings?.settings.baseCurrency ?? - this.baseCurrency; const { orders, portfolioOrders, transactionPoints } = await this.getTransactionPoints({ @@ -397,9 +465,8 @@ export class PortfolioService { transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT) ); const startDate = this.getStartDate(dateRange, portfolioStart); - const currentPositions = await portfolioCalculator.getCurrentPositions( - startDate - ); + const currentPositions = + await portfolioCalculator.getCurrentPositions(startDate); const cashDetails = await this.accountService.getCashDetails({ filters, @@ -408,10 +475,18 @@ export class PortfolioService { }); const holdings: PortfolioDetails['holdings'] = {}; - const totalInvestmentInBaseCurrency = currentPositions.totalInvestment.plus( + const totalValueInBaseCurrency = currentPositions.currentValue.plus( cashDetails.balanceInBaseCurrency ); - let filteredValueInBaseCurrency = currentPositions.currentValue; + + const isFilteredByAccount = + filters?.some((filter) => { + return filter.type === 'ACCOUNT'; + }) ?? false; + + let filteredValueInBaseCurrency = isFilteredByAccount + ? totalValueInBaseCurrency + : currentPositions.currentValue; if ( filters?.length === 0 || @@ -424,19 +499,18 @@ export class PortfolioService { ); } - const dataGatheringItems = currentPositions.positions.map((position) => { - return { - dataSource: position.dataSource, - symbol: position.symbol - }; - }); - const symbols = currentPositions.positions.map( - (position) => position.symbol + const dataGatheringItems = currentPositions.positions.map( + ({ dataSource, symbol }) => { + return { + dataSource, + symbol + }; + } ); const [dataProviderResponses, symbolProfiles] = await Promise.all([ - this.dataProviderService.getQuotes(dataGatheringItems), - this.symbolProfileService.getSymbolProfilesBySymbols(symbols) + this.dataProviderService.getQuotes({ items: dataGatheringItems }), + this.symbolProfileService.getSymbolProfiles(dataGatheringItems) ]); const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {}; @@ -445,11 +519,9 @@ export class PortfolioService { } const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {}; - for (const position of currentPositions.positions) { - portfolioItemsNow[position.symbol] = position; - } for (const item of currentPositions.positions) { + portfolioItemsNow[item.symbol] = item; if (item.quantity.lte(0)) { // Ignore positions without any quantity continue; @@ -459,41 +531,41 @@ export class PortfolioService { const symbolProfile = symbolProfileMap[item.symbol]; const dataProviderResponse = dataProviderResponses[item.symbol]; - const markets: { [key in Market]: number } = { + const markets: PortfolioPosition['markets'] = { + [UNKNOWN_KEY]: 0, developedMarkets: 0, emergingMarkets: 0, otherMarkets: 0 }; + const marketsAdvanced: PortfolioPosition['marketsAdvanced'] = { + [UNKNOWN_KEY]: 0, + asiaPacific: 0, + emergingMarkets: 0, + europe: 0, + japan: 0, + northAmerica: 0, + otherMarkets: 0 + }; - for (const country of symbolProfile.countries) { - if (developedMarkets.includes(country.code)) { - markets.developedMarkets = new Big(markets.developedMarkets) - .plus(country.weight) - .toNumber(); - } else if (emergingMarkets.includes(country.code)) { - markets.emergingMarkets = new Big(markets.emergingMarkets) - .plus(country.weight) - .toNumber(); - } else { - markets.otherMarkets = new Big(markets.otherMarkets) - .plus(country.weight) - .toNumber(); - } - } + this.calculateMarketsAllocation( + symbolProfile, + markets, + marketsAdvanced, + value + ); holdings[item.symbol] = { markets, - allocationCurrent: filteredValueInBaseCurrency.eq(0) + marketsAdvanced, + allocationInPercentage: filteredValueInBaseCurrency.eq(0) ? 0 : value.div(filteredValueInBaseCurrency).toNumber(), - allocationInvestment: item.investment - .div(totalInvestmentInBaseCurrency) - .toNumber(), assetClass: symbolProfile.assetClass, assetSubClass: symbolProfile.assetSubClass, countries: symbolProfile.countries, currency: item.currency, dataSource: symbolProfile.dataSource, + dateOfFirstActivity: parseDate(item.firstBuyDate), grossPerformance: item.grossPerformance?.toNumber() ?? 0, grossPerformancePercent: item.grossPerformancePercentage?.toNumber() ?? 0, @@ -506,32 +578,23 @@ export class PortfolioService { quantity: item.quantity.toNumber(), sectors: symbolProfile.sectors, symbol: item.symbol, + tags: item.tags, transactionCount: item.transactionCount, url: symbolProfile.url, - value: value.toNumber() + valueInBaseCurrency: value.toNumber() }; } - if ( - filters?.length === 0 || - (filters?.length === 1 && - filters[0].type === 'ASSET_CLASS' && - filters[0].id === 'CASH') - ) { - const cashPositions = await this.getCashPositions({ - cashDetails, - emergencyFund, - userCurrency, - investment: totalInvestmentInBaseCurrency, - value: filteredValueInBaseCurrency - }); - - for (const symbol of Object.keys(cashPositions)) { - holdings[symbol] = cashPositions[symbol]; - } - } + await this.handleCashPosition( + filters, + isFilteredByAccount, + cashDetails, + userCurrency, + filteredValueInBaseCurrency, + holdings + ); - const accounts = await this.getValueOfAccounts({ + const { accounts, platforms } = await this.getValueOfAccountsAndPlatforms({ filters, orders, portfolioItemsNow, @@ -540,11 +603,32 @@ export class PortfolioService { withExcludedAccounts }); - const summary = await this.getSummary({ impersonationId }); + filteredValueInBaseCurrency = await this.handleEmergencyFunds( + filters, + cashDetails, + userCurrency, + filteredValueInBaseCurrency, + emergencyFund, + orders, + accounts, + holdings + ); + + const summary = await this.getSummary({ + impersonationId, + userCurrency, + userId, + balanceInBaseCurrency: cashDetails.balanceInBaseCurrency, + emergencyFundPositionsValueInBaseCurrency: + this.getEmergencyFundPositionsValueInBaseCurrency({ + holdings + }) + }); return { accounts, holdings, + platforms, summary, filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(), filteredValueInPercentage: summary.netWorth @@ -555,13 +639,166 @@ export class PortfolioService { }; } + private async handleCashPosition( + filters: Filter[], + isFilteredByAccount: boolean, + cashDetails: CashDetails, + userCurrency: string, + filteredValueInBaseCurrency: Big, + holdings: { [symbol: string]: PortfolioPosition } + ) { + const isFilteredByCash = filters?.some((filter) => { + return filter.type === 'ASSET_CLASS' && filter.id === 'CASH'; + }); + + if (filters?.length === 0 || isFilteredByAccount || isFilteredByCash) { + const cashPositions = await this.getCashPositions({ + cashDetails, + userCurrency, + value: filteredValueInBaseCurrency + }); + + for (const symbol of Object.keys(cashPositions)) { + holdings[symbol] = cashPositions[symbol]; + } + } + } + + private async handleEmergencyFunds( + filters: Filter[], + cashDetails: CashDetails, + userCurrency: string, + filteredValueInBaseCurrency: Big, + emergencyFund: Big, + orders: Activity[], + accounts: { + [id: string]: { + balance: number; + currency: string; + name: string; + valueInBaseCurrency: number; + valueInPercentage?: number; + }; + }, + holdings: { [symbol: string]: PortfolioPosition } + ) { + if ( + filters?.length === 1 && + filters[0].id === EMERGENCY_FUND_TAG_ID && + filters[0].type === 'TAG' + ) { + const emergencyFundCashPositions = await this.getCashPositions({ + cashDetails, + userCurrency, + value: filteredValueInBaseCurrency + }); + + const emergencyFundInCash = emergencyFund + .minus( + this.getEmergencyFundPositionsValueInBaseCurrency({ + holdings + }) + ) + .toNumber(); + + filteredValueInBaseCurrency = emergencyFund; + + accounts[UNKNOWN_KEY] = { + balance: 0, + currency: userCurrency, + name: UNKNOWN_KEY, + valueInBaseCurrency: emergencyFundInCash + }; + + holdings[userCurrency] = { + ...emergencyFundCashPositions[userCurrency], + investment: emergencyFundInCash, + valueInBaseCurrency: emergencyFundInCash + }; + } + return filteredValueInBaseCurrency; + } + + private calculateMarketsAllocation( + symbolProfile: EnhancedSymbolProfile, + markets: { + developedMarkets: number; + emergingMarkets: number; + otherMarkets: number; + }, + marketsAdvanced: { + asiaPacific: number; + emergingMarkets: number; + europe: number; + japan: number; + northAmerica: number; + otherMarkets: number; + }, + value: Big + ) { + if (symbolProfile.countries.length > 0) { + for (const country of symbolProfile.countries) { + if (developedMarkets.includes(country.code)) { + markets.developedMarkets = new Big(markets.developedMarkets) + .plus(country.weight) + .toNumber(); + } else if (emergingMarkets.includes(country.code)) { + markets.emergingMarkets = new Big(markets.emergingMarkets) + .plus(country.weight) + .toNumber(); + } else { + markets.otherMarkets = new Big(markets.otherMarkets) + .plus(country.weight) + .toNumber(); + } + + if (country.code === 'JP') { + marketsAdvanced.japan = new Big(marketsAdvanced.japan) + .plus(country.weight) + .toNumber(); + } else if (country.code === 'CA' || country.code === 'US') { + marketsAdvanced.northAmerica = new Big(marketsAdvanced.northAmerica) + .plus(country.weight) + .toNumber(); + } else if (asiaPacificMarkets.includes(country.code)) { + marketsAdvanced.asiaPacific = new Big(marketsAdvanced.asiaPacific) + .plus(country.weight) + .toNumber(); + } else if (emergingMarkets.includes(country.code)) { + marketsAdvanced.emergingMarkets = new Big( + marketsAdvanced.emergingMarkets + ) + .plus(country.weight) + .toNumber(); + } else if (europeMarkets.includes(country.code)) { + marketsAdvanced.europe = new Big(marketsAdvanced.europe) + .plus(country.weight) + .toNumber(); + } else { + marketsAdvanced.otherMarkets = new Big(marketsAdvanced.otherMarkets) + .plus(country.weight) + .toNumber(); + } + } + } else { + markets[UNKNOWN_KEY] = new Big(markets[UNKNOWN_KEY]) + .plus(value) + .toNumber(); + + marketsAdvanced[UNKNOWN_KEY] = new Big(marketsAdvanced[UNKNOWN_KEY]) + .plus(value) + .toNumber(); + } + } + public async getPosition( aDataSource: DataSource, aImpersonationId: string, aSymbol: string ): Promise { - const userCurrency = this.request.user.Settings.settings.baseCurrency; const userId = await this.getUserId(aImpersonationId, this.request.user.id); + const user = await this.userService.user({ id: userId }); + const userCurrency = this.getUserCurrency(user); const orders = ( await this.orderService.getOrders({ @@ -582,6 +819,10 @@ export class PortfolioService { return { tags, averagePrice: undefined, + dataProviderInfo: undefined, + dividendInBaseCurrency: undefined, + stakeRewards: undefined, + feeInBaseCurrency: undefined, firstBuyDate: undefined, grossPerformance: undefined, grossPerformancePercent: undefined, @@ -601,14 +842,19 @@ export class PortfolioService { } const positionCurrency = orders[0].SymbolProfile.currency; - const [SymbolProfile] = - await this.symbolProfileService.getSymbolProfilesBySymbols([aSymbol]); + const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([ + { dataSource: aDataSource, symbol: aSymbol } + ]); const portfolioOrders: PortfolioOrder[] = orders .filter((order) => { tags = tags.concat(order.tags); - return order.type === 'BUY' || order.type === 'SELL'; + return ( + order.type === 'BUY' || + order.type === 'SELL' || + order.type === 'STAKE' + ); }) .map((order) => ({ currency: order.SymbolProfile.currency, @@ -618,6 +864,7 @@ export class PortfolioService { name: order.SymbolProfile?.name, quantity: new Big(order.quantity), symbol: order.SymbolProfile.symbol, + tags: order.tags, type: order.type, unitPrice: new Big(order.unitPrice) })); @@ -634,9 +881,8 @@ export class PortfolioService { const transactionPoints = portfolioCalculator.getTransactionPoints(); const portfolioStart = parseDate(transactionPoints[0].date); - const currentPositions = await portfolioCalculator.getCurrentPositions( - portfolioStart - ); + const currentPositions = + await portfolioCalculator.getCurrentPositions(portfolioStart); const position = currentPositions.positions.find( (item) => item.symbol === aSymbol @@ -647,12 +893,33 @@ export class PortfolioService { averagePrice, currency, dataSource, + fee, firstBuyDate, marketPrice, quantity, transactionCount } = position; + const dividendInBaseCurrency = getSum( + orders + .filter(({ type }) => { + return type === 'DIVIDEND'; + }) + .map(({ valueInBaseCurrency }) => { + return new Big(valueInBaseCurrency); + }) + ); + + const stakeRewards = getSum( + orders + .filter(({ type }) => { + return type === 'STAKE'; + }) + .map(({ quantity }) => { + return new Big(quantity); + }) + ); + // Convert investment, gross and net performance to currency of user const investment = this.exchangeRateDataService.toCurrency( position.investment?.toNumber(), @@ -681,15 +948,6 @@ export class PortfolioService { let maxPrice = Math.max(orders[0].unitPrice, marketPrice); let minPrice = Math.min(orders[0].unitPrice, marketPrice); - if (!historicalData?.[aSymbol]?.[firstBuyDate]) { - // Add historical entry for buy date, if no historical data available - historicalDataArray.push({ - averagePrice: orders[0].unitPrice, - date: firstBuyDate, - value: orders[0].unitPrice - }); - } - if (historicalData[aSymbol]) { let j = -1; for (const [date, { marketPrice }] of Object.entries( @@ -701,25 +959,44 @@ export class PortfolioService { ) { j++; } + let currentAveragePrice = 0; + let currentQuantity = 0; + const currentSymbol = transactionPoints[j].items.find( - (item) => item.symbol === aSymbol + ({ symbol }) => { + return symbol === aSymbol; + } ); + if (currentSymbol) { currentAveragePrice = currentSymbol.quantity.eq(0) ? 0 : currentSymbol.investment.div(currentSymbol.quantity).toNumber(); + currentQuantity = currentSymbol.quantity.toNumber(); } historicalDataArray.push({ date, averagePrice: currentAveragePrice, - value: marketPrice + marketPrice: + historicalDataArray.length > 0 + ? marketPrice + : currentAveragePrice, + quantity: currentQuantity }); maxPrice = Math.max(marketPrice ?? 0, maxPrice); minPrice = Math.min(marketPrice ?? Number.MAX_SAFE_INTEGER, minPrice); } + } else { + // Add historical entry for buy date, if no historical data available + historicalDataArray.push({ + averagePrice: orders[0].unitPrice, + date: firstBuyDate, + marketPrice: orders[0].unitPrice, + quantity: orders[0].quantity + }); } return { @@ -735,6 +1012,14 @@ export class PortfolioService { tags, transactionCount, averagePrice: averagePrice.toNumber(), + dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0], + dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), + stakeRewards: stakeRewards.toNumber(), + feeInBaseCurrency: this.exchangeRateDataService.toCurrency( + fee.toNumber(), + SymbolProfile.currency, + userCurrency + ), grossPerformancePercent: position.grossPerformancePercentage?.toNumber(), historicalData: historicalDataArray, @@ -747,9 +1032,9 @@ export class PortfolioService { ) }; } else { - const currentData = await this.dataProviderService.getQuotes([ - { dataSource: DataSource.YAHOO, symbol: aSymbol } - ]); + const currentData = await this.dataProviderService.getQuotes({ + items: [{ dataSource: DataSource.YAHOO, symbol: aSymbol }] + }); const marketPrice = currentData[aSymbol]?.marketPrice; let historicalData = await this.dataProviderService.getHistorical( @@ -791,6 +1076,10 @@ export class PortfolioService { SymbolProfile, tags, averagePrice: 0, + dataProviderInfo: undefined, + dividendInBaseCurrency: 0, + stakeRewards: 0, + feeInBaseCurrency: 0, firstBuyDate: undefined, grossPerformance: undefined, grossPerformancePercent: undefined, @@ -805,14 +1094,23 @@ export class PortfolioService { } } - public async getPositions( - aImpersonationId: string, - aDateRange: DateRange = 'max' - ): Promise<{ hasErrors: boolean; positions: Position[] }> { - const userId = await this.getUserId(aImpersonationId, this.request.user.id); + public async getPositions({ + dateRange = 'max', + filters, + impersonationId + }: { + dateRange?: DateRange; + filters?: Filter[]; + impersonationId: string; + }): Promise<{ hasErrors: boolean; positions: Position[] }> { + const searchQuery = filters.find(({ type }) => { + return type === 'SEARCH_QUERY'; + })?.id; + const userId = await this.getUserId(impersonationId, this.request.user.id); const { portfolioOrders, transactionPoints } = await this.getTransactionPoints({ + filters, userId }); @@ -832,25 +1130,28 @@ export class PortfolioService { portfolioCalculator.setTransactionPoints(transactionPoints); const portfolioStart = parseDate(transactionPoints[0].date); - const startDate = this.getStartDate(aDateRange, portfolioStart); - const currentPositions = await portfolioCalculator.getCurrentPositions( - startDate - ); + const startDate = this.getStartDate(dateRange, portfolioStart); + const currentPositions = + await portfolioCalculator.getCurrentPositions(startDate); - const positions = currentPositions.positions.filter( - (item) => !item.quantity.eq(0) - ); - const dataGatheringItem = positions.map((position) => { + let positions = currentPositions.positions.filter(({ quantity }) => { + return !quantity.eq(0); + }); + + const dataGatheringItems = positions.map(({ dataSource, symbol }) => { return { - dataSource: position.dataSource, - symbol: position.symbol + dataSource, + symbol }; }); - const symbols = positions.map((position) => position.symbol); const [dataProviderResponses, symbolProfiles] = await Promise.all([ - this.dataProviderService.getQuotes(dataGatheringItem), - this.symbolProfileService.getSymbolProfilesBySymbols(symbols) + this.dataProviderService.getQuotes({ items: dataGatheringItems }), + this.symbolProfileService.getSymbolProfiles( + positions.map(({ dataSource, symbol }) => { + return { dataSource, symbol }; + }) + ) ]); const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {}; @@ -858,6 +1159,18 @@ export class PortfolioService { symbolProfileMap[symbolProfile.symbol] = symbolProfile; } + if (searchQuery) { + positions = positions.filter(({ symbol }) => { + const enhancedSymbolProfile = symbolProfileMap[symbol]; + + return ( + enhancedSymbolProfile.isin?.toLowerCase().startsWith(searchQuery) || + enhancedSymbolProfile.name?.toLowerCase().startsWith(searchQuery) || + enhancedSymbolProfile.symbol?.toLowerCase().startsWith(searchQuery) + ); + }); + } + return { hasErrors: currentPositions.hasErrors, positions: positions.map((position) => { @@ -883,20 +1196,27 @@ export class PortfolioService { public async getPerformance({ dateRange = 'max', - impersonationId + filters, + impersonationId, + userId }: { dateRange?: DateRange; + filters?: Filter[]; impersonationId: string; + userId: string; }): Promise { - const userId = await this.getUserId(impersonationId, this.request.user.id); + userId = await this.getUserId(impersonationId, userId); + const user = await this.userService.user({ id: userId }); + const userCurrency = this.getUserCurrency(user); const { portfolioOrders, transactionPoints } = await this.getTransactionPoints({ + filters, userId }); const portfolioCalculator = new PortfolioCalculator({ - currency: this.request.user.Settings.settings.baseCurrency, + currency: userCurrency, currentRateService: this.currentRateService, orders: portfolioOrders }); @@ -921,33 +1241,28 @@ export class PortfolioService { const portfolioStart = parseDate(transactionPoints[0].date); const startDate = this.getStartDate(dateRange, portfolioStart); - const currentPositions = await portfolioCalculator.getCurrentPositions( - startDate - ); - - const hasErrors = currentPositions.hasErrors; - const currentValue = currentPositions.currentValue.toNumber(); - const currentGrossPerformance = currentPositions.grossPerformance; - const currentGrossPerformancePercent = - currentPositions.grossPerformancePercentage; - let currentNetPerformance = currentPositions.netPerformance; - let currentNetPerformancePercent = - currentPositions.netPerformancePercentage; - const totalInvestment = currentPositions.totalInvestment; - - // if (currentGrossPerformance.mul(currentGrossPerformancePercent).lt(0)) { - // // If algebraic sign is different, harmonize it - // currentGrossPerformancePercent = currentGrossPerformancePercent.mul(-1); - // } - - // if (currentNetPerformance.mul(currentNetPerformancePercent).lt(0)) { - // // If algebraic sign is different, harmonize it - // currentNetPerformancePercent = currentNetPerformancePercent.mul(-1); - // } + const { + currentValue, + errors, + grossPerformance, + grossPerformancePercentage, + hasErrors, + netPerformance, + netPerformancePercentage, + totalInvestment + } = await portfolioCalculator.getCurrentPositions(startDate); + + const currentGrossPerformance = grossPerformance; + const currentGrossPerformancePercent = grossPerformancePercentage; + let currentNetPerformance = netPerformance; + let currentNetPerformancePercent = netPerformancePercentage; const historicalDataContainer = await this.getChart({ dateRange, - impersonationId + filters, + impersonationId, + userCurrency, + userId }); const itemOfToday = historicalDataContainer.items.find((item) => { @@ -962,28 +1277,28 @@ export class PortfolioService { } return { + errors, + hasErrors, chart: historicalDataContainer.items.map( ({ date, - netPerformance, + netPerformance: netPerformanceOfItem, netPerformanceInPercentage, - totalInvestment, + totalInvestment: totalInvestmentOfItem, value }) => { return { date, - netPerformance, netPerformanceInPercentage, - totalInvestment, - value + value, + netPerformance: netPerformanceOfItem, + totalInvestment: totalInvestmentOfItem }; } ), - errors: currentPositions.errors, firstOrderDate: parseDate(historicalDataContainer.items[0]?.date), - hasErrors: currentPositions.hasErrors || hasErrors, performance: { - currentValue, + currentValue: currentValue.toNumber(), currentGrossPerformance: currentGrossPerformance.toNumber(), currentGrossPerformancePercent: currentGrossPerformancePercent.toNumber(), @@ -995,92 +1310,98 @@ export class PortfolioService { } public async getReport(impersonationId: string): Promise { - const currency = this.request.user.Settings.settings.baseCurrency; const userId = await this.getUserId(impersonationId, this.request.user.id); + const user = await this.userService.user({ id: userId }); + const userCurrency = this.getUserCurrency(user); const { orders, portfolioOrders, transactionPoints } = await this.getTransactionPoints({ userId }); - if (isEmpty(orders)) { - return { - rules: {} - }; - } - const portfolioCalculator = new PortfolioCalculator({ - currency, + currency: userCurrency, currentRateService: this.currentRateService, orders: portfolioOrders }); portfolioCalculator.setTransactionPoints(transactionPoints); - const portfolioStart = parseDate(transactionPoints[0].date); - const currentPositions = await portfolioCalculator.getCurrentPositions( - portfolioStart + const portfolioStart = parseDate( + transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT) + ); + const currentPositions = + await portfolioCalculator.getCurrentPositions(portfolioStart); + + const positions = currentPositions.positions.filter( + (item) => !item.quantity.eq(0) ); const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {}; - for (const position of currentPositions.positions) { + + for (const position of positions) { portfolioItemsNow[position.symbol] = position; } - const accounts = await this.getValueOfAccounts({ + + const { accounts } = await this.getValueOfAccountsAndPlatforms({ orders, portfolioItemsNow, - userId, - userCurrency: currency + userCurrency, + userId }); + + const userSettings = this.request.user.Settings.settings; + return { rules: { - accountClusterRisk: await this.rulesService.evaluate( - [ - new AccountClusterRiskInitialInvestment( - this.exchangeRateDataService, - accounts + accountClusterRisk: isEmpty(orders) + ? undefined + : await this.rulesService.evaluate( + [ + new AccountClusterRiskCurrentInvestment( + this.exchangeRateDataService, + accounts + ), + new AccountClusterRiskSingleAccount( + this.exchangeRateDataService, + accounts + ) + ], + userSettings ), - new AccountClusterRiskCurrentInvestment( - this.exchangeRateDataService, - accounts + currencyClusterRisk: isEmpty(orders) + ? undefined + : await this.rulesService.evaluate( + [ + new CurrencyClusterRiskBaseCurrencyCurrentInvestment( + this.exchangeRateDataService, + positions + ), + new CurrencyClusterRiskCurrentInvestment( + this.exchangeRateDataService, + positions + ) + ], + userSettings ), - new AccountClusterRiskSingleAccount( - this.exchangeRateDataService, - accounts - ) - ], - this.request.user.Settings.settings - ), - currencyClusterRisk: await this.rulesService.evaluate( + emergencyFund: await this.rulesService.evaluate( [ - new CurrencyClusterRiskBaseCurrencyInitialInvestment( - this.exchangeRateDataService, - currentPositions - ), - new CurrencyClusterRiskBaseCurrencyCurrentInvestment( + new EmergencyFundSetup( this.exchangeRateDataService, - currentPositions - ), - new CurrencyClusterRiskInitialInvestment( - this.exchangeRateDataService, - currentPositions - ), - new CurrencyClusterRiskCurrentInvestment( - this.exchangeRateDataService, - currentPositions + userSettings.emergencyFund ) ], - this.request.user.Settings.settings + userSettings ), fees: await this.rulesService.evaluate( [ new FeeRatioInitialInvestment( this.exchangeRateDataService, currentPositions.totalInvestment.toNumber(), - this.getFees(orders).toNumber() + this.getFees({ userCurrency, activities: orders }).toNumber() ) ], - this.request.user.Settings.settings + userSettings ) } }; @@ -1088,18 +1409,19 @@ export class PortfolioService { private async getCashPositions({ cashDetails, - emergencyFund, - investment, userCurrency, value }: { cashDetails: CashDetails; - emergencyFund: Big; - investment: Big; - value: Big; userCurrency: string; + value: Big; }) { - const cashPositions: PortfolioDetails['holdings'] = {}; + const cashPositions: PortfolioDetails['holdings'] = { + [userCurrency]: this.getInitialCashPosition({ + balance: 0, + currency: userCurrency + }) + }; for (const account of cashDetails.accounts) { const convertedBalance = this.exchangeRateDataService.toCurrency( @@ -1114,127 +1436,131 @@ export class PortfolioService { if (cashPositions[account.currency]) { cashPositions[account.currency].investment += convertedBalance; - cashPositions[account.currency].value += convertedBalance; + cashPositions[account.currency].valueInBaseCurrency += convertedBalance; } else { - cashPositions[account.currency] = { - allocationCurrent: 0, - allocationInvestment: 0, - assetClass: AssetClass.CASH, - assetSubClass: AssetClass.CASH, - countries: [], - currency: account.currency, - dataSource: undefined, - grossPerformance: 0, - grossPerformancePercent: 0, - investment: convertedBalance, - marketPrice: 0, - marketState: 'open', - name: account.currency, - netPerformance: 0, - netPerformancePercent: 0, - quantity: 0, - sectors: [], - symbol: account.currency, - transactionCount: 0, - value: convertedBalance - }; + cashPositions[account.currency] = this.getInitialCashPosition({ + balance: convertedBalance, + currency: account.currency + }); } } - if (emergencyFund.gt(0)) { - cashPositions[ASSET_SUB_CLASS_EMERGENCY_FUND] = { - ...cashPositions[userCurrency], - assetSubClass: ASSET_SUB_CLASS_EMERGENCY_FUND, - investment: emergencyFund.toNumber(), - name: ASSET_SUB_CLASS_EMERGENCY_FUND, - symbol: ASSET_SUB_CLASS_EMERGENCY_FUND, - value: emergencyFund.toNumber() - }; - - cashPositions[userCurrency].investment = new Big( - cashPositions[userCurrency].investment - ) - .minus(emergencyFund) - .toNumber(); - cashPositions[userCurrency].value = new Big( - cashPositions[userCurrency].value - ) - .minus(emergencyFund) - .toNumber(); - } - for (const symbol of Object.keys(cashPositions)) { // Calculate allocations for each currency - cashPositions[symbol].allocationCurrent = new Big( - cashPositions[symbol].value - ) - .div(value) - .toNumber(); - cashPositions[symbol].allocationInvestment = new Big( - cashPositions[symbol].investment - ) - .div(investment) - .toNumber(); + cashPositions[symbol].allocationInPercentage = value.gt(0) + ? new Big(cashPositions[symbol].valueInBaseCurrency) + .div(value) + .toNumber() + : 0; } return cashPositions; } - private getDividend(orders: OrderWithAccount[], date = new Date(0)) { - return orders - .filter((order) => { - // Filter out all orders before given date and type dividend - return ( - isBefore(date, new Date(order.date)) && - order.type === TypeOfOrder.DIVIDEND - ); - }) - .map((order) => { - return this.exchangeRateDataService.toCurrency( - new Big(order.quantity).mul(order.unitPrice).toNumber(), - order.SymbolProfile.currency, - this.request.user.Settings.settings.baseCurrency - ); - }) - .reduce( - (previous, current) => new Big(previous).plus(current), - new Big(0) - ); + private getDividendsByGroup({ + dividends, + groupBy + }: { + dividends: InvestmentItem[]; + groupBy: GroupBy; + }): InvestmentItem[] { + if (dividends.length === 0) { + return []; + } + + const dividendsByGroup: InvestmentItem[] = []; + let currentDate: Date; + let investmentByGroup = new Big(0); + + for (const [index, dividend] of dividends.entries()) { + if ( + isSameYear(parseDate(dividend.date), currentDate) && + (groupBy === 'year' || + isSameMonth(parseDate(dividend.date), currentDate)) + ) { + // Same group: Add up dividends + + investmentByGroup = investmentByGroup.plus(dividend.investment); + } else { + // New group: Store previous group and reset + + if (currentDate) { + dividendsByGroup.push({ + date: format( + set(currentDate, { + date: 1, + month: groupBy === 'year' ? 0 : currentDate.getMonth() + }), + DATE_FORMAT + ), + investment: investmentByGroup.toNumber() + }); + } + + currentDate = parseDate(dividend.date); + investmentByGroup = new Big(dividend.investment); + } + + if (index === dividends.length - 1) { + // Store current month (latest order) + dividendsByGroup.push({ + date: format( + set(currentDate, { + date: 1, + month: groupBy === 'year' ? 0 : currentDate.getMonth() + }), + DATE_FORMAT + ), + investment: investmentByGroup.toNumber() + }); + } + } + + return dividendsByGroup; } - private getFees(orders: OrderWithAccount[], date = new Date(0)) { - return orders - .filter((order) => { - // Filter out all orders before given date - return isBefore(date, new Date(order.date)); - }) - .map((order) => { - return this.exchangeRateDataService.toCurrency( - order.fee, - order.SymbolProfile.currency, - this.request.user.Settings.settings.baseCurrency - ); - }) - .reduce( - (previous, current) => new Big(previous).plus(current), - new Big(0) + private getEmergencyFundPositionsValueInBaseCurrency({ + holdings + }: { + holdings: PortfolioDetails['holdings']; + }) { + const emergencyFundHoldings = Object.values(holdings).filter(({ tags }) => { + return ( + tags?.some(({ id }) => { + return id === EMERGENCY_FUND_TAG_ID; + }) ?? false ); + }); + + let valueInBaseCurrencyOfEmergencyFundPositions = new Big(0); + + for (const { valueInBaseCurrency } of emergencyFundHoldings) { + valueInBaseCurrencyOfEmergencyFundPositions = + valueInBaseCurrencyOfEmergencyFundPositions.plus(valueInBaseCurrency); + } + + return valueInBaseCurrencyOfEmergencyFundPositions.toNumber(); } - private getItems(orders: OrderWithAccount[], date = new Date(0)) { - return orders - .filter((order) => { - // Filter out all orders before given date and type item - return ( - isBefore(date, new Date(order.date)) && - order.type === TypeOfOrder.ITEM - ); + private getFees({ + activities, + date = new Date(0), + userCurrency + }: { + activities: OrderWithAccount[]; + date?: Date; + userCurrency: string; + }) { + return activities + .filter((activity) => { + // Filter out all activities before given date (drafts) + return isBefore(date, new Date(activity.date)); }) - .map((order) => { + .map(({ fee, SymbolProfile }) => { return this.exchangeRateDataService.toCurrency( - new Big(order.quantity).mul(order.unitPrice).toNumber(), - order.SymbolProfile.currency, - this.request.user.Settings.settings.baseCurrency + fee, + SymbolProfile.currency, + userCurrency ); }) .reduce( @@ -1243,72 +1569,190 @@ export class PortfolioService { ); } + private getInitialCashPosition({ + balance, + currency + }: { + balance: number; + currency: string; + }): PortfolioPosition { + return { + currency, + allocationInPercentage: 0, + assetClass: AssetClass.CASH, + assetSubClass: AssetClass.CASH, + countries: [], + dataSource: undefined, + dateOfFirstActivity: undefined, + grossPerformance: 0, + grossPerformancePercent: 0, + investment: balance, + marketPrice: 0, + marketState: 'open', + name: currency, + netPerformance: 0, + netPerformancePercent: 0, + quantity: 0, + sectors: [], + symbol: currency, + tags: [], + transactionCount: 0, + valueInBaseCurrency: balance + }; + } + private getStartDate(aDateRange: DateRange, portfolioStart: Date) { switch (aDateRange) { case '1d': - portfolioStart = max([portfolioStart, subDays(new Date(), 1)]); + portfolioStart = max([ + portfolioStart, + subDays(new Date().setHours(0, 0, 0, 0), 1) + ]); break; case 'ytd': - portfolioStart = max([portfolioStart, setDayOfYear(new Date(), 1)]); + portfolioStart = max([ + portfolioStart, + setDayOfYear(new Date().setHours(0, 0, 0, 0), 1) + ]); break; case '1y': - portfolioStart = max([portfolioStart, subYears(new Date(), 1)]); + portfolioStart = max([ + portfolioStart, + subYears(new Date().setHours(0, 0, 0, 0), 1) + ]); break; case '5y': - portfolioStart = max([portfolioStart, subYears(new Date(), 5)]); + portfolioStart = max([ + portfolioStart, + subYears(new Date().setHours(0, 0, 0, 0), 5) + ]); break; } return portfolioStart; } + private getStreaks({ + investments, + savingsRate + }: { + investments: InvestmentItem[]; + savingsRate: number; + }) { + let currentStreak = 0; + let longestStreak = 0; + + for (const { investment } of investments) { + if (investment >= savingsRate) { + currentStreak++; + longestStreak = Math.max(longestStreak, currentStreak); + } else { + currentStreak = 0; + } + } + + return { currentStreak, longestStreak }; + } + private async getSummary({ - impersonationId + balanceInBaseCurrency, + emergencyFundPositionsValueInBaseCurrency, + impersonationId, + userCurrency, + userId }: { + balanceInBaseCurrency: number; + emergencyFundPositionsValueInBaseCurrency: number; impersonationId: string; + userCurrency: string; + userId: string; }): Promise { - const userCurrency = this.request.user.Settings.settings.baseCurrency; - const userId = await this.getUserId(impersonationId, this.request.user.id); + userId = await this.getUserId(impersonationId, userId); const user = await this.userService.user({ id: userId }); const performanceInformation = await this.getPerformance({ - impersonationId - }); - - const { balanceInBaseCurrency } = await this.accountService.getCashDetails({ - userId, - currency: userCurrency - }); - const orders = await this.orderService.getOrders({ - userCurrency, + impersonationId, userId }); - const excludedActivities = ( - await this.orderService.getOrders({ - userCurrency, - userId, - withExcludedAccounts: true - }) - ).filter(({ Account: account }) => { - return account?.isExcluded ?? false; + const ordersRaw = await this.orderService.getOrders({ + userCurrency, + userId, + withExcludedAccounts: true }); + const activities: Activity[] = []; + const excludedActivities: Activity[] = []; + let dividend = 0; + let fees = 0; + let items = 0; + let interest = 0; + + let liabilities = 0; + + let totalBuy = 0; + let totalSell = 0; + for (let order of ordersRaw) { + if (order.Account?.isExcluded ?? false) { + excludedActivities.push(order); + } else { + activities.push(order); + fees += this.exchangeRateDataService.toCurrency( + order.fee, + order.SymbolProfile.currency, + userCurrency + ); + let amount = this.exchangeRateDataService.toCurrency( + new Big(order.quantity).mul(order.unitPrice).toNumber(), + order.SymbolProfile.currency, + userCurrency + ); + switch (order.type) { + case 'DIVIDEND': + dividend += amount; + break; + case 'ITEM': + items += amount; + break; + case 'SELL': + totalSell += amount; + break; + case 'BUY': + totalBuy += amount; + break; + case 'LIABILITY': + liabilities += amount; + break; + case 'INTEREST': + interest += amount; + break; + } + } + } - const dividend = this.getDividend(orders).toNumber(); const emergencyFund = new Big( - (user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 + Math.max( + emergencyFundPositionsValueInBaseCurrency, + (user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 + ) ); - const fees = this.getFees(orders).toNumber(); - const firstOrderDate = orders[0]?.date; - const items = this.getItems(orders).toNumber(); - const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY'); - const totalSell = this.getTotalByType(orders, userCurrency, 'SELL'); + const firstOrderDate = activities[0]?.date; - const cash = new Big(balanceInBaseCurrency).minus(emergencyFund).toNumber(); + const cash = new Big(balanceInBaseCurrency) + .minus(emergencyFund) + .plus(emergencyFundPositionsValueInBaseCurrency) + .toNumber(); const committedFunds = new Big(totalBuy).minus(totalSell); - const totalOfExcludedActivities = new Big( - this.getTotalByType(excludedActivities, userCurrency, 'BUY') - ).minus(this.getTotalByType(excludedActivities, userCurrency, 'SELL')); + const totalOfExcludedActivities = this.getSumOfActivityType({ + userCurrency, + activities: excludedActivities, + activityType: 'BUY' + }).minus( + this.getSumOfActivityType({ + userCurrency, + activities: excludedActivities, + activityType: 'SELL' + }) + ); const cashDetailsWithExcludedAccounts = await this.accountService.getCashDetails({ @@ -1329,6 +1773,7 @@ export class PortfolioService { .plus(performanceInformation.performance.currentValue) .plus(items) .plus(excludedAccountsAndActivities) + .minus(liabilities) .toNumber(); const daysInMarket = differenceInDays(new Date(), firstOrderDate); @@ -1354,18 +1799,62 @@ export class PortfolioService { excludedAccountsAndActivities, fees, firstOrderDate, + interest, items, + liabilities, netWorth, totalBuy, totalSell, committedFunds: committedFunds.toNumber(), - emergencyFund: emergencyFund.toNumber(), - ordersCount: orders.filter((order) => { - return order.type === 'BUY' || order.type === 'SELL'; + emergencyFund: { + assets: emergencyFundPositionsValueInBaseCurrency, + cash: emergencyFund + .minus(emergencyFundPositionsValueInBaseCurrency) + .toNumber(), + total: emergencyFund.toNumber() + }, + fireWealth: new Big(performanceInformation.performance.currentValue) + .minus(emergencyFundPositionsValueInBaseCurrency) + .toNumber(), + ordersCount: activities.filter(({ type }) => { + return type === 'BUY' || type === 'SELL'; }).length }; } + private getSumOfActivityType({ + activities, + activityType, + date = new Date(0), + userCurrency + }: { + activities: OrderWithAccount[]; + activityType: ActivityType; + date?: Date; + userCurrency: string; + }) { + return activities + .filter((activity) => { + // Filter out all activities before given date (drafts) and + // activity type + return ( + isBefore(date, new Date(activity.date)) && + activity.type === activityType + ); + }) + .map(({ quantity, SymbolProfile, unitPrice }) => { + return this.exchangeRateDataService.toCurrency( + new Big(quantity).mul(unitPrice).toNumber(), + SymbolProfile.currency, + userCurrency + ); + }) + .reduce( + (previous, current) => new Big(previous).plus(current), + new Big(0) + ); + } + private async getTransactionPoints({ filters, includeDrafts = false, @@ -1378,11 +1867,11 @@ export class PortfolioService { withExcludedAccounts?: boolean; }): Promise<{ transactionPoints: TransactionPoint[]; - orders: OrderWithAccount[]; + orders: Activity[]; portfolioOrders: PortfolioOrder[]; }> { const userCurrency = - this.request.user?.Settings?.settings.baseCurrency ?? this.baseCurrency; + this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY; const orders = await this.orderService.getOrders({ filters, @@ -1390,7 +1879,7 @@ export class PortfolioService { userCurrency, userId, withExcludedAccounts, - types: ['BUY', 'SELL'] + types: ['BUY', 'SELL', 'STAKE'] }); if (orders.length <= 0) { @@ -1411,6 +1900,7 @@ export class PortfolioService { name: order.SymbolProfile?.name, quantity: new Big(order.quantity), symbol: order.SymbolProfile.symbol, + tags: order.tags, type: order.type, unitPrice: new Big( this.exchangeRateDataService.toCurrency( @@ -1436,7 +1926,22 @@ export class PortfolioService { }; } - private async getValueOfAccounts({ + private getUserCurrency(aUser: UserWithSettings) { + return ( + aUser.Settings?.settings.baseCurrency ?? + this.request.user?.Settings?.settings.baseCurrency ?? + DEFAULT_CURRENCY + ); + } + + private async getUserId(aImpersonationId: string, aUserId: string) { + const impersonationUserId = + await this.impersonationService.validateImpersonationId(aImpersonationId); + + return impersonationUserId || aUserId; + } + + private async getValueOfAccountsAndPlatforms({ filters = [], orders, portfolioItemsNow, @@ -1451,7 +1956,16 @@ export class PortfolioService { userId: string; withExcludedAccounts?: boolean; }) { + const ordersOfTypeItemOrLiability = await this.orderService.getOrders({ + filters, + userCurrency, + userId, + withExcludedAccounts, + types: ['ITEM', 'LIABILITY'] + }); + const accounts: PortfolioDetails['accounts'] = {}; + const platforms: PortfolioDetails['platforms'] = {}; let currentAccounts: (Account & { Order?: Order[]; @@ -1462,6 +1976,7 @@ export class PortfolioService { currentAccounts = await this.accountService.getAccounts(userId); } else if (filters.length === 1 && filters[0].type === 'ACCOUNT') { currentAccounts = await this.accountService.accounts({ + include: { Platform: true }, where: { id: filters[0].id } }); } else { @@ -1472,6 +1987,7 @@ export class PortfolioService { ); currentAccounts = await this.accountService.accounts({ + include: { Platform: true }, where: { id: { in: accountIds } } }); } @@ -1481,88 +1997,91 @@ export class PortfolioService { }); for (const account of currentAccounts) { - const ordersByAccount = orders.filter(({ accountId }) => { + let ordersByAccount = orders.filter(({ accountId }) => { return accountId === account.id; }); + const ordersOfTypeItemOrLiabilityByAccount = + ordersOfTypeItemOrLiability.filter(({ accountId }) => { + return accountId === account.id; + }); + + ordersByAccount = ordersByAccount.concat( + ordersOfTypeItemOrLiabilityByAccount + ); + accounts[account.id] = { balance: account.balance, currency: account.currency, - current: this.exchangeRateDataService.toCurrency( - account.balance, - account.currency, - userCurrency - ), name: account.name, - original: this.exchangeRateDataService.toCurrency( + valueInBaseCurrency: this.exchangeRateDataService.toCurrency( account.balance, account.currency, userCurrency ) }; - for (const order of ordersByAccount) { - let currentValueOfSymbolInBaseCurrency = - order.quantity * - portfolioItemsNow[order.SymbolProfile.symbol].marketPrice; - let originalValueOfSymbolInBaseCurrency = + if (platforms[account.Platform?.id || UNKNOWN_KEY]?.valueInBaseCurrency) { + platforms[account.Platform?.id || UNKNOWN_KEY].valueInBaseCurrency += this.exchangeRateDataService.toCurrency( - order.quantity * order.unitPrice, - order.SymbolProfile.currency, + account.balance, + account.currency, userCurrency ); + } else { + platforms[account.Platform?.id || UNKNOWN_KEY] = { + balance: account.balance, + currency: account.currency, + name: account.Platform?.name, + valueInBaseCurrency: this.exchangeRateDataService.toCurrency( + account.balance, + account.currency, + userCurrency + ) + }; + } - if (order.type === 'SELL') { + for (const order of ordersByAccount) { + let currentValueOfSymbolInBaseCurrency = + order.quantity * + (portfolioItemsNow[order.SymbolProfile.symbol]?.marketPrice ?? + order.unitPrice ?? + 0); + + if (order.type === 'LIABILITY' || order.type === 'SELL') { currentValueOfSymbolInBaseCurrency *= -1; - originalValueOfSymbolInBaseCurrency *= -1; } - if (accounts[order.Account?.id || UNKNOWN_KEY]?.current) { - accounts[order.Account?.id || UNKNOWN_KEY].current += + if (accounts[order.Account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) { + accounts[order.Account?.id || UNKNOWN_KEY].valueInBaseCurrency += currentValueOfSymbolInBaseCurrency; - accounts[order.Account?.id || UNKNOWN_KEY].original += - originalValueOfSymbolInBaseCurrency; } else { accounts[order.Account?.id || UNKNOWN_KEY] = { balance: 0, currency: order.Account?.currency, - current: currentValueOfSymbolInBaseCurrency, name: account.name, - original: originalValueOfSymbolInBaseCurrency + valueInBaseCurrency: currentValueOfSymbolInBaseCurrency + }; + } + + if ( + platforms[order.Account?.Platform?.id || UNKNOWN_KEY] + ?.valueInBaseCurrency + ) { + platforms[ + order.Account?.Platform?.id || UNKNOWN_KEY + ].valueInBaseCurrency += currentValueOfSymbolInBaseCurrency; + } else { + platforms[order.Account?.Platform?.id || UNKNOWN_KEY] = { + balance: 0, + currency: order.Account?.currency, + name: account.Platform?.name, + valueInBaseCurrency: currentValueOfSymbolInBaseCurrency }; } } } - return accounts; - } - - private async getUserId(aImpersonationId: string, aUserId: string) { - const impersonationUserId = - await this.impersonationService.validateImpersonationId( - aImpersonationId, - aUserId - ); - - return impersonationUserId || aUserId; - } - - private getTotalByType( - orders: OrderWithAccount[], - currency: string, - type: TypeOfOrder - ) { - return orders - .filter( - (order) => !isAfter(order.date, endOfToday()) && order.type === type - ) - .map((order) => { - return this.exchangeRateDataService.toCurrency( - order.quantity * order.unitPrice, - order.SymbolProfile.currency, - currency - ); - }) - .reduce((previous, current) => previous + current, 0); + return { accounts, platforms }; } } diff --git a/apps/api/src/app/redis-cache/interfaces/redis-cache.interface.ts b/apps/api/src/app/redis-cache/interfaces/redis-cache.interface.ts new file mode 100644 index 000000000..194da0bc8 --- /dev/null +++ b/apps/api/src/app/redis-cache/interfaces/redis-cache.interface.ts @@ -0,0 +1,7 @@ +import { Cache } from 'cache-manager'; + +import type { RedisStore } from './redis-store.interface'; + +export interface RedisCache extends Cache { + store: RedisStore; +} diff --git a/apps/api/src/app/redis-cache/interfaces/redis-store.interface.ts b/apps/api/src/app/redis-cache/interfaces/redis-store.interface.ts new file mode 100644 index 000000000..2ad5df485 --- /dev/null +++ b/apps/api/src/app/redis-cache/interfaces/redis-store.interface.ts @@ -0,0 +1,8 @@ +import { Store } from 'cache-manager'; +import { createClient } from 'redis'; + +export interface RedisStore extends Store { + getClient: () => ReturnType; + isCacheableValue: (value: any) => boolean; + name: 'redis'; +} diff --git a/apps/api/src/app/redis-cache/redis-cache.module.ts b/apps/api/src/app/redis-cache/redis-cache.module.ts index 299a25bab..46ed6dc50 100644 --- a/apps/api/src/app/redis-cache/redis-cache.module.ts +++ b/apps/api/src/app/redis-cache/redis-cache.module.ts @@ -1,7 +1,9 @@ -import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; -import { CacheManagerOptions, CacheModule, Module } from '@nestjs/common'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { CacheModule } from '@nestjs/cache-manager'; +import { Module } from '@nestjs/common'; import * as redisStore from 'cache-manager-redis-store'; +import type { RedisClientOptions } from 'redis'; import { RedisCacheService } from './redis-cache.service'; @@ -11,7 +13,7 @@ import { RedisCacheService } from './redis-cache.service'; imports: [ConfigurationModule], inject: [ConfigurationService], useFactory: async (configurationService: ConfigurationService) => { - return { + return { host: configurationService.get('REDIS_HOST'), max: configurationService.get('MAX_ITEM_IN_CACHE'), password: configurationService.get('REDIS_PASSWORD'), diff --git a/apps/api/src/app/redis-cache/redis-cache.service.ts b/apps/api/src/app/redis-cache/redis-cache.service.ts index f930b3a72..1e8243144 100644 --- a/apps/api/src/app/redis-cache/redis-cache.service.ts +++ b/apps/api/src/app/redis-cache/redis-cache.service.ts @@ -1,18 +1,32 @@ -import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; -import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common'; -import { Cache } from 'cache-manager'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; +import { UniqueAsset } from '@ghostfolio/common/interfaces'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Inject, Injectable, Logger } from '@nestjs/common'; + +import type { RedisCache } from './interfaces/redis-cache.interface'; @Injectable() export class RedisCacheService { public constructor( - @Inject(CACHE_MANAGER) private readonly cache: Cache, + @Inject(CACHE_MANAGER) private readonly cache: RedisCache, private readonly configurationService: ConfigurationService - ) {} + ) { + const client = cache.store.getClient(); + + client.on('error', (error) => { + Logger.error(error, 'RedisCacheService'); + }); + } public async get(key: string): Promise { return await this.cache.get(key); } + public getQuoteKey({ dataSource, symbol }: UniqueAsset) { + return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`; + } + public async remove(key: string) { await this.cache.del(key); } @@ -22,8 +36,10 @@ export class RedisCacheService { } public async set(key: string, value: string, ttlInSeconds?: number) { - await this.cache.set(key, value, { - ttl: ttlInSeconds ?? this.configurationService.get('CACHE_TTL') - }); + await this.cache.set( + key, + value, + ttlInSeconds ?? this.configurationService.get('CACHE_TTL') + ); } } diff --git a/apps/api/src/app/sitemap/sitemap.controller.ts b/apps/api/src/app/sitemap/sitemap.controller.ts new file mode 100644 index 000000000..cd28c06db --- /dev/null +++ b/apps/api/src/app/sitemap/sitemap.controller.ts @@ -0,0 +1,36 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { + DATE_FORMAT, + getYesterday, + interpolate +} from '@ghostfolio/common/helper'; +import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common'; +import { format } from 'date-fns'; +import { Response } from 'express'; + +@Controller('sitemap.xml') +export class SitemapController { + public sitemapXml = ''; + + public constructor() { + try { + this.sitemapXml = fs.readFileSync( + path.join(__dirname, 'assets', 'sitemap.xml'), + 'utf8' + ); + } catch {} + } + + @Get() + @Version(VERSION_NEUTRAL) + public async flushCache(@Res() response: Response): Promise { + response.setHeader('content-type', 'application/xml'); + response.send( + interpolate(this.sitemapXml, { + currentDate: format(getYesterday(), DATE_FORMAT) + }) + ); + } +} diff --git a/apps/api/src/app/sitemap/sitemap.module.ts b/apps/api/src/app/sitemap/sitemap.module.ts new file mode 100644 index 000000000..2fe7358d4 --- /dev/null +++ b/apps/api/src/app/sitemap/sitemap.module.ts @@ -0,0 +1,24 @@ +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; +import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; +import { Module } from '@nestjs/common'; + +import { SitemapController } from './sitemap.controller'; + +@Module({ + controllers: [SitemapController], + imports: [ + ConfigurationModule, + DataGatheringModule, + DataProviderModule, + ExchangeRateDataModule, + PrismaModule, + RedisCacheModule, + SymbolProfileModule + ] +}) +export class SitemapModule {} diff --git a/apps/api/src/app/subscription/subscription.controller.ts b/apps/api/src/app/subscription/subscription.controller.ts index 70317fe76..e063a8636 100644 --- a/apps/api/src/app/subscription/subscription.controller.ts +++ b/apps/api/src/app/subscription/subscription.controller.ts @@ -1,4 +1,4 @@ -import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { DEFAULT_LANGUAGE_CODE, @@ -21,6 +21,7 @@ import { } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; +import { Request, Response } from 'express'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { SubscriptionService } from './subscription.service'; @@ -62,6 +63,7 @@ export class SubscriptionController { await this.subscriptionService.createSubscription({ duration: coupon.duration, + price: 0, userId: this.request.user.id }); @@ -86,9 +88,12 @@ export class SubscriptionController { } @Get('stripe/callback') - public async stripeCallback(@Req() req, @Res() res) { + public async stripeCallback( + @Req() request: Request, + @Res() response: Response + ) { const userId = await this.subscriptionService.createSubscriptionViaStripe( - req.query.checkoutSessionId + request.query.checkoutSessionId ); Logger.log( @@ -96,7 +101,7 @@ export class SubscriptionController { 'SubscriptionController' ); - res.redirect( + response.redirect( `${this.configurationService.get( 'ROOT_URL' )}/${DEFAULT_LANGUAGE_CODE}/account` @@ -112,7 +117,7 @@ export class SubscriptionController { return await this.subscriptionService.createCheckoutSession({ couponId, priceId, - userId: this.request.user.id + user: this.request.user }); } catch (error) { Logger.error(error, 'SubscriptionController'); diff --git a/apps/api/src/app/subscription/subscription.module.ts b/apps/api/src/app/subscription/subscription.module.ts index df0861657..c2c80c135 100644 --- a/apps/api/src/app/subscription/subscription.module.ts +++ b/apps/api/src/app/subscription/subscription.module.ts @@ -1,5 +1,5 @@ -import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; -import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { Module } from '@nestjs/common'; diff --git a/apps/api/src/app/subscription/subscription.service.ts b/apps/api/src/app/subscription/subscription.service.ts index fa061f369..d94dd68ad 100644 --- a/apps/api/src/app/subscription/subscription.service.ts +++ b/apps/api/src/app/subscription/subscription.service.ts @@ -1,7 +1,8 @@ -import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; -import { PrismaService } from '@ghostfolio/api/services/prisma.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; -import { SubscriptionType } from '@ghostfolio/common/types/subscription.type'; +import { UserWithSettings } from '@ghostfolio/common/types'; +import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type'; import { Injectable, Logger } from '@nestjs/common'; import { Subscription } from '@prisma/client'; import { addMilliseconds, isBefore } from 'date-fns'; @@ -19,7 +20,7 @@ export class SubscriptionService { this.stripe = new Stripe( this.configurationService.get('STRIPE_SECRET_KEY'), { - apiVersion: '2020-08-27' + apiVersion: '2022-11-15' } ); } @@ -27,17 +28,17 @@ export class SubscriptionService { public async createCheckoutSession({ couponId, priceId, - userId + user }: { couponId?: string; priceId: string; - userId: string; + user: UserWithSettings; }) { const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = { - cancel_url: `${this.configurationService.get( - 'ROOT_URL' - )}/${DEFAULT_LANGUAGE_CODE}/account`, - client_reference_id: userId, + cancel_url: `${this.configurationService.get('ROOT_URL')}/${ + user.Settings?.settings?.language ?? DEFAULT_LANGUAGE_CODE + }/account`, + client_reference_id: user.id, line_items: [ { price: priceId, @@ -70,13 +71,16 @@ export class SubscriptionService { public async createSubscription({ duration = '1 year', + price, userId }: { duration?: StringValue; + price: number; userId: string; }) { await this.prismaService.subscription.create({ data: { + price, expiresAt: addMilliseconds(new Date(), ms(duration)), User: { connect: { @@ -89,14 +93,12 @@ export class SubscriptionService { public async createSubscriptionViaStripe(aCheckoutSessionId: string) { try { - const session = await this.stripe.checkout.sessions.retrieve( - aCheckoutSessionId - ); - - await this.createSubscription({ userId: session.client_reference_id }); + const session = + await this.stripe.checkout.sessions.retrieve(aCheckoutSessionId); - await this.stripe.customers.update(session.customer as string, { - description: session.client_reference_id + await this.createSubscription({ + price: session.amount_total / 100, + userId: session.client_reference_id }); return session.client_reference_id; @@ -105,7 +107,9 @@ export class SubscriptionService { } } - public getSubscription(aSubscriptions: Subscription[]) { + public getSubscription( + aSubscriptions: Subscription[] + ): UserWithSettings['subscription'] { if (aSubscriptions.length > 0) { const latestSubscription = aSubscriptions.reduce((a, b) => { return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b; @@ -113,12 +117,14 @@ export class SubscriptionService { return { expiresAt: latestSubscription.expiresAt, + offer: latestSubscription.price === 0 ? 'default' : 'renewal', type: isBefore(new Date(), latestSubscription.expiresAt) ? SubscriptionType.Premium : SubscriptionType.Basic }; } else { return { + offer: 'default', type: SubscriptionType.Basic }; } diff --git a/apps/api/src/app/symbol/interfaces/lookup-item.interface.ts b/apps/api/src/app/symbol/interfaces/lookup-item.interface.ts index cf45f4c7e..e9c90b0bc 100644 --- a/apps/api/src/app/symbol/interfaces/lookup-item.interface.ts +++ b/apps/api/src/app/symbol/interfaces/lookup-item.interface.ts @@ -1,6 +1,8 @@ -import { DataSource } from '@prisma/client'; +import { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; export interface LookupItem { + assetClass: AssetClass; + assetSubClass: AssetSubClass; currency: string; dataSource: DataSource; name: string; diff --git a/apps/api/src/app/symbol/symbol.controller.ts b/apps/api/src/app/symbol/symbol.controller.ts index dd50e0dee..ad9042991 100644 --- a/apps/api/src/app/symbol/symbol.controller.ts +++ b/apps/api/src/app/symbol/symbol.controller.ts @@ -1,17 +1,21 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; +import type { RequestWithUser } from '@ghostfolio/common/types'; import { Controller, Get, HttpException, + Inject, Param, Query, UseGuards, UseInterceptors } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; import { DataSource } from '@prisma/client'; +import { parseISO } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { isDate, isEmpty } from 'lodash'; @@ -21,7 +25,10 @@ import { SymbolService } from './symbol.service'; @Controller('symbol') export class SymbolController { - public constructor(private readonly symbolService: SymbolService) {} + public constructor( + @Inject(REQUEST) private readonly request: RequestWithUser, + private readonly symbolService: SymbolService + ) {} /** * Must be before /:symbol @@ -30,10 +37,15 @@ export class SymbolController { @UseGuards(AuthGuard('jwt')) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async lookupSymbol( - @Query() { query = '' } + @Query('includeIndices') includeIndices: boolean = false, + @Query('query') query = '' ): Promise<{ items: LookupItem[] }> { try { - return this.symbolService.lookup(query.toLowerCase()); + return this.symbolService.lookup({ + includeIndices, + query: query.toLowerCase(), + user: this.request.user + }); } catch { throw new HttpException( getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), @@ -51,7 +63,7 @@ export class SymbolController { public async getSymbolData( @Param('dataSource') dataSource: DataSource, @Param('symbol') symbol: string, - @Query('includeHistoricalData') includeHistoricalData?: number + @Query('includeHistoricalData') includeHistoricalData = 0 ): Promise { if (!DataSource[dataSource]) { throw new HttpException( @@ -82,7 +94,7 @@ export class SymbolController { @Param('dateString') dateString: string, @Param('symbol') symbol: string ): Promise { - const date = new Date(dateString); + const date = parseISO(dateString); if (!isDate(date)) { throw new HttpException( @@ -91,10 +103,19 @@ export class SymbolController { ); } - return this.symbolService.getForDate({ + const result = await this.symbolService.getForDate({ dataSource, date, symbol }); + + if (!result || isEmpty(result)) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + return result; } } diff --git a/apps/api/src/app/symbol/symbol.module.ts b/apps/api/src/app/symbol/symbol.module.ts index 2b47334a6..4d9f2d5eb 100644 --- a/apps/api/src/app/symbol/symbol.module.ts +++ b/apps/api/src/app/symbol/symbol.module.ts @@ -1,7 +1,7 @@ -import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; -import { MarketDataModule } from '@ghostfolio/api/services/market-data.module'; -import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { Module } from '@nestjs/common'; import { SymbolController } from './symbol.controller'; diff --git a/apps/api/src/app/symbol/symbol.service.ts b/apps/api/src/app/symbol/symbol.service.ts index e24aa71b2..5eacbb1b0 100644 --- a/apps/api/src/app/symbol/symbol.service.ts +++ b/apps/api/src/app/symbol/symbol.service.ts @@ -3,11 +3,11 @@ import { IDataGatheringItem, IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; -import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { HistoricalDataItem } from '@ghostfolio/common/interfaces'; +import { UserWithSettings } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; -import { DataSource } from '@prisma/client'; import { format, subDays } from 'date-fns'; import { LookupItem } from './interfaces/lookup-item.interface'; @@ -27,12 +27,12 @@ export class SymbolService { dataGatheringItem: IDataGatheringItem; includeHistoricalData?: number; }): Promise { - const quotes = await this.dataProviderService.getQuotes([ - dataGatheringItem - ]); + const quotes = await this.dataProviderService.getQuotes({ + items: [dataGatheringItem] + }); const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {}; - if (dataGatheringItem.dataSource && marketPrice) { + if (dataGatheringItem.dataSource && marketPrice >= 0) { let historicalData: HistoricalDataItem[] = []; if (includeHistoricalData > 0) { @@ -65,13 +65,9 @@ export class SymbolService { public async getForDate({ dataSource, - date, + date = new Date(), symbol - }: { - dataSource: DataSource; - date: Date; - symbol: string; - }): Promise { + }: IDataGatheringItem): Promise { const historicalData = await this.dataProviderService.getHistoricalRaw( [{ dataSource, symbol }], date, @@ -84,15 +80,27 @@ export class SymbolService { }; } - public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> { + public async lookup({ + includeIndices = false, + query, + user + }: { + includeIndices?: boolean; + query: string; + user: UserWithSettings; + }): Promise<{ items: LookupItem[] }> { const results: { items: LookupItem[] } = { items: [] }; - if (!aQuery) { + if (!query) { return results; } try { - const { items } = await this.dataProviderService.search(aQuery); + const { items } = await this.dataProviderService.search({ + includeIndices, + query, + user + }); results.items = items; return results; } catch (error) { diff --git a/apps/api/src/app/tag/create-tag.dto.ts b/apps/api/src/app/tag/create-tag.dto.ts new file mode 100644 index 000000000..650a0ce12 --- /dev/null +++ b/apps/api/src/app/tag/create-tag.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class CreateTagDto { + @IsString() + name: string; +} diff --git a/apps/api/src/app/tag/tag.controller.ts b/apps/api/src/app/tag/tag.controller.ts new file mode 100644 index 000000000..950719201 --- /dev/null +++ b/apps/api/src/app/tag/tag.controller.ts @@ -0,0 +1,104 @@ +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import type { RequestWithUser } from '@ghostfolio/common/types'; +import { + Body, + Controller, + Delete, + Get, + HttpException, + Inject, + Param, + Post, + Put, + UseGuards +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { Tag } from '@prisma/client'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +import { CreateTagDto } from './create-tag.dto'; +import { TagService } from './tag.service'; +import { UpdateTagDto } from './update-tag.dto'; + +@Controller('tag') +export class TagController { + public constructor( + @Inject(REQUEST) private readonly request: RequestWithUser, + private readonly tagService: TagService + ) {} + + @Get() + @UseGuards(AuthGuard('jwt')) + public async getTags() { + return this.tagService.getTagsWithActivityCount(); + } + + @Post() + @UseGuards(AuthGuard('jwt')) + public async createTag(@Body() data: CreateTagDto): Promise { + if (!hasPermission(this.request.user.permissions, permissions.createTag)) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.tagService.createTag(data); + } + + @Put(':id') + @UseGuards(AuthGuard('jwt')) + public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) { + if (!hasPermission(this.request.user.permissions, permissions.updateTag)) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + const originalTag = await this.tagService.getTag({ + id + }); + + if (!originalTag) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.tagService.updateTag({ + data: { + ...data + }, + where: { + id + } + }); + } + + @Delete(':id') + @UseGuards(AuthGuard('jwt')) + public async deleteTag(@Param('id') id: string) { + if (!hasPermission(this.request.user.permissions, permissions.deleteTag)) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + const originalTag = await this.tagService.getTag({ + id + }); + + if (!originalTag) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.tagService.deleteTag({ id }); + } +} diff --git a/apps/api/src/app/tag/tag.module.ts b/apps/api/src/app/tag/tag.module.ts new file mode 100644 index 000000000..810105c51 --- /dev/null +++ b/apps/api/src/app/tag/tag.module.ts @@ -0,0 +1,13 @@ +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { Module } from '@nestjs/common'; + +import { TagController } from './tag.controller'; +import { TagService } from './tag.service'; + +@Module({ + controllers: [TagController], + exports: [TagService], + imports: [PrismaModule], + providers: [TagService] +}) +export class TagModule {} diff --git a/apps/api/src/app/tag/tag.service.ts b/apps/api/src/app/tag/tag.service.ts new file mode 100644 index 000000000..9da7cc475 --- /dev/null +++ b/apps/api/src/app/tag/tag.service.ts @@ -0,0 +1,79 @@ +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { Injectable } from '@nestjs/common'; +import { Prisma, Tag } from '@prisma/client'; + +@Injectable() +export class TagService { + public constructor(private readonly prismaService: PrismaService) {} + + public async createTag(data: Prisma.TagCreateInput) { + return this.prismaService.tag.create({ + data + }); + } + + public async deleteTag(where: Prisma.TagWhereUniqueInput): Promise { + return this.prismaService.tag.delete({ where }); + } + + public async getTag( + tagWhereUniqueInput: Prisma.TagWhereUniqueInput + ): Promise { + return this.prismaService.tag.findUnique({ + where: tagWhereUniqueInput + }); + } + + public async getTags({ + cursor, + orderBy, + skip, + take, + where + }: { + cursor?: Prisma.TagWhereUniqueInput; + orderBy?: Prisma.TagOrderByWithRelationInput; + skip?: number; + take?: number; + where?: Prisma.TagWhereInput; + } = {}) { + return this.prismaService.tag.findMany({ + cursor, + orderBy, + skip, + take, + where + }); + } + + public async getTagsWithActivityCount() { + const tagsWithOrderCount = await this.prismaService.tag.findMany({ + include: { + _count: { + select: { orders: true } + } + } + }); + + return tagsWithOrderCount.map(({ _count, id, name }) => { + return { + id, + name, + activityCount: _count.orders + }; + }); + } + + public async updateTag({ + data, + where + }: { + data: Prisma.TagUpdateInput; + where: Prisma.TagWhereUniqueInput; + }): Promise { + return this.prismaService.tag.update({ + data, + where + }); + } +} diff --git a/apps/api/src/app/tag/update-tag.dto.ts b/apps/api/src/app/tag/update-tag.dto.ts new file mode 100644 index 000000000..b26ffde11 --- /dev/null +++ b/apps/api/src/app/tag/update-tag.dto.ts @@ -0,0 +1,9 @@ +import { IsString } from 'class-validator'; + +export class UpdateTagDto { + @IsString() + id: string; + + @IsString() + name: string; +} diff --git a/apps/api/src/app/user/update-user-setting.dto.ts b/apps/api/src/app/user/update-user-setting.dto.ts index 97062df9d..e510880ed 100644 --- a/apps/api/src/app/user/update-user-setting.dto.ts +++ b/apps/api/src/app/user/update-user-setting.dto.ts @@ -5,6 +5,7 @@ import type { } from '@ghostfolio/common/types'; import { IsBoolean, + IsISO8601, IsIn, IsNumber, IsOptional, @@ -12,6 +13,10 @@ import { } from 'class-validator'; export class UpdateUserSettingDto { + @IsNumber() + @IsOptional() + annualInterestRate?: number; + @IsOptional() @IsString() baseCurrency?: string; @@ -48,6 +53,14 @@ export class UpdateUserSettingDto { @IsOptional() locale?: string; + @IsNumber() + @IsOptional() + projectedTotalAmount?: number; + + @IsISO8601() + @IsOptional() + retirementDate?: string; + @IsNumber() @IsOptional() savingsRate?: number; diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index 2b1f063bf..44d21e9c9 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -1,6 +1,4 @@ -import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; -import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config'; import { User, UserSettings } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import type { RequestWithUser } from '@ghostfolio/common/types'; @@ -31,7 +29,6 @@ import { UserService } from './user.service'; @Controller('user') export class UserController { public constructor( - private readonly configurationService: ConfigurationService, private readonly jwtService: JwtService, private readonly propertyService: PropertyService, @Inject(REQUEST) private readonly request: RequestWithUser, @@ -69,23 +66,20 @@ export class UserController { @Post() public async signupUser(): Promise { - if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) { - const isReadOnlyMode = (await this.propertyService.getByKey( - PROPERTY_IS_READ_ONLY_MODE - )) as boolean; + const isUserSignupEnabled = + await this.propertyService.isUserSignupEnabled(); - if (isReadOnlyMode) { - throw new HttpException( - getReasonPhrase(StatusCodes.FORBIDDEN), - StatusCodes.FORBIDDEN - ); - } + if (!isUserSignupEnabled) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); } const hasAdmin = await this.userService.hasAdmin(); const { accessToken, id, role } = await this.userService.createUser({ - role: hasAdmin ? 'USER' : 'ADMIN' + data: { role: hasAdmin ? 'USER' : 'ADMIN' } }); return { diff --git a/apps/api/src/app/user/user.module.ts b/apps/api/src/app/user/user.module.ts index 6a705524f..3df366bc1 100644 --- a/apps/api/src/app/user/user.module.ts +++ b/apps/api/src/app/user/user.module.ts @@ -1,6 +1,6 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; -import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; -import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { Module } from '@nestjs/common'; diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 13f82aa47..a176c43f3 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -1,39 +1,40 @@ import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; -import { PrismaService } from '@ghostfolio/api/services/prisma.service'; +import { environment } from '@ghostfolio/api/environments/environment'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service'; -import { PROPERTY_IS_READ_ONLY_MODE, locale } from '@ghostfolio/common/config'; import { - User as IUser, - UserSettings, - UserWithSettings -} from '@ghostfolio/common/interfaces'; + DEFAULT_CURRENCY, + PROPERTY_IS_READ_ONLY_MODE, + locale +} from '@ghostfolio/common/config'; +import { User as IUser, UserSettings } from '@ghostfolio/common/interfaces'; import { getPermissions, hasRole, permissions } from '@ghostfolio/common/permissions'; +import { UserWithSettings } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; import { Prisma, Role, User } from '@prisma/client'; -import { sortBy } from 'lodash'; +import { differenceInDays } from 'date-fns'; +import { sortBy, without } from 'lodash'; const crypto = require('crypto'); @Injectable() export class UserService { - public static DEFAULT_CURRENCY = 'USD'; - - private baseCurrency: string; - public constructor( private readonly configurationService: ConfigurationService, private readonly prismaService: PrismaService, private readonly propertyService: PropertyService, private readonly subscriptionService: SubscriptionService, private readonly tagService: TagService - ) { - this.baseCurrency = this.configurationService.get('BASE_CURRENCY'); + ) {} + + public async count(args?: Prisma.UserCountArgs) { + return this.prismaService.user.count(args); } public async getUser( @@ -97,6 +98,7 @@ export class UserService { const { accessToken, Account, + Analytics, authChallenge, createdAt, id, @@ -107,7 +109,12 @@ export class UserService { thirdPartyId, updatedAt } = await this.prismaService.user.findUnique({ - include: { Account: true, Settings: true, Subscription: true }, + include: { + Account: true, + Analytics: true, + Settings: true, + Subscription: true + }, where: userWhereUniqueInput }); @@ -119,9 +126,10 @@ export class UserService { id, provider, role, - Settings, + Settings: Settings as UserWithSettings['Settings'], thirdPartyId, - updatedAt + updatedAt, + activityCount: Analytics?.activityCount }; if (user?.Settings) { @@ -139,8 +147,7 @@ export class UserService { // Set default value for base currency if (!(user.Settings.settings as UserSettings)?.baseCurrency) { - (user.Settings.settings as UserSettings).baseCurrency = - UserService.DEFAULT_CURRENCY; + (user.Settings.settings as UserSettings).baseCurrency = DEFAULT_CURRENCY; } // Set default value for date range @@ -154,15 +161,52 @@ export class UserService { (user.Settings.settings as UserSettings).viewMode = 'DEFAULT'; } + let currentPermissions = getPermissions(user.role); + + if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) { + currentPermissions = without( + currentPermissions, + permissions.accessAssistant + ); + } + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { user.subscription = this.subscriptionService.getSubscription(Subscription); - } - let currentPermissions = getPermissions(user.role); + if (user.subscription?.type === 'Basic') { + const daysSinceRegistration = differenceInDays( + new Date(), + user.createdAt + ); + let frequency = 20; + + if (daysSinceRegistration > 180) { + frequency = 3; + } else if (daysSinceRegistration > 60) { + frequency = 5; + } else if (daysSinceRegistration > 30) { + frequency = 10; + } else if (daysSinceRegistration > 15) { + frequency = 15; + } + + if (Analytics?.activityCount % frequency === 1) { + currentPermissions.push(permissions.enableSubscriptionInterstitial); + } + + currentPermissions = without( + currentPermissions, + permissions.createAccess + ); + + // Reset benchmark + user.Settings.settings.benchmark = undefined; + } - if (user.subscription?.type === 'Premium') { - currentPermissions.push(permissions.reportDataGlitch); + if (user.subscription?.type === 'Premium') { + currentPermissions.push(permissions.reportDataGlitch); + } } if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) { @@ -185,6 +229,10 @@ export class UserService { } } + if (!environment.production && role === 'ADMIN') { + currentPermissions.push(permissions.impersonateAllUsers); + } + user.Account = sortBy(user.Account, (account) => { return account.name; }); @@ -217,7 +265,11 @@ export class UserService { return hash.digest('hex'); } - public async createUser(data: Prisma.UserCreateInput): Promise { + public async createUser({ + data + }: { + data: Prisma.UserCreateInput; + }): Promise { if (!data?.provider) { data.provider = 'ANONYMOUS'; } @@ -227,7 +279,7 @@ export class UserService { ...data, Account: { create: { - currency: this.baseCurrency, + currency: DEFAULT_CURRENCY, isDefault: true, name: 'Default Account' } @@ -235,13 +287,21 @@ export class UserService { Settings: { create: { settings: { - currency: this.baseCurrency + currency: DEFAULT_CURRENCY } } } } }); + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + await this.prismaService.analytics.create({ + data: { + User: { connect: { id: user.id } } + } + }); + } + if (data.provider === 'ANONYMOUS') { const accessToken = this.createAccessToken( user.id, @@ -276,21 +336,29 @@ export class UserService { } public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise { - await this.prismaService.access.deleteMany({ - where: { OR: [{ granteeUserId: where.id }, { userId: where.id }] } - }); + try { + await this.prismaService.access.deleteMany({ + where: { OR: [{ granteeUserId: where.id }, { userId: where.id }] } + }); + } catch {} - await this.prismaService.account.deleteMany({ - where: { userId: where.id } - }); + try { + await this.prismaService.account.deleteMany({ + where: { userId: where.id } + }); + } catch {} - await this.prismaService.analytics.delete({ - where: { userId: where.id } - }); + try { + await this.prismaService.analytics.delete({ + where: { userId: where.id } + }); + } catch {} - await this.prismaService.order.deleteMany({ - where: { userId: where.id } - }); + try { + await this.prismaService.order.deleteMany({ + where: { userId: where.id } + }); + } catch {} try { await this.prismaService.settings.delete({ diff --git a/apps/api/src/assets/countries/asia-pacific-markets.json b/apps/api/src/assets/countries/asia-pacific-markets.json new file mode 100644 index 000000000..adbb0750e --- /dev/null +++ b/apps/api/src/assets/countries/asia-pacific-markets.json @@ -0,0 +1 @@ +["AU", "HK", "NZ", "SG"] diff --git a/apps/api/src/assets/countries/europe-markets.json b/apps/api/src/assets/countries/europe-markets.json new file mode 100644 index 000000000..26eb2176c --- /dev/null +++ b/apps/api/src/assets/countries/europe-markets.json @@ -0,0 +1,19 @@ +[ + "AT", + "BE", + "CH", + "DE", + "DK", + "ES", + "FI", + "FR", + "GB", + "IE", + "IL", + "IT", + "LU", + "NL", + "NO", + "PT", + "SE" +] diff --git a/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json b/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json index 93e9521aa..7b1f42e31 100644 --- a/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json +++ b/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json @@ -10,14 +10,11 @@ "1337": "EliteCoin", "1717": "1717 Masonic Commemorative Token", "2015": "2015 coin", - "$ANRX": "AnRKey X", - "$BASED": "Based Money", - "$KIRBYRELOADED": "Kirby Reloaded", "$MAID": "MaidCoin", "$ROPE": "Rope", "$TIME": "Madagascar Token", - "$TRDL": "Strudel Finance", "$TREAM": "World Stream Finance", + "00": "ZER0ZER0", "007": "007 coin", "0XBTC": "0xBitcoin", "0xDIARY": "The 0xDiary Token", @@ -32,6 +29,7 @@ "1IRST": "1irstcoin", "1PECO": "1peco", "1SG": "1SG", + "1SOL": "1Sol", "1ST": "FirstBlood", "1TRC": "1TRONIC", "1UP": "Uptrennd", @@ -40,6 +38,7 @@ "2BACCO": "2BACCO Coin", "2BASED": "2Based Finance", "2CRZ": "2crazyNFT", + "2GCC": "2G Carbon Coin", "2GIVE": "2GiveCoin", "2GT": "2GETHER", "2KEY": "2key.network", @@ -47,11 +46,14 @@ "2TF": "2TF", "300F": "300FIT", "32BIT": "32Bitcoin", + "37C": "37Protocol", "3DES": "3DES", "3FT": "ThreeFold Token", "3ULL": "3ULL Coin", "3XD": "3DChain", + "420CHAN": "420chan", "4ART": "4ART Coin", + "4CHAN": "4Chan", "4JNET": "4JNET", "77G": "GraphenTech", "7E": "7ELEVEN", @@ -60,6 +62,7 @@ "8BT": "8 Circuit Studios", "8PAY": "8Pay", "8X8": "8X8 Protocol", + "9GAG": "9GAG", "A5T": "Alpha5", "AAA": "Moon Rabbit", "AAB": "AAX Token", @@ -71,8 +74,10 @@ "AAVE": "Aave", "ABA": "EcoBall", "ABBC": "ABBC Coin", + "ABC": "ABC Chain", "ABCC": "ABCC Token", "ABEY": "Abey", + "ABIC": "Arabic", "ABJ": "Abjcoin", "ABL": "Airbloc", "ABT": "ArcBlock", @@ -94,14 +99,18 @@ "ACH": "Alchemy Pay", "ACHC": "AchieveCoin", "ACID": "AcidCoin", + "ACK": "Arcade Kingdoms", "ACM": "AC Milan Fan Token", "ACN": "AvonCoin", "ACOIN": "ACoin", "ACP": "Anarchists Prime", + "ACQ": "Acquire.Fi", + "ACS": "Access Protocol", "ACT": "Achain", "ACTIN": "Actinium", "ACTN": "Action Coin", "ACU": "ACU Platform", + "ACX": "Across Protocol", "ACXT": "ACDX Exchange Token", "ACYC": "All Coins Yield Capital", "ADA": "Cardano", @@ -110,6 +119,7 @@ "ADAO": "ADADao", "ADAPAD": "ADAPad", "ADAT": "Adadex Tools", + "ADAX": "ADAX", "ADB": "Adbank", "ADC": "AudioCoin", "ADD": "ADD.xyz", @@ -144,6 +154,7 @@ "AET": "AfterEther", "AETH": "Aave ETH", "AETHC": "Ankr Reward-Bearing Staked ETH", + "AETHERV2": "AetherV2", "AEVO": "Always Evolving", "AFC": "Arsenal Fan Token", "AFCT": "Allforcrypto", @@ -152,7 +163,6 @@ "AFIN": "Asian Fintech", "AFIT": "Actifit", "AFK": "AFKDAO", - "AFN": "AltaFin", "AFO": "AllForOneBusiness", "AFTT": "Africa Trading Chain", "AFX": "Afrix", @@ -161,6 +171,7 @@ "AGEUR": "agEUR", "AGF": "Augmented Finance", "AGI": "SingularityNET", + "AGLA": "Angola", "AGLD": "Adventure Gold", "AGM": "Argoneum", "AGPC": "AGPC", @@ -170,9 +181,10 @@ "AGT": "aGifttoken", "AGV": "Astra Guild Ventures", "AGVC": "AgaveCoin", + "AGX": "Agricoin", "AHOO": "Ahoolee", "AHT": "AhaToken", - "AI": "Multiverse", + "AI": "AiDoge", "AIB": "AdvancedInternetBlock", "AIBB": "AiBB", "AIBK": "AIB Utility Token", @@ -205,12 +217,14 @@ "AKA": "Akroma", "AKITA": "Akita Inu", "AKN": "Akoin", + "AKNC": "Aave KNC v1", "AKRO": "Akropolis", "AKT": "Akash Network", "AKTIO": "AKTIO Coin", "ALA": "ALA", "ALBT": "AllianceBlock", "ALC": "Arab League Coin", + "ALCAZAR": "Alcazar", "ALCE": "Alcedo", "ALCH": "Alchemy", "ALCHE": "Alchemist", @@ -221,18 +235,21 @@ "ALF": "AlphaCoin", "ALG": "Algory", "ALGO": "Algorand", + "ALGOBLK": "AlgoBlocks", "ALH": "AlloHash", "ALI": "Alethea Artificial Liquid Intelligence Token", "ALIAS": "Alias", "ALIC": "AliCoin", "ALICE": "My Neighbor Alice", "ALIEN": "AlienCoin", + "ALINK": "Aave LINK v1", "ALIS": "ALISmedia", "ALITA": "Alita Network", "ALIX": "AlinX", "ALKI": "Alkimi", "ALLBI": "ALL BEST ICO", "ALLEY": "NFT Alley", + "ALLIN": "All in", "ALN": "Aluna", "ALOHA": "Aloha", "ALP": "Alphacon", @@ -240,10 +257,12 @@ "ALPACA": "Alpaca Finance", "ALPH": "Alephium", "ALPHA": "Alpha Finance Lab", + "ALPHAC": "Alpha Coin", "ALPHR": "Alphr", "ALPINE": "Alpine F1 Team Fan Token", "ALPS": "Alpenschillling", "ALT": "Alitas", + "ALTA": "Alta Finance", "ALTB": "Altbase", "ALTCOIN": "ALTcoin", "ALTCOM": "AltCommunity Coin", @@ -258,14 +277,16 @@ "AMA": "MrWeb", "AMAL": "AMAL", "AMATEN": "Amaten", - "AMB": "Amber", + "AMB": "AirDAO", "AMBER": "AmberCoin", "AMBT": "AMBT Token", "AMDC": "Allmedi Coin", + "AMDG": "AMDG", "AME": "Amepay", "AMERICANCOIN": "AmericanCoin", "AMIO": "Amino Network", "AMIS": "AMIS", + "AMKT": "Alongside Crypto Market Index", "AMLT": "AMLT", "AMM": "MicroMoney", "AMMO": "Ammo Rewards", @@ -275,11 +296,13 @@ "AMOS": "Amos", "AMP": "Amp", "AMPL": "Ampleforth", + "AMPLIFI": "AmpliFi", "AMS": "Amsterdam Coin", "AMT": "Acumen", "AMX": "Amero", "AMY": "Amygws", "AMZE": "The Amaze World", + "ANA": "Nirvana ANA", "ANAL": "AnalCoin", "ANB": "Angryb", "ANC": "Anchor Protocol", @@ -294,14 +317,17 @@ "ANGLE": "ANGLE", "ANI": "Animecoin", "ANJ": "Aragon Court", + "ANJI": "Anji", "ANK": "AlphaLink", "ANKA.BITCI": "Ankaragücü Fan Token", "ANKORUS": "Ankorus Token", "ANKR": "Ankr Network", + "ANKRETH": "Ankr Staked ETH", "ANML": "Animal Concerts", "ANN": "Annex Finance", "ANON": "ANON", "ANONCOIN": "Anoncoin", + "ANRX": "AnRKey X", "ANS": "ANS Crypto Coin", "ANSR": "Answerly", "ANT": "Aragon", @@ -314,8 +340,8 @@ "ANV": "Aniverse", "ANW": "Anchor Neural World", "ANY": "Anyswap", - "AOA": "Aurora", "AOG": "AgeOfGods", + "AOK": "AOK", "AOP": "Averopay", "AOS": "AOS", "APAD": "Anypad", @@ -323,7 +349,8 @@ "APE": "ApeCoin", "APECOIN": "Asia Pacific Electronic Coin", "APED": "Baddest Alpha Ape Bundle", - "APEX": "ApexCoin", + "APEX": "ApeX Protocol", + "APEXCOIN": "ApexCoin", "APH": "Aphelion", "API": "Application Programming Interface", "API3": "API3", @@ -333,16 +360,19 @@ "APM": "apM Coin", "APN": "Apron", "APOD": "AirPod", + "APOLLO": "Apollo Crypto", "APP": "SappChat", "APPC": "AppCoins", + "APRICOT": "Apricot Finance", "APRIL": "April", "APS": "APRES", - "APT": "Apricot Finance", + "APT": "Aptos", "APTCOIN": "Aptcoin", "APW": "APWine", - "APX": "Apx", + "APX": "ApolloX", "APXP": "APEX Protocol", "APXT": "ApolloX", + "APXVENTURES": "Apx", "APY": "APY.Finance", "APYS": "APYSwap", "APZ": "Alprockz", @@ -357,8 +387,9 @@ "ARA": "Ara Token", "ARATA": "Arata", "ARAW": "Araw", - "ARB": "Arbit Coin", + "ARB": "Arbitrum", "ARBI": "Arbi", + "ARBIT": "Arbit Coin", "ARBT": "ARBITRAGE", "ARC": "ArcticCoin", "ARCA": "Arca", @@ -375,20 +406,25 @@ "ARENA": "Arena", "AREPA": "Arepacoin", "ARES": "Ares Protocol", - "ARG": "Argentum", + "ARG": "Argentine Football Association Fan Token", + "ARGENTUM": "Argentum", "ARGO": "ArGoApp", "ARGON": "Argon", "ARGUS": "ArgusCoin", "ARI": "AriCoin", + "ARIA": "Legends of Aria", "ARIA20": "Arianee", + "ARIX": "Arix", "ARK": "ARK", "ARKER": "Arker", + "ARKM": "Arkham", "ARKN": "Ark Rivals", "ARM": "Armory Coin", "ARMOR": "ARMOR", "ARMR": "ARMR", "ARMS": "2Acoin", "ARNA": "ARNA Panacea", + "ARNM": "Arenum", "ARNO": "ARNO", "ARNX": "Aeron", "ARNXM": "Armor NXM", @@ -404,12 +440,14 @@ "ARTE": "Artemine", "ARTEM": "Artem", "ARTEON": "Arteon", + "ARTEQ": "artèQ", "ARTEX": "Artex", "ARTF": "Artfinity Token", "ARTG": "Goya Giant Token", "ARTH": "ARTH", "ARTI": "Arti Project", "ARTII": "ARTII Token", + "ARTL": "ARTL", "ARTM": "ARTM", "ARTP": "ArtPro", "ARTY": "Artyfact", @@ -418,12 +456,14 @@ "ARX": "ARCS", "ARY": "Block Array", "AS": "AmaStar", + "ASA": "ASA Coin", "ASAFE2": "Allsafe", "ASD": "AscendEX Token", "ASG": "Asgard", "ASGC": "ASG", "ASH": "ASH", "ASIA": "Asia Coin", + "ASIMI": "ASIMI", "ASK": "Permission Coin", "ASKO": "Asko", "ASM": "Assemble Protocol", @@ -433,15 +473,18 @@ "ASQT": "ASQ Protocol", "ASR": "AS Roma Fan Token", "ASS": "Australian Safe Shepherd", - "ASSA": "ASSARA", + "ASSA": "AssaPlay", + "ASSARA": "ASSARA", "ASST": "AssetStream", "AST": "AirSwap", "ASTA": "ASTA", "ASTO": "Altered State Token", "ASTON": "Aston", "ASTR": "Astar", + "ASTRAFER": "Astrafer", "ASTRAL": "Astral", "ASTRO": "AstroSwap", + "ASTROC": "Astroport Classic", "ASTROLION": "AstroLion", "ASTRONAUT": "Astronaut", "ASUNA": "Asuna Hentai", @@ -453,7 +496,7 @@ "ATCC": "ATC Coin", "ATD": "A2DAO", "ATFS": "ATFS Project", - "ATH": "AetherV2", + "ATH": "All Time High Vodka", "ATHE": "Atheios", "ATK": "Attack Wagon", "ATKN": "A-Token", @@ -468,6 +511,7 @@ "ATOLO": "RIZON", "ATOM": "Cosmos", "ATON": "Further Network", + "ATOZ": "Race Kingdom", "ATP": "Atlas Protocol", "ATR": "Ather", "ATRI": "Atari Token", @@ -478,7 +522,8 @@ "AU": "AutoCrypto", "AUA": "ArubaCoin", "AUC": "Auctus", - "AUCTION": "Auction", + "AUCO": "Advanced United Continent", + "AUCTION": "Bounce", "AUDC": "Aussie Digital", "AUDIO": "Audius", "AUDX": "eToro Australian Dollar", @@ -487,6 +532,7 @@ "AUNIT": "Aunit", "AUPC": "Authpaper", "AUR": "AUREO", + "AURO": "Aurora", "AURORA": "Aurora", "AURORAC": "Auroracoin", "AUROS": "AurusGOLD", @@ -495,6 +541,7 @@ "AURY": "Aurory", "AUSCM": "Auric Network", "AUSD": "Appeal dollar", + "AUSDC": "Aave USDC v1", "AUT": "Autoria", "AUTHORSHIP": "Authorship", "AUTO": "Auto", @@ -504,12 +551,15 @@ "AVA": "Travala", "AVAL": "Avaluse", "AVALON": "Avalon", + "AVAT": "AVATA Network", "AVAX": "Avalanche", "AVAXIOU": "Avalanche IOU", + "AVDO": "AvocadoCoin", "AVE": "Avesta", "AVG": "Avocado DAO", "AVH": "Animation Vision Cash", "AVINOC": "AVINOC", + "AVL": "Aston Villa Fan Token", "AVN": "AVNRich", "AVO": "Avoteo", "AVT": "Aventus", @@ -522,7 +572,7 @@ "AWR": "Award", "AWS": "AurusSILVER", "AWT": "Airdrop World", - "AWX": "AurusDeFi", + "AWX": "AurusX", "AX": "AlphaX", "AXC": "AXIA Coin", "AXE": "Axe", @@ -531,7 +581,8 @@ "AXIAV3": "Axia", "AXIOM": "Axiom Coin", "AXIS": "Axis DeFi", - "AXL": "AXL INU", + "AXL": "Axelar", + "AXLINU": "AXL INU", "AXN": "Axion", "AXNT": "Axentro", "AXPR": "aXpire", @@ -541,10 +592,15 @@ "AXYS": "Axys", "AYA": "Aryacoin", "AZ": "Azbit", + "AZA": "Kaliza", "AZART": "Azart", "AZBI": "AZBI CORE", + "AZERO": "Aleph Zero", "AZU": "Azultec", "AZUKI": "Azuki", + "AZUM": "Azuma Coin", + "AZY": "Amazy", + "B": "BankCoin", "B20": "B20", "B21": "B21", "B26": "B26 Finance", @@ -553,11 +609,9 @@ "B2X": "SegWit2x", "B3": "B3 Coin", "B91": "B91", - "B@": "BankCoin", "BAAS": "BaaSid", "BABL": "Babylon Finance", "BABY": "BabySwap", - "BABYB": "Baby Bali", "BABYCUBAN": "Baby Cuban", "BABYDOGE": "BabyDoge", "BABYELON": "BabyElon", @@ -566,8 +620,10 @@ "BABYSAITAMA": "Baby Saitama", "BABYTK": "Baby Tiger King", "BAC": "Basis Cash", + "BACK": "DollarBack", "BACOIN": "BACoin", "BACON": "BaconDAO (BACON)", + "BAD": "Bad Idea AI", "BADGER": "Badger DAO", "BAG": "BondAppetit", "BAGS": "Basis Gold Share", @@ -583,6 +639,7 @@ "BANC": "Babes and Nerds", "BANCA": "BANCA", "BAND": "Band Protocol", + "BANDEX": "Banana Index", "BANK": "Float Protocol", "BANKETH": "BankEth", "BANNER": "BannerCoin", @@ -593,6 +650,7 @@ "BART": "BarterTrade", "BAS": "Basis Share", "BASE": "Base Protocol", + "BASED": "Based Money", "BASH": "LuckChain", "BASHC": "BashCoin", "BASHOS": "Bashoswap", @@ -607,6 +665,7 @@ "BAX": "BABB", "BAXS": "BoxAxis", "BAY": "BitBay", + "BB": "Baby Bali", "BB1": "Bitbond", "BBADGER": "Badger Sett Badger", "BBANK": "BlockBank", @@ -615,6 +674,7 @@ "BBCT": "TraDove B2BCoin", "BBDT": "BBD Token", "BBF": "Bubblefong", + "BBFT": "Block Busters Tech Token", "BBG": "BigBang", "BBGC": "BigBang Game", "BBI": "BelugaPay", @@ -625,7 +685,7 @@ "BBP": "BiblePay", "BBR": "Boolberry", "BBS": "BBSCoin", - "BBT": "BitBoost", + "BBT": "BitBook", "BBTC": "BlakeBitcoin", "BC": "Bitcoin Confidential", "BCA": "Bitcoin Atom", @@ -649,6 +709,7 @@ "BCMC1": "BeforeCoinMarketCap", "BCN": "ByteCoin", "BCNA": "BitCanna", + "BCNT": "Bincentive", "BCNX": "BCNEX", "BCO": "BridgeCoin", "BCOIN": "Bombcrypto", @@ -656,13 +717,12 @@ "BCPT": "BlockMason Credit Protocol", "BCR": "BitCredit", "BCS": "Business Credit Substitute", - "BCT": "Bitcratic Token", + "BCT": "Toucan Protocol: Base Carbon Tonne", "BCUG": "Blockchain Cuties Universe Governance", "BCV": "BitCapitalVendor", "BCVB": "BCV Blue Chip", "BCX": "BitcoinX", "BCY": "BitCrystals", - "BCZ": "Bitcoin CZ", "BCZERO": "Buggyra Coin Zero", "BDAY": "Birthday Cake", "BDB": "Big Data Block", @@ -676,7 +736,9 @@ "BDPI": "Interest Bearing Defi Pulse Index", "BDR": "BlueDragon", "BDX": "Beldex", + "BDY": "Buddy DAO", "BEACH": "BeachCoin", + "BEAI": "BeNFT Solutions", "BEAM": "Beam", "BEAN": "BeanCash", "BEAST": "CryptoBeast", @@ -684,9 +746,10 @@ "BEC": "Betherchip", "BECH": "Beauty Chain", "BED": "Bankless BED Index", - "BEE": "Bee Token", + "BEE": "Herbee", "BEER": "BEER Coin", - "BEET": "Beetle Coin", + "BEETLE": "Beetle Coin", + "BEETOKEN": "Bee Token", "BEETS": "Beethoven X", "BEL": "Bella Protocol", "BELA": "Bela", @@ -697,6 +760,7 @@ "BENJACOIN": "Benjacoin", "BENJI": "BenjiRolls", "BENT": "Bent Finance", + "BENX": "BlueBenx", "BENZI": "Ben Zi Token", "BEP": "Blucon", "BEPR": "Blockchain Euro Project", @@ -714,6 +778,7 @@ "BETT": "Bettium", "BETU": "Betu", "BEX": "BEX token", + "BEY": "Beyond Finance", "BEYOND": "Beyond Protocol", "BEZ": "Bezop", "BEZOGE": "Bezoge Earth", @@ -722,6 +787,7 @@ "BFCH": "Big Fun Chain", "BFDT": "Befund", "BFEX": "BFEX", + "BFHT": "BeFaster Holder Token", "BFI": "BitDefi", "BFIC": "Bficoin", "BFLOKI": "BurnFloki", @@ -734,25 +800,29 @@ "BGG": "BGG Token", "BGLD": "Based Gold", "BGONE": "BigONE Token", + "BGS": "Battle of Guardians Share", "BHAO": "Bithao", "BHAX": "Bithashex", "BHC": "BillionHappiness", - "BHD": "Bitcoin HD", "BHEROES": "BombHeroes coin", "BHIRE": "BitHIRE", "BHIVE": "Hive", "BHO": "Bholdus Token", "BHP": "Blockchain of Hash Power", "BHPC": "BHPCash", + "BIBL": "Biblecoin", "BIC": "Bikercoins", "BICO": "Biconomy", - "BID": "Bidao", + "BID": "TopBidder", + "BIDAO": "Bidao", "BIDCOM": "Bidcommerce", "BIDI": "Bidipass", "BIDR": "Binance IDR Stable Coin", "BIFI": "Beefy.Finance", "BIFIF": "BiFi", + "BIG": "Big Eyes", "BIGHAN": "BighanCoin", + "BIGSB": "BigShortBets", "BIGUP": "BigUp", "BIH": "BitHostCoin", "BIHU": "Key", @@ -761,11 +831,13 @@ "BIM": "BitminerCoin", "BIND": "Compendia", "BINEM": "Binemon", + "BINGO": "Tomorrowland", "BINS": "Bitsense", "BINTEX": "Bintex Futures", - "BIO": "Biocoin", + "BIO": "BITONE", "BIOB": "BioBar", "BIOC": "BioCrypt", + "BIOCOIN": "Biocoin", "BIOFI": "Biometric Financial", "BIOS": "BiosCrypto", "BIOT": "Bio Passport", @@ -778,14 +850,18 @@ "BIST": "Bistroo", "BIT": "BitDAO", "BIT16": "16BitCoin", + "BITAIR": "Bitair", "BITASEAN": "BitAsean", + "BITBOOST": "BitBoost", "BITC": "BitCash", "BITCAR": "BitCar", + "BITCCA": "Bitcci Cash", "BITCI": "Bitcicoin", "BITCM": "Bitcomo", "BITCNY": "bitCNY", "BITCOINC": "Bitcoin Classic", "BITCOINV": "BitcoinV", + "BITCRATIC": "Bitcratic Token", "BITF": "Bit Financial", "BITG": "Bitcoin Green", "BITGOLD": "bitGold", @@ -801,6 +877,7 @@ "BITS": "BitstarCoin", "BITSD": "Bits Digit", "BITSILVER": "bitSilver", + "BITSPACE": "Bitspace", "BITSZ": "Bitsz", "BITT": "BiTToken", "BITTO": "BITTO", @@ -809,6 +886,7 @@ "BITX": "BitScreener", "BITZ": "Bitz Coin", "BIUT": "Bit Trust System", + "BIVE": "BIZVERSE", "BIX": "BiboxCoin", "BIXB": "BIXBCOIN", "BIZZ": "BIZZCOIN", @@ -831,12 +909,14 @@ "BLAZR": "BlazerCoin", "BLC": "BlakeCoin", "BLCT": "Bloomzed Loyalty Club Ticket", + "BLD": "Agoric", "BLES": "Blind Boxes", "BLHC": "BlackholeCoin", "BLIN": "Blin Metaverse", "BLINK": "BlockMason Link", "BLINU": "Baby Lambo Inu", "BLITZ": "BlitzCoin", + "BLITZP": "BlitzPredict", "BLK": "BlackCoin", "BLKC": "BlackHat Coin", "BLKD": "Blinked", @@ -864,9 +944,12 @@ "BLU": "BlueCoin", "BLUE": "Ethereum Blue", "BLUESPARROW": "BlueSparrow Token", + "BLUESPARROWOLD": "BlueSparrowToken", + "BLUR": "Blur", "BLURT": "Blurt", "BLUT": "Bluetherium", "BLV": "Blockvest", + "BLV3": "Crypto Legions V3", "BLWA": "BlockWarrior", "BLX": "Balloon-X", "BLXM": "bloXmove Token", @@ -876,6 +959,8 @@ "BM": "BitMoon", "BMARS": "Binamars", "BMC": "Blackmoon Crypto", + "BME": "BitcoMine", + "BMEX": "BitMEX", "BMG": "Borneo", "BMH": "BlockMesh", "BMI": "Bridge Mutual", @@ -893,6 +978,7 @@ "BNBCH": "BNB Cash", "BNBH": "BnbHeroes Token", "BNC": "Bifrost Native Coin", + "BND": "Bened", "BNF": "BonFi", "BNIX": "BNIX Token", "BNK": "Bankera", @@ -902,6 +988,7 @@ "BNR": "BiNeuro", "BNRTX": "BnrtxCoin", "BNS": "BNS token", + "BNSD": "BNSD Finance", "BNSOLD": "BNS token ", "BNT": "Bancor Network Token", "BNTE": "Bountie", @@ -913,7 +1000,9 @@ "BOBA": "Boba Network", "BOBC": "Bobcoin", "BOBS": "Bob's Repair", + "BOBT": "BOB Token", "BODHI": "Bodhi Network", + "BODYP": "Body Profile", "BOE": "Bodhi", "BOG": "Bogged Finance", "BOGCOIN": "Bogcoin", @@ -925,15 +1014,15 @@ "BOLTT": "BolttCoin", "BOMB": "BOMB", "BOMBC": "BombCoin", + "BOMBM": "Bomb Money", "BON": "Bonpay", "BONA": "Bonafi", "BOND": "BarnBridge", - "BONDED": "Fringe Finance", "BONDLY": "Bondly", - "BONE": "Bone ShibaSwap (BONE)", + "BONE": "Bone ShibaSwap", "BONES": "BonesCoin", "BONIX": "Blockonix", - "BONK": "BONK Token", + "BONK": "Bonk", "BONO": "Bonorum Coin", "BONTE": "Bontecoin", "BONUSCAKE": "Bonus Cake", @@ -978,7 +1067,7 @@ "BPS": "BitcoinPoS", "BPT": "BlackPool Token", "BPTC": "Business Platform Tomato Coin", - "BPX": "BlitzPredict", + "BPX": "Black Phoenix", "BQ": "Bitqy", "BQC": "BQCoin", "BQQQ": "Bitsdaq Token", @@ -1007,6 +1096,7 @@ "BRIK": "BrikBit", "BRISE": "Bitgert", "BRIT": "BritCoin", + "BRITTO": "Britto", "BRIX": "OpenBrix", "BRK": "BreakoutCoin", "BRKL": "Brokoli Token", @@ -1015,8 +1105,9 @@ "BRNK": "Brank", "BRNX": "Bronix", "BRO": "Bitradio", + "BROCK": "Bitrock", "BRONZ": "BitBronze", - "BRT": "Britto", + "BRT": "Bikerush", "BRTR": "Barter", "BRTX": "Bertinity", "BRWL": "Blockchain Brawlers", @@ -1038,13 +1129,13 @@ "BSCV": "Bscview", "BSE": "BitSerial", "BSEND": "BitSend", + "BSGG": "Betswap.gg", "BSGS": "Basis Gold Share", "BSI": "Bali Social Integrated", "BSKT": "BasketCoin", "BSL": "BankSocial", "BSOV": "BitcoinSoV", "BSP": "BallSwap", - "BSPARROW": "BlueSparrowToken", "BSPM": "Bitcoin Supreme", "BSR": "BitSoar Coin", "BST": "Beshare Token", @@ -1054,7 +1145,7 @@ "BSTY": "GlobalBoost", "BSV": "Bitcoin SV", "BSW": "Biswap", - "BSX": "Bitspace", + "BSX": "Basilisk", "BSYS": "BSYS", "BT": "BT.Finance", "BT1": "Bitfinex Bitcoin Future", @@ -1062,9 +1153,10 @@ "BTA": "Bata", "BTB": "BitBar", "BTBL": "Bitball", + "BTBS": "BitBase Token", "BTC": "Bitcoin", "BTC2": "Bitcoin 2", - "BTCA": "Bitair", + "BTCA": "BITCOIN ADDITIONAL", "BTCAS": "BitcoinAsia", "BTCB": "Bitcoin BEP2", "BTCBR": "Bitcoin BR", @@ -1075,11 +1167,14 @@ "BTCF": "BitcoinFile", "BTCGO": "BitcoinGo", "BTCH": "Bitcoin Hush", + "BTCHD": "Bitcoin HD", "BTCK": "Bitcoin Turbo Koin", "BTCL": "BTC Lite", "BTCM": "BTCMoon", "BTCN": "BitcoiNote", "BTCP": "Bitcoin Private", + "BTCPAY": "Bitcoin Pay", + "BTCPX": "BTC Proxy", "BTCR": "BitCurrency", "BTCRED": "Bitcoin Red", "BTCRY": "BitCrystal", @@ -1093,6 +1188,7 @@ "BTDX": "Bitcloud 2.0", "BTE": "BTEcoin", "BTF": "Blockchain Traded Fund", + "BTFA": "Banana Task Force Ape", "BTG": "Bitcoin Gold", "BTH": "Bithereum", "BTK": "Bostoken", @@ -1143,16 +1239,21 @@ "BUILDIN": "Buildin Token", "BUILDTEAM": "BuildTeam", "BUK": "CryptoBuk", + "BULL": "Bullieverse", "BULLC": "BuySell", - "BULLS": "BullshitCoin", + "BULLION": "BullionFX", + "BULLS": "Bull Coin", + "BULLSH": "Bullshit Inu", "BUMN": "BUMooN", + "BUMP": "Bumper", "BUN": "BunnyCoin", "BUNNY": "Pancake Bunny", "BUNNYROCKET": "BunnyRocket", "BURGER": "Burger Swap", + "BURN": "Bitburn", "BURNDOGE": "BurnDoge", "BURP": "CoinBurp", - "BUSD": "BUSD", + "BUSD": "Binance USD", "BUSDC": "BUSD", "BUSY": "Busy DAO", "BUT": "BitUP Token", @@ -1168,20 +1269,22 @@ "BWF": "Beowulf", "BWK": "Bulwark", "BWN": "BitWings", + "BWO": "Battle World", "BWS": "BitcoinWSpectrum", "BWT": "Bittwatt", "BWT2": "Bitwin 2.0", "BWX": "Blue Whale", + "BX": "BlockXpress", "BXA": "Blockchain Exchange Alliance", "BXC": "BonusCloud", "BXF": "BlackFort Token", "BXH": "BXH", "BXK": "Bitbook Gambling", "BXT": "BitTokens", + "BXTB": "BXTB Foundation", "BXX": "Baanx", "BXY": "Beaxy", "BYC": "ByteCent", - "BYN": "Beyond Finance", "BYTHER": "Bytether ", "BYTS": "Bytus", "BYTZ": "BYTZ", @@ -1190,6 +1293,9 @@ "BZKY": "Bizkey", "BZL": "BZLCoin", "BZNT": "Bezant", + "BZR": "Bazaars", + "BZRX": "bZx Protocol", + "BZX": "Bitcoin Zero", "BZZ": "Swarmv", "BZZONE": "Bzzone", "BamitCoin": "BAM", @@ -1204,6 +1310,7 @@ "CABS": "CryptoABS", "CACH": "Cachecoin", "CACHE": "Cache", + "CACHEGOLD": "CACHE Gold", "CADN": "Content and AD Network", "CADX": "eToro Canadian Dollar", "CAG": "Change", @@ -1221,7 +1328,7 @@ "CAMC": "Camcoin", "CAMP": "Camp", "CAN": "Channels", - "CAND": "Canlead", + "CAND": "Canary Dollar", "CANDY": "UnicornGo Candy", "CANN": "CannabisCoin", "CANTI": "Cantina Royale", @@ -1229,31 +1336,37 @@ "CAP": "BottleCaps", "CAPD": "Capdax", "CAPP": "Cappasity", + "CAPRICOIN": "CapriCoin", "CAPS": "Ternoa", "CAPT": "Bitcoin Captain", + "CAPTAINPLANET": "Captain Planet", "CAR": "CarBlock", "CARAT": "Carats Token", "CARBON": "Carboncoin", "CARD": "Cardstack", "CARDS": "Cardstarter", "CARE": "Carebit", + "CARES": "CareCoin", "CARPE": "CarpeDiemCoin", "CARR": "Carnomaly", "CARROT": "CarrotSwap", - "CARRY": "Carry", "CART": "CryptoArt.Ai", "CARTAXI": "CarTaxi", "CARTERCOIN": "CarterCoin", "CAS": "Cashaa", "CASH": "CashCoin", "CASHT": "Cash Tech", + "CASIO": "CasinoXMetaverse", "CASPER": "Casper DeFi", "CAST": "Castello Coin", + "CASTLE": "bitCastle", "CAT": "Cat Token", "CATC": "Catcoin", "CATCOIN": "CatCoin Cash", "CATE": "CateCoin", "CATGIRL": "Catgirl", + "CATHEON": "Catheon Gaming", + "CATS": "CatCoin Token", "CATT": "Catex", "CATX": "CAT.trade Protocol", "CATZ": "CatzCoin", @@ -1267,6 +1380,7 @@ "CBD": "CBD Crystals", "CBDC": "CannaBCoin", "CBE": "The Chain of Business Entertainment", + "CBETH": "Coinbase Wrapped Staked ETH", "CBFT": "CoinBene Future Token", "CBG": "Chainbing", "CBK": "Cobak Token", @@ -1281,10 +1395,10 @@ "CBT": "CommerceBlock Token", "CBUCKS": "CRYPTOBUCKS", "CBUK": "CurveBlock", - "CBX": "CryptoBullion", - "CC": "CyberCoin", + "CBX": "CropBytes", + "CC": "CloudChat", "CC10": "Cryptocurrency Top 10 Tokens Index", - "CCA": "Counos Coin", + "CCA": "CCA", "CCAKE": "CheeseCake Swap", "CCAR": "CryptoCars", "CCC": "CCCoin", @@ -1301,8 +1415,9 @@ "CCOMM": "Crypto Commonwealth", "CCOMP": "cCOMP", "CCOS": "CrowdCoinage", + "CCP": "CryptoCoinPay", "CCRB": "CryptoCarbon", - "CCT": "Crystal Clear Token", + "CCT": "Carbon Credit", "CCTN": "Connectchain", "CCX": "Conceal", "CCXC": "CoolinDarkCoin", @@ -1318,6 +1433,7 @@ "CEDEX": "CEDEX Coin", "CEEK": "CEEK Smart VR Token", "CEFS": "CryptopiaFeeShares", + "CEJI": "Ceji", "CEL": "Celsius Network", "CELEB": "CELEBPLUS", "CELL": "Cellframe", @@ -1330,6 +1446,7 @@ "CENNZ": "Centrality Token", "CENT": "CENTERCOIN", "CENTRA": "Centra", + "CENX": "Centcex", "CERE": "Cere Network", "CESC": "Crypto Escudo", "CET": "CoinEx Token", @@ -1354,8 +1471,10 @@ "CGA": "Cryptographic Anomaly", "CGG": "Chain Guardians", "CGLD": "Celo Gold", + "CGO": "Comtech Gold", + "CGPT": "ChainGPT", "CGS": "Crypto Gladiator Shards", - "CGT": "CACHE Gold", + "CGT": "Coin Gabbar Token", "CGU": "Crypto Gaming United", "CHA": "Charity Coin", "CHADS": "CHADS VC", @@ -1364,6 +1483,7 @@ "CHAL": "Chalice Finance", "CHAMP": "NFT Champions", "CHAN": "ChanCoin", + "CHANGE": "ChangeX", "CHAO": "23 Skidoo", "CHARIZARD": "Charizard Inu", "CHARM": "Charm Coin", @@ -1373,15 +1493,16 @@ "CHAT": "OpenChat", "CHBR": "CryptoHub", "CHC": "ChainCoin", - "CHE": "CherrySwap", "CHECK": "Paycheck", "CHECKR": "CheckerChain", "CHECOIN": "CheCoin", "CHEDDA": "Chedda", + "CHEEL": "Cheelee", "CHEESE": "CHEESE", "CHEESUS": "Cheesus", "CHEQ": "CHEQD Network", "CHER": "Cherry Network", + "CHERRY": "CherrySwap", "CHESS": "Tranchess", "CHESSCOIN": "ChessCoin", "CHEX": "Chintai", @@ -1410,6 +1531,7 @@ "CHOW": "Chow Chow Finance", "CHP": "CoinPoker Token", "CHR": "Chroma", + "CHRP": "Chirpley", "CHS": "Chainsquare", "CHSB": "SwissBorg", "CHT": "Countinghouse Fund", @@ -1418,7 +1540,8 @@ "CHX": "Own", "CHY": "Concern Poverty Chain", "CHZ": "Chiliz", - "CIC": "CIChain", + "CIC": "Crazy Internet Coin", + "CICHAIN": "CIChain", "CIF": "Crypto Improvement Fund", "CIM": "COINCOME", "CIN": "CinderCoin", @@ -1450,6 +1573,7 @@ "CLD": "Cloud", "CLDX": "Cloverdex", "CLEARPOLL": "ClearPoll", + "CLEG": "Chain of Legends", "CLEVERCOIN": "CleverCoin", "CLH": "ClearDAO", "CLICK": "Clickcoin", @@ -1459,6 +1583,7 @@ "CLINT": "Clinton", "CLIQ": "DefiCliq", "CLIST": "Chainlist", + "CLM": "CoinClaim", "CLN": "Colu Local Network", "CLNY": "Colony", "CLO": "Callisto Network", @@ -1485,11 +1610,13 @@ "CMERGE": "CoinMerge", "CMK": "Credmark", "CMKR": "cMKR", + "CML": "Camelcoin", "CMM": "Commercium", "CMN": "Crypto Media Network", - "CMOS": "Cosmo", - "CMP": "Compcoin", + "CMOS": "CoinMerge OS", + "CMP": "Caduceus", "CMPCO": "CampusCoin", + "CMQ": "Communique", "CMS": "COMSA", "CMSN": "The Commission", "CMT": "CyberMiles", @@ -1502,6 +1629,8 @@ "CNCT": "CONNECT", "CND": "Cindicator", "CNDL": "Candle", + "CNFI": "Connect Financial", + "CNG": "Changer", "CNHT": "Tether CNH", "CNL": "ConcealCoin", "CNMT": "Coinomat", @@ -1520,9 +1649,8 @@ "CO2": "CO2 Token", "COAL": "BitCoal", "COB": "Cobinhood", - "COC": "Community Coin", + "COC": "Coin of the champions", "COCK": "Shibacock", - "COCOS": "COCOS BCX", "CODEO": "Codeo Token", "CODEX": "CODEX Finance", "CODI": "Codi Finance", @@ -1539,6 +1667,7 @@ "COINDEFI": "Coin", "COING": "Coingrid", "COINLION": "CoinLion", + "COINSCOPE": "Coinscope", "COINSL": "CoinsLoot", "COINVEST": "Coinvest", "COKE": "Cocaine Cowboy Shards", @@ -1546,19 +1675,23 @@ "COLA": "Cola", "COLL": "Collateral Pay", "COLLG": "Collateral Pay Governance", + "COLR": "colR Coin", "COLX": "ColossusCoinXT", "COM": "Coliseum", "COMB": "Combo", - "COMBO": "Furucombo", + "COMBO": "COMBO", "COMFI": "CompliFi", "COMM": "Community Coin", + "COMMUNITYCOIN": "Community Coin", "COMP": "Compound Governance Token", + "COMPCOIN": "Compcoin", "COMPD": "Compound Coin", "COMT": "Community Token", "CONDENSATE": "Condensate", + "CONG": "The Conglomerate Capital", "CONI": "CoinBene", "CONS": "ConSpiracy Coin", - "CONT": "Contentos", + "CONSENTIUM": "Consentium", "CONUN": "CONUN", "CONV": "Convergence", "COOK": "Cook", @@ -1569,21 +1702,24 @@ "COPS": "Cops Finance", "COR": "Corion", "CORAL": "CoralPay", - "CORE": "cVault.finance", + "CORE": "Core", "COREDAO": "coreDAO", "COREG": "Core Group Asset", + "COREUM": "Coreum", "CORGI": "Corgi Inu", "CORN": "CORN", "CORX": "CorionX", - "COS": "COS", + "COS": "Contentos", "COSHI": "CoShi Inu", "COSM": "CosmoChain", "COSMIC": "CosmicSwap", "COSP": "Cosplay Token", + "COSS": "COS", "COSX": "Cosmecoin", "COT": "CoTrader", "COTI": "COTI", "COU": "Couchain", + "COUNOS": "Counos Coin", "COV": "Covesting", "COVA": "COVA", "COVAL": "Circuits of Value", @@ -1614,8 +1750,9 @@ "CPOOL": "Clearpool", "CPROP": "CPROP", "CPRX": "Crypto Perx", - "CPS": "CapriCoin", + "CPS": "Cryptostone", "CPT": "Cryptaur", + "CPU": "CPUcoin", "CPX": "Apex Token", "CPY": "COPYTRACK", "CQST": "ConquestCoin", @@ -1625,9 +1762,11 @@ "CRA": "Crabada", "CRAB": "CrabCoin", "CRACK": "CrackCoin", + "CRADLE": "Cradle of Sins", "CRAFT": "TaleCraft", "CRAFTCOIN": "Craftcoin", "CRAIG": "CraigsCoin", + "CRAMER": "Cramer Coin", "CRANEPAY": "Cranepay", "CRAVE": "CraveCoin", "CRB": "Creditbit", @@ -1639,11 +1778,14 @@ "CRDS": "Credits", "CRDT": "CRDT", "CRDTS": "Credits", + "CRE": "Carry", "CREA": "CreativeChain", "CREAM": "Cream", + "CREATIVE": "Creative Token", "CRED": "Verify", "CREDI": "Credefi", - "CREDIT": "TerraCredit", + "CREDIT": "Credit", + "CREDITS": "Credits", "CREDO": "Credo", "CREED": "Thecreed", "CREO": "Creo Engine", @@ -1660,9 +1802,11 @@ "CRNK": "CrankCoin", "CRO": "Cronos", "CROAT": "Croat", + "CROGE": "Crogecoin", "CRON": "Cryptocean", "CROPPER": "CropperFinance", "CROWD": "CrowdCoin", + "CROWDWIZ": "Crowdwiz", "CRP": "Crypton", "CRPS": "CryptoPennies", "CRPT": "Crypterium", @@ -1673,21 +1817,24 @@ "CRTS": "Cratos", "CRU": "Crust Network", "CRV": "Curve DAO Token", + "CRVUSD": "crvUSD", "CRW": "Crown Coin", "CRWD": "CRWD Network", "CRWNY": "Crowny Token", "CRX": "ChronosCoin", "CRYP": "CrypticCoin", "CRYPT": "CryptCoin", + "CRYPTOBULLION": "CryptoBullion", "CRYPTOE": "Cryptoenter", "CRYPTONITE": "Cryptonite", "CRYPTOPRO": "CryptoProfile", + "CRYSTALCLEAR": "Crystal Clear Token", "CSAC": "Credit Safe Application Chain", "CSAI": "Compound SAI", "CSC": "CasinoCoin", "CSEN": "Consensus", "CSH": "CashOut", - "CSM": "Consentium", + "CSM": "Crust Shadow", "CSMIC": "Cosmic", "CSNO": "BitDice", "CSNP": "CrowdSale Network", @@ -1698,7 +1845,9 @@ "CSQ": "cosquare", "CSR": "Cashera", "CSS": "CoinSwap Token", + "CSTC": "CryptosTribe", "CSTL": "Castle", + "CSTR": "CoreStarter", "CSUSHI": "cSUSHI", "CSWAP": "CardSwap", "CSX": "Coinstox", @@ -1710,28 +1859,31 @@ "CTF": "CyberTime Finance", "CTI": "ClinTex CTi", "CTIC": "Coinmatic", - "CTK": "CertiK", + "CTK": "Shentu", "CTKN": "Curaizon", "CTL": "Citadel", "CTLX": "Cash Telex", + "CTN": "Continuum Finance", "CTO": "Crypto", - "CTP": "Captain Planet", + "CTP": "Ctomorrow Platform", "CTPL": "Cultiplan", "CTPT": "Contents Protocol", "CTR": "Creator Platform", + "CTS": "Citrus", "CTSI": "Cartesi", "CTT": "Castweet", "CTW": "Citowise", "CTX": "Cryptex", "CTXC": "Cortex", "CTY": "Connecty", - "CUBE": "Somnium Space CUBEs", + "CUBE": "Cube Network", "CUBEAUTO": "Cube", "CUDOS": "Cudos", "CUE": "CUE Protocol", "CUEX": "Cuex", "CULT": "Cult DAO", "CUMMIES": "CumRocket", + "CUNI": "Compound Uni", "CURA": "Cura Network", "CURE": "Curecoin", "CURI": "Curium", @@ -1748,6 +1900,7 @@ "CV": "CarVertical", "CVA": "Crypto Village Accelerator", "CVAG": "Crypto Village Accelerator CVAG", + "CVAULT": "cVault.finance", "CVC": "Civic", "CVCC": "CryptoVerificationCoin", "CVCOIN": "Crypviser", @@ -1765,6 +1918,7 @@ "CW": "CardWallet", "CWAR": "Cryowar Token", "CWBTC": "Compound Wrapped BTC", + "CWD": "CROWD", "CWEB": "Coinweb", "CWEX": "Crypto Wine Exchange", "CWIS": "Crypto Wisdom Coin", @@ -1784,6 +1938,7 @@ "CXPAD": "CoinxPad", "CXT": "Coinonat", "CYBER": "CyberWay", + "CYBERC": "CyberCoin", "CYBERD": "Cyber Doge", "CYBR": "CYBR", "CYC": "Cyclone Protocol", @@ -1793,6 +1948,7 @@ "CYFI": "cYFI", "CYG": "Cygnus", "CYL": "Crystal Token", + "CYOP": "CyOp Protocol", "CYP": "CypherPunkCoin", "CYRS": "Cyrus Token", "CYS": "BlooCYS", @@ -1802,6 +1958,7 @@ "CZRX": "Compound 0x", "CZZ": "ClassZZ", "D": "Denarius", + "D11": "DeFi11", "D4RK": "DarkPayCoin", "DAB": "DABANKING", "DAC": "Davinci Coin", @@ -1810,6 +1967,7 @@ "DACC2": "DACC2", "DACH": "DACH Coin", "DACS": "Dacsee", + "DACXI": "Dacxi", "DAD": "DAD", "DADDYDOGE": "Daddy Doge", "DADI": "Edge", @@ -1826,6 +1984,7 @@ "DAL": "DAOLaunch", "DALI": "Dalichain", "DAM": "Datamine", + "DAMEX": "DAMEX", "DAMO": "Coinzen", "DAN": "Daneel", "DANA": "Ardana", @@ -1840,6 +1999,7 @@ "DAPPX": "dAppstore", "DAPS": "DAPS Coin", "DAR": "Mines of Dalarnia", + "DARA": "Immutable", "DARB": "Darb Token", "DARC": "Konstellation", "DARCRUS": "Darcrus", @@ -1869,24 +2029,29 @@ "DBC": "DeepBrain Chain", "DBCCOIN": "Datablockchain", "DBD": "Day By Day", + "DBEAR": "DBear Coin", "DBET": "Decent.bet", "DBIC": "DubaiCoin", "DBIX": "DubaiCoin", + "DBL": "Doubloon", "DBOX": "DefiBox", "DBR": "Düber", "DBTC": "DebitCoin", "DBTN": "Universa Native token", "DBUND": "DarkBundles", "DBUY": "Doont Buy", - "DBX.CUR": "DropBox", + "DBX": "DBX", "DBY": "Dobuy", "DBZ": "Diamond Boyz Coin", + "DC": "Dogechain", "DCASH": "Diabolo", "DCB": "Decubate", "DCC": "Distributed Credit Chain", "DCCT": "DocuChain", + "DCIP": "Decentralized Community Investment Protocol", "DCK": "DickCoin", "DCN": "Dentacoin", + "DCNT": "Decanect", "DCR": "Decred", "DCRE": "DeltaCredits", "DCS.": "deCLOUDs", @@ -1911,10 +2076,12 @@ "DEAL": "iDealCash", "DEB": "Debitum Token", "DEBASE": "Debase", + "DEBT": "The Debt Box", "DEC": "Decentr", "DEEP": "DeepCloud AI", "DEEPG": "Deep Gold", "DEEX": "DEEX", + "DEEZ": "DEEZ NUTS", "DEFI": "Defi", "DEFI5": "DEFI Top 5 Tokens Index", "DEFIL": "DeFIL", @@ -1923,13 +2090,15 @@ "DEFLCT": "Deflect", "DEFLY": "Deflyball", "DEFT": "DeFi Factory Token", + "DEFY": "DEFY", "DEG": "Degis", "DEGO": "Dego Finance", "DEGOV": "Degov", "DEHUB": "DeHub", "DEI": "Deimos", "DEK": "DekBox", - "DEL": "DelChain", + "DEL": "Decimal", + "DELCHAIN": "DelChain", "DELFI": "DeltaFi", "DELTA": "Delta Financial", "DELTAC": "DeltaChain", @@ -1968,6 +2137,7 @@ "DFBT": "DentalFix", "DFC": "DeFiScale", "DFD": "DefiDollar DAO", + "DFG": "Defigram", "DFGL": "DeFi Gold", "DFI": "DeFiChain", "DFIAT": "DeFiato", @@ -1995,10 +2165,12 @@ "DGMS": "Digigems", "DGN": "Diagon", "DGORE": "DogeGoreCoin", + "DGP": "DGPayment", "DGPT": "DigiPulse", "DGTX": "Digitex Token", "DGVC": "DegenVC", "DGX": "Digix Gold token", + "DHR": "DeHR Network", "DHS": "Dirham Crypto", "DHT": "dHedge DAO", "DHV": "DeHive", @@ -2010,25 +2182,29 @@ "DICEM": "DICE Money", "DICETRX": "TRONbetDice", "DID": "Didcoin", + "DIE": "Die Protocol", "DIEM": "Facebook Diem", "DIESEL": "Diesel", "DIFX": "Digital Financial Exchange", - "DIG": "Dignity", + "DIG": "DIEGO", "DIGG": "DIGG", "DIGIC": "DigiCube", "DIGIF": "DigiFel", "DIGITAL": "Digital Reserve Currency", + "DIGNITY": "Dignity", "DIGS": "Diggits", "DIKO": "Arkadiko", "DILI": "D Community", "DIM": "DIMCOIN", "DIME": "DimeCoin", + "DIMO": "DIMO", "DIN": "Dinero", "DINGER": "Dinger Token", "DINGO": "Dingocoin", "DINO": "DinoSwap", "DINU": "Dogey-Inu", "DIO": "Decimated", + "DIONE": "Dione", "DIP": "Etherisc", "DIS": "TosDis", "DISCOVERY": "DiscoveryIoT", @@ -2042,9 +2218,11 @@ "DKA": "dKargo", "DKC": "DarkKnightCoin", "DKD": "Dekado", + "DKEY": "DKEY Bank", "DKKT": "DKK Token", + "DKS": "DarkShield", "DLA": "Dolla", - "DLC": "DollarCoin", + "DLC": "Diamond Launch", "DLISK": "Dlisk", "DLO": "Delio", "DLPD": "DLP Duck Token", @@ -2079,6 +2257,7 @@ "DNTX": "DNAtix", "DNXC": "DinoX", "DNZ.BITCI": "Denizlispor Fan Token", + "DOBO": "DogeBonk", "DOC": "Dochain", "DOCC": "Doc Coin", "DOCK": "Dock.io", @@ -2089,16 +2268,21 @@ "DOG": "The Doge NFT", "DOGA": "Dogami", "DOGACOIN": "DogaCoin", + "DOGBOSS": "Dog Boss", "DOGDEFI": "DogDeFiCoin", "DOGE": "Dogecoin", + "DOGE20": "Doge 2.0", "DOGEBNB": "DogeBNB", "DOGEC": "DogeCash", + "DOGECEO": "Doge CEO", "DOGECOIN": "Buff Doge Coin", "DOGECOLA": "DogeCola", + "DOGECUBE": "DogeCube", "DOGED": "DogeCoinDark", "DOGEDAO": "DogeDao", "DOGEDASH": "Doge Dash", "DOGEGF": "DogeGF", + "DOGEMETA": "Dogemetaverse", "DOGESWAP": "Dogeswap Token (HECO)", "DOGETH": "EtherDoge", "DOGEX": "DogeHouse Capital", @@ -2110,14 +2294,16 @@ "DOGZ": "Dogz", "DOKI": "Doki Doki Finance", "DOLA": "Dola USD Stablecoin", + "DOLLARCOIN": "DollarCoin", "DOLZ": "DOLZ", "DOME": "Everdome", "DOMI": "Domi", - "DON": "Don-key", + "DON": "Donnie Finance", "DONATION": "DonationCoin", + "DONK": "Don-key", + "DONTBUYMEME": "Meme", "DOOH": "Bidooh", "DOOR": "DOOR", - "DOP": "Drops", "DOPE": "DopeCoin", "DOR": "Dorado", "DORA": "Dora Factory", @@ -2125,6 +2311,7 @@ "DOSE": "DOSE", "DOT": "Polkadot", "DOTC": "Dotcoin", + "DOTR": "Cydotori", "DOUGH": "PieDAO v2 (DOUGH)", "DOV": "DOVU", "DOWS": "Shadows", @@ -2142,6 +2329,7 @@ "DPY": "Delphy", "DRA": "DraculaCoin", "DRACO": "DT Token", + "DRACOO": "DracooMaster", "DRAGONMA": "Dragon Mainland Shards", "DRC": "DRC Mobility", "DRCT": "Ally Direct", @@ -2154,12 +2342,15 @@ "DRGN": "Dragonchain", "DRINK": "DrinkChain", "DRIP": "Drip Network", + "DRIV": "DRIVEZ", + "DRIVECRYPTO": "Drive Crypto", "DRKC": "DarkCash", "DRKT": "DarkTron", "DRM": "DoDreamChain", "DRM8": "Dream8Coin", "DRONE": "Drone Coin", "DROP": "Dropil", + "DROPS": "Drops", "DRP": "DCORP", "DRPU": "DRP Utility", "DRS": "Digital Rupees", @@ -2189,7 +2380,7 @@ "DTN": "Datareum", "DTO": "DotOracle", "DTOP": "DTOP Token", - "DTR": "Dynamic Trading Rights", + "DTR": "Dotori", "DTRC": "Datarius", "DTX": "DataBroker DAO", "DUB": "DubCoin", @@ -2207,15 +2398,17 @@ "DUO": "ParallelCoin", "DUOT": "DUO Network", "DUSK": "Dusk Network", + "DUST": "DUST Protocol", "DUX": "DuxCoin", "DV": "Dreamverse", "DVC": "DragonVein", "DVDX": "Derived", - "DVF": "DVF", + "DVF": "Rhino.fi", "DVG": "DAOventures", "DVI": "Dvision Network", "DVP": "Decentralized Vulnerability Platform", "DVPN": "Sentinel", + "DVRS": "DaoVerse", "DVS": "Davies", "DVT": "DeVault", "DVTC": "DivotyCoin", @@ -2237,11 +2430,13 @@ "DYC": "Dycoin", "DYDX": "dYdX", "DYN": "Dynamic", + "DYNAMICTRADING": "Dynamic Trading Rights", "DYNCOIN": "Dyncoin", "DYNMT": "Dynamite", "DYNO": "DYNO", - "DYP": "DeFi Yield Protocol", + "DYP": "Dypius", "DYT": "DoYourTip", + "DZAR": "Digital Rand", "DZCC": "DZCC", "DZI": "DeFinition", "Dow": "DowCoin", @@ -2251,9 +2446,11 @@ "EAC": "Education Assessment Cult", "EAGS": "EagsCoin", "EAI": "Edain", + "EARN": "EarnGuild", "EARTH": "Earth Token", "EARTHCOIN": "EarthCoin", "EASYF": "EasyFeedback", + "EAT": "EDGE Activity Token", "EAURIC": "Eauric", "EB3": "EB3coin", "EBASE": "EURBASE", @@ -2281,8 +2478,10 @@ "ECOC": "ECOcoin", "ECOCH": "ECOChain", "ECOFI": "EcoFi", + "ECOIN": "Ecoin", "ECOM": "Omnitude", "ECOREAL": "Ecoreal Estate", + "ECOX": "ECOx", "ECP": "ECP+ Technology", "ECR": "EcoVerse", "ECT": "SuperEdge", @@ -2320,12 +2519,14 @@ "EGGC": "EggCoin", "EGGP": "Eggplant Finance", "EGI": "eGame", - "EGLD": "Elrond", + "EGLD": "eGold", "EGO": "EGOcoin", "EGR": "Egoras Rights", "EGS": "EdgeSwap", "EGT": "Egretia", + "EGX": "Enegra", "EHASH": "EHash", + "EHIVE": "eHive", "EHRT": "Eight Hours Token", "EIFI": "EIFI FINANCE", "EJAC": "EJA Coin", @@ -2350,6 +2551,7 @@ "ELEN": "Everlens", "ELES": "Elements Estates", "ELF": "aelf", + "ELG": "EscoinToken", "ELI": "GoCrypto", "ELIC": "Elicoin", "ELITE": "EthereumLite", @@ -2360,16 +2562,19 @@ "ELON": "Dogelon Mars", "ELONCAT": "ELON CAT COIN", "ELONGD": "Elongate Deluxe", + "ELONGT": "Elon GOAT", "ELONONE": "AstroElon", "ELP": "Ellerium", - "ELS": "Elysium", - "ELT": "Electron", + "ELS": "Ethlas", + "ELT": "Element Black", "ELTC2": "eLTC", "ELTCOIN": "ELTCOIN", "ELU": "Elumia", "ELV": "Elvantis", + "ELVN": "11Minutes", "ELX": "Energy Ledger", "ELY": "Elysian", + "ELYSIUM": "Elysium", "EM": "Eminer", "EMANATE": "EMANATE", "EMAR": "EmaratCoin", @@ -2381,6 +2586,7 @@ "EMC2": "Einsteinium", "EMD": "Emerald", "EMIGR": "EmiratesGoldCoin", + "EML": "EML Protocol", "EMN.CUR": "Eastman Chemical", "EMON": "Ethermon", "EMOT": "Sentigraph.io", @@ -2394,6 +2600,7 @@ "EMV": "Ethereum Movie Venture", "EMX": "EMX", "ENC": "Encores Token", + "ENCD": "Encircled", "ENCN": "EndChain", "ENCRYPG": "EncrypGen", "ENCX": "Encrybit", @@ -2421,6 +2628,7 @@ "ENU": "Enumivo", "ENV": "ENVOY", "ENVIENTA": "Envienta", + "ENVION": "Envion", "ENX": "ENEX", "EOC": "EveryonesCoin", "EON": "Exscudo", @@ -2440,7 +2648,7 @@ "EPTT": "Evident Proof Transaction Token", "EPX": "Ellipsis X", "EPY": "Empyrean", - "EQ": "EQUI", + "EQ": "Equilibrium", "EQC": "Ethereum Qchain Token", "EQL": "EQUAL", "EQM": "Equilibrium Coin", @@ -2448,30 +2656,32 @@ "EQT": "EquiTrader", "EQUAD": "Quadrant Protocol", "EQUAL": "EqualCoin", - "EQUI": "EQUI Token", + "EQUI": "EQUI", + "EQUITOKEN": "EQUI Token", "EQX": "EQIFi", "EQZ": "Equalizer", "ERA": "ETHA", "ERA7": "Era Token", "ERB": "ERBCoin", "ERC": "EuropeCoin", - "ERC20": "Index ERC20", + "ERC20": "ERC20", "ERD": "Elrond", "ERE": "Erecoin", "EREAL": "eREAL", "ERG": "Ergo", "ERIS": "Eristica", "ERK": "Eureka Coin", - "ERN": "Ethernity Chain", "ERO": "Eroscoin", "ERON": "ERON", "EROTICA": "Erotica", - "EROWAN": "Sifchain", - "ERR": "ErrorCoin", + "ERR": "Coinerr", "ERROR": "484 Fund", + "ERRORCOIN": "ErrorCoin", "ERSDL": "UnFederalReserve", "ERT": "Esports.com", + "ERTH": "Erth Point", "ERTHA": "Ertha", + "ERW": "ZeLoop Eco Reward", "ERY": "Eryllium", "ES": "Era Swap Token", "ESBC": "ESBC", @@ -2481,6 +2691,7 @@ "ESGC": "ESG Chain", "ESH": "Switch", "ESN": "Ethersocial", + "ESNC": "Galaxy Arena Metaverse", "ESP": "Espers", "ESS": "Essentia", "EST": "ESports Chain", @@ -2503,11 +2714,15 @@ "ETH2": "Eth 2.0 Staking by Pool-X", "ETH2X-FLI": "ETH 2x Flexible Leverage Index", "ETHA": "ETHA Lend", + "ETHAX": "ETHAX", "ETHB": "EtherBTC", "ETHBN": "EtherBone", "ETHD": "Ethereum Dark", "ETHER": "Etherparty", "ETHERDELTA": "EtherDelta", + "ETHERKING": "Ether Kingdoms Token", + "ETHERNITY": "Ethernity Chain", + "ETHF": "EthereumFair", "ETHIX": "EthicHub", "ETHM": "Ethereum Meta", "ETHO": "The Etho Protocol", @@ -2515,11 +2730,15 @@ "ETHP": "ETHPlus", "ETHPAD": "ETHPad", "ETHPLO": "ETHplode", + "ETHPOS": "ETHPoS", + "ETHPOW": "ETHPoW", "ETHPR": "Ethereum Premium", "ETHPY": "Etherpay", "ETHS": "EthereumScrypt", "ETHSHIB": "Eth Shiba", "ETHV": "Ethverse", + "ETHW": "Ethereum PoW", + "ETHX": "Stader ETHx", "ETHY": "Ethereum Yield", "ETI": "EtherInc", "ETK": "Energi Token", @@ -2533,7 +2752,7 @@ "ETR": "Electric Token", "ETRNT": "Eternal Trusts", "ETS": "ETH Share", - "ETSC": "​Ether star blockchain", + "ETSC": "Ether star blockchain", "ETT": "EncryptoTel", "ETY": "Ethereum Cloud", "ETZ": "EtherZero", @@ -2545,6 +2764,7 @@ "EUNO": "EUNO", "EURN": "NOKU EUR", "EUROC": "Euro Coin", + "EUROE": "EUROe Stablecoin", "EURS": "STASIS EURS", "EURT": "Euro Tether", "EURU": "Upper Euro", @@ -2554,16 +2774,19 @@ "EVAULT": "EthereumVault", "EVC": "Eventchain", "EVCC": "Eco Value Coin", + "EVCOIN": "EverestCoin", "EVE": "Devery", "EVED": "Evedo", "EVENC": "EvenCoin", "EVENT": "Event Token", "EVER": "Everscale", + "EVEREST": "Everest", "EVERGREEN": "EverGreenCoin", "EVERLIFE": "EverLife.AI", "EVIL": "EvilCoin", "EVMOS": "Evmos", - "EVN": "Envion", + "EVN": "Evn Token", + "EVO": "EvoVerses", "EVOL": "EVOL NETWORK", "EVOS": "EVOS", "EVR": "Everus", @@ -2576,9 +2799,11 @@ "EVZ": "Electric Vehicle Zone", "EWC": "Erugo World Coin", "EWT": "Energy Web Token", + "EWTT": "Ecowatt", "EXB": "ExaByte (EXB)", "EXC": "Eximchain", "EXCC": "ExchangeCoin", + "EXCHANGEN": "ExchangeN", "EXCL": "Exclusive Coin", "EXE": "ExeCoin", "EXFI": "Flare Finance", @@ -2587,7 +2812,7 @@ "EXLT": "ExtraLovers", "EXM": "EXMO Coin", "EXMR": "EXMR FDN", - "EXN": "ExchangeN", + "EXN": "Exeno", "EXO": "Exosis", "EXP": "Expanse", "EXRD": "Radix", @@ -2596,8 +2821,9 @@ "EXTP": "TradePlace", "EXY": "Experty", "EXZO": "ExzoCoin 2.0", - "EYE": "EYE Token", + "EYE": "MEDIA EYE", "EYES": "Eyes Protocol", + "EYETOKEN": "EYE Token", "EZ": "EasyFi V2", "EZC": "EZCoin", "EZM": "EZMarket", @@ -2610,27 +2836,34 @@ "FAB": "FABRK Token", "FABA": "Faba Invest", "FABRIC": "MetaFabric", - "FACE": "Faceter", + "FACE": "FaceDAO", + "FACETER": "Faceter", + "FACTOM": "Factom", "FADO": "FADO Go", "FAG": "PoorFag", "FAI": "Fairum", "FAIR": "FairCoin", "FAIRC": "Faireum Token", "FAIRG": "FairGame", + "FAKE": "FAKE COIN", + "FAKT": "Medifakt", "FALCONS": "Falcon Swaps", - "FAME": "Fame", + "FAME": "Fame MMA", "FAMEC": "FameCoin", "FAMILY": "The Bitcoin Family", "FAN": "Fanadise", "FAN360": "Fan360", + "FANC": "fanC", + "FANV": "FanVerse", "FANZ": "FanChain", "FAR": "Farmland Protocol", "FARA": "FaraLand", "FARM": "Harvest Finance", "FARMA": "FarmaTrust", "FARMC": "FARM Coin", - "FAST": "Fastswap", + "FAST": "PodFast", "FASTMOON": "FastMoon", + "FASTSWAP": "Fastswap", "FAT": "Fatcoin", "FATCAKE": "FatCake", "FAYRE": "Fayre", @@ -2639,9 +2872,10 @@ "FBB": "FilmBusinessBuster", "FBN": "Five balance", "FBNB": "ForeverBNB", - "FBX": "ForthBox", + "FBX": "Finance Blocks", "FC": "Facecoin", "FC2": "Fuel2Coin", + "FCD": "FreshCut Diamond", "FCF": "French Connection Finance", "FCH": "Freecash", "FCL": "Fractal", @@ -2650,18 +2884,27 @@ "FCON": "SpaceFalcon", "FCQ": "Fortem Capital", "FCS": "CryptoFocus", - "FCT": "Factom", + "FCT": "FirmaChain", "FCTC": "FaucetCoin", + "FDC": "Fidance", + "FDLS": "FIDELIS", + "FDM": "Fandom", "FDO": "Firdaos", "FDR": "French Digital Reserve", - "FDT": "FIAT DAO Token", + "FDT": "Frutti Dino", + "FDUSD": "First Digital USD", "FDX": "fidentiaX", "FDZ": "Friendz", "FEAR": "Fear", "FEED": "Feeder Finance", + "FEENIXV2": "ProjectFeenixv2", "FEG": "FEG Token", "FEI": "Fei Protocol", "FEN": "First Ever NFT", + "FENOMY": "Fenomy", + "FER": "Ferro", + "FERC": "FairERC20", + "FERMA": "Ferma", "FESS": "Fesschain", "FET": "Fetch.AI", "FEVR": "RealFevr", @@ -2676,10 +2919,12 @@ "FFN": "Fairy Forest", "FFUEL": "getFIFO", "FFYI": "Fiscus FYI", + "FGD": "Freedom God DAO", "FGZ": "Free Game Zone", "FIBRE": "FIBRE", "FIC": "Filecash", "FIDA": "Bonfida", + "FIDLE": "Fidlecoin", "FIELD": "Fieldcoin", "FIFTY": "FIFTYONEFIFTY", "FIG": "FlowCom", @@ -2695,7 +2940,7 @@ "FIND": "FindCoin", "FINE": "Refinable", "FINOM": "Finom FIN Token", - "FINU": "Fire Inu", + "FINU": "Formula Inu", "FIO": "FIO Protocol", "FIRE": "FireCoin", "FIRO": "Firo", @@ -2705,8 +2950,11 @@ "FIT": "Financial Investment Token", "FITC": "Fitcoin", "FITFI": "Step App", + "FIU": "beFITTER", "FIWA": "Defi Warrior", + "FJB": "Freedom. Jobs. Business.", "FJC": "FujiCoin", + "FJT": "Fuji FJT", "FK": "FK Coin", "FKX": "FortKnoxster", "FL": "Freeliquid", @@ -2717,12 +2965,13 @@ "FLASH": "Flashstake", "FLASHC": "FLASH coin", "FLC": "FlowChainCoin", - "FLD": "FLUID", + "FLD": "FluidAI", "FLDC": "Folding Coin", "FLDT": "FairyLand", "FLETA": "FLETA", "FLEX": "FLEX Coin", "FLG": "Folgory Coin", + "FLIC": "Skaflic", "FLIK": "FLiK", "FLIP": "BitFlip", "FLIXX": "Flixxo", @@ -2732,12 +2981,13 @@ "FLO": "Flo", "FLOAT": "Float Protocol", "FLOKI": "Floki Inu", + "FLOOR": "FloorDAO", "FLOT": "FireLotto", "FLOVM": "FLOV MARKET", - "FLOW": "Flow - Dapper Labs", + "FLOW": "Flow", "FLOWP": "Flow Protocol", "FLP": "Gameflip", - "FLR": "Spark", + "FLR": "Flare", "FLRS": "Flourish Coin", "FLS": "Flits", "FLT": "FlutterCoin", @@ -2749,7 +2999,7 @@ "FLX": "Reflexer Ungovernance Token", "FLY": "Franklin", "FLYCOIN": "FlyCoin", - "FMCT": "FirmaChain", + "FLZ": "Fellaz", "FME": "FME", "FMEX": "FMex", "FMG": "FM Gallery", @@ -2759,8 +3009,9 @@ "FNB": "FNB protocol", "FNC": "Fancy Games", "FNCY": "Fancy That", - "FND": "FundRequest", + "FND": "Rare FND", "FNDZ": "FNDZ Token", + "FNF": "FunFi", "FNK": "FunKeyPay", "FNL": "Finlocale", "FNO": "Fonero", @@ -2772,6 +3023,7 @@ "FOC": "TheForce Trade", "FOCV": "FOCV", "FODL": "Fodl Finance", + "FOF": "Future Of Fintech", "FOGE": "Fat Doge", "FOIN": "Foin", "FOL": "Folder Protocol", @@ -2796,14 +3048,18 @@ "FORM": "Formation FI", "FORT": "Forta", "FORTH": "Ampleforth Governance Token", + "FORTHB": "ForthBox", + "FORTUNA": "Fortuna", "FORTUNE": "Fortune", - "FOTA": "Fortuna", + "FOTA": "Fight Of The Ages", "FOTO": "Unique Photo", + "FOUNTAIN": "Fountain", "FOUR": "4THPILLAR TECHNOLOGIES", "FOX": "ShapeShift FOX Token", "FOXD": "Foxdcoin", "FOXF": "Fox Finance", "FOXT": "Fox Trading", + "FOXV2": "FoxFinanceV2", "FPC": "Futurepia", "FPIS": "Frax Price Index Share", "FR": "Freedom Reserve", @@ -2820,21 +3076,25 @@ "FRED": "FREDEnergy", "FREE": "FREE coin", "FREEROSS": "FreeRossDAO", + "FREL": "Freela", "FREN": "FREN", "FRIN": "Fringe Finance", "FRK": "Franko", "FRM": "Ferrum Network", "FRN": "Francs", + "FRNT": "Final Frontier", "FROG": "FrogSwap", "FROGE": "Froge Finance", "FRONT": "Frontier", "FROYO": "Froyo Games", - "FRR": "Frontrow ", + "FRP": "Fame Reward Plus", + "FRR": "Frontrow", "FRSP": "Forkspot", "FRST": "FirstCoin", "FRTS": "Fruits", "FRV": "Fitrova", "FRWC": "Frankywillcoin", + "FRZSS": "Frz Solar System", "FSBT": "Forty Seven Bank", "FSC": "FriendshipCoin", "FSHN": "Fashion Coin", @@ -2842,13 +3102,14 @@ "FST": "Futureswap", "FSTC": "FastCoin", "FSW": "Falconswap", + "FT": "Fracton Protocol", "FTB": "Fit&Beat", "FTC": "FeatherCoin", "FTG": "fantomGO", "FTI": "FansTime", "FTK": "FToken", "FTM": "Fantom", - "FTN": "Fountain", + "FTN": "Fasttoken", "FTO": "FuturoCoin", "FTP": "FuturePoints", "FTR": "FactR", @@ -2864,20 +3125,25 @@ "FUEL": "Jetfuel Finance", "FUJIN": "Fujinto", "FUKU": "Furukuru", + "FUMO": "Alien Milady Fumo", "FUN": "FUN Token", "FUNC": "FunCoin", "FUND": "Unification", "FUNDC": "FUNDChains", "FUNDP": "Fund Platform", + "FUNDREQUEST": "FundRequest", "FUNDYOUR": "FundYourselfNow", "FUNDZ": "FundFantasy", "FUNK": "Cypherfunks Coin", + "FUR": "Furio", + "FURU": "Furucombo", "FURY": "Engines of Fury", "FUS": "Fus", "FUSE": "Fuse Network Token", "FUTC": "FutCoin", "FUTURE": "FutureCoin", "FUZZ": "Fuzzballs", + "FVT": "Finance Vote", "FWB": "Friends With Benefits Pro", "FWC": "Qatar 2022", "FWT": "Freeway Token", @@ -2888,10 +3154,13 @@ "FXP": "FXPay", "FXS": "Frax Share", "FXT": "FuzeX", + "FXY": "Floxypay", + "FYN": "Affyn", "FYP": "FlypMe", "FYZ": "Fyooz", "FYZNFT": "Fyooz NFT", "G": "GRN Grid", + "G1X": "GoldFinX", "G50": "G50", "G999": "G999", "GAD": "Green App Development", @@ -2902,9 +3171,10 @@ "GAIN": "Gainfy", "GAINS": "Gains", "GAKH": "GAKHcoin", - "GAL": "Project Galaxy", + "GAL": "Galxe", "GALA": "Gala", "GALATA": "Galatasaray Fan Token", + "GALAX": "Galaxy Finance", "GALAXY": "GalaxyCoin", "GALEON": "Galeon", "GALI": "Galilel", @@ -2918,6 +3188,7 @@ "GAMEIN": "Game Infinity", "GAMESTARS": "Game Stars", "GAMEX": "GameX", + "GAMI": "GAMI World", "GAMINGSHIBA": "GamingShiba", "GAMMA": "Gamma Strategies", "GAN": "Galactic Arena: The NFTverse", @@ -2937,6 +3208,8 @@ "GASTRO": "GastroCoin", "GAT": "GATCOIN", "GATE": "GATENet", + "GATEWAY": "Gateway Protocol", + "GAYPEPE": "Gay Pepe", "GAZE": "GazeTV", "GB": "GoldBlocks", "GBA": "Geeba", @@ -2948,6 +3221,7 @@ "GBIT": "GravityBit", "GBK": "Goldblock", "GBO": "Gabro.io", + "GBPT": "poundtoken", "GBRC": "GBR Coin", "GBT": "GameBetCoin", "GBTC": "GigTricks", @@ -2955,14 +3229,17 @@ "GBXT": "Globitex Token", "GBYTE": "Obyte", "GC": "Gric Coin", + "GCAKE": "Pancake Games", "GCC": "GuccioneCoin", "GCN": "gCn Coin", "GCOIN": "Galaxy Fight Club", "GCR": "Global Currency Reserve", "GDAO": "Governor DAO", - "GDC": "GrandCoin", + "GDC": "Global Digital Content", "GDDY": "Giddy", + "GDE": "Golden Eagle", "GDL": "GodlyCoin", + "GDO": "GroupDao", "GDOGE": "Golden Doge", "GDR": "Guider.Travel", "GDS": "Grat Deal Coin", @@ -2983,6 +3260,7 @@ "GEMZ": "Gemz Social", "GEN": "DAOstack", "GENE": "Genopets", + "GENIE": "The Genie", "GENIX": "Genix", "GENS": "Genshiro", "GENSTAKE": "Genstake", @@ -3008,11 +3286,12 @@ "GFCS": "Global Funeral Care", "GFI": "Goldfinch", "GFN": "Graphene", - "GFT": "Giftcoin", + "GFT": "Gifto", "GFUN": "GoldFund", "GFX": "GamyFi Token", "GGC": "Global Game Coin", "GGG": "Good Games Guild", + "GGM": "Monster Galaxy", "GGOLD": "GramGold Coin", "GGP": "GGPro", "GGR": "GGRocket", @@ -3020,17 +3299,22 @@ "GHC": "Galaxy Heroes Coin", "GHCOLD": "Galaxy Heroes Coin", "GHD": "Giftedhands", + "GHNY": "Grizzly Honey", + "GHO": "GHO", "GHOST": "GhostbyMcAfee", "GHOSTCOIN": "GhostCoin", "GHOSTM": "GhostMarket", "GHOUL": "Ghoul Coin", "GHST": "Aavegotchi", + "GHT": "Global Human Trust", "GHX": "GamerCoin", "GIB": "Bible Coin", "GIC": "Giant", "GIF": "Gift Token", "GIFT": "GiftNet", "GIG": "GigaCoin", + "GIGA": "GigaSwap", + "GIGX": "GigXCoin", "GIM": "Gimli", "GIMMER": "Gimmer", "GIN": "GINcoin", @@ -3038,7 +3322,6 @@ "GIO": "Graviocoin", "GIOT": "Giotto Coin", "GIVE": "GiveCoin", - "GIX": "GoldFinX", "GIZ": "GIZMOcoin", "GJC": "Global Jobcoin", "GKI": "GKi", @@ -3048,6 +3331,7 @@ "GLD": "Goldario", "GLDR": "Golder Coin", "GLDS": "Gdigit", + "GLDX": "Goldex", "GLDY": "Buzzshow", "GLEEC": "Gleec Coin", "GLM": "Golem Network Token", @@ -3070,11 +3354,12 @@ "GMCN": "GambleCoin", "GMCOIN": "GMCoin", "GMEE": "GAMEE", + "GMEX": "Game Coin", "GMI": "GamiFi", "GML": "GameLeagueCoin", "GMM": "Gamium", "GMMT": "Green Mining Movement Token", - "GMPD": "GamesPad ", + "GMPD": "GamesPad", "GMR": "GAMER", "GMS": "Gemstra", "GMT": "STEPN", @@ -3109,7 +3394,9 @@ "GOFX": "GooseFX", "GOG": "Guild of Guardians", "GOGO": "GOGO Finance", + "GOKU": "Goku", "GOL": "GogolCoin", + "GOLC": "GOLCOIN", "GOLD": "CyberDragon Gold", "GOLDENG": "Golden Goose", "GOLDMIN": "GoldMiner", @@ -3139,6 +3426,7 @@ "GOVT": "The Government Network", "GOZ": "Göztepe S.K. Fan Token", "GP": "Wizards And Dragons", + "GPBP": "Genius Playboy Billionaire Philanthropist", "GPKR": "Gold Poker", "GPL": "Gold Pressed Latinum", "GPPT": "Pluto Project Coin", @@ -3148,6 +3436,8 @@ "GPX": "GPEX", "GQ": "Galactic Quadrant", "GR": "GROM", + "GRAIL": "Camelot Token", + "GRANDCOIN": "GrandCoin", "GRAV": "Graviton", "GRAVITYF": "Gravity Finance", "GRAYLL": "GRAYLL", @@ -3163,6 +3453,7 @@ "GRFT": "Graft Blockchain", "GRID": "Grid+", "GRIDCOIN": "GridCoin", + "GRIDZ": "GridZone.io", "GRIMEX": "SpaceGrime", "GRIN": "Grin", "GRLC": "Garlicoin", @@ -3177,6 +3468,7 @@ "GRS": "Groestlcoin", "GRT": "The Graph", "GRUMPY": "Grumpy Finance", + "GRV": "Grove", "GRVE": "Grave", "GRW": "GrowthCoin", "GRWI": "Growers International", @@ -3189,8 +3481,11 @@ "GSM": "GSM Coin", "GSPI": "GSPI", "GSR": "GeyserCoin", - "GST": "Green Satoshi Token", + "GST": "Green Satoshi Token (SOL)", + "GSTBSC": "Green Satoshi Token (BSC)", "GSTC": "GSTCOIN", + "GSTETH": "Green Satoshi Token (ETH)", + "GSTS": "Gunstar Metaverse", "GSTT": "GSTT", "GSWAP": "Gameswap", "GSX": "Gold Secured Currency", @@ -3203,7 +3498,6 @@ "GTIB": "Global Trust Coin", "GTK": "GoToken", "GTN": "GlitzKoin", - "GTO": "GIFTO", "GTON": "GTON Capital", "GTR": "Gturbo", "GTSE": "Global Tourism Sharing Ecology", @@ -3211,6 +3505,7 @@ "GUAR": "Guarium", "GUE": "GuerillaCoin", "GUESS": "Peerguess", + "GUILD": "BlockchainSpace", "GULF": "GulfCoin", "GUM": "Gourmet Galaxy", "GUN": "GunCoin", @@ -3219,10 +3514,11 @@ "GUSD": "Gemini Dollar", "GUSDT": "Global Utility Smart Digital Token", "GVE": "Globalvillage Ecosystem", - "GVR": "Grove", + "GVR": "Grove [OLD]", "GVT": "Genesis Vision", "GWT": "Galaxy War", "GX": "GameX", + "GXA": "Galaxia", "GXC": "GXChain", "GXT": "Gem Exchange And Trading", "GYEN": "GYEN", @@ -3234,7 +3530,9 @@ "GZIL": "governance ZIL", "GZLR": "Guzzler", "GZONE": "GameZone", + "GZX": "GreenZoneX", "H2O": "H2O Dao", + "H2ON": "H2O Securities", "H3O": "Hydrominer", "HAC": "Hackspace Capital", "HACHIKO": "Hachiko Inu Token", @@ -3245,14 +3543,18 @@ "HALF": "0.5X Long Bitcoin Token", "HALFSHIT": "0.5X Long Shitcoin Index Token", "HALLO": "Halloween Coin", - "HALO": "Halo Platform", + "HALO": "Halo Coin", + "HALOPLATFORM": "Halo Platform", + "HAM": "Hamster", "HAMS": "HamsterCoin", "HANA": "Hanacoin", "HAND": "ShowHand", + "HANDY": "Handy", "HANU": "Hanu Yokia", + "HAO": "HistoryDAO", "HAPI": "HAPI", "HARAMBE": "Harambe", - "HARD": "Hard Protocol", + "HARD": "Kava Lend", "HART": "HARA", "HASH": "Provenance Blockchain", "HASHT": "HASH Token", @@ -3270,6 +3572,7 @@ "HBE": "healthbank", "HBN": "HoboNickels", "HBO": "Hash Bridge Oracle", + "HBOT": "Hummingbot", "HBRS": "HubrisOne", "HBT": "Habitat", "HBTC": "Huobi BTC", @@ -3284,18 +3587,22 @@ "HDG": "Hedge Token", "HDRN": "Hedron", "HDV": "Hydraverse", + "HDX": "HydraDX", "HE": "Heroes & Empires", "HEAL": "Etheal", "HEART": "Humans", "HEARTBOUT": "HeartBout Pay", + "HEARTR": "Heart Rate", "HEAT": "Heat Ledger", "HEC": "Hector Finance", + "HECTA": "Hectagon", "HEDG": "HedgeTrade", "HEDGE": "Hedgecoin", "HEEL": "HeelCoin", "HEGIC": "Hegic", "HELIOS": "Mission Helios", "HELL": "HELL COIN", + "HELLO": "HELLO", "HELMET": "Helmet Insure", "HELPS": "HelpSeed", "HEP": "Health Potion", @@ -3313,7 +3620,7 @@ "HEZ": "Hermez Network Token", "HFI": "Holder Finance", "HFIL": "Huobi Fil", - "HFT": "Hirefreehands", + "HFT": "Hashflow", "HGET": "Hedget", "HGO": "HireGo", "HGOLD": "HollyGold", @@ -3322,15 +3629,19 @@ "HH": "Holyheld", "HHEM": "Healthureum", "HI": "hi Dollar", + "HIBAYC": "hiBAYC", "HIBIKI": "Hibiki Finance", "HIBS": "Hiblocks", "HID": "Hypersign Identity", "HIDU": "H-Education World", + "HIENS4": "hiENS4", + "HIFI": "Hifi Finance", "HIGH": "Highstreet", "HIH": "HiHealth", "HILL": "President Clinton", "HINA": "Hina Inu", "HINT": "Hintchain", + "HIPPO": "HIPPO", "HIRE": "HireMatch", "HIT": "HitChain", "HITBTC": "HitBTC Token", @@ -3364,8 +3675,10 @@ "HNS": "Handshake", "HNST": "Honest", "HNT": "Helium", + "HNTR": "Hunter", "HNY": "Honey", "HNZO": "Hanzo Inu", + "HOBO": "HOBO THE BEAR", "HOD": "HoDooi.com", "HODL": "HOdlcoin", "HOGE": "Hoge Finance", @@ -3375,21 +3688,25 @@ "HOMI": "HOMIHELP", "HONEY": "Honey", "HONOR": "HonorLand", + "HOOK": "Hooked Protocol", "HOP": "Hop Protocol", "HOPR": "HOPR", "HORD": "Hord", "HORSE": "Ethorse", "HORUS": "HorusPay", + "HOSKY": "Hosky", "HOT": "Holo", "HOTCROSS": "Hot Cross", "HOTN": "HotNow", "HOTT": "HOT Token", + "HOWL": "Coyote", "HP": "HeroPark", "HPAD": "HarmonyPad", - "HPAY": "HadePay", + "HPAY": "HedgePay", "HPB": "High Performance Blockchain", "HPC": "HappyCoin", "HPL": "HappyLand (HPL)", + "HPN": "HyperonChain", "HPT": "Huobi Pool Token", "HPX": "HUPAYX", "HQT": "HyperQuant", @@ -3399,19 +3716,24 @@ "HRD": "Hoard", "HRDG": "HRDGCOIN", "HRO": "HEROIC.com", + "HRTS": "YellowHeart Protocol", "HSC": "HashCoin", + "HSF": "Hillstone Finance", "HSN": "Hyper Speed Network", "HSP": "Horse Power", "HSS": "Hashshare", "HST": "Decision Token", "HT": "Huobi Token", "HTA": "Historia", + "HTB": "Hotbit", "HTC": "Hitcoin", "HTDF": "Orient Walt", "HTER": "Biogen", "HTML": "HTML Coin", "HTN": "Heart Number", + "HTO": "Heavenland HTO", "HTR": "Hathor", + "HTT": "Hello Art", "HUB": "Hub Token", "HUBII": "Hubii Network", "HUC": "HunterCoin", @@ -3448,13 +3770,15 @@ "HYN": "Hyperion", "HYP": "HyperStake", "HYPE": "Hype", - "HYPER": "HyperCoin", + "HYPER": "HyperChainX", + "HYPERCOIN": "HyperCoin", "HYPERD": "HyperDAO", "HYPERS": "HyperSpace", "HYS": "Heiss Shares", "HYT": "HoryouToken", "HYVE": "Hyve", "HZ": "Horizon", + "HZM": "HZM Coin", "HZN": "Horizon Protocol", "HZT": "HazMatCoin", "I0C": "I0coin", @@ -3464,6 +3788,7 @@ "IAM": "IAME Identity", "IB": "Iron Bank", "IBANK": "iBankCoin", + "IBAT": "Battle Infinity", "IBETH": "Interest Bearing ETH", "IBEUR": "Iron Bank EURO", "IBFR": "iBuffer Token", @@ -3477,22 +3802,24 @@ "ICASH": "ICASH", "ICB": "IceBergCoin", "ICC": "Insta Cash Coin", - "ICE": "Popsicle Finance", + "ICE": "Decentral Games ICE", "ICH": "IdeaChain", "ICHI": "ICHI", "ICHN": "i-chain", "ICHX": "IceChain", "ICN": "Iconomi", "ICOB": "Icobid", + "ICOM": "iCommunity", "ICON": "Iconic", "ICONS": "SportsIcon", "ICOO": "ICO OpenLedger", "ICOS": "ICOBox", "ICP": "Internet Computer", + "ICSA": "Icosa", "ICST": "ICST", "ICT": "Intrachain", "ICX": "ICON Project", - "ID": "Everest", + "ID": "SPACE", "IDAC": "IDAC", "IDAP": "IDAP", "IDC": "IdealCoin", @@ -3507,6 +3834,7 @@ "IDLE": "IDLE", "IDM": "IDM", "IDNA": "Idena", + "IDO": "Idexo", "IDORU": "Vip2Fan", "IDRT": "Rupiah Token", "IDT": "InvestDigital", @@ -3514,6 +3842,7 @@ "IDV": "Idavoll DAO", "IDX": "Index Chain", "IDXM": "IDEX Membership", + "IDXS": "In-Dex Sale", "IEC": "IvugeoEvolutionCoin", "IETH": "iEthereum", "IFC": "Infinite Coin", @@ -3528,18 +3857,22 @@ "IGI": "Igi", "IGNIS": "Ignis", "IGTT": "IGT", + "IGU": "IguVerse", "IHC": "Inflation Hedging Coin", "IHF": "Invictus Hyperion Fund", "IHT": "I-House Token", "IIC": "Intelligent Investment Chain", "IJC": "IjasCoin", + "IJZ": "iinjaz", "ILA": "Infinite Launch", "ILC": "ILCOIN", "ILCT": "ILCoin Token", "ILK": "Inlock", "ILT": "iOlite", "ILV": "Illuvium", + "IMBTC": "The Tokenized Bitcoin", "IMC": "i Money Crypto", + "IME": "Imperium Empires", "IMG": "ImageCoin", "IMGZ": "Imigize", "IMI": "Influencer", @@ -3549,12 +3882,13 @@ "IMPACTXP": "ImpactXP", "IMPCH": "Impeach", "IMPCN": "Brain Space", + "IMPER": "Impermax", "IMPS": "Impulse Coin", - "IMPT": "Ether Kingdoms Token", + "IMPT": "IMPT", "IMPULSE": "IMPULSE by FDR", "IMS": "Independent Money System", "IMST": "Imsmart", - "IMT": "MoneyToken", + "IMT": "IMOV", "IMU": "imusify", "IMVR": "ImmVRse", "IMX": "Immutable X", @@ -3568,8 +3902,11 @@ "IND": "Indorse", "INDEX": "Index Cooperative", "INDI": "IndiGG", + "INDIA": "Indiacoin", "INDICOIN": "IndiCoin", "INE": "IntelliShare", + "INERY": "Inery", + "INES": "Inescoin", "INET": "Insure Network", "INF": "Infinium", "INFC": "Influence Chain", @@ -3580,8 +3917,8 @@ "INFT": "Infinito", "INFTT": "iNFT Token", "INFX": "Influxcoin", - "ING": "Iungo", - "INJ": "Injective Protocol", + "ING": "Infinity Games", + "INJ": "Injective", "INK": "Ink", "INN": "Innova", "INNBC": "Innovative Bioresearch Coin", @@ -3592,12 +3929,14 @@ "INSANE": "InsaneCoin", "INSN": "Insane Coin", "INST": "Instadapp", + "INSTAMINE": "Instamine Nuggets", "INSTAR": "Insights Network", "INSUR": "InsurAce", "INSURC": "InsurChain Coin", "INT": "Internet Node token", "INTER": "Inter Milan Fan Token", "INTO": "Influ Token", + "INTR": "Interlay", "INU": "INU Token", "INUYASHA": "Inuyasha", "INV": "Inverse Finance", @@ -3607,10 +3946,10 @@ "INVIC": "Invictus", "INVOX": "Invox Finance", "INVX": "Investx", - "INX": "INX Token", + "INX": "Insight Protocol", "INXM": "InMax", - "INXP": "Insight Protocol", "INXT": "Internxt", + "INXTOKEN": "INX Token", "IOC": "IOCoin", "IOEN": "Internet of Energy Network", "IOEX": "ioeX", @@ -3628,6 +3967,7 @@ "IOV": "Starname", "IOVT": "IOV", "IOWN": "iOWN Token", + "IP3": "Cripco", "IPAD": "Infinity Pad", "IPC": "IPChain", "IPDN": "IPDnetwork", @@ -3635,8 +3975,9 @@ "IPSX": "IP Exchange", "IPT": "Crypt-ON", "IPUX": "IPUX", - "IPX": "Tachyon Protocol", - "IQ": "Everipedia", + "IPV": "IPVERSE", + "IPX": "InpulseX", + "IQ": "IQ", "IQC": "IQ.cash", "IQN": "IQeon", "IQQ": "Iqoniq", @@ -3649,17 +3990,23 @@ "ISDT": "ISTARDUST", "ISG": "ISG", "ISH": "Interstellar Holdings", + "ISHND": "StrongHands Finance", "ISIKC": "Isiklar Coin", "ISKY": "Infinity Skies", "ISL": "IslaCoin", + "ISLAMI": "ISLAMICOIN", "ISP": "Ispolink", "ISR": "Insureum", "ISRG.CUR": "Intuitive Surgical, Inc.", "ISTEP": "iSTEP", - "ITA": "Italocoin", + "ITA": "Italian National Football Team Fan Token", + "ITALOCOIN": "Italocoin", "ITAM": "ITAM Games", + "ITAMCUBE": "CUBE", "ITC": "IoT Chain", + "ITEM": "ITEMVERSE", "ITF": "Intelligent Trading", + "ITG": "iTrust Governance", "ITGR": "Integral", "ITL": "Italian Lira", "ITM": "intimate.io", @@ -3667,6 +4014,9 @@ "ITR": "INTRO", "ITU": "iTrue", "ITZ": "Interzone", + "IUNGO": "Iungo", + "IUX": "GeniuX", + "IVAR": "Ivar Coin", "IVC": "Investy Coin", "IVN": "IVN Security", "IVY": "IvyKoin", @@ -3675,12 +4025,14 @@ "IWT": "IwToken", "IX": "X-Block", "IXC": "IXcoin", + "IXP": "IMPACTXPRIME", "IXS": "IX Swap", "IXT": "iXledger", "IZA": "Inzura", "IZE": "IZE", "IZER": "IZEROIUM", - "IZI": "IZIChain", + "IZI": "Izumi Finance", + "IZICHAIN": "IZIChain", "IZX": "IZX", "IZZY": "Izzy", "InBit": "PrepayWay", @@ -3688,11 +4040,13 @@ "J8T": "JET8", "J9BC": "J9CASINO", "JACS": "JACS", + "JACY": "JACY", "JADE": "Jade Protocol", "JADEC": "Jade Currency", "JAM": "Tune.Fm", "JANE": "JaneCoin", "JAR": "Jarvis+", + "JARED": "Jared From Subway", "JASMY": "JasmyCoin", "JBS": "JumBucks Coin", "JBX": "Juicebox", @@ -3707,13 +4061,14 @@ "JEFF": "Jeff in Space", "JEJUDOGE": "Jejudoge", "JEM": "Jem", - "JET": "Jetcoin", + "JET": "Jet Protocol", + "JETCOIN": "Jetcoin", "JEW": "Shekel", "JEWEL": "DeFi Kingdoms", "JEX": "JEX Token", "JFI": "JackPool.finance", "JFIN": "JFIN Coin", - "JGN": "Juggernaut (JGN)", + "JGN": "Juggernaut", "JIAOZI": "Jiaozi", "JIB": "Jibbit", "JIF": "JiffyCoin", @@ -3735,6 +4090,7 @@ "JOY": "Joycoin", "JOYS": "JOYS", "JOYT": "JoyToken", + "JP": "JP", "JPAW": "Jpaw Inu", "JPEG": "JPEG'd", "JPYC": "JPYC", @@ -3751,6 +4107,7 @@ "JUL": "Joule", "JULB": "JustLiquidity Binance", "JULD": "JulSwap", + "JUMBO": "Jumbo Exchange", "JUMP": "Jumpcoin", "JUN": "Jun \"M\" Coin", "JUNO": "JUNO", @@ -3770,17 +4127,20 @@ "KAL": "Kaleido", "KALA": "Kalata Protocol", "KALAM": "Kalamint", + "KALI": "Kalissa", "KALLY": "Polkally", - "KALM": "Kalmar", + "KALM": "KALM", "KALYCOIN": "KalyCoin", "KAM": "BitKAM", "KAN": "Bitkan", "KAPU": "Kapu", "KAR": "Karura", + "KARATE": "Karate Combat", "KAREN": "KarenCoin", "KARMA": "Karma", "KARMAD": "Karma DAO", "KART": "Dragon Kart", + "KAS": "Kaspa", "KASSIAHOME": "Kassia Home", "KASTA": "Kasta", "KAT": "Kambria", @@ -3818,6 +4178,7 @@ "KEC": "KEYCO", "KED": "Klingon Empire Darsek", "KEEP": "Keep Network", + "KEES": "Korea Entertainment Education & Shopping", "KEI": "Keisuke Inu", "KEK": "KekCoin", "KEL": "KelVPN", @@ -3825,39 +4186,48 @@ "KEN": "Kencoin", "KEP": "Kepler", "KETAN": "Ketan", - "KEX": "KexCoin", + "KEX": "Kira Network", + "KEXCOIN": "KexCoin", "KEY": "SelfKey", "KEYC": "KeyCoin", "KEYFI": "KeyFi", "KFC": "Chicken", "KFI": "Klever Finance", "KFT": "Knit Finance", - "KGC": "KrugerCoin", + "KGC": "Krypton Galaxy Coin", "KGO": "Kiwigo", "KHM": "Kohima", "KIAN": "Porta", "KIBA": "Kiba Inu", "KICK": "Kick", - "KICKS": "SESSIA", + "KICKS": "GetKicks", "KIF": "KittenFinance", + "KILLER": "Fat Cat Killer", "KILT": "KILT Protocol", "KIM": "King Money", "KIMCHI": "KIMCHI.finance", "KIN": "Kin", "KIND": "Kind Ads", "KINE": "Kine Protocol", - "KING": "KingSwap", + "KING": "KING", "KING93": "King93", + "KINGDOMQUEST": "Kingdom Quest", + "KINGF": "King Finance", "KINGSHIB": "King Shiba", + "KINGSWAP": "KingSwap", "KINT": "Kintsugi", "KIRBY": "Kirby Inu", + "KIRBYRELOADED": "Kirby Reloaded", "KIRO": "Kirobo", "KISC": "Kaiser", "KISHIMOTO": "Kishimoto Inu", "KISHU": "Kishu Inu", + "KITA": "KITA INU", "KITSU": "Kitsune Inu", "KITTY": "Kitty Inu", "KKO": "Kineko", + "KKT": "Kingdom Karnage", + "KLAP": "Klap Finance", "KLAY": "Klaytn", "KLC": "KiloCoin", "KLEE": "KleeKai", @@ -3872,13 +4242,17 @@ "KMON": "Kryptomon", "KMX": "KiMex", "KNC": "Kyber Network Crystal v2", + "KNCL": "Kyber Network Crystal Legacy", "KNG": "BetKings", "KNGN": "KingN Coin", + "KNOT": "Karmaverse", "KNOW": "KNOW", "KNT": "Knekted", "KNW": "Knowledge", "KOBE": "Shabu Shabu", "KOBO": "KoboCoin", + "KODACHI": "Kodachi Token", + "KOI": "Koi Network", "KOIN": "Koinos", "KOK": "KOK Coin", "KOKO": "KokoSwap", @@ -3896,6 +4270,7 @@ "KP4R": "Keep4r", "KPAD": "KickPad", "KPC": "KEEPs Coin", + "KPL": "Kepple", "KPOP": "KPOP Coin", "KRAK": "Kraken", "KRATOS": "KRATOS", @@ -3903,6 +4278,7 @@ "KRC": "KRCoin", "KRD": "Krypton DAO", "KREDS": "KREDS", + "KRIPTO": "Kripto", "KRL": "Kryll", "KRM": "Karma", "KRN": "KRYZA Network", @@ -3912,6 +4288,8 @@ "KRP": "Kryptoin", "KRRX": "Kyrrex", "KRT": "TerraKRW", + "KRU": "Kingaru", + "KRUGERCOIN": "KrugerCoin", "KRX": "RAVN Korrax", "KS2": "Kingdomswap", "KSC": "KStarCoin", @@ -3927,30 +4305,37 @@ "KT": "Kuai Token", "KTK": "KryptCoin", "KTN": "Kattana", + "KTO": "Kounotori", "KTON": "Darwinia Commitment Token", "KTS": "Klimatas", "KTT": "K-Tune", "KTX": "KwikTrust", "KUB": "Bitkub Coin", + "KUBE": "KubeCoin", "KUBO": "KUBO", "KUBOS": "KubosCoin", "KUE": "Kuende", + "KUJI": "Kujira", "KUMA": "Kuma Inu", "KUNCI": "Kunci Coin", "KUR": "Kuro", "KURT": "Kurrent", + "KUSA": "Kusa Inu", "KUSD": "Kowala", "KUSH": "KushCoin", "KUV": "Kuverit", + "KVERSE": "KEEPs Coin", "KVI": "KVI Chain", "KVNT": "KVANT", "KVT": "Kinesis Velocity Token", "KWATT": "4New", "KWD": "KIWI DEFI", + "KWENTA": "Kwenta", "KWH": "KWHCoin", "KWIK": "KwikSwap", "KWS": "Knight War Spirits", "KXUSD": "kxUSD", + "KYCC": "KYCCOIN", "KYL": "Kylin Network", "KYOKO": "Kyoko", "KYTE": "Kambria Yield Tuning Engine", @@ -3965,6 +4350,9 @@ "LABX": "Stakinglab", "LACCOIN": "LocalAgro", "LACE": "Lovelace World", + "LADYS": "Milady Meme Coin", + "LAEEB": "LaEeb", + "LAELAPS": "Laelaps", "LAIKA": "Laika Protocol", "LALA": "LaLa World", "LAMB": "Lambda", @@ -3982,6 +4370,7 @@ "LATX": "Latium", "LAVA": "Lavaswap", "LAVAX": "LavaX Labs", + "LAW": "Law Token", "LAX": "LAPO", "LAYER": "UniLayer", "LAZ": "Lazarus", @@ -3989,6 +4378,7 @@ "LBA": "Cred", "LBC": "LBRY Credits", "LBK": "LBK", + "LBL": "LABEL Foundation", "LBLOCK": "Lucky Block", "LBR": "LaborCrypto", "LBTC": "LiteBitcoin", @@ -4013,6 +4403,7 @@ "LDN": "Ludena Protocol", "LDO": "Lido DAO", "LDOGE": "LiteDoge", + "LDX": "Litedex", "LEA": "LeaCoin", "LEAF": "LeafCoin", "LEAG": "LeagueDAO Governance Token", @@ -4022,6 +4413,7 @@ "LEDU": "Education Ecosystem", "LEGO": "Lego Coin", "LELE": "Lelecoin", + "LEMC": "LemonChain", "LEMD": "Lemond", "LEMO": "LemoChain", "LEMON": "LemonCoin", @@ -4032,25 +4424,30 @@ "LEO": "LEO Token", "LEOPARD": "Leopard", "LEOS": "Leonicorn Swap", + "LEOX": "Galileo", "LEPA": "Lepasa", "LEPEN": "LePenCoin", "LESS": "Less Network", "LET": "LinkEye", "LEU": "CryptoLEU", - "LEV": "Leverj", + "LEV": "Levante U.D. Fan Token", + "LEVER": "LeverFi", + "LEVERJ": "Leverj", "LEVL": "Levolution", "LEX": "Elxis", "LEXI": "LEXIT", + "LEZ": "Peoplez", "LF": "Linkflow", "LFC": "BigLifeCoin", "LFT": "Lend Flare Dao", - "LFW": "Legend of Fantasy War", + "LFW": "Linked Finance World", "LGBTQ": "LGBTQoin", "LGCY": "LGCY Network", "LGD": "Legends Cryptocurrency", "LGO": "Legolas Exchange", "LGOT": "LGO Token", "LGR": "Logarithm", + "LGX": "Legion Network", "LHB": "Lendhub", "LHC": "LHCoin", "LHD": "LitecoinHD", @@ -4059,7 +4456,6 @@ "LIBERA": "Libera Financial", "LIBERO": "Libero Financial", "LIBFX": "Libfx", - "LIBRE": "Libre DeFi", "LIC": "Ligercoin", "LID": "Liquidity Dividends Protocol", "LIDER": "Lider Token", @@ -4077,9 +4473,11 @@ "LINA": "Linear", "LINANET": "Lina", "LINDA": "Metrix", + "LING": "Lingose", "LINK": "Chainlink", "LINKA": "LINKA", "LINKC": "LINKCHAIN", + "LINU": "Luna Inu", "LINX": "Linx", "LION": "Lion Token", "LIPC": "LIpcoin", @@ -4092,6 +4490,7 @@ "LITENETT": "Litenett", "LITH": "Lithium Finance", "LITHIUM": "Lithium", + "LITHO": "Lithosphere", "LITION": "Lition", "LIV": "LiviaCoin", "LIVE": "TRONbetLive", @@ -4100,21 +4499,23 @@ "LK": "Liker", "LK7": "Lucky7Coin", "LKC": "LuckyCoin", + "LKD": "LinkDao", "LKK": "Lykke", "LKN": "LinkCoin Token", - "LKR": "Polkalokr", "LKT": "Locklet", "LKU": "Lukiu", "LKY": "LuckyCoin", "LLAND": "Lyfe Land", "LLG": "Loligo", "LLION": "Lydian Lion", - "LM": "LM Token", + "LM": "LeisureMeta", "LMAO": "LMAO Finance", "LMC": "LomoCoin", "LMCH": "Latamcash", + "LMCSWAP": "LimoCoin SWAP", "LMR": "Lumerin", "LMT": "Lympo Market Token", + "LMTOKEN": "LM Token", "LMXC": "LimonX", "LMY": "Lunch Money", "LN": "LINK", @@ -4124,8 +4525,9 @@ "LNKC": "Linker Coin", "LNL": "LunarLink", "LNR": "Lunar", - "LNT": "Launchtok", + "LNT": "Lottonation", "LNX": "Lunox Token", + "LOA": "League of Ancients", "LOAN": "Lendoit", "LOBS": "Lobstex", "LOC": "LockTrip", @@ -4135,8 +4537,11 @@ "LOCK": "Contracto", "LOCO": "Loco", "LOCUS": "Locus Chain", + "LOF": "Land of Fantasy", "LOG": "Wood Coin", + "LOIS": "Lois Token", "LOKA": "League of Kingdoms", + "LOKR": "Polkalokr", "LOL": "EMOGI Network", "LOLC": "LOL Coin", "LON": "Tokenlon", @@ -4167,16 +4572,19 @@ "LPNT": "Luxurious Pro Network Token", "LPOOL": "Launchpool", "LPT": "Livepeer", + "LPY": "LeisurePay", "LQ8": "Liquid8", "LQBTC": "Liquid Bitcoin", "LQD": "Liquid", "LQDN": "Liquidity Network", + "LQDR": "LiquidDriver", "LQR": "Laqira Protocol", "LQTY": "Liquity", "LRC": "Loopring", "LRG": "Largo Coin", "LRN": "Loopring [NEO]", "LSD": "LightSpeedCoin", + "LSETH": "Liquid Staked ETH", "LSK": "Lisk", "LSP": "Lumenswap", "LSS": "Lossless", @@ -4205,6 +4613,7 @@ "LTNM": "Bitcoin Latinum", "LTO": "LTO Network", "LTPC": "Lightpaycoin", + "LTR": "LogiTron", "LTRBT": "Little Rabbit", "LTS": "Litestar Coin", "LTX": "Lattice Token", @@ -4216,22 +4625,28 @@ "LUCKYB": "LuckyBlocks", "LUCY": "Lucy", "LUDO": "Ludo", + "LUFC": "Leeds United Fan Token", "LUFFY": "Luffy", + "LUFFYOLD": "Luffy", "LUM": "Illuminates", "LUMA": "LUMA Token", "LUMI": "LUMI Credits", "LUN": "Lunyr", "LUNA": "Terra", + "LUNAT": "Lunatics", "LUNC": "Terra Classic", "LUNCH": "LunchDAO", "LUNE": "Luneko", "LUNES": "Lunes", + "LUNG": "LunaGens", "LUNR": "Lunr Token", "LUS": "Luna Rush", "LUSD": "Liquity USD", "LUT": "Cinemadrom", "LUTETIUM": "Lutetium Coin", "LUX": "LUXCoin", + "LUXO": "Luxo", + "LUXY": "Luxy", "LVG": "Leverage Coin", "LVIP": "Limitless VIP", "LVN": "LivenPay", @@ -4239,6 +4654,7 @@ "LWF": "Local World Forwarders", "LX": "Moonlight", "LXC": "LibrexCoin", + "LXF": "LuxFi", "LXT": "LITEX", "LXTO": "LuxTTO", "LYB": "LyraBar", @@ -4250,6 +4666,7 @@ "LYN": "LYNCHPIN Token", "LYNK": "Lynked.World", "LYNX": "Lynx", + "LYO": "LYO Credit", "LYQD": "eLYQD", "LYRA": "Scrypta", "LYTX": "LYTIX", @@ -4260,9 +4677,11 @@ "MAC": "MachineCoin", "MADANA": "MADANA", "MADC": "MadCoin", + "MADOG": "MarvelDoge", "MAEP": "Maester Protocol", "MAG": "Magnet", "MAGIC": "Magic", + "MAGICF": "MagicFox", "MAHA": "MahaDAO", "MAI": "Mindsync", "MAID": "MaidSafe Coin", @@ -4273,15 +4692,19 @@ "MANA": "Decentraland", "MANC": "Mancium", "MANDALA": "Mandala Exchange Token", + "MANDOX": "MandoX", "MANGA": "Manga Token", "MANNA": "Manna", + "MANTLE": "Mantle", "MAP": "MAP Protocol", "MAPC": "MapCoin", "MAPE": "Mecha Morphing", "MAPR": "Maya Preferred 223", "MAPS": "MAPS", + "MARGINLESS": "Marginless", "MARI": "MarijuanaCoin", "MARK": "Benchmark Protocol", + "MARLEY": "Marley Token", "MARO": "Maro", "MARS": "MarsCoin", "MARS4": "MARS4", @@ -4306,21 +4729,26 @@ "MATIC": "Polygon", "MATPAD": "MaticPad", "MATTER": "AntiMatter", + "MAV": "Maverick Protocol", "MAX": "MaxCoin", "MAXR": "Max Revive", "MAY": "Theresa May Coin", + "MAYACOIN": "MayaCoin", "MAZC": "MyMazzu", "MB": "MineBee", "MB8": "MB8 Coin", + "MBASE": "Minebase", "MBCASH": "MBCash", "MBCC": "Blockchain-Based Distributed Super Computing Platform", "MBET": "MoonBet", + "MBF": "MoonBear.Finance", "MBI": "Monster Byte Inc", "MBIT": "Mbitbooks", "MBL": "MovieBloc", "MBLC": "Mont Blanc", "MBM": "MobileBridge Momentum", "MBN": "Mobilian Coin", + "MBONK": "megaBonk", "MBOX": "MOBOX", "MBP": "MobiPad", "MBRS": "Embers", @@ -4328,7 +4756,7 @@ "MBT": "Metablackout", "MBTC": "MicroBitcoin", "MBTX": "MinedBlock", - "MBX": "MobieCoin", + "MBX": "Marblex", "MC": "Merit Circle", "MCAP": "MCAP", "MCAR": "MasterCar", @@ -4337,7 +4765,9 @@ "MCAU": "Meld Gold", "MCB": "MCDEX", "MCC": "Magic Cube Coin", + "MCD": "CDbio", "MCF": "MCFinance", + "MCG": "MicroChains Gov Token", "MCH": "Meconcash", "MCI": "Musiconomi", "MCN": "mCoin", @@ -4359,12 +4789,14 @@ "MDCL": "Medicalchain", "MDF": "MatrixETF", "MDH": "Telemedicoin", + "MDICE": "Multidice", "MDM": "Medium", "MDN": "Modicoin", "MDS": "MediShares", "MDT": "Measurable Data Token", "MDU": "MDUKEY", - "MDX": "Mdex", + "MDX": "Mdex (BSC)", + "MDXH": "Mdex (HECO)", "ME": "All.me", "MEAN": "Meanfi", "MEC": "MegaCoin", @@ -4374,6 +4806,8 @@ "MEDIC": "MedicCoin", "MEDICO": "Mediconnect", "MEDIT": "MediterraneanCoin", + "MEE": "Medieval Empires", + "MEED": "Meeds DAO", "MEET": "CoinMeet", "MEETONE": "MEET.ONE", "MEGA": "MegaFlash", @@ -4381,10 +4815,12 @@ "MELD": "Melodity", "MELI": "Meli Games", "MELLO": "Mello Token", + "MELOS": "Melos Studio", "MELT": "Defrost Finance", "MEM": "Memecoin", - "MEME": "Meme", + "MEME": "Memetic", "MEMEINU": "Meme Inu", + "MEMORYCOIN": "MemoryCoin", "MENGO": "Flamengo Fan Token", "MENLO": "Menlo One", "MEPAD": "MemePad", @@ -4398,33 +4834,44 @@ "MESA": "MetaVisa", "MESG": "MESG", "MESH": "MeshBox", + "MESSI": "MESSI COIN", "MET": "Metronome", "META": "Metadium", "METAC": "Metacoin", "METACAT": "MetaCat", "METADOGEV2": "MetaDoge V2", - "METAL": "MetalCoin", + "METAF": "MetaFastest", + "METAG": "MetagamZ", + "METAL": "Metal Blockchain", + "METALCOIN": "MetalCoin", "METAN": "Metan Evolutions", "METAPK": "Metapocket", + "METAS": "Metaseer", + "METAV": "MetaVPad", + "METAVIE": "Metavie", "METAX": "MetaverseX", "METEOR": "Meteorite Network", "METER": "Meter Stable", + "METFI": "MetFi", "METH": "Farming Bad", "METI": "Metis", "METIS": "Metis Token", "METM": "MetaMorph", "METO": "Metafluence", + "METOLD": "Metronome", + "MEV": "MEVerse", "MEVR": "Metaverse VR", "MEWTWO": "Mewtwo Inu", "MEX": "MEX", "MEXC": "MEXC Token", "MEXP": "MOJI Experience Points", + "MF": "MetaFighter", "MF1": "Meta Finance", "MFC": "MFCoin", "MFG": "SyncFab", "MFI": "Marginswap", "MFS": "Moonbase File System", - "MFT": "Hifi Finance", + "MFT": "Hifi Finance (Old)", "MFUND": "Memefund", "MFX": "MFChain", "MG": "MinerGate Token", @@ -4436,6 +4883,7 @@ "MGN": "MagnaCoin", "MGO": "MobileGo", "MGP": "MangoChain", + "MGPT": "MotoGP Fan Token", "MGT": "Megatech", "MGUL": "Mogul Coin", "MGX": "MargiX", @@ -4449,9 +4897,12 @@ "MIB": "Mobile Integrated Blockchain", "MIBO": "miBoodle", "MIC": "Mithril Cash", - "MIDAS": "Midas Dollar Share", + "MIDAS": "Midas", + "MIDASDOLLAR": "Midas Dollar Share", "MIDN": "Midnight", + "MIE": "MIE Network", "MIG": "Migranet", + "MIININGNFT": "MiningNFT", "MIKS": "MIKS COIN", "MIL": "Milllionaire Coin", "MILC": "MIcro Licensing Coin", @@ -4472,7 +4923,7 @@ "MINDEX": "Mindexcoin", "MINDGENE": "Mind Gene", "MINDS": "Minds", - "MINE": "Instamine Nuggets", + "MINE": "SpaceMine", "MINEX": "Minex", "MINI": "Mini", "MINIDOGE": "MiniDOGE", @@ -4489,6 +4940,7 @@ "MIODIO": "MIODIOCOIN", "MIOTA": "IOTA", "MIR": "Mirror Protocol", + "MIRACLE": "MIRACLE", "MIRC": "MIR COIN", "MIS": "Mithril Share", "MISA": "Sangkara", @@ -4496,13 +4948,14 @@ "MISHKA": "Mishka Token", "MISS": "MISS", "MIST": "Mist", - "MIT": "MiMiner", + "MIT": "Galaxy Blitz", "MITC": "MusicLife", "MITH": "Mithril", "MITX": "Morpheus Infrastructure Token", "MIV": "MakeItViral", "MIVRS": "Minionverse", "MIX": "MIXMARVEL", + "MJT": "MojitoSwap", "MKEY": "MEDIKEY", "MKR": "Maker", "ML": "Market Ledger", @@ -4515,9 +4968,12 @@ "MLT": "MultiGames", "MLTC": "MultiWallet Coin", "MM": "Millimeter", - "MMC": "MemoryCoin", + "MMAI": "MetamonkeyAi", + "MMAPS": "MapMetrics", + "MMC": "Monopoly Millionaire Control", "MMETA": "Duckie Land Multi Metaverse", "MMF": "MMFinance", + "MMG": "Monopoly Millionaire Game", "MMNXT": "MMNXT", "MMO": "MMOCoin", "MMPRO": "Market Making Pro", @@ -4531,8 +4987,10 @@ "MNC": "MainCoin", "MND": "Mound Token", "MNDCC": "Mondo Community Coin", + "MNDE": "Marinade", "MNE": "Minereum", "MNET": "MINE Network", + "MNFT": "Marvelous NFTs", "MNG": "Moon Nation Game", "MNGO": "Mango protocol", "MNM": "Mineum", @@ -4542,19 +5000,21 @@ "MNST": "MoonStarter", "MNTC": "Manet Coin", "MNTG": "Monetas", + "MNTL": "AssetMantle", "MNTP": "GoldMint", "MNV": "MonetaVerde", "MNVM": "Novam", "MNW": "Morpheus Network", "MNX": "MinexCoin", "MNY": "MoonieNFT", - "MNZ": "Monaize", + "MNZ": "Menzy", "MO": "Morality", "MOAC": "MOAC", "MOAR": "Moar Finance", "MOAT": "Mother Of All Tokens", "MOB": "MobileCoin", "MOBI": "Mobius", + "MOBIE": "MobieCoin", "MOBU": "MOBU", "MOC": "Mossland", "MOCHI": "Mochiswap", @@ -4566,7 +5026,9 @@ "MOF": "Molecular Future (TRC20)", "MOFI": "MobiFi", "MOFOLD": "Molecular Future (ERC20)", + "MOG": "Mog Coin", "MOGU": "Mogu", + "MOGX": "Mogu", "MOI": "MyOwnItem", "MOIN": "MoinCoin", "MOJO": "Mojocoin", @@ -4576,19 +5038,26 @@ "MOLK": "Mobilink Token", "MOM": "Mother of Memes", "MOMA": "Mochi Market", - "MON": "Monstock", + "MON": "Medamon", "MONA": "MonaCoin", + "MONAIZE": "Monaize", "MONARCH": "TRUEMONARCH", "MONAV": "Monavale", "MONETA": "Moneta", "MONEY": "MoneyCoin", + "MONEYBYTE": "MoneyByte", + "MONEYIMT": "MoneyToken", "MONF": "Monfter", + "MONG": "MongCoin", + "MONG20": "Mongoose 2.0", "MONI": "Monsta Infinite", "MONK": "Monkey Project", "MONKEY": "Monkey", + "MONKEYS": "Monkeys Token", "MONO": "MonoX", "MONONOKEINU": "Mononoke Inu", "MONS": "Monsters Clan", + "MONST": "Monstock", "MONT": "Monarch Token", "MOO": "MooMonster", "MOOI": "Moonai", @@ -4598,15 +5067,19 @@ "MOONC": "MoonCoin", "MOOND": "Dark Moon", "MOONDAY": "Moonday Finance", + "MOONER": "CoinMooner", "MOONEY": "Moon DAO", "MOONLIGHT": "Moonlight Token", "MOONSHOT": "Moonshot", "MOOO": "Hashtagger", "MOOV": "dotmoovs", + "MOOX": "Moox Protocol", + "MOPS": "Mops", "MORA": "Meliora", "MORE": "More Coin", "MOS": "MOS Coin", "MOT": "Olympus Labs", + "MOTG": "MetaOctagon", "MOTI": "Motion", "MOTO": "Motocoin", "MOV": "MovieCoin", @@ -4627,6 +5100,8 @@ "MPH": "Morpher", "MPL": "Maple", "MPLUS": "M+Plus", + "MPLX": "Metaplex", + "MPM": "Monopoly Meta", "MPRO": "MediumProject", "MPT": "Meetple", "MPXT": "Myplacex", @@ -4643,7 +5118,7 @@ "MRN": "Mercoin", "MRNA": "Moderna", "MRP": "MorpheusCoin", - "MRS": "Marginless", + "MRS": "Metars Genesis", "MRSA": "MrsaCoin", "MRT": "MinersReward", "MRV": "Macroverse", @@ -4660,15 +5135,19 @@ "MSQ": "MSquare Global", "MSR": "Masari", "MST": "Idle Mystic", + "MSTO": "Millennium Sapphire", "MSU": "MetaSoccer", "MSWAP": "MoneySwap", "MT": "MyToken", "MTA": "Meta", + "MTB": "MetaBridge", "MTBC": "Metabolic", "MTC": "MEDICAL TOKEN CURRENCY", "MTCMN": "MTC Mesh", "MTCN": "Multiven", + "MTD": "Minted", "MTEL": "MEDoctor", + "MTG": "MagnetGold", "MTH": "Monetha", "MTHD": "Method Finance", "MTK": "Moya Token", @@ -4679,6 +5158,8 @@ "MTR": "MasterTraderCoin", "MTRC": "ModulTrade", "MTRG": "Meter", + "MTRM": "Materium", + "MTRX": "Metarix", "MTS": "Metastrike", "MTSH": "Mitoshi", "MTT": "MulTra", @@ -4692,6 +5173,7 @@ "MUE": "MonetaryUnit", "MULTI": "Multichain", "MULTIBOT": "Multibot", + "MULTIV": "Multiverse", "MUN": "MUNcoin", "MUNCH": "Munch Token", "MUSD": "mStable USD", @@ -4706,6 +5188,9 @@ "MUU": "MilkCoin", "MV": "GensoKishi Metaverse", "MVC": "MileVerse", + "MVD": "Metavault", + "MVDG": "MetaVerse Dog", + "MVEDA": "MedicalVeda", "MVI": "Metaverse Index", "MVL": "MVL", "MVP": "MVP Coin", @@ -4723,7 +5208,7 @@ "MXW": "Maxonrow", "MXX": "Multiplier", "MYB": "MyBit", - "MYC": "MayaCoin", + "MYC": "Mycelium", "MYCE": "MY Ceremonial Event", "MYCELIUM": "Mycelium Token", "MYDFS": "MyDFS", @@ -4734,7 +5219,9 @@ "MYOBU": "Myōbu", "MYRA": "Mytheria", "MYST": "Mysterium", - "MYTH": "Myth Token", + "MYT": "Mytrade", + "MYTH": "Mythos", + "MYTHT": "Myth Token", "MYTV": "MyTVchain", "MZC": "MazaCoin", "MZG": "Moozicore", @@ -4764,11 +5251,13 @@ "NAS2": "Nas2Coin", "NASADOGE": "Nasa Doge", "NASH": "NeoWorld Cash", + "NASSR": "Alnassr FC Fan Token", "NAT": "Natmin", "NATION": "Nation3", "NAUSICAA": "Nausicaa-Inu", "NAUT": "Nautilus Coin", "NAV": "NavCoin", + "NAVC": "NavC token", "NAVI": "NaviAddress", "NAVIB": "Navibration", "NAWA": "Narwhale.finance", @@ -4788,6 +5277,7 @@ "NC": "Nayuta Coin", "NCASH": "Nucleus Vision", "NCC": "NeuroChain", + "NCDT": "Nuco.Cloud", "NCOV": "CoronaCoin", "NCP": "Newton Coin", "NCR": "Neos Credits", @@ -4804,10 +5294,12 @@ "NEBL": "Neblio", "NEBU": "Nebuchadnezzar", "NEC": "Nectar", + "NEER": "Metaverse.Network Pioneer", "NEET": "NEET Finance", "NEETCOIN": "Neetcoin", "NEF": "NefariousCoin", "NEFTIPEDIA": "NEFTiPEDiA", + "NEKI": "Neki Token", "NEKO": "The Neko", "NEO": "NEO", "NEOG": "NEO Gold", @@ -4821,6 +5313,8 @@ "NETCOIN": "Netcoincapital", "NETKO": "Netko", "NEU": "Neumark", + "NEURALINK": "Neuralink", + "NEUTRO": "Neutro Protocol", "NEVA": "NevaCoin", "NEW": "Newton", "NEWB": "Newbium", @@ -4840,9 +5334,11 @@ "NFT": "APENFT", "NFTART": "NFT Art Finance", "NFTB": "NFTb", + "NFTD": "NFTrade", "NFTI": "NFT Index", "NFTL": "NFTLaunch", "NFTP": "NFT", + "NFTT": "NFT", "NFTX": "NFTX", "NFTXHI": "NFTX Hashmasks Index", "NFTY": "NFTY Token", @@ -4854,12 +5350,12 @@ "NGIN": "Ngin", "NGL": "Gold Fever", "NGM": "e-Money", - "NHBTC": "NEST Protocol", "NHCT": "Nano Healthcare Token", "NIC": "NewInvestCoin", "NICE": "Nice", "NICEC": "NiceCoin", "NIF": "Unifty", + "NIFT": "Niftify", "NIFTSY": "Envelop", "NII": "nahmii", "NIIFI": "NiiFi", @@ -4867,8 +5363,11 @@ "NIMFA": "Nimfamoney", "NIN": "Next Innovation", "NINKY": "Ninky", + "NINO": "Ninneko", + "NIOX": "Autonio", "NIT": "Nesten", "NITRO": "Nitro League", + "NITROG": "Nitro", "NIX": "NIX", "NKA": "IncaKoin", "NKC": "Nukecoinz", @@ -4890,12 +5389,14 @@ "NNB": "NNB Token", "NNC": "NEO Name Credit", "NNI": "NeoNomad Exchange", + "NNN": "Novem Gold", "NOA": "NOA PLAY", "NOAH": "NOAHCOIN", "NOBL": "NobleCoin", "NOBS": "No BS Crypto", "NODE": "Whole Network", "NODIS": "Nodis", + "NODL": "Nodle Network", "NOIA": "Syntropy", "NOIZ": "NOIZ", "NOKU": "NOKU Master token", @@ -4907,9 +5408,11 @@ "NOS": "Nosana", "NOSN": "nOS", "NOTE": "Notional Finance", + "NOVA": "Nova Finance", "NOW": "NOW Token", "NOX": "NITRO", "NOXB": "Noxbox", + "NPAS": "New Paradigm Assets Solution", "NPC": "NPCcoin", "NPER": "NPER", "NPLC": "Plus Coin", @@ -4939,6 +5442,8 @@ "NSP": "NOMAD.space", "NSR": "NuShares", "NSS": "NSS Coin", + "NSTE": "NewSolution 2.0", + "NSUR": "NSUR Coin", "NSURE": "Nsure Network", "NT": "NEXTYPE Finance", "NTB": "TokenAsset", @@ -4947,7 +5452,7 @@ "NTCC": "NeptuneClassic", "NTK": "Neurotoken", "NTM": "NetM", - "NTO": "Neutro Protocol", + "NTO": "Neton", "NTR": "Nether", "NTRN": "Neutron", "NTS": "Notarised", @@ -4962,6 +5467,7 @@ "NULS": "Nuls", "NUM": "Numbers Protocol", "NUMBERS": "NumbersCoin", + "NUMI": "Numitor", "NUNET": "NuNet", "NUSD": "Nomin USD", "NUT": "Native Utility Token", @@ -4985,9 +5491,11 @@ "NXE": "NXEcoin", "NXM": "Nexus Mutual", "NXMC": "NextMindCoin", + "NXRA": "AllianceBlock Nexera", "NXS": "Nexus", "NXT": "Nxt", "NXTI": "NXTI", + "NXTT": "Next Earth", "NXTTY": "NXTTY", "NYAN": "NyanCoin", "NYANTE": "Nyantereum International", @@ -5011,6 +5519,7 @@ "OATH": "OATH Protocol", "OAX": "Oax", "OBITS": "Obits Coin", + "OBOT": "Obortech", "OBROK": "OBRok", "OBS": "One Basis Cash", "OBSCURE": "Obscurebay", @@ -5025,6 +5534,7 @@ "OCL": "Oceanlab", "OCN": "Odyssey", "OCT": "Octopus Network", + "OCTA": "OctaSpace", "OCTAX": "OctaX", "OCTI": "Oction", "OCTO": "OctoFi", @@ -5048,6 +5558,7 @@ "OGOD": "GOTOGOD", "OGSP": "OriginSport", "OGT": "One Game", + "OGV": "Origin Dollar Governance", "OH": "Oh! Finance", "OHM": "Olympus", "OHMV2": "Olympus v2", @@ -5056,24 +5567,29 @@ "OILD": "OilWellCoin", "OIN": "OIN Finance", "OIO": "Online", + "OJA": "Ojamu", "OJX": "Ojooo", "OK": "OKCash", - "OKB": "OKX", + "OKB": "OKB", + "OKG": "Ookeenga", "OKOIN": "OKOIN", "OKS": "Oikos", - "OKT": "OKC Token", + "OKSE": "Okse", + "OKT": "OKT Chain", "OLAND": "Oceanland", "OLDSF": "OldSafeCoin", - "OLE": "Olive", + "OLE": "OpenLeverage", + "OLIVE": "Olive", "OLOID": "OLOID", "OLT": "OneLedger", "OLV": "OldV", "OLXA": "OLXA", "OLY": "Olyseum", "OLYMP": "OlympCoin", - "OM": "MANTRA DAO", + "OM": "MANTRA", "OMA": "OmegaCoin", - "OMC": "OmniCron", + "OMAX": "Omax", + "OMC": "Omchain", "OMEGA": "OMEGA", "OMG": "OMG Network", "OMGC": "OmiseGO Classic", @@ -5081,6 +5597,7 @@ "OMIC": "Omicron", "OMNI": "Omni", "OMNIA": "OmniaVerse", + "OMNICRON": "OmniCron", "OMT": "Mars Token", "OMX": "Project Shivom", "ON": "OFIN Token", @@ -5101,6 +5618,7 @@ "ONS": "One Share", "ONSTON": "Onston", "ONT": "Ontology", + "ONUS": "ONUS", "ONX": "Onix", "OOE": "OpenOcean", "OOGI": "OOGI", @@ -5112,8 +5630,8 @@ "OPC": "OP Coin", "OPCT": "Opacity", "OPEN": "Open Platform", - "OPENDAO": "OPEN Governance Token", - "OPENDAOSOS": "OpenDAO", + "OPENDAO": "OpenDAO", + "OPENGO": "OPEN Governance Token", "OPENRI": "Open Rights Exchange", "OPES": "Opes", "OPET": "ÕpetFoundation", @@ -5165,8 +5683,9 @@ "OSF": "One Solution", "OSMO": "Osmosis", "OSQTH": "Opyn Squeeth", - "OST": "Simple Token", + "OST": "OST", "OSWAP": "OpenSwap", + "OTHR": "OtherDAO", "OTN": "Open Trading Network", "OTO": "OTOCASH", "OTX": "Octanox", @@ -5183,6 +5702,7 @@ "OWN": "Ownly", "OWNDATA": "OWNDATA", "OX": "betbox", + "OXB": "Oxbull Tech", "OXD": "0xDAO", "OXEN": "Oxen", "OXT": "Orchid Protocol", @@ -5192,8 +5712,10 @@ "OYS": "Oyster Platform", "OZG": "Ozagold", "OZP": "OZAPHYRE", + "P202": "Project 202", "P2PS": "P2P Solutions Foundation", - "PAC": "PAC Global", + "PAAL": "PAAL AI", + "PAC": "PAC Protocol", "PACOCA": "Pacoca", "PAD": "NearPad", "PAF": "Pacific", @@ -5202,10 +5724,14 @@ "PAINT": "MurAll", "PAK": "Pakcoin", "PAL": "PolicyPal Network", + "PALLA": "Pallapay", + "PALM": "PalmPay", "PAMP": "PAMP Network", "PAN": "Pantos", "PAND": "Panda Finance", + "PANDA": "PandaDAO", "PANDO": "Pando", + "PANDOP": "PandoProject", "PANGEA": "PANGEA", "PAPADOGE": "Papa Doge", "PAPER": "Dope Wars Paper", @@ -5214,10 +5740,13 @@ "PARA": "Paralink Network", "PARAB": "Parabolic", "PARAL": "Parallel", + "PARALL": "Parallel Finance", "PARANOIA": "ParanoiaCoin", + "PARAW": "Para", "PARETO": "Pareto Network Token", "PARKGENE": "PARKGENE", "PARLAY": "Parlay", + "PARMA": "PARMA Fan Token", "PARQ": "PARQ", "PART": "Particl", "PAS": "Passive Coin", @@ -5225,12 +5754,15 @@ "PASL": "Pascal Lite", "PASS": "Blockpass", "PAT": "PATRON", + "PATH": "PathDAO", "PAVO": "Pavocoin", "PAXEX": "PAXEX", "PAXG": "PAX Gold", + "PAXW": "pax.world", "PAY": "TenX", "PAYCON": "Paycon", "PAYP": "PayPeer", + "PAYT": "PayAccept", "PAZZI": "Paparazzi", "PBASE": "Polkabase", "PBC": "PabyosiCoin", @@ -5240,10 +5772,12 @@ "PBQ": "PUBLIQ", "PBR": "PolkaBridge", "PBT": "Primalbase", + "PBTC35A": "pBTC35A", "PBX": "Paribus", "PC": "Promotion Coin", "PCC": "PCORE", "PCCM": "Poseidon Chain", + "PCE": "PEACE COIN", "PCH": "POPCHAIN", "PCHS": "Peaches.Finance", "PCI": "PayProtocol Paycoin", @@ -5268,6 +5802,8 @@ "PEAK": "PEAKDEFI", "PEARL": "Pearl Finance", "PEC": "PeaceCoin", + "PEEL": "Meta Apes", + "PEEPA": "Peepa", "PEEPS": "The People’s Coin", "PEG": "PegNet", "PEGS": "PegShares", @@ -5279,7 +5815,8 @@ "PENTA": "Penta", "PEOPLE": "ConstitutionDAO", "PEOS": "pEOS", - "PEPE": "Memetic", + "PEPE": "Pepe", + "PEPE20": "Pepe 2.0", "PEPECASH": "Pepe Cash", "PEPPER": "Pepper Token", "PEPS": "PEPS Coin", @@ -5293,17 +5830,19 @@ "PERX": "PeerEx Network", "PESA": "Credible", "PESOBIT": "PesoBit", - "PET": "Battle Pets", + "PET": "Hello Pets", "PETG": "Pet Games", "PETL": "Petlife", "PETN": "Pylon Eco Token", "PETO": "Petoverse", + "PETT": "Pett Network", "PEX": "Pexcoin", "PFID": "Pofid Dao", "PFL": "Professional Fighters League Fan Token", "PFR": "PayFair", "PFT": "Pitch Finance Token", "PFY": "Portify", + "PGALA": "pGALA", "PGC": "Pegascoin", "PGF7T": "PGF500", "PGL": "Prospectors", @@ -5311,7 +5850,7 @@ "PGT": "Polyient Games Governance Token", "PGTS": "Puregold token", "PGU": "Polyient Games Unity", - "PGX": "PhiGold Coin", + "PGX": "Pegaxy Stone", "PHA": "Phala Network", "PHAE": "Phaeton", "PHALA": "Phalanx", @@ -5321,6 +5860,7 @@ "PHCR": "PhotoChromic", "PHI": "PHI Token", "PHIBA": "Papa Shiba", + "PHIGOLD": "PhiGold Coin", "PHL": "Philcoin", "PHM": "Phomeum", "PHN": "Phayny", @@ -5334,10 +5874,12 @@ "PHT": "Photon Token", "PHTC": "Photochain", "PHV": "PATHHIVE", - "PI": "PCHAIN", + "PI": "Plian", + "PIAS": "PIAS", "PIB": "Pibble", "PICA": "PicaArtMoney", "PICKLE": "Pickle Finance", + "PICO": "PicoGo", "PIE": "Persistent Information Exchange", "PIG": "Pig Finance", "PIGGY": "Piggy", @@ -5349,6 +5891,7 @@ "PINK": "PinkCoin", "PINKX": "PantherCoin", "PINMO": "Pinmo", + "PINO": "Pinocchu", "PINU": "Piccolo Inu", "PIO": "Pioneershares", "PIPI": "Pippi Finance", @@ -5372,7 +5915,7 @@ "PKD": "PetKingdom", "PKF": "PolkaFoundry", "PKN": "Poken", - "PKR": "Polker", + "PKOIN": "Pocketcoin", "PKT": "PKT", "PLA": "PlayDapp", "PLAAS": "PLAAS FARMERS TOKEN", @@ -5383,15 +5926,18 @@ "PLANET": "PlanetCoin", "PLANETS": "PlanetWatch", "PLASTIK": "Plastiks", - "PLAT": "Platinum", + "PLAT": "BitGuild PLAT", "PLATC": "PlatinCoin", + "PLATINUM": "Platinum", "PLATO": "Plato Game", "PLAY": "HEROcoin", "PLAYC": "PlayChip", + "PLAYCOIN": "PlayCoin", "PLAYKEY": "Playkey", "PLBT": "Polybius", "PLC": "PlusCoin", "PLCU": "PLC Ultima", + "PLD": "Plutonian DAO", "PLE": "Plethori", "PLEO": "Empleos", "PLEX": "PLEX", @@ -5404,9 +5950,12 @@ "PLNC": "PLNCoin", "PLNX": "Planumex", "PLOT": "PlotX", + "PLQ": "Planq", "PLR": "Pillar", + "PLS": "Pulsechain", "PLSD": "PulseDogecoin", "PLSPAD": "PulsePad", + "PLSX": "PulseX", "PLT": "Poollotto.finance", "PLTC": "PlatonCoin", "PLTX": "PlutusX", @@ -5416,8 +5965,9 @@ "PLUGCN": "Plug Chain", "PLURA": "PluraCoin", "PLUS1": "PlusOneCoin", + "PLUTUS": "PlutusDAO", "PLX": "PlexCoin", - "PLY": "PlayCoin", + "PLY": "Aurigami", "PMA": "PumaPay", "PMEER": "Qitmeer", "PMGT": "Perth Mint Gold Token", @@ -5426,12 +5976,12 @@ "PMTN": "Peer Mountain", "PNC": "PlatiniumCoin", "PND": "PandaCoin", + "PNFT": "Pawn My NFT", "PNG": "Pangolin", "PNGN": "SpacePenguin", "PNK": "Kleros", "PNL": "True PNL", "PNODE": "Pinknode", - "PNP": "LogisticsX", "PNT": "pNetwork Token", "PNX": "PhantomX", "PNY": "Peony Coin", @@ -5447,6 +5997,7 @@ "POINTS": "Cryptsy Points", "POK": "Pokmonsters", "POKEM": "Pokemonio", + "POKEMON": "Pokemon", "POKER": "PokerCoin", "POKT": "Pocket Network", "POL": "Pool-X", @@ -5457,30 +6008,39 @@ "POLIS": "Star Atlas DAO", "POLISPLAY": "PolisPay", "POLK": "Polkamarkets", + "POLKER": "Polker", "POLL": "Pollchain", "POLNX": "eToro Polish Zloty", "POLS": "Polkastarter", + "POLVEN": "Polka Ventures", "POLX": "Polylastic", "POLY": "Polymath Network", "POLYDOGE": "PolyDoge", "POLYPAD": "PolyPad", + "POLYX": "Polymesh", "PON": "Ponder", "POND": "Marlin", "PONYO": "Ponyo Impact", "PONZU": "Ponzu Inu", + "POO": "POOMOON", "POODL": "Poodl", "POOL": "PoolTogether", "POOLZ": "Poolz Finance", "POP": "PopularCoin", "POP!": "POP", "POPC": "PopChest", + "POPK": "POPKON", + "POPSICLE": "Popsicle Finance", + "POR": "Portugal National Team Fan Token", "PORT": "Port Finance", "PORTAL": "Portal", "PORTO": "FC Porto", + "PORTU": "Portuma", "POS": "PoSToken", "POSEX": "PosEx", "POSI": "Position Token", "POSQ": "Poseidon Quark", + "POSS": "Posschain", "POST": "InterPlanetary Search Engine", "POSTC": "PostCoin", "POT": "PotCoin", @@ -5489,7 +6049,7 @@ "POWR": "Power Ledger", "PP": "ProducePay Chain", "PPAD": "PlayPad", - "PPALPHA": "PP ALPHA DAO", + "PPALPHA": "Phoenix Protocol", "PPAY": "Plasma Finance", "PPBLZ": "Pepemon Pepeballs", "PPC": "PeerCoin", @@ -5514,16 +6074,21 @@ "PRES": "President Trump", "PRFT": "Proof Suite Token", "PRG": "Paragon", + "PRI": "PRIVATEUM INITIATIVE", "PRIA": "PRIA", "PRIDE": "Nomad Exiles", "PRIMATE": "Primate", - "PRIME": "PrimeChain", + "PRIME": "Echelon Prime", + "PRIMECHAIN": "PrimeChain", "PRINT": "Printer.Finance", + "PRINTERIUM": "Printerium", "PRINTS": "FingerprintsDAO", "PRISM": "Prism", "PRIX": "Privatix", "PRL": "Oyster Pearl", "PRM": "PrismChain", + "PRMX": "PREMA", + "PRNT": "Prime Numbers", "PRO": "Propy", "PROB": "ProBit Token", "PROBIN": "Probinex", @@ -5536,18 +6101,21 @@ "PROPS": "Props", "PROS": "Prosper", "PROT": "PROT", + "PROTO": "Protocon", "PROTON": "Proton", "PROUD": "PROUD Money", "PROXI": "PROXI", - "PRP": "Papyrus", + "PRP": "Pepe Prime", "PRPS": "Purpose", "PRPT": "Purple Token", "PRQ": "PARSIQ", "PRS": "PressOne", "PRT": "Parrot Protocol", + "PRTG": "Pre-Retogeum", "PRV": "PrivacySwap", "PRVS": "Previse", - "PRX": "Printerium", + "PRX": "Parex", + "PRXY": "Proxy", "PRY": "PRIMARY", "PSB": "Planet Sandbox", "PSC": "PSC Token", @@ -5558,8 +6126,10 @@ "PSI": "PSIcoin", "PSILOC": "Psilocybin", "PSK": "Pool of Stake", + "PSL": "Pastel", "PSLIP": "Pinkslip Finance", "PSM": "Prasm", + "PSOL": "Parasol Finance", "PSP": "ParaSwap", "PST": "Primas", "PSTAKE": "pSTAKE Finance", @@ -5584,11 +6154,13 @@ "PUGL": "PugLife", "PULI": "Puli", "PULSE": "Pulse", + "PUMLX": "PUMLx", "PUNDIX": "Pundi X", "PUNK": "SteamPunk", "PUPA": "PupaCoin", "PURA": "Pura", - "PURE": "Pure", + "PURE": "Puriever", + "PUREALT": "Pure", "PUSD": "PegsUSD", "PUSH": "Ethereum Push Notification Service", "PUSHI": "Pushi", @@ -5612,6 +6184,7 @@ "PYE": "CreamPYE", "PYLNT": "Pylon Network", "PYLON": "Pylon Finance", + "PYM": "Playermon", "PYN": "Paycent", "PYP": "PayPro", "PYQ": "PolyQuity", @@ -5619,6 +6192,7 @@ "PYRAM": "Pyram Token", "PYRK": "Pyrk", "PYT": "Payther", + "PYUSD": "PayPal USD", "PZM": "Prizm", "Q1S": "Quantum1Net", "Q2C": "QubitCoin", @@ -5633,7 +6207,8 @@ "QBIT": "Project Quantum", "QBK": "QuBuck Coin", "QBT": "Cubits", - "QBX": "qiibee", + "QBU": "Quannabu", + "QBX": "qiibee foundation", "QBZ": "QUEENBEE", "QC": "Qcash", "QCH": "QChi", @@ -5643,9 +6218,11 @@ "QDX": "Quidax", "QFI": "QFinance", "QI": "BENQI", + "QIE": "QI Blockchain", "QISWAP": "QiSwap", "QKC": "QuarkChain", - "QLC": "QLC Chain", + "QLC": "Kepple [OLD]", + "QLINDO": "QLINDO", "QMALL": "QMALL TOKEN", "QNT": "Quant", "QNTR": "Quantor", @@ -5674,6 +6251,7 @@ "QUA": "Quantum Tech", "QUACK": "Rich Quack", "QUAM": "Quam Network", + "QUANT": "Quant Finance", "QUARASHI": "Quarashi Network", "QUARTZ": "Sandclock", "QUASA": "Quasacoin", @@ -5681,6 +6259,7 @@ "QUBE": "Qube", "QUBITICA": "Qubitica", "QUICK": "Quickswap", + "QUICKOLD": "Quickswap", "QUIDD": "Quidd", "QUINT": "Quint", "QUIZ": "Quizando", @@ -5696,11 +6275,13 @@ "RAC": "RAcoin", "RACA": "Radio Caca", "RACEFI": "RaceFi", - "RAD": "Radicle", + "RAD": "Radworks", "RADAR": "DappRadar", "RADI": "RadicalCoin", + "RADIO": "RadioShack", "RADR": "CoinRadr", "RAI": "Rai Reflex Index", + "RAIDER": "Crypto Raiders", "RAIF": "RAI Finance", "RAIL": "Railgun", "RAIN": "Rainmaker Games", @@ -5713,21 +6294,25 @@ "RAM": "Ramifi Protocol", "RAMP": "RAMP", "RANKER": "RankerDao", - "RAP": "Rapture", + "RAP": "Philosoraptor", "RAPDOGE": "RapDoge", "RARE": "SuperRare", "RARI": "Rarible", + "RATECOIN": "Ratecoin", "RATING": "DPRating", "RATIO": "Ratio", "RAVE": "Ravendex", "RAVELOUS": "Ravelous", "RAVEN": "Raven Protocol", + "RAVENCOINC": "Ravencoin Classic", "RAWG": "RAWG", "RAY": "Raydium", "RAYS": "Rays Network", "RAZE": "Raze Network", "RAZOR": "Razor Network", + "RB": "REBorn", "RBC": "Rubic", + "RBD": "Rubidium", "RBDT": "RoBust Defense Token", "RBIES": "Rubies", "RBIF": "Robo Inu Finance", @@ -5741,10 +6326,13 @@ "RBUNNY": "Rocket Bunny", "RBW": "Crypto Unicorns Rainbow", "RBX": "RiptoBuX", + "RBXS": "RBXSamurai", "RBY": "RubyCoin", "RC": "Russiacoin", "RC20": "RoboCalls", "RCC": "Reality Clash", + "RCCC": "RCCC", + "RCG": "Recharge", "RCH": "Rich", "RCN": "Ripio", "RCOIN": "RCoin", @@ -5754,29 +6342,37 @@ "RDC": "Ordocoin", "RDD": "Reddcoin", "RDN": "Raiden Network Token", + "RDNT": "Radiant Capital", "RDPX": "Dopex Rebate Token", "RDR": "Rise of Defenders", "RDS": "Reger Diamond", "RDT": "Ridotto", + "RDX": "Redux Protocol", "REA": "Realisto", "REAL": "RealLink", "REALM": "Realm", + "REALMS": "Realms of Ethernity", "REALPLATFORM": "REAL", "REALY": "Realy Metaverse", "REAP": "ReapChain", "REBL": "REBL", "REC": "Rec Token (REC)", + "RECKOON": "Reckoon", "RECOM": "Recom", - "RED": "RED", + "RED": "RED TOKEN", "REDC": "RedCab", "REDCO": "Redcoin", + "REDDIT": "Reddit", "REDI": "REDi", + "REDLANG": "RED", + "REDLC": "Redlight Chain", "REDN": "Reden", "REE": "ReeCoin", "REEF": "Reef", - "REF": "RefToken", + "REF": "Ref Finance", "REFI": "Realfinance Network", "REFLECTO": "Reflecto", + "REFTOKEN": "RefToken", "REGALCOIN": "Regalcoin", "REHAB": "NFT Rehab", "REI": "REI Network", @@ -5794,6 +6390,7 @@ "RENBTC": "renBTC", "RENC": "RENC", "RENDOGE": "renDOGE", + "RENS": "Rens", "RENTBE": "Rentberry", "REP": "Augur", "REPO": "Repo Coin", @@ -5803,6 +6400,7 @@ "REST": "Restore", "RET": "RealTract", "RETAIL": "Retail.Global", + "RETH": "Rocket Pool ETH", "RETH2": "rETH2", "RETIRE": "Retire Token", "REU": "REUCOIN", @@ -5829,6 +6427,7 @@ "RGP": "Rigel Protocol", "RGT": "Rari Governance Token", "RHEA": "Rhea", + "RHINO": "RHINO", "RHOC": "RChain", "RHP": "Rhypton Club", "RIC": "Riecoin", @@ -5836,7 +6435,8 @@ "RICECOIN": "RiceCoin", "RICH": "Richie", "RICKMORTY": "Rick And Morty", - "RIDE": "Ride My Car", + "RIDE": "Holoride", + "RIDEMY": "Ride My Car", "RIF": "RIF Token", "RIFI": "Rikkei Finance", "RIGEL": "Rigel Finance", @@ -5892,20 +6492,20 @@ "ROG": "ROGin AI", "ROI": "ROIcoin", "ROK": "Rockchain", - "RON": "Ronin", "RONCOIN": "RON", + "RONIN": "Ronin", "ROOBEE": "ROOBEE", "ROOK": "KeeperDAO", "ROOM": "OptionRoom", "ROOT": "RootCoin", "ROOTS": "RootProject", - "ROPE": "Rope Token", "ROS": "ROS Coin", "ROSE": "Oasis Labs", "ROSN": "Roseon Finance", "ROT": "Rotten", "ROUND": "RoundCoin", "ROUTE": "Router Protocol", + "ROWAN": "Sifchain", "ROX": "Robotina", "ROYA": "Royale", "ROYAL": "RoyalCoin", @@ -5925,11 +6525,13 @@ "RSF": "Royal Sting", "RSIN": "Roketsin", "RSR": "Reserve Rights", + "RSS3": "RSS3", "RST": "REGA Risk Sharing Token", "RSUN": "RisingSun", "RSV": "Reserve", "RT2": "RotoCoin", "RTB": "AB-CHAIN", + "RTC": "Reltime", "RTE": "Rate3", "RTH": "Rotharium", "RTM": "Raptoreum", @@ -5950,8 +6552,10 @@ "RUST": "RustCoin", "RUSTBITS": "Rustbits", "RUX": "Gacrux NFT", - "RVC": "Ravencoin Classic", - "RVF": "Rocket Vault", + "RVC": "Revenue Coin", + "RVF": "RocketX exchange", + "RVLNG": "RevolutionGames", + "RVLT": "Revolt 2 Earn", "RVN": "Ravencoin", "RVO": "AhrvoDEEX", "RVP": "Revolution Populi", @@ -5963,12 +6567,16 @@ "RWE": "Real-World Evidence", "RWN": "Rowan Token", "RWS": "Robonomics Web Services", + "RXD": "Radiant", + "RXT": "RIMAUNANGIS", "RYC": "RoyalCoin", "RYCN": "RoyalCoin 2.0", "RYO": "Ryo", + "RYOMA": "Ryoma", "RYOSHI": "Ryoshis Vision", "RYZ": "Anryze", "RZR": "RazorCoin", + "S2K": "Sports 2K75", "S4F": "S4FE", "S8C": "S88 Coin", "SABR": "SABR Coin", @@ -5987,6 +6595,8 @@ "SAGA": "SagaCoin", "SAI": "SAI", "SAITAMA": "Saitama Inu", + "SAITAMAV1": "Saitama v1", + "SAITANOBI": "Saitanobi", "SAITO": "Saito", "SAK": "SharkCoin", "SAKATA": "Sakata Inu", @@ -6007,6 +6617,7 @@ "SAP": "SwapAll", "SAPP": "Sapphire", "SAR": "Saren", + "SARCO": "Sarcophagus", "SAS": "Stand Share", "SASHIMI": "Sashimi", "SAT": "Satisfaction Token", @@ -6022,7 +6633,6 @@ "SBA": "simplyBrand", "SBC": "StableCoin", "SBCC": "Smart Block Chain City", - "SBD": "Steem Dollars", "SBE": "Sombe", "SBGO": "Bingo Share", "SBR": "Saber", @@ -6032,6 +6642,7 @@ "SBTC": "Super Bitcoin", "SC": "Siacoin", "SCA": "SiaClassic", + "SCAM": "Scam Coin", "SCAP": "SafeCapital", "SCAR": "Velhalla", "SCASH": "SpaceCash", @@ -6064,6 +6675,7 @@ "SCT": "ScryptToken", "SCTK": "SharesChain", "SCY": "Synchrony", + "SD": "Stader", "SDA": "SDChain", "SDAO": "SingularityDAO", "SDC": "ShadowCash", @@ -6074,6 +6686,7 @@ "SDRN": "Senderon", "SDS": "Alchemint Standards", "SDT": "TerraSDT", + "SDUSD": "SDUSD", "SDX": "SwapDEX", "SEA": "Second Exchange Alliance", "SEAL": "Seal Finance", @@ -6088,7 +6701,9 @@ "SEELE": "Seele", "SEEN": "SEEN", "SEER": "SEER", + "SEI": "Sei", "SEL": "SelenCoin", + "SELF": "SELFCrypto", "SEM": "Semux", "SEN": "Sentaro", "SENATE": "SENATE", @@ -6099,11 +6714,15 @@ "SENSO": "SENSO", "SENT": "Sentinel", "SEON": "Seedon", + "SEOR": "SEOR Network", "SEOS": "Smart Eye Operating System", "SEPA": "Secure Pad", "SEQ": "Sequence", + "SERG": "Seiren Games Network", "SERO": "Super Zero", "SERV": "Serve", + "SESSIA": "SESSIA", + "SETH": "sETH", "SETH2": "sETH2", "SETHER": "Sether", "SETS": "Sensitrust", @@ -6120,11 +6739,13 @@ "SFUEL": "SparkPoint Fuel", "SFUND": "Seedify.fund", "SFX": "SUBX FINANCE LAB", + "SG": "SocialGood", "SGB": "Songbird", "SGDX": "eToro Singapore Dollar", "SGE": "Society of Galactic Exploration", "SGLY": "Singularity", "SGN": "Signals Network", + "SGO": "SafuuGO", "SGOLD": "SpaceGold", "SGP": "SGPay", "SGR": "Sogur Currency", @@ -6144,10 +6765,12 @@ "SHEESH": "Sheesh it is bussin bussin", "SHEESHA": "Sheesha Finance", "SHELL": "Shell Token", + "SHERA": "Shera Tokens", "SHFL": "SHUFFLE!", "SHFT": "Shyft Network", "SHI": "Shirtum", "SHIB": "Shiba Inu", + "SHIB05": "Half Shiba Inu", "SHIBACASH": "ShibaCash", "SHIBAMOM": "Shiba Mom", "SHIBDOGE": "ShibaDoge", @@ -6178,6 +6801,8 @@ "SHR": "ShareToken", "SHREK": "ShrekCoin", "SHROOM": "Shroom.Finance", + "SHROOMFOX": "Magic Shroom", + "SHS": "SHEESH", "SHX": "Stronghold Token", "SI": "Siren", "SIB": "SibCoin", @@ -6185,9 +6810,11 @@ "SIDUS": "Sidus", "SIERRA": "Sierracoin", "SIFT": "Smart Investment Fund Token", + "SIFU": "SIFU", "SIG": "Signal", - "SIGN": "SignatureChain", + "SIGN": "Signin", "SIGNA": "Signa", + "SIGNAT": "SignatureChain", "SIGT": "Signatum", "SIGU": "Singular", "SIL": "SIL Finance Token V2", @@ -6197,6 +6824,7 @@ "SILO": "Silo Finance", "SILVERWAY": "Silverway", "SIMPLE": "SimpleChain", + "SIN": "Sinverse", "SINE": "Sinelock", "SINGLE": "Single Finance", "SINS": "SafeInsure", @@ -6210,6 +6838,7 @@ "SJCX": "StorjCoin", "SKB": "SkullBuzz", "SKC": "Skeincoin", + "SKEB": "Skeb", "SKET": "Sketch coin", "SKEY": "SmartKey", "SKI": "Skillchain", @@ -6234,7 +6863,7 @@ "SLC": "Solice", "SLEEP": "Sleep Ecosystem", "SLEEPEE": "SleepFuture", - "SLG": "SterlingCoin", + "SLG": "Land Of Conquest", "SLICE": "Tranche Finance", "SLICEC": "SLICE", "SLIM": "Solanium", @@ -6246,6 +6875,7 @@ "SLOKI": "Super Floki", "SLP": "Smooth Love Potion", "SLR": "SolarCoin", + "SLRR": "Solarr", "SLRS": "Solrise Finance", "SLS": "SaluS", "SLST": "SmartLands", @@ -6257,26 +6887,38 @@ "SMARTCREDIT": "SmartCredit Token", "SMARTLOX": "SmartLOX", "SMARTNFT": "SmartNFT", + "SMARTSHARE": "Smartshare", "SMARTUP": "Smartup", "SMAT": "Smathium", + "SMBR": "Sombra", "SMBSWAP": "SimbCoin Swap", "SMC": "SmartCoin", + "SMCW": "Space Misfits", "SMD": "SMD Coin", + "SMETA": "StarkMeta", "SMF": "SmurfCoin", "SMG": "Smaugs NFT", "SMI": "SafeMoon Inu", "SMILE": "Smile Token", + "SML": "Saltmarble", "SMLY": "SmileyCoin", "SMOKE": "Smoke", "SMON": "StarMon", + "SMOON": "SaylorMoon", "SMPL": "SMPL Foundation", + "SMR": "Shimmer", "SMRAT": "Secured MoonRat", "SMSR": "Samsara Coin", "SMT": "SmartMesh", + "SMTF": "SmartFi", "SMTY": "Smoothy", + "SNACK": "Crypto Snack", + "SNAP": "SnapEx", "SNB": "SynchroBitcoin", "SNC": "SunContract", "SND": "Sandcoin", + "SNE": "StrongNode", + "SNEK": "Snek", "SNET": "Snetwork", "SNFT.BITCI": "Spanish National Team Fan Token", "SNGLS": "SingularDTV", @@ -6317,16 +6959,20 @@ "SOLO": "Sologenic", "SOLR": "SolRazr", "SOLVE": "SOLVE", + "SOM": "Souls of Meta", "SOMA": "Soma", + "SOMNIUM": "Somnium Space CUBEs", "SON": "Simone", "SONAR": "SonarWatch", "SONG": "Song Coin", - "SOON": "SoonCoin", + "SOON": "Soonaverse", + "SOONCOIN": "SoonCoin", "SOP": "SoPay", "SORA": "Sora Validator Token", "SOSNOVKINO": "Sosnovkino", "SOTA": "SOTA Finance", "SOUL": "Phantasma", + "SOULS": "Soulsaver", "SOUND": "Sound Coin", "SOURCE": "ReSource Protocol", "SOV": "Sovryn", @@ -6340,6 +6986,7 @@ "SPAIN": "SpainCoin", "SPANK": "SpankChain", "SPARTA": "Spartan Protocol Token", + "SPAT": "Meta Spatial", "SPAY": "SpaceY 2025", "SPC": "SpaceChain ERC20", "SPC.QRC": "SpaceChain (QRC-20)", @@ -6383,21 +7030,28 @@ "SPRTZ": "SpritzCoin", "SPS": "Splinterlands", "SPT": "SPECTRUM", + "SPUME": "Spume", "SPWN": "Bitspawn", "SPX": "Sp8de", + "SPXC": "SpaceXCoin", + "SPY": "Smarty Pay", + "SQAT": "Syndiqate", "SQG": "Squid Token", "SQL": "Squall Coin", "SQR": "Squeezer", "SQUAWK": "Squawk", "SQUID": "Squid Game", + "SQUIDGROW": "SquidGrow", "SRC": "SecureCoin", "SRCOIN": "SRCoin", "SREUR": "SocialRemit", "SRK": "SparkPoint", + "SRLTY": "SaitaRealty", "SRM": "Serum", "SRN": "SirinLabs", "SRNT": "Serenity", "SRP": "Starpunk", + "SRT": "Smart Reward Token", "SRWD": "ShibRWD", "SRX": "StorX", "SS": "Sharder", @@ -6406,7 +7060,6 @@ "SSG": "Surviving Soldiers", "SSGT": "Safeswap", "SSH": "StreamSpace", - "SSP": "Smartshare", "SSS": "StarSharks", "SST": "SIMBA Storage Token", "SSTC": "SunShotCoin", @@ -6428,33 +7081,43 @@ "STARBASE": "Starbase", "STARC": "StarChain", "STARL": "StarLink", + "STARLAUNCH": "StarLaunch", "STARLY": "Starly", "STARP": "Star Pacific Coin", - "STARS": "StarLaunch", + "STARS": "Mogul Productions", "STARSH": "StarShip Token", "START": "StartCoin", "STARTA": "Starta", "STASH": "BitStash", + "STAT": "STAT", + "STATER": "Stater", "STATERA": "Statera", "STAX": "Staxcoin", "STBU": "Stobox Token", - "STC": "Student Coin", + "STC": "Satoshi Island", "STCN": "Stakecoin", "STEEM": "Steem", + "STEEMD": "Steem Dollars", "STEEP": "SteepCoin", "STEN": "Steneum Coin", "STEP": "Step Finance", "STEPH": "Step Hero", + "STEPR": "Step", "STEPS": "Steps", + "STERLINGCOIN": "SterlingCoin", "STETH": "Staked Ether", + "STEWIE": "Stewie Coin", "STEX": "STEX", "STF": "Structure Finance", + "STFX": "STFX", "STG": "Stargate Finance", "STHR": "Stakerush", + "STI": "Seek Tiger", "STING": "Sting", "STK": "STK Token", "STKAAVE": "Staked Aave", "STKATOM": "pSTAKE Staked ATOM", + "STKK": "Streakk", "STKXPRT": "pSTAKE Staked XPRT", "STMAN": "Stickman Battleground", "STMX": "StormX", @@ -6475,26 +7138,33 @@ "STPL": "Stream Protocol", "STPT": "STP Network", "STQ": "Storiqa Token", + "STR": "Sourceless", "STRAKS": "Straks", "STRAX": "Stratis", - "STRAY": "Animal Token", + "STRAY": "Stray Dog", "STREAM": "STREAMIT COIN", + "STRIP": "Stripto", "STRK": "Strike", "STRM": "StreamCoin", "STRNGR": "Stronger", "STRONG": "Strong", "STRP": "Strips Finance", "STRS": "STARS", + "STRX": "StrikeX", "STS": "SBank", "STSOL": "Lido Staked SOL", + "STSR": "SatelStar", "STU": "BitJob", + "STUDENTC": "Student Coin", "STV": "Sativa Coin", "STX": "Stacks", "STZ": "99Starz", "STZEN": "StakedZEN", "SUB": "Substratum Network", "SUCR": "Sucre", + "SUDO": "sudoswap", "SUGAR": "Sugar Exchange", + "SUI": "Sui", "SUKU": "SUKU", "SUM": "SumSwap", "SUMO": "Sumokoin", @@ -6507,7 +7177,7 @@ "SUP": "Supcoin", "SUP8EME": "SUP8EME Token", "SUPE": "Supe Infinity", - "SUPER": "SuperFarm", + "SUPER": "SuperVerse", "SUPERBID": "SuperBid", "SUPERC": "SuperCoin", "SUPERTX": "SuperTX", @@ -6518,6 +7188,7 @@ "SUSD": "sUSD", "SUSHI": "Sushi", "SUTER": "Suterusu", + "SUZUME": "Shita-kiri Suzume", "SVD": "savedroid", "SVS": "GivingToServices SVS", "SVT": "Solvent", @@ -6534,6 +7205,7 @@ "SWC": "Scanetchain Token", "SWD": "SW DAO", "SWDAO": "Super Whale DAO", + "SWEAT": "Sweat Economy", "SWEET": "SweetStake", "SWFL": "Swapfolio", "SWFTC": "SWFTCoin", @@ -6546,14 +7218,15 @@ "SWP": "Kava Swap", "SWRV": "Swerve", "SWT": "Swarm City Token", - "SWTH": "Switcheo", + "SWTH": "Carbon", "SWYFTT": "SWYFT", "SX": "SX Network", "SXC": "SexCoin", "SXDT": "SPECTRE Dividend Token", - "SXP": "Swipe", + "SXP": "SXP", "SXUT": "SPECTRE Utility Token", "SYBC": "SYB Coin", + "SYBTC": "sBTC", "SYC": "SynchroCoin", "SYL": "XSL Labs", "SYLO": "Sylo", @@ -6565,12 +7238,16 @@ "SYNLEV": "SynLev", "SYNR": "MOBLAND", "SYNX": "Syndicate", + "SYPOOL": "Sypool", "SYS": "SysCoin", "T": "Threshold Network Token", "TAAS": "Token as a Service", "TAB": "MollyCoin", "TABOO": "Taboo Token", "TAC": "Traceability Chain", + "TACHYON": "Tachyon Protocol", + "TAD": "Tadpole Finance", + "TAF": "TAF", "TAGR": "Think And Get Rich Coin", "TAI": "tBridge Token", "TAIYO": "Taiyo", @@ -6579,12 +7256,18 @@ "TAKI": "Taki", "TALAO": "Talao", "TALK": "Talken", + "TAMA": "Tamadoge", "TAN": "Taklimakan", "TANGO": "keyTango", "TANK": "CryptoTanks", - "TAP": "TappingCoin", + "TAO": "Fusotao", + "TAP": "TAP FANTASY", "TAPC": "Tap Coin", + "TAPPINGCOIN": "TappingCoin", + "TAPT": "Tortuga Staked Aptos", "TARA": "Taraxa", + "TARI": "Tari World", + "TAROT": "Tarot", "TARP": "Totally A Rug Pull", "TAS": "TARUSH", "TASH": "Smart Trip Platform", @@ -6592,11 +7275,13 @@ "TAT": "Tatcoin", "TAU": "Lamden Tau", "TAUC": "Taurus Coin", + "TAUM": "Orbitau Taureum", "TAUR": "Marnotaur", + "TAVA": "ALTAVA", "TBAC": "BlockAura", "TBAR": "Titanium BAR", "TBB": "Trade Butler Bot", - "TBC": "Trecento Blockchain Capital", + "TBC": "Ten Best Coins", "TBCC": "TBCC", "TBCX": "TrashBurn", "TBE": "TrustBase", @@ -6608,6 +7293,7 @@ "TCANDY": "TripCandy", "TCAP": "Total Crypto Market Cap", "TCC": "The ChampCoin", + "TCG2": "TCG Coin 2.0", "TCH": "Thorecash", "TCHAIN": "Tchain", "TCHB": "Teachers Blockchain", @@ -6622,14 +7308,17 @@ "TD": "Trade Chain", "TDE": "Trade Ecology Token", "TDFB": "TDFB", + "TDFY": "Tidefi", "TDP": "TrueDeck", "TDROP": "ThetaDrop", "TDS": "TokenDesk", + "TDX": "Tidex Token", "TEAM": "TeamUP", "TEC": "TeCoin", "TECH": "TechCoin", "TECRA": "TecraCoin", "TEDDY": "Teddy Doge", + "TEER": "Integritee", "TEK": "TekCoin", "TEL": "Telcoin", "TELE": "Miracle Tele", @@ -6660,6 +7349,7 @@ "TFC": "The Freedom Coin", "TFI": "TrustFi Network Token", "TFL": "True Flip Lottery", + "TFLOW": "TradeFlow", "TFS": "TFS Token", "TFT": "The Famous Token", "TFUEL": "Theta Fuel", @@ -6668,10 +7358,13 @@ "TGCC": "TheGCCcoin", "TGR": "Tegro", "TGT": "TargetCoin", + "THALES": "Thales", "THC": "The Hempcoin", + "THECITADEL": "The Citadel", "THEDAO": "The DAO", "THEMIS": "Themis", - "THETA": "Theta", + "THEOS": "Theos", + "THETA": "Theta Network", "THEX": "Thore Exchange", "THG": "Thetan Arena", "THN": "Throne", @@ -6692,12 +7385,15 @@ "TIE": "Ties Network", "TIFI": "TiFi Token", "TIG": "Tigereum", + "TIGER": "JungleKing TigerCoin", "TIIM": "TriipMiles", "TIKI": "Tiki Token", + "TIKTOKEN": "TikToken", "TIME": "Chrono.tech", "TIMI": "Timicoin", "TINC": "Tiny Coin", "TINKU": "TinkuCoin", + "TINU": "Telegram Inu", "TIOX": "TIOx", "TIP": "Tip Blockchain", "TIPS": "FedoraCoin", @@ -6715,6 +7411,7 @@ "TKMN": "Tokemon", "TKN": "Monolith", "TKO": "Tokocrypto", + "TKP": "TOKPIE", "TKR": "CryptoInsight", "TKS": "Tokes", "TKT": "Crypto Tickets", @@ -6726,6 +7423,7 @@ "TME": "Timereum", "TMED": "MDsquare", "TMN": "TranslateMe", + "TMON": "Two Monkey Juice Bar", "TMT": "Tamy Token", "TMTG": "The Midas Touch Gold", "TN": "TurtleNetwork", @@ -6742,20 +7440,27 @@ "TOKE": "Tokemak", "TOKEN": "Chainswap", "TOKENSTARS": "TokenStars", + "TOKKI": "CRYPTOKKI", "TOKO": "ToKoin", "TOKU": "TokugawaCoin", "TOL": "Tolar", - "TOM": "Tomahawkcoin", + "TOM": "TOM Finance", + "TOMAHAWKCOIN": "Tomahawkcoin", "TOMB": "Tomb", + "TOMI": "tomiNet", "TOMO": "TomoChain", "TOMOE": "TomoChain ERC20", + "TOMS": "TomTomCoin", "TON": "Tokamak Network", "TONCOIN": "The Open Network", "TONE": "TE-FOOD", "TONIC": "Tectonic", "TONTOKEN": "TONToken", + "TOOB": "Toobcoin", "TOOLS": "TOOLS", + "TOON": "Pontoon", "TOPC": "Topchain", + "TOPG": "Tate Token", "TOPN": "TOP Network", "TOR": "TOR", "TORG": "TORG", @@ -6767,7 +7472,9 @@ "TOTM": "Totem", "TOWER": "Tower", "TOWN": "Town Star", + "TOX": "INTOverse", "TOZ": "Tozex", + "TP": "Token Swap", "TPAD": "TrustPad", "TPAY": "TokenPay", "TPC": "Techpay", @@ -6785,6 +7492,7 @@ "TRAT": "Tratok", "TRAVA": "Trava Finance", "TRAXIA": "Traxia Membership Token", + "TRAXX": "Traxx", "TRB": "Tellor", "TRBT": "Tribute", "TRC": "TerraCoin", @@ -6792,8 +7500,10 @@ "TRCL": "Treecle", "TRCT": "Tracto", "TRDC": "Traders Coin", + "TRDL": "Strudel Finance", "TRDS": "Traders Token", "TRDT": "Trident", + "TRECENTO": "Trecento Blockchain Capital", "TREE": "HyperionX", "TREEB": "Retreeb", "TRET": "Tourist Review", @@ -6803,7 +7513,9 @@ "TRIAS": "Trias", "TRIBE": "Tribe", "TRIBETOKEN": "TribeToken", + "TRIBL": "Tribal Token", "TRICK": "TrickyCoin", + "TRICKLE": "Trickle", "TRIG": "Trigger", "TRINI": "Trinity Network Credit", "TRIO": "Tripio", @@ -6811,6 +7523,7 @@ "TRIX": "TriumphX", "TRK": "TruckCoin", "TRL": "Triall", + "TRNDZ": "Trendsy", "TROLL": "Trollcoin", "TRONPAD": "TRONPAD", "TROP": "Interop", @@ -6844,6 +7557,7 @@ "TSHP": "12Ships", "TSL": "Energo", "TSR": "Tesra", + "TSUKA": "Dejitaru Tsuka", "TSX": "TradeStars", "TT": "ThunderCore", "TTC": "TTC PROTOCOL", @@ -6865,6 +7579,7 @@ "TWC": "Twilight", "TWD": "Terra World Token", "TWEE": "TWEEBAA", + "TWEP": "The Web3 Project", "TWIN": "Twinci", "TWIST": "TwisterCoin", "TWLV": "Twelve Coin", @@ -6875,10 +7590,11 @@ "TYC": "Tycoon", "TYCOON": "CryptoTycoon", "TYPE": "Typerium", + "TYRANT": "Fable Of The Dragon", "TYT": "Tianya Token", "TZC": "TrezarCoin", "TZKI": "Tsuzuki Inu", - "U": "Ucoin", + "U": "Unidef", "U8D": "Universal Dollar", "UAEC": "United Arab Emirates Coin", "UAT": "UltrAlpha", @@ -6895,15 +7611,19 @@ "UBXT": "UpBots", "UC": "YouLive Coin", "UCA": "UCA Coin", + "UCAP": "Unicap.finance", "UCASH": "U.CASH", + "UCG": "Universe Crystal Gene", "UCH": "UChain", "UCN": "UC Coin", "UCO": "Uniris", + "UCOIN": "Ucoin", "UCT": "UnitedCrowd", "UDO": "Unido", - "UDOO": "Howdoo", + "UDOO": "Hyprr", "UDT": "Unlock Protocol", "UEC": "United Emirates Coin", + "UEDC": "United Emirate Decentralized Coin", "UENC": "UniversalEnergyChain", "UET": "Useless Ethereum Token", "UETL": "Useless Eth Token Lite", @@ -6930,6 +7650,7 @@ "UM": "UncleMine", "UMA": "UMA", "UMAD": "MADworld", + "UMAMI": "Umami", "UMB": "Umbrella Network", "UMBR": "Umbria Network", "UMC": "Umbrella Coin", @@ -6955,28 +7676,31 @@ "UNIC": "Unicly", "UNICORN": "UNICORN Token", "UNIDX": "UniDex", - "UNIFI": "Unifi", "UNIFY": "Unify", "UNIM": "Unicorn Milk", "UNIQ": "Uniqredit", "UNIQUE": "Unique One", "UNISTAKE": "Unistake", "UNIT": "Universal Currency", + "UNITED": "UnitedCoins", "UNITRADE": "UniTrade", "UNITS": "GameUnits", "UNITY": "SuperNET", "UNIVRS": "Universe", "UNIX": "UniX", + "UNLEASH": "UnleashClub", "UNN": "UNION Protocol Governance Token", "UNO": "Unobtanium", "UNORE": "UnoRe", "UNQ": "UNQ", "UNQT": "Unique Utility Token", + "UNR": "Unirealchain", "UNRC": "UniversalRoyalCoin", "UNW": "UniWorld", "UOP": "Utopia Genesis Foundation", "UOS": "UOS", "UP": "UpToken", + "UPCG": "Upcomings", "UPCO2": "Universal Carbon", "UPCOIN": "UPcoin", "UPEUR": "Universal Euro", @@ -6985,6 +7709,7 @@ "UPP": "Sentinel Protocol", "UPR": "Upfire", "UPT": "Universal Protocol Token", + "UPUNK": "Unicly CryptoPunks Collection", "UPUSD": "Universal US Dollar", "UPX": "uPlexa", "UQC": "Uquid Coin", @@ -6992,6 +7717,7 @@ "URAC": "Uranus", "URALS": "Urals Coin", "URO": "UroCoin", + "URQA": "UREEQA", "URUS": "Urus Token", "URX": "URANIUMX", "USAT": "USAT", @@ -7007,6 +7733,7 @@ "USDFL": "USDFreeLiquidity", "USDG": "USDG", "USDH": "HonestCoin", + "USDI": "Interest Protocol USDi", "USDJ": "USDJ", "USDK": "USDK", "USDN": "Neutrino USD", @@ -7017,6 +7744,7 @@ "USDT": "Tether", "USDU": "Upper Dollar", "USDX": "USDX Stablecoin", + "USDZ": "Zedxion USDZ", "USE": "Usechain Token", "USG": "USGold", "USHIBA": "American Shiba", @@ -7034,6 +7762,7 @@ "UTT": "United Traders Token", "UTU": "UTU Protocol", "UUU": "U Network", + "UWU": "uwu", "UZUMAKI": "Uzumaki Inu", "VAB": "Vabble", "VADER": "Vader Protocol", @@ -7048,12 +7777,15 @@ "VANY": "Vanywhere", "VAPOR": "Vaporcoin", "VARIUS": "Varius", + "VBG": "Vibing", + "VBIT": "Valobit", "VBK": "VeriBlock", "VBSC": "Votechain", "VBT": "VB Token", "VCF": "Valencia CF Fan Token", "VCG": "VCGamers", "VCK": "28VCK", + "VCORE": "VCORE", "VDG": "VeriDocGlobal", "VDL": "Vidulum", "VDO": "VidioCoin", @@ -7067,7 +7799,9 @@ "VEG": "BitVegan", "VEGA": "Vega Protocol", "VEIL": "VEIL", + "VELA": "Vela Token", "VELO": "Velo", + "VELOD": "Velodrome Finance", "VELOX": "Velox", "VELOXPROJECT": "Velox", "VEMP": "vEmpire DDAO", @@ -7077,9 +7811,11 @@ "VENTION": "Vention", "VENUS": "VenusEnergy", "VEO": "Amoveo", + "VER": "VersalNFT", "VERA": "Vera", "VERI": "Veritaseum", "VERSA": "Versa Token", + "VERSE": "Verse", "VERTEX": "Vertex", "VEST": "VestChain", "VESTA": "Vestarin", @@ -7088,12 +7824,14 @@ "VFOX": "VFOX", "VGO": "Vagabond", "VGX": "Voyager Token", + "VHC": "Vault Hill City", "VI": "Vid", "VIA": "ViaCoin", "VIB": "Viberate", "VIBE": "VIBEHub", "VIBLO": "VIBLO", "VIC": "Victorium", + "VICA": "ViCA Token", "VICEX": "ViceToken", "VID": "VideoCoin", "VIDT": "VIDT Datalink", @@ -7125,21 +7863,27 @@ "VLT": "Veltor", "VLTC": "VaultCoin", "VLTX": "Volentix", + "VLTY": "Vaulty", "VLX": "Velas", "VLXPAD": "VelasPad", "VMC": "VirtualMining Coin", "VME": "TrueVett", "VNDC": "VNDC", + "VNES": "Vanesse", + "VNM": "Venom", "VNT": "VNT Chain", "VNTW": "Value Network Token", "VNX": "VisionX", + "VNXAU": "VNX Gold", "VNXLU": "VNX Exchange", "VOCO": "Provoco", "VODKA": "Vodka Token", "VOISE": "Voise", "VOL": "Volume Network", "VOLLAR": "Vollar", + "VOLR": "Volare Network", "VOLT": "Volt Inu", + "VOLTOLD": "Volt Inu (Old)", "VOOT": "VootCoin", "VOT": "Votecoin", "VOW": "Vow", @@ -7153,8 +7897,10 @@ "VR": "Victoria", "VRA": "Verasity", "VRC": "VeriCoin", + "VRGW": "Virtual Reality Game World", "VRM": "Verium", "VRN": "Varen", + "VRO": "VeraOne", "VRP": "Prosense.tv", "VRS": "Veros", "VRSC": "Verus Coin", @@ -7196,9 +7942,12 @@ "WAG": "WagyuSwap", "WAGE": "Digiwage", "WAGG": "Waggle Network", + "WAGMIGAMES": "WAGMI Game", "WAI": "Wanaka Farm WAIRERE Token", "WAIF": "Waifu Token", + "WAL": "The Wasted Lands", "WALLET": "Ambire Wallet", + "WALV": "Alvey Chain", "WAM": "Wam", "WAMPL": "Wrapped Ampleforth", "WAN": "Wanchain", @@ -7213,6 +7962,7 @@ "WASH": "WashingtonCoin", "WAVES": "Waves", "WAXE": "WAXE", + "WAXL": "Wrapped Axelar", "WAXP": "Worldwide Asset eXchange", "WAY": "WayCoin", "WBB": "Wild Beast Coin", @@ -7220,6 +7970,7 @@ "WBET": "Wavesbet", "WBIND": "Wrapped BIND", "WBNB": "Wrapped BNB", + "WBT": "WhiteBIT Token", "WBTC": "Wrapped Bitcoin", "WBX": "WiBX", "WCC": "Wincash Coin", @@ -7229,11 +7980,13 @@ "WCG": "World Crypto Gold", "WCOIN": "WCoin", "WCS": "Weecoins", + "WCSOV": "Wrapped CrownSterling", "WCT": "Waves Community Token", "WCUSD": "Wrapped Celo Dollar", "WDC": "WorldCoin", "WDR": "Wider Coin", "WDX": "WeiDex", + "WE": "WeBuy", "WEALTH": "WealthCoin", "WEAR": "MetaWear", "WEB": "Webcoin", @@ -7242,10 +7995,11 @@ "WEC": "Whole Earth Coin", "WEGEN": "WeGen Platform", "WELD": "Weld", - "WELL": "Well", + "WELL": "Moonwell", + "WELLTOKEN": "Well", "WELT": "Fabwelt", "WELUPS": "Welups Blockchain", - "WEMIX": "Wemix Token", + "WEMIX": "WEMIX", "WENLAMBO": "Wenlambo", "WEST": "Waves Enterprise", "WET": "WeShow Token", @@ -7274,7 +8028,7 @@ "WICC": "WaykiChain", "WIFEDOGE": "Wifedoge", "WIFI": "Wifi Coin", - "WIKEN": "WITH", + "WIKEN": "Project WITH", "WIKI": "Wiki Token", "WILC": "Wrapped ILCOIN", "WILD": "Wilder World", @@ -7285,6 +8039,7 @@ "WINGS": "Wings DAO", "WINK": "Wink", "WINR": "JustBet", + "WINRY": "Winry Inu", "WINT": "WinToken", "WIRTUAL": "Wirtual", "WIS": "Experty Wisdom Token", @@ -7292,21 +8047,29 @@ "WISE": "Wise Token", "WISH": "MyWish", "WIT": "Witnet", + "WITCH": "Witch", "WITCOIN": "Witcoin", "WIX": "Wixlar", - "WIZ": "Crowdwiz", + "WIZ": "WIZ Protocol", "WKD": "Wakanda Inu", + "WLD": "Worldcoin", "WLF": "Wolfs Group", "WLITI": "wLITI", "WLK": "Wolk", + "WLKN": "Walken", "WLO": "WOLLO", "WLUNA": "Wrapped LUNA Token", + "WLXT": "Wallex Token", "WMATIC": "Wrapped Matic", "WMB": "WatermelonBlock", "WMC": "WMCoin", + "WMEMO": "Wonderful Memories", + "WMF": "Whale Maker Fund", + "WMINIMA": "Wrapped Minima", "WMT": "World Mobile Token", "WNCG": "Wrapped NCG", "WND": "WonderHero", + "WNDR": "Wonderman Nation", "WNET": "Wavesnode.net", "WNK": "The Winkyverse", "WNRZ": "WinPlay", @@ -7315,12 +8078,15 @@ "WNZ": "Winerz", "WOA": "Wrapped Origin Axie", "WOD": "World of Defish", + "WOID": "WORLD ID", "WOJ": "Wojak Finance", "WOLF": "Insanity Coin", "WOLFILAND": "Wolfiland", "WOLFY": "WOLFY", "WOLVERINU": "WOLVERINU", "WOM": "WOM", + "WOMB": "Wombat Exchange", + "WOMBAT": "Wombat", "WOMEN": "WomenCoin", "WOMI": "Wrapped ECOMI", "WON": "WeBlock", @@ -7330,6 +8096,7 @@ "WOOFY": "Woofy", "WOOL": "Wolf Game Wool", "WOONK": "Woonkly", + "WOOO": "wooonen", "WOOP": "Woonkly Power", "WOP": "WorldPay", "WORLD": "World Token", @@ -7340,6 +8107,7 @@ "WOZX": "Efforce", "WPC": "WePiggy Coin", "WPE": "OPES (Wrapped PE)", + "WPLS": "Wrapped Pulse", "WPP": "Green Energy Token", "WPR": "WePower", "WQT": "Work Quest", @@ -7350,21 +8118,27 @@ "WRX": "WazirX", "WRZ": "Weriz", "WSB": "WallStreetBets DApp", + "WSBABY": "Wall Street Baby", "WSCRT": "Secret ERC20", "WSDOGE": "Doge of Woof Street", "WSG": "Wall Street Games", + "WSI": "WeSendit", "WSIENNA": "Sienna ERC20", "WSTETH": "Lido wstETH", + "WSTR": "Wrapped Star", "WSX": "WeAreSatoshi", "WT": "WeToken", "WTC": "Waltonchain", - "WTF": "WTF Token", + "WTF": "Waterfall Governance", + "WTFT": "WTF Token", "WTK": "WadzPay Token", "WTL": "Welltrado", + "WTN": "Wateenswap", "WTON": "Wrapped TON Crystal", "WTT": "Giga Watt", "WUST": "Wrapped UST Token", "WWB": "Wowbit", + "WWDOGE": "Wrapped WDOGE", "WWY": "WeWay", "WXDAI": "Wrapped XDAI", "WXT": "WXT", @@ -7373,10 +8147,14 @@ "WZEC": "Wrapped Zcash", "WZENIQ": "Wrapped Zeniq (ETH)", "WZRD": "Wizardia", + "X": "AI-X", "X2": "X2Coin", "X2Y2": "X2Y2", "X42": "X42 Protocol", + "X7DAO": "X7DAO", + "X7R": "X7R", "X8X": "X8Currency", + "XACT": "XactToken", "XAEAXII": "XAEA-Xii Token", "XAI": "SideShift Token", "XAMP": "Antiample", @@ -7401,6 +8179,7 @@ "XBOT": "SocialXbotCoin", "XBP": "Black Pearl Coin", "XBS": "Bitstake", + "XBT": "Xbit", "XBTS": "Beats", "XBX": "BiteX", "XBY": "XTRABYTES", @@ -7416,8 +8195,9 @@ "XCI": "Cannabis Industry Coin", "XCLR": "ClearCoin", "XCM": "CoinMetro", - "XCN": "Chain", + "XCN": "Onyxcoin", "XCO": "XCoin", + "XCONSOL": "X-Consoles", "XCP": "CounterParty", "XCPO": "Copico", "XCR": "Crypti", @@ -7428,9 +8208,11 @@ "XCXT": "CoinonatX", "XD": "Data Transaction Token", "XDAG": "Dagger", + "XDAI": "XDAI", "XDATA": "Streamr XDATA", "XDB": "DragonSphere", "XDC": "Xinfin Network", + "XDCE": "XinFin Coin", "XDEF2": "Xdef Finance", "XDEFI": "XDEFI", "XDEN": "Xiden", @@ -7446,12 +8228,14 @@ "XEL": "Xel", "XELS": "XELS Coin", "XEM": "NEM", - "XEN": "XenixCoin", + "XEN": "XEN Crypto", "XEND": "Xend Finance", + "XENIX": "XenixCoin", "XENO": "Xenoverse", "XEP": "Electra Protocol", "XES": "Proxeus", "XET": "Xfinite Entertainment Token", + "XETA": "Xana", "XETH": "Xplosive Ethereum", "XFC": "Football Coin", "XFI": "Xfinance", @@ -7462,18 +8246,20 @@ "XFYI": "XCredit", "XG": "XG Sports", "XGB": "GoldenBird", + "XGLI": "Glitter Finance", "XGOX": "Go!", "XGR": "GoldReserve", "XGT": "Xion Finance", "XHI": "HiCoin", "XHT": "HollaEx", "XHV": "Haven Protocol", + "XI": "Xi", "XIASI": "Xiasi Inu", "XID": "Sphre AIR", "XIDO": "Xido Finance", - "XIL": "ProjectX", + "XIL": "Xillion", "XIN": "Mixin", - "XIO": "XIO", + "XIO": "Blockzero Labs", "XIOS": "Xios", "XIOT": "Xiotri", "XIV": "Project Inverse", @@ -7488,15 +8274,18 @@ "XLQ": "Alqo", "XLR": "Solaris", "XLT": "Nexalt", + "XMARK": "xMARK", "XMC": "Monero Classic", "XMCC": "Monoeci", "XMG": "Coin Magi", "XMN": "Motion", "XMO": "Monero Original", "XMON": "XMON", + "XMP": "Mapt.Coin", "XMR": "Monero", "XMRG": "Monero Gold", "XMS": "Megastake", + "XMT": "MetalSwap", "XMV": "MoneroV", "XMX": "XMax", "XMY": "MyriadCoin", @@ -7512,7 +8301,9 @@ "XNS": "Insolar", "XNT": "Exenium", "XNX": "XanaxCoin", + "XODEX": "Xodex", "XOR": "Sora", + "XOT": "Okuru", "XOV": "XOVBank", "XP": "Experience Points", "XPAT": "Bitnation Pangea", @@ -7522,6 +8313,7 @@ "XPD": "PetroDollar", "XPH": "PharmaCoin", "XPL": "Exclusive Platform", + "XPLA": "XPLA", "XPM": "PrimeCoin", "XPN": "PANTHEON X", "XPNET": "XP Network", @@ -7541,7 +8333,7 @@ "XQC": "Quras Token", "XQN": "Quotient", "XQR": "Qredit", - "XRA": "Ratecoin", + "XRA": "Xriba", "XRC": "xRhodium", "XRD": "Radix", "XRE": "RevolverCoin", @@ -7560,6 +8352,7 @@ "XSP": "XSwap", "XSPC": "SpectreSecurityCoin", "XSPEC": "Spectre", + "XSPECTAR": "xSPECTAR", "XSPT": "PoolStamp", "XSR": "Xensor", "XST": "StealthCoin", @@ -7569,6 +8362,7 @@ "XT": "XT.com Token", "XT3": "Xt3ch", "XTAG": "xHashtag", + "XTAL": "XTAL", "XTC": "TileCoin", "XTK": "xToken", "XTM": "TORUM", @@ -7576,6 +8370,7 @@ "XTP": "Tap", "XTRA": "ExtraCredit", "XTREME": "ExtremeCoin", + "XTUSD": "XT Stablecoin XTUSD", "XTX": "Xtock", "XTZ": "Tezos", "XUC": "Exchange Union", @@ -7593,6 +8388,7 @@ "XWT": "World Trade Funds", "XXA": "Ixinium", "XXX": "XXXCoin", + "XY": "XY Finance", "XYM": "Symbol", "XYO": "XY Oracle", "XYZ": "Universe.XYZ", @@ -7610,13 +8406,18 @@ "YBC": "YbCoin", "YCC": "Yuan Chain Coin", "YCE": "MYCE", + "YCO": "Y Coin", + "YCT": "Youclout", + "YDF": "Yieldification", "YDR": "YDragon", "YEC": "Ycash", "YEE": "Yeeco", "YEED": "Yggdrash", "YEFI": "YeFi", "YEL": "Yel.Finance", - "YES": "YesCoin", + "YEON": "Yeon", + "YES": "Yes World", + "YESCOIN": "YesCoin", "YETI": "Yeti Finance", "YETU": "Yetucoin", "YFARM": "YFARM Token", @@ -7663,6 +8464,7 @@ "YUANG": "Yuang Coin", "YUCJ": "Yu Coin", "YUCT": "Yucreat", + "YUDI": "Yudi", "YUM": "Yumerium", "YUMMY": "Yummy", "YUP": "Crowdholding", @@ -7678,6 +8480,7 @@ "ZANO": "Zano", "ZAP": "Zap", "ZARX": "eToro South African Rand", + "ZASH": "ZIMBOCASH", "ZAT": "ZatGo", "ZB": "ZB", "ZBC": "Zebec Protocol", @@ -7686,7 +8489,7 @@ "ZCG": "ZCashGOLD", "ZCHN": "Zichain", "ZCL": "ZClassic", - "ZCN": "0chain", + "ZCN": "Züs", "ZCO": "Zebi Coin", "ZCON": "Zcon Protocol", "ZCOR": "Zrocor", @@ -7697,6 +8500,8 @@ "ZEC": "ZCash", "ZECD": "ZCashDarkCoin", "ZED": "ZedCoins", + "ZEDTOKEN": "Zed Token", + "ZEDXION": "Zedxion", "ZEE": "ZeroSwap", "ZEFU": "Zenfuse", "ZEIT": "ZeitCoin", @@ -7717,7 +8522,10 @@ "ZET2": "Zeta2Coin", "ZEUM": "Colizeum", "ZFL": "Zuflo Coin", + "ZFM": "ZFMCOIN", + "ZGD": "ZambesiGold", "ZIG": "Zignaly", + "ZIK": "Ziktalk", "ZIL": "Zilliqa", "ZILBERCOIN": "Zilbercoin", "ZINC": "ZINC", @@ -7727,6 +8535,7 @@ "ZIRVE": "Zirve Coin", "ZIX": "ZIX Token", "ZJLT": "ZJLT Distributed Factoring Network", + "ZKBOB": "BOB", "ZKP": "Panther Protocol", "ZKS": "ZKSpace", "ZKT": "zkTube", @@ -7742,6 +8551,7 @@ "ZND": "Zenad", "ZNE": "ZoneCoin", "ZNN": "Zenon", + "ZNT": "Zenith Finance", "ZNY": "BitZeny", "ZNZ": "ZENZO", "ZOC": "01coin", @@ -7754,9 +8564,11 @@ "ZOOM": "ZoomCoin", "ZOON": "CryptoZoon", "ZOOT": "Zoo Token", + "ZORA": "Zoracles", "ZORT": "Zort", "ZP": "Zen Protocol", "ZPAE": "ZelaaPayAE", + "ZPAY": "ZoidPay", "ZPR": "ZPER", "ZPT": "Zeepin", "ZPTC": "Zeptacoin", @@ -7771,6 +8583,7 @@ "ZUM": "ZumCoin", "ZUNA": "ZUNA", "ZUR": "Zurcoin", + "ZURR": "ZURRENCY", "ZUSD": "ZUSD", "ZUT": "Zero Utility Token", "ZVC": "ZVCHAIN", @@ -7782,11 +8595,12 @@ "ZYR": "Zyrri", "ZYRO": "Zyro", "ZYTARA": "Zytara dollar", + "ZZ": "ZigZag", + "ZZC": "ZudgeZury", "ZZZ": "zzz.finance", "eFIC": "FIC Network", "ePRX": "eProxy", "gOHM": "Governance OHM", - "pBTC35A": "pBTC35A", "redBUX": "redBUX", "sOHM": "Staked Olympus", "wsOHM": "Wrapped Staked Olympus" diff --git a/apps/api/src/assets/cryptocurrencies/custom.json b/apps/api/src/assets/cryptocurrencies/custom.json index 2ccad3881..1215114bb 100644 --- a/apps/api/src/assets/cryptocurrencies/custom.json +++ b/apps/api/src/assets/cryptocurrencies/custom.json @@ -1,4 +1,5 @@ { + "CYBER24781": "CyberConnect", "LUNA1": "Terra", "LUNA2": "Terra", "SGB1": "Songbird", diff --git a/apps/api/src/assets/sitemap.xml b/apps/api/src/assets/sitemap.xml new file mode 100644 index 000000000..2a3650752 --- /dev/null +++ b/apps/api/src/assets/sitemap.xml @@ -0,0 +1,943 @@ + + + + https://ghostfol.io/de + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/blog + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/blog/2021/07/hallo-ghostfolio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/features + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/haeufig-gestellte-fragen + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/maerkte + daily + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/open + daily + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/preise + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/registrierung + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-altoo + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capmon + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-copilot-money + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-delta + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-divvydiary + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-exirio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finary + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-folishare + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-getquin + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-gospatz + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-justetf + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-kubera + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-markets.sh + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-maybe-finance + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-monse + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-parqet + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-plannix + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-portfolio-dividend-tracker + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-portseido + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-projectionlab + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-seeking-alpha + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sharesight + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-simple-portfolio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-snowball-analytics + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-stockle + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-stockmarketeye + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sumio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-utluna + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-yeekatee + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ueber-uns + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ueber-uns/changelog + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ueber-uns/datenschutzbestimmungen + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ueber-uns/lizenz + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ueber-uns/oss-friends + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/about + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/about/changelog + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/about/license + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/about/oss-friends + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog/2021/07/hello-ghostfolio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog/2022/01/ghostfolio-first-months-in-open-source + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog/2022/07/ghostfolio-meets-internet-identity + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog/2022/07/how-do-i-get-my-finances-in-order + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog/2022/08/500-stars-on-github + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog/2022/10/hacktoberfest-2022 + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog/2022/11/black-friday-2022 + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog/2022/12/the-importance-of-tracking-your-personal-finances + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog/2023/02/ghostfolio-meets-umbrel + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog/2023/07/exploring-the-path-to-fire + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog/2023/08/ghostfolio-joins-oss-friends + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog/2023/09/ghostfolio-2 + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/blog/2023/09/hacktoberfest-2023 + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/faq + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/features + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/markets + daily + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/open + daily + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/pricing + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/register + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capmon + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-copilot-money + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-delta + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-divvydiary + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finary + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-getquin + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-gospatz + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-justetf + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-kubera + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-markets.sh + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-maybe-finance + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-monse + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-parqet + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-plannix + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-portfolio-dividend-tracker + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-portseido + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-projectionlab + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-seeking-alpha + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sharesight + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-simple-portfolio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-snowball-analytics + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stockle + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stockmarketeye + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-utluna + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-yeekatee + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/es + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/es/funcionalidades + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/es/mercados + daily + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/es/open + daily + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/es/precios + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/es/preguntas-mas-frecuentes + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/es/recursos + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/es/registro + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/es/sobre + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/es/sobre/changelog + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/es/sobre/licencia + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/es/sobre/oss-friends + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/es/sobre/politica-de-privacidad + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/fr + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/fr/a-propos + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/fr/a-propos/changelog + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/fr/a-propos/licence + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/fr/a-propos/oss-friends + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/fr/a-propos/politique-de-confidentialite + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/fr/enregistrement + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/fr/fonctionnalites + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/fr/foire-aux-questions + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/fr/marches + daily + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/fr/open + daily + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/fr/prix + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/fr/ressources + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/domande-piu-frequenti + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/funzionalita + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/informazioni-su + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/informazioni-su/changelog + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/informazioni-su/informativa-sulla-privacy + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/informazioni-su/licenza + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/informazioni-su/oss-friends + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/iscrizione + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/mercati + daily + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/open + daily + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/prezzi + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-altoo + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-campmon + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-copilot-money + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-delta + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-divvydiary + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-exirio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finary + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-folishare + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-getquin + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-gospatz + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-justetf + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-kubera + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-markets.sh + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-maybe-finance + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-monse + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-parqet + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-plannix + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-portfolio-dividend-tracker + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-portseido + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-projectionlab + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-seeking-alpha + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sharesight + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-simple-portfolio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-snowball-analytics + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-stockle + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-stockmarketeye + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sumio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-utluna + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-yeekatee + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-altoo + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capmon + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-copilot-money + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-delta + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-divvydiary + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-exirio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finary + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-folishare + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-getquin + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-gospatz + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-justetf + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-kubera + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-markets.sh + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-maybe-finance + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-monse + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-parqet + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-plannix + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-portfolio-dividend-tracker + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-portseido + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-projectionlab + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-seeking-alpha + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sharesight + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-simple-portfolio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-snowball-analytics + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-stockle + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-stockmarketeye + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sumio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-utluna + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-yeekatee + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/functionaliteiten + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/markten + daily + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/open + daily + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/over + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/over/changelog + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/over/licentie + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/over/oss-friends + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/over/privacybeleid + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/prijzen + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/registratie + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/veelgestelde-vragen + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pt + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pt/blog + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pt/funcionalidades + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pt/mercados + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pt/open + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pt/perguntas-mais-frequentes + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pt/precos + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pt/recursos + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pt/registo + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pt/sobre + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pt/sobre/changelog + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pt/sobre/licenca + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pt/sobre/oss-friends + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/pt/sobre/politica-de-privacidade + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/tr + ${currentDate}T00:00:00+00:00 + + diff --git a/apps/api/src/helper/object.helper.ts b/apps/api/src/helper/object.helper.ts index 2f389352f..2f0399fb8 100644 --- a/apps/api/src/helper/object.helper.ts +++ b/apps/api/src/helper/object.helper.ts @@ -1,8 +1,9 @@ -import { cloneDeep, isObject } from 'lodash'; +import Big from 'big.js'; +import { cloneDeep, isArray, isObject } from 'lodash'; export function hasNotDefinedValuesInObject(aObject: Object): boolean { for (const key in aObject) { - if (aObject[key] === null || aObject[key] === null) { + if (aObject[key] === null || aObject[key] === undefined) { return true; } else if (isObject(aObject[key])) { return hasNotDefinedValuesInObject(aObject[key]); @@ -15,9 +16,11 @@ export function hasNotDefinedValuesInObject(aObject: Object): boolean { export function nullifyValuesInObject(aObject: T, keys: string[]): T { const object = cloneDeep(aObject); - keys.forEach((key) => { - object[key] = null; - }); + if (object) { + keys.forEach((key) => { + object[key] = null; + }); + } return object; } @@ -27,3 +30,51 @@ export function nullifyValuesInObjects(aObjects: T[], keys: string[]): T[] { return nullifyValuesInObject(object, keys); }); } + +export function redactAttributes({ + object, + options +}: { + object: any; + options: { attribute: string; valueMap: { [key: string]: any } }[]; +}): any { + if (!object || !options || !options.length) { + return object; + } + + const redactedObject = cloneDeep(object); + + for (const option of options) { + if (redactedObject.hasOwnProperty(option.attribute)) { + if (option.valueMap['*'] || option.valueMap['*'] === null) { + redactedObject[option.attribute] = option.valueMap['*']; + } else if (option.valueMap[redactedObject[option.attribute]]) { + redactedObject[option.attribute] = + option.valueMap[redactedObject[option.attribute]]; + } + } else { + // If the attribute is not present on the current object, + // check if it exists on any nested objects + for (const property in redactedObject) { + if (isArray(redactedObject[property])) { + redactedObject[property] = redactedObject[property].map( + (currentObject) => { + return redactAttributes({ options, object: currentObject }); + } + ); + } else if ( + isObject(redactedObject[property]) && + !(redactedObject[property] instanceof Big) + ) { + // Recursively call the function on the nested object + redactedObject[property] = redactAttributes({ + options, + object: redactedObject[property] + }); + } + } + } + } + + return redactedObject; +} diff --git a/apps/api/src/interceptors/redact-values-in-response.interceptor.ts b/apps/api/src/interceptors/redact-values-in-response.interceptor.ts index 4a9c5bef2..6b10a4ebb 100644 --- a/apps/api/src/interceptors/redact-values-in-response.interceptor.ts +++ b/apps/api/src/interceptors/redact-values-in-response.interceptor.ts @@ -1,4 +1,6 @@ -import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { redactAttributes } from '@ghostfolio/api/helper/object.helper'; +import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { CallHandler, ExecutionContext, @@ -12,7 +14,7 @@ import { map } from 'rxjs/operators'; export class RedactValuesInResponseInterceptor implements NestInterceptor { - public constructor() {} + public constructor(private userService: UserService) {} public intercept( context: ExecutionContext, @@ -21,34 +23,43 @@ export class RedactValuesInResponseInterceptor return next.handle().pipe( map((data: any) => { const request = context.switchToHttp().getRequest(); - const hasImpersonationId = !!request.headers?.['impersonation-id']; + const hasImpersonationId = + !!request.headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()]; - if (hasImpersonationId) { - if (data.accounts) { - for (const accountId of Object.keys(data.accounts)) { - if (data.accounts[accountId]?.balance !== undefined) { - data.accounts[accountId].balance = null; - } - } - } - - if (data.activities) { - data.activities = data.activities.map((activity: Activity) => { - if (activity.Account?.balance !== undefined) { - activity.Account.balance = null; - } - - return activity; - }); - } - - if (data.filteredValueInBaseCurrency) { - data.filteredValueInBaseCurrency = null; - } - - if (data.totalValueInBaseCurrency) { - data.totalValueInBaseCurrency = null; - } + if ( + hasImpersonationId || + this.userService.isRestrictedView(request.user) + ) { + data = redactAttributes({ + object: data, + options: [ + 'balance', + 'balanceInBaseCurrency', + 'comment', + 'convertedBalance', + 'dividendInBaseCurrency', + 'fee', + 'feeInBaseCurrency', + 'filteredValueInBaseCurrency', + 'grossPerformance', + 'investment', + 'netPerformance', + 'quantity', + 'symbolMapping', + 'totalBalanceInBaseCurrency', + 'totalValueInBaseCurrency', + 'unitPrice', + 'value', + 'valueInBaseCurrency' + ].map((attribute) => { + return { + attribute, + valueMap: { + '*': null + } + }; + }) + }); } return data; diff --git a/apps/api/src/interceptors/transform-data-source-in-request.interceptor.ts b/apps/api/src/interceptors/transform-data-source-in-request.interceptor.ts index aa9952473..ad2579638 100644 --- a/apps/api/src/interceptors/transform-data-source-in-request.interceptor.ts +++ b/apps/api/src/interceptors/transform-data-source-in-request.interceptor.ts @@ -1,3 +1,4 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { decodeDataSource } from '@ghostfolio/common/helper'; import { CallHandler, @@ -5,10 +6,9 @@ import { Injectable, NestInterceptor } from '@nestjs/common'; +import { DataSource } from '@prisma/client'; import { Observable } from 'rxjs'; -import { ConfigurationService } from '../services/configuration.service'; - @Injectable() export class TransformDataSourceInRequestInterceptor implements NestInterceptor @@ -24,12 +24,25 @@ export class TransformDataSourceInRequestInterceptor const http = context.switchToHttp(); const request = http.getRequest(); - if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true) { - if (request.body.dataSource) { + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + if (request.body.activities) { + request.body.activities = request.body.activities.map((activity) => { + if (DataSource[activity.dataSource]) { + return activity; + } else { + return { + ...activity, + dataSource: decodeDataSource(activity.dataSource) + }; + } + }); + } + + if (request.body.dataSource && !DataSource[request.body.dataSource]) { request.body.dataSource = decodeDataSource(request.body.dataSource); } - if (request.params.dataSource) { + if (request.params.dataSource && !DataSource[request.params.dataSource]) { request.params.dataSource = decodeDataSource(request.params.dataSource); } } diff --git a/apps/api/src/interceptors/transform-data-source-in-response.interceptor.ts b/apps/api/src/interceptors/transform-data-source-in-response.interceptor.ts index 4b80038f5..6968a0f0f 100644 --- a/apps/api/src/interceptors/transform-data-source-in-response.interceptor.ts +++ b/apps/api/src/interceptors/transform-data-source-in-response.interceptor.ts @@ -1,3 +1,5 @@ +import { redactAttributes } from '@ghostfolio/api/helper/object.helper'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { encodeDataSource } from '@ghostfolio/common/helper'; import { CallHandler, @@ -5,12 +7,10 @@ import { Injectable, NestInterceptor } from '@nestjs/common'; -import { isArray } from 'lodash'; +import { DataSource } from '@prisma/client'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { ConfigurationService } from '../services/configuration.service'; - @Injectable() export class TransformDataSourceInResponseInterceptor implements NestInterceptor @@ -25,66 +25,24 @@ export class TransformDataSourceInResponseInterceptor ): Observable { return next.handle().pipe( map((data: any) => { - if ( - this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true - ) { - if (data.activities) { - data.activities.map((activity) => { - activity.SymbolProfile.dataSource = encodeDataSource( - activity.SymbolProfile.dataSource - ); - return activity; - }); - } - - if (isArray(data.benchmarks)) { - data.benchmarks.map((benchmark) => { - benchmark.dataSource = encodeDataSource(benchmark.dataSource); - return benchmark; - }); - } - - if (data.dataSource) { - data.dataSource = encodeDataSource(data.dataSource); - } - - if (data.errors) { - for (const error of data.errors) { - if (error.dataSource) { - error.dataSource = encodeDataSource(error.dataSource); + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + data = redactAttributes({ + options: [ + { + attribute: 'dataSource', + valueMap: Object.keys(DataSource).reduce( + (valueMap, dataSource) => { + valueMap[dataSource] = encodeDataSource( + DataSource[dataSource] + ); + return valueMap; + }, + {} + ) } - } - } - - if (data.holdings) { - for (const symbol of Object.keys(data.holdings)) { - if (data.holdings[symbol].dataSource) { - data.holdings[symbol].dataSource = encodeDataSource( - data.holdings[symbol].dataSource - ); - } - } - } - - if (data.items) { - data.items.map((item) => { - item.dataSource = encodeDataSource(item.dataSource); - return item; - }); - } - - if (data.positions) { - data.positions.map((position) => { - position.dataSource = encodeDataSource(position.dataSource); - return position; - }); - } - - if (data.SymbolProfile) { - data.SymbolProfile.dataSource = encodeDataSource( - data.SymbolProfile.dataSource - ); - } + ], + object: data + }); } return data; diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index b8bfb81de..016f82473 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,30 +1,30 @@ import { Logger, ValidationPipe, VersioningType } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; +import type { NestExpressApplication } from '@nestjs/platform-express'; +import * as bodyParser from 'body-parser'; +import helmet from 'helmet'; import { AppModule } from './app/app.module'; import { environment } from './environments/environment'; +import { HtmlTemplateMiddleware } from './middlewares/html-template.middleware'; async function bootstrap() { const configApp = await NestFactory.create(AppModule); const configService = configApp.get(ConfigService); - const NODE_ENV = - configService.get<'development' | 'production'>('NODE_ENV') ?? - 'development'; - - const app = await NestFactory.create(AppModule, { - logger: - NODE_ENV === 'production' - ? ['error', 'log', 'warn'] - : ['debug', 'error', 'log', 'verbose', 'warn'] + const app = await NestFactory.create(AppModule, { + logger: environment.production + ? ['error', 'log', 'warn'] + : ['debug', 'error', 'log', 'verbose', 'warn'] }); + app.enableCors(); app.enableVersioning({ defaultVersion: '1', type: VersioningType.URI }); - app.setGlobalPrefix('api'); + app.setGlobalPrefix('api', { exclude: ['sitemap.xml'] }); app.useGlobalPipes( new ValidationPipe({ forbidNonWhitelisted: true, @@ -33,8 +33,31 @@ async function bootstrap() { }) ); + // Support 10mb csv/json files for importing activities + app.use(bodyParser.json({ limit: '10mb' })); + + if (configService.get('ENABLE_FEATURE_SUBSCRIPTION') === 'true') { + app.use( + helmet({ + contentSecurityPolicy: { + directives: { + connectSrc: ["'self'", 'https://js.stripe.com'], // Allow connections to Stripe + frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe + scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe + scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers + styleSrc: ["'self'", "'unsafe-inline'"] // Allow inline styles + } + }, + crossOriginOpenerPolicy: false // Disable Cross-Origin-Opener-Policy header (for Internet Identity) + }) + ); + } + + app.use(HtmlTemplateMiddleware); + const HOST = configService.get('HOST') || '0.0.0.0'; const PORT = configService.get('PORT') || 3333; + await app.listen(PORT, HOST, () => { logLogo(); Logger.log(`Listening at http://${HOST}:${PORT}`); diff --git a/apps/api/src/middlewares/html-template.middleware.ts b/apps/api/src/middlewares/html-template.middleware.ts new file mode 100644 index 000000000..9d44bdbe0 --- /dev/null +++ b/apps/api/src/middlewares/html-template.middleware.ts @@ -0,0 +1,141 @@ +import * as fs from 'fs'; +import { join } from 'path'; + +import { environment } from '@ghostfolio/api/environments/environment'; +import { + DEFAULT_LANGUAGE_CODE, + DEFAULT_ROOT_URL, + SUPPORTED_LANGUAGE_CODES +} from '@ghostfolio/common/config'; +import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper'; +import { format } from 'date-fns'; +import { NextFunction, Request, Response } from 'express'; + +const descriptions = { + de: 'Mit dem Finanz-Dashboard Ghostfolio können Sie Ihr Vermögen in Form von Aktien, ETFs oder Kryptowährungen verteilt über mehrere Finanzinstitute überwachen.', + en: 'Ghostfolio is a personal finance dashboard to keep track of your assets like stocks, ETFs or cryptocurrencies across multiple platforms.', + es: 'Ghostfolio es un dashboard de finanzas personales para hacer un seguimiento de tus activos como acciones, ETFs o criptodivisas a través de múltiples plataformas.', + fr: 'Ghostfolio est un dashboard de finances personnelles qui permet de suivre vos actifs comme les actions, les ETF ou les crypto-monnaies sur plusieurs plateformes.', + it: 'Ghostfolio è un dashboard di finanza personale per tenere traccia delle vostre attività come azioni, ETF o criptovalute su più piattaforme.', + nl: 'Ghostfolio is een persoonlijk financieel dashboard om uw activa zoals aandelen, ETF’s of cryptocurrencies over meerdere platforms bij te houden.', + pt: 'Ghostfolio é um dashboard de finanças pessoais para acompanhar os seus activos como acções, ETFs ou criptomoedas em múltiplas plataformas.', + tr: 'Ghostfolio, hisse senetleri, ETF’ler veya kripto para birimleri gibi varlıklarınızı birden fazla platformda takip etmenizi sağlayan bir kişisel finans panosudur.' +}; + +const title = 'Ghostfolio – Open Source Wealth Management Software'; +const titleShort = 'Ghostfolio'; + +let indexHtmlMap: { [languageCode: string]: string } = {}; + +try { + indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce( + (map, languageCode) => ({ + ...map, + [languageCode]: fs.readFileSync( + join(__dirname, '..', 'client', languageCode, 'index.html'), + 'utf8' + ) + }), + {} + ); +} catch {} + +const locales = { + '/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt': { + featureGraphicPath: 'assets/images/blog/ghostfolio-x-sackgeld.png', + title: `Ghostfolio auf Sackgeld.com vorgestellt - ${titleShort}` + }, + '/en/blog/2022/08/500-stars-on-github': { + featureGraphicPath: 'assets/images/blog/500-stars-on-github.jpg', + title: `500 Stars - ${titleShort}` + }, + '/en/blog/2022/10/hacktoberfest-2022': { + featureGraphicPath: 'assets/images/blog/hacktoberfest-2022.png', + title: `Hacktoberfest 2022 - ${titleShort}` + }, + '/en/blog/2022/12/the-importance-of-tracking-your-personal-finances': { + featureGraphicPath: 'assets/images/blog/20221226.jpg', + title: `The importance of tracking your personal finances - ${titleShort}` + }, + '/en/blog/2023/02/ghostfolio-meets-umbrel': { + featureGraphicPath: 'assets/images/blog/ghostfolio-x-umbrel.png', + title: `Ghostfolio meets Umbrel - ${titleShort}` + }, + '/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github': { + featureGraphicPath: 'assets/images/blog/1000-stars-on-github.jpg', + title: `Ghostfolio reaches 1’000 Stars on GitHub - ${titleShort}` + }, + '/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio': { + featureGraphicPath: 'assets/images/blog/20230520.jpg', + title: `Unlock your Financial Potential with Ghostfolio - ${titleShort}` + }, + '/en/blog/2023/07/exploring-the-path-to-fire': { + featureGraphicPath: 'assets/images/blog/20230701.jpg', + title: `Exploring the Path to FIRE - ${titleShort}` + }, + '/en/blog/2023/08/ghostfolio-joins-oss-friends': { + featureGraphicPath: 'assets/images/blog/ghostfolio-joins-oss-friends.png', + title: `Ghostfolio joins OSS Friends - ${titleShort}` + }, + '/en/blog/2023/09/ghostfolio-2': { + featureGraphicPath: 'assets/images/blog/ghostfolio-2.jpg', + title: `Announcing Ghostfolio 2.0 - ${titleShort}` + }, + '/en/blog/2023/09/hacktoberfest-2023': { + featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png', + title: `Hacktoberfest 2023 - ${titleShort}` + } +}; + +const isFileRequest = (filename: string) => { + if (filename === '/assets/LICENSE') { + return true; + } else if ( + filename.includes('auth/ey') || + filename.includes( + 'personal-finance-tools/open-source-alternative-to-markets.sh' + ) + ) { + return false; + } + + return filename.split('.').pop() !== filename; +}; + +export const HtmlTemplateMiddleware = async ( + request: Request, + response: Response, + next: NextFunction +) => { + const path = request.originalUrl.replace(/\/$/, ''); + let languageCode = path.substr(1, 2); + + if (!SUPPORTED_LANGUAGE_CODES.includes(languageCode)) { + languageCode = DEFAULT_LANGUAGE_CODE; + } + + const currentDate = format(new Date(), DATE_FORMAT); + const rootUrl = process.env.ROOT_URL || DEFAULT_ROOT_URL; + + if ( + path.startsWith('/api/') || + isFileRequest(path) || + !environment.production + ) { + // Skip + next(); + } else { + const indexHtml = interpolate(indexHtmlMap[languageCode], { + currentDate, + languageCode, + path, + rootUrl, + description: descriptions[languageCode], + featureGraphicPath: + locales[path]?.featureGraphicPath ?? 'assets/cover.png', + title: locales[path]?.title ?? title + }); + + return response.send(indexHtml); + } +}; diff --git a/apps/api/src/models/order.ts b/apps/api/src/models/order.ts index 8882ebe37..f24f4455d 100644 --- a/apps/api/src/models/order.ts +++ b/apps/api/src/models/order.ts @@ -1,8 +1,7 @@ +import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces'; import { Account, SymbolProfile, Type as TypeOfOrder } from '@prisma/client'; import { v4 as uuidv4 } from 'uuid'; -import { IOrder } from '../services/interfaces/interfaces'; - export class Order { private account: Account; private currency: string; diff --git a/apps/api/src/models/rule.ts b/apps/api/src/models/rule.ts index ad1629ac3..171da810d 100644 --- a/apps/api/src/models/rule.ts +++ b/apps/api/src/models/rule.ts @@ -1,5 +1,5 @@ import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { groupBy } from '@ghostfolio/common/helper'; import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces'; 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 078123743..23d3307de 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,21 +1,24 @@ import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; +import { Rule } from '@ghostfolio/api/models/rule'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PortfolioDetails, PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces'; -import { Rule } from '../../rule'; - export class AccountClusterRiskCurrentInvestment extends Rule { + private accounts: PortfolioDetails['accounts']; + public constructor( protected exchangeRateDataService: ExchangeRateDataService, - private accounts: PortfolioDetails['accounts'] + accounts: PortfolioDetails['accounts'] ) { super(exchangeRateDataService, { - name: 'Current Investment' + name: 'Investment' }); + + this.accounts = accounts; } public evaluate(ruleSettings: Settings) { @@ -28,7 +31,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule { for (const [accountId, account] of Object.entries(this.accounts)) { accounts[accountId] = { name: account.name, - investment: account.current + investment: account.valueInBaseCurrency }; } diff --git a/apps/api/src/models/rules/account-cluster-risk/initial-investment.ts b/apps/api/src/models/rules/account-cluster-risk/initial-investment.ts deleted file mode 100644 index f490b0d6d..000000000 --- a/apps/api/src/models/rules/account-cluster-risk/initial-investment.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; -import { - PortfolioDetails, - PortfolioPosition, - UserSettings -} from '@ghostfolio/common/interfaces'; - -import { Rule } from '../../rule'; - -export class AccountClusterRiskInitialInvestment extends Rule { - public constructor( - protected exchangeRateDataService: ExchangeRateDataService, - private accounts: PortfolioDetails['accounts'] - ) { - super(exchangeRateDataService, { - name: 'Initial Investment' - }); - } - - public evaluate(ruleSettings?: Settings) { - const accounts: { - [symbol: string]: Pick & { - investment: number; - }; - } = {}; - - for (const [accountId, account] of Object.entries(this.accounts)) { - accounts[accountId] = { - name: account.name, - investment: account.original - }; - } - - let maxItem; - let totalInvestment = 0; - - for (const account of Object.values(accounts)) { - if (!maxItem) { - maxItem = account; - } - - // Calculate total investment - totalInvestment += account.investment; - - // Find maximum - if (account.investment > maxItem?.investment) { - maxItem = account; - } - } - - const maxInvestmentRatio = maxItem.investment / totalInvestment; - - if (maxInvestmentRatio > ruleSettings.threshold) { - return { - evaluation: `Over ${ - ruleSettings.threshold * 100 - }% of your initial investment is at ${maxItem.name} (${( - maxInvestmentRatio * 100 - ).toPrecision(3)}%)`, - value: false - }; - } - - return { - evaluation: `The major part of your initial investment is at ${ - maxItem.name - } (${(maxInvestmentRatio * 100).toPrecision(3)}%) and does not exceed ${ - ruleSettings.threshold * 100 - }%`, - value: true - }; - } - - public getSettings(aUserSettings: UserSettings): Settings { - return { - baseCurrency: aUserSettings.baseCurrency, - isActive: true, - threshold: 0.5 - }; - } -} - -interface Settings extends RuleSettings { - baseCurrency: string; - isActive: boolean; - threshold: number; -} diff --git a/apps/api/src/models/rules/account-cluster-risk/single-account.ts b/apps/api/src/models/rules/account-cluster-risk/single-account.ts index c9bd0b35f..b5028228a 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,17 +1,20 @@ import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; +import { Rule } from '@ghostfolio/api/models/rule'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces'; -import { Rule } from '../../rule'; - export class AccountClusterRiskSingleAccount extends Rule { + private accounts: PortfolioDetails['accounts']; + public constructor( protected exchangeRateDataService: ExchangeRateDataService, - private accounts: PortfolioDetails['accounts'] + accounts: PortfolioDetails['accounts'] ) { super(exchangeRateDataService, { name: 'Single Account' }); + + this.accounts = accounts; } public evaluate() { @@ -19,13 +22,13 @@ export class AccountClusterRiskSingleAccount extends Rule { if (accounts.length === 1) { return { - evaluation: `All your investment is managed by a single account`, + evaluation: `Your net worth is managed by a single account`, value: false }; } return { - evaluation: `Your investment is managed by ${accounts.length} accounts`, + evaluation: `Your net worth is managed by ${accounts.length} accounts`, value: true }; } 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 5f1f4cf93..a23a208c3 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,23 +1,25 @@ -import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; -import { UserSettings } from '@ghostfolio/common/interfaces'; - -import { Rule } from '../../rule'; +import { Rule } from '@ghostfolio/api/models/rule'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces'; export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule { + private positions: TimelinePosition[]; + public constructor( protected exchangeRateDataService: ExchangeRateDataService, - private currentPositions: CurrentPositions + positions: TimelinePosition[] ) { super(exchangeRateDataService, { - name: 'Current Investment: Base Currency' + name: 'Investment: Base Currency' }); + + this.positions = positions; } public evaluate(ruleSettings: Settings) { const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute( - this.currentPositions.positions, + this.positions, 'currency', ruleSettings.baseCurrency ); diff --git a/apps/api/src/models/rules/currency-cluster-risk/base-currency-initial-investment.ts b/apps/api/src/models/rules/currency-cluster-risk/base-currency-initial-investment.ts deleted file mode 100644 index 1d43f5619..000000000 --- a/apps/api/src/models/rules/currency-cluster-risk/base-currency-initial-investment.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface'; -import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; -import { UserSettings } from '@ghostfolio/common/interfaces'; - -import { Rule } from '../../rule'; - -export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule { - public constructor( - protected exchangeRateDataService: ExchangeRateDataService, - private currentPositions: CurrentPositions - ) { - super(exchangeRateDataService, { - name: 'Initial Investment: Base Currency' - }); - } - - public evaluate(ruleSettings: Settings) { - const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute( - this.currentPositions.positions, - 'currency', - ruleSettings.baseCurrency - ); - - let maxItem = positionsGroupedByCurrency[0]; - let totalInvestment = 0; - - positionsGroupedByCurrency.forEach((groupItem) => { - // Calculate total investment - totalInvestment += groupItem.investment; - - // Find maximum - if (groupItem.investment > maxItem.investment) { - maxItem = groupItem; - } - }); - - const baseCurrencyItem = positionsGroupedByCurrency.find((item) => { - return item.groupKey === ruleSettings.baseCurrency; - }); - - const baseCurrencyInvestmentRatio = - baseCurrencyItem?.investment / totalInvestment || 0; - - if (maxItem.groupKey !== ruleSettings.baseCurrency) { - return { - evaluation: `The major part of your initial investment is not in your base currency (${( - baseCurrencyInvestmentRatio * 100 - ).toPrecision(3)}% in ${ruleSettings.baseCurrency})`, - value: false - }; - } - - return { - evaluation: `The major part of your initial investment is in your base currency (${( - baseCurrencyInvestmentRatio * 100 - ).toPrecision(3)}% in ${ruleSettings.baseCurrency})`, - value: true - }; - } - - public getSettings(aUserSettings: UserSettings): Settings { - return { - baseCurrency: aUserSettings.baseCurrency, - isActive: true - }; - } -} - -interface Settings extends RuleSettings { - baseCurrency: string; -} diff --git a/apps/api/src/models/rules/currency-cluster-risk/current-investment.ts b/apps/api/src/models/rules/currency-cluster-risk/current-investment.ts index c233ffc9c..bd6e060ef 100644 --- a/apps/api/src/models/rules/currency-cluster-risk/current-investment.ts +++ b/apps/api/src/models/rules/currency-cluster-risk/current-investment.ts @@ -1,23 +1,25 @@ -import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; -import { UserSettings } from '@ghostfolio/common/interfaces'; - -import { Rule } from '../../rule'; +import { Rule } from '@ghostfolio/api/models/rule'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces'; export class CurrencyClusterRiskCurrentInvestment extends Rule { + private positions: TimelinePosition[]; + public constructor( - public exchangeRateDataService: ExchangeRateDataService, - private currentPositions: CurrentPositions + protected exchangeRateDataService: ExchangeRateDataService, + positions: TimelinePosition[] ) { super(exchangeRateDataService, { - name: 'Current Investment' + name: 'Investment' }); + + this.positions = positions; } public evaluate(ruleSettings: Settings) { const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute( - this.currentPositions.positions, + this.positions, 'currency', ruleSettings.baseCurrency ); diff --git a/apps/api/src/models/rules/currency-cluster-risk/initial-investment.ts b/apps/api/src/models/rules/currency-cluster-risk/initial-investment.ts deleted file mode 100644 index 331074f16..000000000 --- a/apps/api/src/models/rules/currency-cluster-risk/initial-investment.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface'; -import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; -import { UserSettings } from '@ghostfolio/common/interfaces'; - -import { Rule } from '../../rule'; - -export class CurrencyClusterRiskInitialInvestment extends Rule { - public constructor( - protected exchangeRateDataService: ExchangeRateDataService, - private currentPositions: CurrentPositions - ) { - super(exchangeRateDataService, { - name: 'Initial Investment' - }); - } - - public evaluate(ruleSettings: Settings) { - const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute( - this.currentPositions.positions, - 'currency', - ruleSettings.baseCurrency - ); - - let maxItem = positionsGroupedByCurrency[0]; - let totalInvestment = 0; - - positionsGroupedByCurrency.forEach((groupItem) => { - // Calculate total investment - totalInvestment += groupItem.investment; - - // Find maximum - if (groupItem.investment > maxItem.investment) { - maxItem = groupItem; - } - }); - - const maxInvestmentRatio = maxItem.investment / totalInvestment; - - if (maxInvestmentRatio > ruleSettings.threshold) { - return { - evaluation: `Over ${ - ruleSettings.threshold * 100 - }% of your initial investment is in ${maxItem.groupKey} (${( - maxInvestmentRatio * 100 - ).toPrecision(3)}%)`, - value: false - }; - } - - return { - evaluation: `The major part of your initial investment is in ${ - maxItem.groupKey - } (${(maxInvestmentRatio * 100).toPrecision(3)}%) and does not exceed ${ - ruleSettings.threshold * 100 - }%`, - value: true - }; - } - - public getSettings(aUserSettings: UserSettings): Settings { - return { - baseCurrency: aUserSettings.baseCurrency, - isActive: true, - threshold: 0.5 - }; - } -} - -interface Settings extends RuleSettings { - baseCurrency: string; - threshold: number; -} diff --git a/apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts b/apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts new file mode 100644 index 000000000..b6248ab51 --- /dev/null +++ b/apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts @@ -0,0 +1,46 @@ +import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; +import { Rule } from '@ghostfolio/api/models/rule'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { UserSettings } from '@ghostfolio/common/interfaces'; + +export class EmergencyFundSetup extends Rule { + private emergencyFund: number; + + public constructor( + protected exchangeRateDataService: ExchangeRateDataService, + emergencyFund: number + ) { + super(exchangeRateDataService, { + name: 'Emergency Fund: Set up' + }); + + this.emergencyFund = emergencyFund; + } + + public evaluate(ruleSettings: Settings) { + if (this.emergencyFund > ruleSettings.threshold) { + return { + evaluation: 'An emergency fund has been set up', + value: true + }; + } + + return { + evaluation: 'No emergency fund has been set up', + value: false + }; + } + + public getSettings(aUserSettings: UserSettings): Settings { + return { + baseCurrency: aUserSettings.baseCurrency, + isActive: true, + threshold: 0 + }; + } +} + +interface Settings extends RuleSettings { + baseCurrency: string; + threshold: number; +} diff --git a/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts b/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts index d3e4ea827..0ba70d23c 100644 --- a/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts +++ b/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts @@ -1,22 +1,29 @@ import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; +import { Rule } from '@ghostfolio/api/models/rule'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { UserSettings } from '@ghostfolio/common/interfaces'; -import { Rule } from '../../rule'; - export class FeeRatioInitialInvestment extends Rule { + private fees: number; + private totalInvestment: number; + public constructor( protected exchangeRateDataService: ExchangeRateDataService, - private totalInvestment: number, - private fees: number + totalInvestment: number, + fees: number ) { super(exchangeRateDataService, { - name: 'Initial Investment' + name: 'Fee Ratio' }); + + this.fees = fees; + this.totalInvestment = totalInvestment; } public evaluate(ruleSettings: Settings) { - const feeRatio = this.fees / this.totalInvestment; + const feeRatio = this.totalInvestment + ? this.fees / this.totalInvestment + : 0; if (feeRatio > ruleSettings.threshold) { return { diff --git a/apps/api/src/services/account-balance/account-balance.module.ts b/apps/api/src/services/account-balance/account-balance.module.ts new file mode 100644 index 000000000..53c695b5f --- /dev/null +++ b/apps/api/src/services/account-balance/account-balance.module.ts @@ -0,0 +1,10 @@ +import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { Module } from '@nestjs/common'; + +@Module({ + exports: [AccountBalanceService], + imports: [PrismaModule], + providers: [AccountBalanceService] +}) +export class AccountBalanceModule {} diff --git a/apps/api/src/services/account-balance/account-balance.service.ts b/apps/api/src/services/account-balance/account-balance.service.ts new file mode 100644 index 000000000..9cd2d31ac --- /dev/null +++ b/apps/api/src/services/account-balance/account-balance.service.ts @@ -0,0 +1,16 @@ +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { Injectable } from '@nestjs/common'; +import { AccountBalance, Prisma } from '@prisma/client'; + +@Injectable() +export class AccountBalanceService { + public constructor(private readonly prismaService: PrismaService) {} + + public async createAccountBalance( + data: Prisma.AccountBalanceCreateInput + ): Promise { + return this.prismaService.accountBalance.create({ + data + }); + } +} diff --git a/apps/api/src/services/api/api.service.ts b/apps/api/src/services/api/api.service.ts index 2a6b1fb06..204aa030e 100644 --- a/apps/api/src/services/api/api.service.ts +++ b/apps/api/src/services/api/api.service.ts @@ -8,14 +8,17 @@ export class ApiService { public buildFiltersFromQueryParams({ filterByAccounts, filterByAssetClasses, + filterBySearchQuery, filterByTags }: { filterByAccounts?: string; filterByAssetClasses?: string; + filterBySearchQuery?: string; filterByTags?: string; }): Filter[] { const accountIds = filterByAccounts?.split(',') ?? []; const assetClasses = filterByAssetClasses?.split(',') ?? []; + const searchQuery = filterBySearchQuery?.toLowerCase(); const tagIds = filterByTags?.split(',') ?? []; return [ @@ -31,6 +34,10 @@ export class ApiService { type: 'ASSET_CLASS' }; }), + { + id: searchQuery, + type: 'SEARCH_QUERY' + }, ...tagIds.map((tagId) => { return { id: tagId, diff --git a/apps/api/src/services/configuration.module.ts b/apps/api/src/services/configuration/configuration.module.ts similarity index 85% rename from apps/api/src/services/configuration.module.ts rename to apps/api/src/services/configuration/configuration.module.ts index b91475941..61b8bb4af 100644 --- a/apps/api/src/services/configuration.module.ts +++ b/apps/api/src/services/configuration/configuration.module.ts @@ -1,4 +1,4 @@ -import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { Module } from '@nestjs/common'; @Module({ diff --git a/apps/api/src/services/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts similarity index 77% rename from apps/api/src/services/configuration.service.ts rename to apps/api/src/services/configuration/configuration.service.ts index 9cddec1f0..40a04f5a0 100644 --- a/apps/api/src/services/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -1,9 +1,9 @@ +import { Environment } from '@ghostfolio/api/services/interfaces/environment.interface'; +import { DEFAULT_ROOT_URL } from '@ghostfolio/common/config'; import { Injectable } from '@nestjs/common'; import { DataSource } from '@prisma/client'; import { bool, cleanEnv, host, json, num, port, str } from 'envalid'; -import { Environment } from './interfaces/environment.interface'; - @Injectable() export class ConfigurationService { private readonly environmentConfiguration: Environment; @@ -12,22 +12,23 @@ export class ConfigurationService { this.environmentConfiguration = cleanEnv(process.env, { ACCESS_TOKEN_SALT: str(), ALPHA_VANTAGE_API_KEY: str({ default: '' }), - BASE_CURRENCY: str({ default: 'USD' }), + BETTER_UPTIME_API_KEY: str({ default: '' }), + CACHE_QUOTES_TTL: num({ default: 1 }), CACHE_TTL: num({ default: 1 }), - DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }), + DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }), + DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }), DATA_SOURCES: json({ - default: [DataSource.GHOSTFOLIO, DataSource.YAHOO] + default: [DataSource.COINGECKO, DataSource.MANUAL, DataSource.YAHOO] }), ENABLE_FEATURE_BLOG: bool({ default: false }), - ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), - ENABLE_FEATURE_IMPORT: bool({ default: true }), ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }), ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }), ENABLE_FEATURE_STATISTICS: bool({ default: false }), ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }), ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }), EOD_HISTORICAL_DATA_API_KEY: str({ default: '' }), + FINANCIAL_MODELING_PREP_API_KEY: str({ default: '' }), GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }), GOOGLE_SECRET: str({ default: 'dummySecret' }), GOOGLE_SHEETS_ACCOUNT: str({ default: '' }), @@ -39,10 +40,10 @@ export class ConfigurationService { MAX_ITEM_IN_CACHE: num({ default: 9999 }), PORT: port({ default: 3333 }), RAPID_API_API_KEY: str({ default: '' }), - REDIS_HOST: host({ default: 'localhost' }), + REDIS_HOST: str({ default: 'localhost' }), REDIS_PASSWORD: str({ default: '' }), REDIS_PORT: port({ default: 6379 }), - ROOT_URL: str({ default: 'http://localhost:4200' }), + ROOT_URL: str({ default: DEFAULT_ROOT_URL }), STRIPE_PUBLIC_KEY: str({ default: '' }), STRIPE_SECRET_KEY: str({ default: '' }), TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }), diff --git a/apps/api/src/services/cron.service.ts b/apps/api/src/services/cron.service.ts index 727ff0998..e3597f049 100644 --- a/apps/api/src/services/cron.service.ts +++ b/apps/api/src/services/cron.service.ts @@ -2,15 +2,18 @@ import { GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS_OPTIONS } from '@ghostfolio/common/config'; +import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { Injectable } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; -import { DataGatheringService } from './data-gathering.service'; -import { ExchangeRateDataService } from './exchange-rate-data.service'; +import { DataGatheringService } from './data-gathering/data-gathering.service'; +import { ExchangeRateDataService } from './exchange-rate-data/exchange-rate-data.service'; import { TwitterBotService } from './twitter-bot/twitter-bot.service'; @Injectable() export class CronService { + private static readonly EVERY_SUNDAY_AT_LUNCH_TIME = '0 12 * * 0'; + public constructor( private readonly dataGatheringService: DataGatheringService, private readonly exchangeRateDataService: ExchangeRateDataService, @@ -28,23 +31,28 @@ export class CronService { } @Cron(CronExpression.EVERY_DAY_AT_5PM) - public async runEveryDayAtFivePM() { + public async runEveryDayAtFivePm() { this.twitterBotService.tweetFearAndGreedIndex(); } - @Cron(CronExpression.EVERY_WEEKEND) - public async runEveryWeekend() { + @Cron(CronService.EVERY_SUNDAY_AT_LUNCH_TIME) + public async runEverySundayAtTwelvePm() { const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); - for (const { dataSource, symbol } of uniqueAssets) { - await this.dataGatheringService.addJobToQueue( - GATHER_ASSET_PROFILE_PROCESS, - { - dataSource, - symbol - }, - GATHER_ASSET_PROFILE_PROCESS_OPTIONS - ); - } + await this.dataGatheringService.addJobsToQueue( + uniqueAssets.map(({ dataSource, symbol }) => { + return { + data: { + dataSource, + symbol + }, + name: GATHER_ASSET_PROFILE_PROCESS, + opts: { + ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, + jobId: getAssetProfileIdentifier({ dataSource, symbol }) + } + }; + }) + ); } } diff --git a/apps/api/src/services/data-gathering.module.ts b/apps/api/src/services/data-gathering/data-gathering.module.ts similarity index 65% rename from apps/api/src/services/data-gathering.module.ts rename to apps/api/src/services/data-gathering/data-gathering.module.ts index 0083a8d75..673364f09 100644 --- a/apps/api/src/services/data-gathering.module.ts +++ b/apps/api/src/services/data-gathering/data-gathering.module.ts @@ -1,17 +1,18 @@ -import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; -import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; -import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config'; import { BullModule } from '@nestjs/bull'; import { Module } from '@nestjs/common'; import ms from 'ms'; import { DataGatheringProcessor } from './data-gathering.processor'; -import { ExchangeRateDataModule } from './exchange-rate-data.module'; -import { MarketDataModule } from './market-data.module'; -import { SymbolProfileModule } from './symbol-profile.module'; @Module({ imports: [ @@ -28,6 +29,7 @@ import { SymbolProfileModule } from './symbol-profile.module'; ExchangeRateDataModule, MarketDataModule, PrismaModule, + PropertyModule, SymbolProfileModule ], providers: [DataGatheringProcessor, DataGatheringService], diff --git a/apps/api/src/services/data-gathering.processor.ts b/apps/api/src/services/data-gathering/data-gathering.processor.ts similarity index 63% rename from apps/api/src/services/data-gathering.processor.ts rename to apps/api/src/services/data-gathering/data-gathering.processor.ts index 7e2a27642..a3ab0e513 100644 --- a/apps/api/src/services/data-gathering.processor.ts +++ b/apps/api/src/services/data-gathering/data-gathering.processor.ts @@ -1,14 +1,19 @@ +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { DATA_GATHERING_QUEUE, GATHER_ASSET_PROFILE_PROCESS, GATHER_HISTORICAL_MARKET_DATA_PROCESS } from '@ghostfolio/common/config'; -import { DATE_FORMAT } from '@ghostfolio/common/helper'; +import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper'; import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { Process, Processor } from '@nestjs/bull'; import { Injectable, Logger } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; import { Job } from 'bull'; import { + addDays, format, getDate, getMonth, @@ -18,9 +23,6 @@ import { } from 'date-fns'; import { DataGatheringService } from './data-gathering.service'; -import { DataProviderService } from './data-provider/data-provider.service'; -import { IDataGatheringItem } from './interfaces/interfaces'; -import { PrismaService } from './prisma.service'; @Injectable() @Processor(DATA_GATHERING_QUEUE) @@ -28,10 +30,10 @@ export class DataGatheringProcessor { public constructor( private readonly dataGatheringService: DataGatheringService, private readonly dataProviderService: DataProviderService, - private readonly prismaService: PrismaService + private readonly marketDataService: MarketDataService ) {} - @Process(GATHER_ASSET_PROFILE_PROCESS) + @Process({ concurrency: 1, name: GATHER_ASSET_PROFILE_PROCESS }) public async gatherAssetProfile(job: Job) { try { await this.dataGatheringService.gatherAssetProfiles([job.data]); @@ -45,18 +47,27 @@ export class DataGatheringProcessor { } } - @Process(GATHER_HISTORICAL_MARKET_DATA_PROCESS) + @Process({ concurrency: 1, name: GATHER_HISTORICAL_MARKET_DATA_PROCESS }) public async gatherHistoricalMarketData(job: Job) { try { const { dataSource, date, symbol } = job.data; + let currentDate = parseISO((date)); + + Logger.log( + `Historical market data gathering has been started for ${symbol} (${dataSource}) at ${format( + currentDate, + DATE_FORMAT + )}`, + `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})` + ); const historicalData = await this.dataProviderService.getHistoricalRaw( [{ dataSource, symbol }], - parseISO((date)), + currentDate, new Date() ); - let currentDate = parseISO((date)); + const data: Prisma.MarketDataUpdateInput[] = []; let lastMarketPrice: number; while ( @@ -82,38 +93,25 @@ export class DataGatheringProcessor { } if (lastMarketPrice) { - try { - await this.prismaService.marketData.create({ - data: { - dataSource, - symbol, - date: new Date( - Date.UTC( - getYear(currentDate), - getMonth(currentDate), - getDate(currentDate), - 0 - ) - ), - marketPrice: lastMarketPrice - } - }); - } catch {} + data.push({ + dataSource, + symbol, + date: getStartOfUtcDate(currentDate), + marketPrice: lastMarketPrice, + state: 'CLOSE' + }); } - // Count month one up for iteration - currentDate = new Date( - Date.UTC( - getYear(currentDate), - getMonth(currentDate), - getDate(currentDate) + 1, - 0 - ) - ); + currentDate = addDays(currentDate, 1); } + await this.marketDataService.updateMany({ data }); + Logger.log( - `Historical market data gathering has been completed for ${symbol} (${dataSource}).`, + `Historical market data gathering has been completed for ${symbol} (${dataSource}) at ${format( + currentDate, + DATE_FORMAT + )}`, `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})` ); } catch (error) { diff --git a/apps/api/src/services/data-gathering.service.ts b/apps/api/src/services/data-gathering/data-gathering.service.ts similarity index 64% rename from apps/api/src/services/data-gathering.service.ts rename to apps/api/src/services/data-gathering/data-gathering.service.ts index e977c9c07..34645b9ea 100644 --- a/apps/api/src/services/data-gathering.service.ts +++ b/apps/api/src/services/data-gathering/data-gathering.service.ts @@ -1,24 +1,29 @@ -import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +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 { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { DATA_GATHERING_QUEUE, GATHER_HISTORICAL_MARKET_DATA_PROCESS, GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS, - QUEUE_JOB_STATUS_LIST + PROPERTY_BENCHMARKS } from '@ghostfolio/common/config'; -import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper'; -import { UniqueAsset } from '@ghostfolio/common/interfaces'; +import { + DATE_FORMAT, + getAssetProfileIdentifier, + resetHours +} from '@ghostfolio/common/helper'; +import { BenchmarkProperty, UniqueAsset } from '@ghostfolio/common/interfaces'; import { InjectQueue } from '@nestjs/bull'; import { Inject, Injectable, Logger } from '@nestjs/common'; import { DataSource } from '@prisma/client'; import { JobOptions, Queue } from 'bull'; -import { format, subDays } from 'date-fns'; - -import { DataProviderService } from './data-provider/data-provider.service'; -import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface'; -import { ExchangeRateDataService } from './exchange-rate-data.service'; -import { IDataGatheringItem } from './interfaces/interfaces'; -import { MarketDataService } from './market-data.service'; -import { PrismaService } from './prisma.service'; +import { format, min, subDays, subYears } from 'date-fns'; +import { isEmpty } from 'lodash'; @Injectable() export class DataGatheringService { @@ -31,20 +36,26 @@ export class DataGatheringService { private readonly exchangeRateDataService: ExchangeRateDataService, private readonly marketDataService: MarketDataService, private readonly prismaService: PrismaService, + private readonly propertyService: PropertyService, private readonly symbolProfileService: SymbolProfileService ) {} - public async addJobToQueue(name: string, data: any, options?: JobOptions) { - const hasJob = await this.hasJob(name, data); + public async addJobToQueue({ + data, + name, + opts + }: { + data: any; + name: string; + opts?: JobOptions; + }) { + return this.dataGatheringQueue.add(name, data, opts); + } - if (hasJob) { - Logger.log( - `Job ${name} with data ${JSON.stringify(data)} already exists.`, - 'DataGatheringService' - ); - } else { - return this.dataGatheringQueue.add(name, data, options); - } + public async addJobsToQueue( + jobs: { data: any; name: string; opts?: JobOptions }[] + ) { + return this.dataGatheringQueue.addBulk(jobs); } public async gather7Days() { @@ -97,7 +108,7 @@ export class DataGatheringService { symbol }, update: { marketPrice }, - where: { date_symbol: { date, symbol } } + where: { dataSource_date_symbol: { dataSource, date, symbol } } }); } } catch (error) { @@ -116,15 +127,14 @@ export class DataGatheringService { uniqueAssets = await this.getUniqueAssets(); } - const assetProfiles = await this.dataProviderService.getAssetProfiles( - uniqueAssets - ); + if (uniqueAssets.length <= 0) { + return; + } + + const assetProfiles = + await this.dataProviderService.getAssetProfiles(uniqueAssets); const symbolProfiles = - await this.symbolProfileService.getSymbolProfilesBySymbols( - uniqueAssets.map(({ symbol }) => { - return symbol; - }) - ); + await this.symbolProfileService.getSymbolProfiles(uniqueAssets); for (const [symbol, assetProfile] of Object.entries(assetProfiles)) { const symbolMapping = symbolProfiles.find((symbolProfile) => { @@ -139,7 +149,9 @@ export class DataGatheringService { }); } catch (error) { Logger.error( - `Failed to enhance data for symbol ${symbol} by ${dataEnhancer.getName()}`, + `Failed to enhance data for ${symbol} (${ + assetProfile.dataSource + }) by ${dataEnhancer.getName()}`, error, 'DataGatheringService' ); @@ -152,10 +164,11 @@ export class DataGatheringService { countries, currency, dataSource, + isin, name, sectors, url - } = assetProfiles[symbol]; + } = assetProfile; try { await this.prismaService.symbolProfile.upsert({ @@ -165,6 +178,7 @@ export class DataGatheringService { countries, currency, dataSource, + isin, name, sectors, symbol, @@ -175,6 +189,7 @@ export class DataGatheringService { assetSubClass, countries, currency, + isin, name, sectors, url @@ -206,68 +221,25 @@ export class DataGatheringService { } public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) { - for (const { dataSource, date, symbol } of aSymbolsWithStartDate) { - if (dataSource === 'MANUAL') { - continue; - } - - await this.addJobToQueue( - GATHER_HISTORICAL_MARKET_DATA_PROCESS, - { - dataSource, - date, - symbol - }, - GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS - ); - } - } - - public async getSymbolsMax(): Promise { - const startDate = - ( - await this.prismaService.order.findFirst({ - orderBy: [{ date: 'asc' }] - }) - )?.date ?? new Date(); - - const currencyPairsToGather = this.exchangeRateDataService - .getCurrencyPairs() - .map(({ dataSource, symbol }) => { + await this.addJobsToQueue( + aSymbolsWithStartDate.map(({ dataSource, date, symbol }) => { return { - dataSource, - symbol, - date: startDate - }; - }); - - const symbolProfilesToGather = ( - await this.prismaService.symbolProfile.findMany({ - orderBy: [{ symbol: 'asc' }], - select: { - dataSource: true, - Order: { - orderBy: [{ date: 'asc' }], - select: { date: true }, - take: 1 + data: { + dataSource, + date, + symbol }, - scraperConfiguration: true, - symbol: true - }, - where: { - dataSource: { - not: 'MANUAL' + name: GATHER_HISTORICAL_MARKET_DATA_PROCESS, + opts: { + ...GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS, + jobId: `${getAssetProfileIdentifier({ + dataSource, + symbol + })}-${format(date, DATE_FORMAT)}` } - } + }; }) - ).map((symbolProfile) => { - return { - ...symbolProfile, - date: symbolProfile.Order?.[0]?.date ?? startDate - }; - }); - - return [...currencyPairsToGather, ...symbolProfilesToGather]; + ); } public async getUniqueAssets(): Promise { @@ -278,7 +250,6 @@ export class DataGatheringService { return symbolProfiles .filter(({ dataSource }) => { return ( - dataSource !== DataSource.GHOSTFOLIO && dataSource !== DataSource.MANUAL && dataSource !== DataSource.RAPID_API ); @@ -291,6 +262,10 @@ export class DataGatheringService { }); } + private getEarliestDate(aStartDate: Date) { + return min([aStartDate, subYears(new Date(), 10)]); + } + private async getSymbols7D(): Promise { const startDate = subDays(resetHours(new Date()), 7); @@ -300,23 +275,19 @@ export class DataGatheringService { dataSource: true, scraperConfiguration: true, symbol: true - }, - where: { - dataSource: { - not: 'MANUAL' - } } }); // Only consider symbols with incomplete market data for the last // 7 days - const symbolsNotToGather = ( + const symbolsWithCompleteMarketData = ( await this.prismaService.marketData.groupBy({ _count: true, by: ['symbol'], orderBy: [{ symbol: 'asc' }], where: { - date: { gt: startDate } + date: { gt: startDate }, + state: 'CLOSE' } }) ) @@ -328,8 +299,14 @@ export class DataGatheringService { }); const symbolProfilesToGather = symbolProfiles - .filter(({ symbol }) => { - return !symbolsNotToGather.includes(symbol); + .filter(({ dataSource, scraperConfiguration, symbol }) => { + const manualDataSourceWithScraperConfiguration = + dataSource === 'MANUAL' && !isEmpty(scraperConfiguration); + + return ( + !symbolsWithCompleteMarketData.includes(symbol) && + (dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration) + ); }) .map((symbolProfile) => { return { @@ -341,7 +318,7 @@ export class DataGatheringService { const currencyPairsToGather = this.exchangeRateDataService .getCurrencyPairs() .filter(({ symbol }) => { - return !symbolsNotToGather.includes(symbol); + return !symbolsWithCompleteMarketData.includes(symbol); }) .map(({ dataSource, symbol }) => { return { @@ -354,17 +331,71 @@ export class DataGatheringService { return [...currencyPairsToGather, ...symbolProfilesToGather]; } - private async hasJob(name: string, data: any) { - const jobs = await this.dataGatheringQueue.getJobs( - QUEUE_JOB_STATUS_LIST.filter((status) => { - return status !== 'completed'; + private async getSymbolsMax(): Promise { + const benchmarkAssetProfileIdMap: { [key: string]: boolean } = {}; + ( + ((await this.propertyService.getByKey( + PROPERTY_BENCHMARKS + )) as BenchmarkProperty[]) ?? [] + ).forEach(({ symbolProfileId }) => { + benchmarkAssetProfileIdMap[symbolProfileId] = true; + }); + const startDate = + ( + await this.prismaService.order.findFirst({ + orderBy: [{ date: 'asc' }] + }) + )?.date ?? new Date(); + + const currencyPairsToGather = this.exchangeRateDataService + .getCurrencyPairs() + .map(({ dataSource, symbol }) => { + return { + dataSource, + symbol, + date: this.getEarliestDate(startDate) + }; + }); + + const symbolProfilesToGather = ( + await this.prismaService.symbolProfile.findMany({ + orderBy: [{ symbol: 'asc' }], + select: { + dataSource: true, + id: true, + Order: { + orderBy: [{ date: 'asc' }], + select: { date: true }, + take: 1 + }, + scraperConfiguration: true, + symbol: true + } }) - ); + ) + .filter((symbolProfile) => { + const manualDataSourceWithScraperConfiguration = + symbolProfile.dataSource === 'MANUAL' && + !isEmpty(symbolProfile.scraperConfiguration); - return jobs.some((job) => { - return ( - job.name === name && JSON.stringify(job.data) === JSON.stringify(data) - ); - }); + return ( + symbolProfile.dataSource !== 'MANUAL' || + manualDataSourceWithScraperConfiguration + ); + }) + .map((symbolProfile) => { + let date = symbolProfile.Order?.[0]?.date ?? startDate; + + if (benchmarkAssetProfileIdMap[symbolProfile.id]) { + date = this.getEarliestDate(startDate); + } + + return { + ...symbolProfile, + date + }; + }); + + return [...currencyPairsToGather, ...symbolProfilesToGather]; } } 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 41bd715b1..973fc5df2 100644 --- a/apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts +++ b/apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts @@ -1,5 +1,5 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { IDataProviderHistoricalResponse, @@ -7,8 +7,9 @@ import { } from '@ghostfolio/api/services/interfaces/interfaces'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { Granularity } from '@ghostfolio/common/types'; -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { DataSource, SymbolProfile } from '@prisma/client'; +import * as Alphavantage from 'alphavantage'; import { format, isAfter, isBefore, parse } from 'date-fns'; import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces'; @@ -20,7 +21,7 @@ export class AlphaVantageService implements DataProviderInterface { public constructor( private readonly configurationService: ConfigurationService ) { - this.alphaVantage = require('alphavantage')({ + this.alphaVantage = Alphavantage({ key: this.configurationService.get('ALPHA_VANTAGE_API_KEY') }); } @@ -33,10 +34,25 @@ export class AlphaVantageService implements DataProviderInterface { aSymbol: string ): Promise> { return { - dataSource: this.getName() + dataSource: this.getName(), + symbol: aSymbol }; } + public async getDividends({ + from, + granularity = 'day', + symbol, + to + }: { + from: Date; + granularity: Granularity; + symbol: string; + to: Date; + }) { + return {}; + } + public async getHistorical( aSymbol: string, aGranularity: Granularity = 'day', @@ -95,12 +111,25 @@ export class AlphaVantageService implements DataProviderInterface { return {}; } - public async search(aQuery: string): Promise<{ items: LookupItem[] }> { - const result = await this.alphaVantage.data.search(aQuery); + public getTestSymbol() { + return undefined; + } + + public async search({ + includeIndices = false, + query + }: { + includeIndices?: boolean; + query: string; + }): Promise<{ items: LookupItem[] }> { + const result = await this.alphaVantage.data.search(query); return { items: result?.bestMatches?.map((bestMatch) => { return { + assetClass: undefined, + assetSubClass: undefined, + currency: bestMatch['8. currency'], dataSource: this.getName(), name: bestMatch['2. name'], symbol: bestMatch['1. symbol'] diff --git a/apps/api/src/services/data-provider/coingecko/coingecko.service.ts b/apps/api/src/services/data-provider/coingecko/coingecko.service.ts new file mode 100644 index 000000000..4360822f0 --- /dev/null +++ b/apps/api/src/services/data-provider/coingecko/coingecko.service.ts @@ -0,0 +1,229 @@ +import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; +import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { + IDataProviderHistoricalResponse, + IDataProviderResponse +} from '@ghostfolio/api/services/interfaces/interfaces'; +import { + DEFAULT_CURRENCY, + DEFAULT_REQUEST_TIMEOUT +} from '@ghostfolio/common/config'; +import { DATE_FORMAT } from '@ghostfolio/common/helper'; +import { DataProviderInfo } from '@ghostfolio/common/interfaces'; +import { Granularity } from '@ghostfolio/common/types'; +import { Injectable, Logger } from '@nestjs/common'; +import { + AssetClass, + AssetSubClass, + DataSource, + SymbolProfile +} from '@prisma/client'; +import { format, fromUnixTime, getUnixTime } from 'date-fns'; +import got from 'got'; + +@Injectable() +export class CoinGeckoService implements DataProviderInterface { + private readonly URL = 'https://api.coingecko.com/api/v3'; + + public constructor() {} + + public canHandle(symbol: string) { + return true; + } + + public async getAssetProfile( + aSymbol: string + ): Promise> { + const response: Partial = { + assetClass: AssetClass.CASH, + assetSubClass: AssetSubClass.CRYPTOCURRENCY, + currency: DEFAULT_CURRENCY, + dataSource: this.getName(), + symbol: aSymbol + }; + + try { + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const { name } = await got(`${this.URL}/coins/${aSymbol}`, { + // @ts-ignore + signal: abortController.signal + }).json(); + + response.name = name; + } catch (error) { + Logger.error(error, 'CoinGeckoService'); + } + + return response; + } + + public async getDividends({ + from, + granularity = 'day', + symbol, + to + }: { + from: Date; + granularity: Granularity; + symbol: string; + to: Date; + }) { + return {}; + } + + public async getHistorical( + aSymbol: string, + aGranularity: Granularity = 'day', + from: Date, + to: Date + ): Promise<{ + [symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; + }> { + try { + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const { prices } = await got( + `${ + this.URL + }/coins/${aSymbol}/market_chart/range?vs_currency=${DEFAULT_CURRENCY.toLowerCase()}&from=${getUnixTime( + from + )}&to=${getUnixTime(to)}`, + { + // @ts-ignore + signal: abortController.signal + } + ).json(); + + const result: { + [symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; + } = { + [aSymbol]: {} + }; + + for (const [timestamp, marketPrice] of prices) { + result[aSymbol][format(fromUnixTime(timestamp / 1000), DATE_FORMAT)] = { + marketPrice + }; + } + + return result; + } catch (error) { + throw new Error( + `Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format( + from, + DATE_FORMAT + )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}` + ); + } + } + + public getMaxNumberOfSymbolsPerRequest() { + return 50; + } + + public getName(): DataSource { + return DataSource.COINGECKO; + } + + public async getQuotes( + aSymbols: string[] + ): Promise<{ [symbol: string]: IDataProviderResponse }> { + const results: { [symbol: string]: IDataProviderResponse } = {}; + + if (aSymbols.length <= 0) { + return {}; + } + + try { + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const response = await got( + `${this.URL}/simple/price?ids=${aSymbols.join( + ',' + )}&vs_currencies=${DEFAULT_CURRENCY.toLowerCase()}`, + { + // @ts-ignore + signal: abortController.signal + } + ).json(); + + for (const symbol in response) { + if (Object.prototype.hasOwnProperty.call(response, symbol)) { + results[symbol] = { + currency: DEFAULT_CURRENCY, + dataProviderInfo: this.getDataProviderInfo(), + dataSource: DataSource.COINGECKO, + marketPrice: response[symbol][DEFAULT_CURRENCY.toLowerCase()], + marketState: 'open' + }; + } + } + } catch (error) { + Logger.error(error, 'CoinGeckoService'); + } + + return results; + } + + public getTestSymbol() { + return 'bitcoin'; + } + + public async search({ + includeIndices = false, + query + }: { + includeIndices?: boolean; + query: string; + }): Promise<{ items: LookupItem[] }> { + let items: LookupItem[] = []; + + try { + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const { coins } = await got(`${this.URL}/search?query=${query}`, { + // @ts-ignore + signal: abortController.signal + }).json(); + + items = coins.map(({ id: symbol, name }) => { + return { + name, + symbol, + assetClass: AssetClass.CASH, + assetSubClass: AssetSubClass.CRYPTOCURRENCY, + currency: DEFAULT_CURRENCY, + dataSource: this.getName() + }; + }); + } catch (error) { + Logger.error(error, 'CoinGeckoService'); + } + + return { items }; + } + + private getDataProviderInfo(): DataProviderInfo { + return { + name: 'CoinGecko', + url: 'https://coingecko.com' + }; + } +} 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 9d4c0704d..069309508 100644 --- a/apps/api/src/services/data-provider/data-enhancer/data-enhancer.module.ts +++ b/apps/api/src/services/data-provider/data-enhancer/data-enhancer.module.ts @@ -1,15 +1,31 @@ +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module'; 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 { Module } from '@nestjs/common'; +import { DataEnhancerService } from './data-enhancer.service'; + @Module({ - exports: ['DataEnhancers', TrackinsightDataEnhancerService], + exports: [ + DataEnhancerService, + TrackinsightDataEnhancerService, + YahooFinanceDataEnhancerService, + 'DataEnhancers' + ], + imports: [ConfigurationModule, CryptocurrencyModule], providers: [ + DataEnhancerService, + TrackinsightDataEnhancerService, + YahooFinanceDataEnhancerService, { - inject: [TrackinsightDataEnhancerService], + inject: [ + TrackinsightDataEnhancerService, + YahooFinanceDataEnhancerService + ], provide: 'DataEnhancers', - useFactory: (trackinsight) => [trackinsight] - }, - TrackinsightDataEnhancerService + useFactory: (trackinsight, yahooFinance) => [trackinsight, yahooFinance] + } ] }) export class DataEnhancerModule {} diff --git a/apps/api/src/services/data-provider/data-enhancer/data-enhancer.service.ts b/apps/api/src/services/data-provider/data-enhancer/data-enhancer.service.ts new file mode 100644 index 000000000..e5038c7c6 --- /dev/null +++ b/apps/api/src/services/data-provider/data-enhancer/data-enhancer.service.ts @@ -0,0 +1,44 @@ +import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; +import { HttpException, Inject, Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +@Injectable() +export class DataEnhancerService { + public constructor( + @Inject('DataEnhancers') + private readonly dataEnhancers: DataEnhancerInterface[] + ) {} + + public async enhance(aName: string) { + const dataEnhancer = this.dataEnhancers.find((dataEnhancer) => { + return dataEnhancer.getName() === aName; + }); + + if (!dataEnhancer) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + try { + const assetProfile = await dataEnhancer.enhance({ + response: { + assetClass: 'EQUITY', + assetSubClass: 'ETF' + }, + symbol: dataEnhancer.getTestSymbol() + }); + + if ( + (assetProfile.countries as unknown as Prisma.JsonArray)?.length > 0 && + (assetProfile.sectors as unknown as Prisma.JsonArray)?.length > 0 + ) { + return true; + } + } catch {} + + return false; + } +} diff --git a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts index 8ebdb1dba..f17bb74c9 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,13 +1,14 @@ import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; +import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config'; import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; +import { Injectable } from '@nestjs/common'; import { SymbolProfile } from '@prisma/client'; -import bent from 'bent'; - -const getJSON = bent('json'); +import got from 'got'; +@Injectable() export class TrackinsightDataEnhancerService implements DataEnhancerInterface { - private static baseUrl = 'https://data.trackinsight.com/holdings'; + private static baseUrl = 'https://www.trackinsight.com/data-api'; private static countries = require('countries-list/dist/countries.json'); private static countriesMapping = { 'Russian Federation': 'Russia' @@ -26,24 +27,90 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { response: Partial; symbol: string; }): Promise> { - if ( - !(response.assetClass === 'EQUITY' && response.assetSubClass === 'ETF') - ) { + if (!(response.assetSubClass === 'ETF')) { return response; } - const result = await getJSON( - `${TrackinsightDataEnhancerService.baseUrl}/${symbol}.json` - ).catch(() => { - return getJSON( - `${TrackinsightDataEnhancerService.baseUrl}/${ - symbol.split('.')[0] - }.json` - ); - }); - - if (result.weight < 0.95) { - // Skip if data is inaccurate + let abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const profile = await got( + `${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol}.json`, + { + // @ts-ignore + signal: abortController.signal + } + ) + .json() + .catch(() => { + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + return got( + `${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol.split( + '.' + )?.[0]}.json`, + { + // @ts-ignore + signal: abortController.signal + } + ) + .json() + .catch(() => { + return {}; + }); + }); + + const isin = profile?.isin?.split(';')?.[0]; + + if (isin) { + response.isin = isin; + } + + abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const holdings = await got( + `${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`, + { + // @ts-ignore + signal: abortController.signal + } + ) + .json() + .catch(() => { + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + return got( + `${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol.split( + '.' + )?.[0]}.json`, + { + // @ts-ignore + signal: abortController.signal + } + ) + .json() + .catch(() => { + return {}; + }); + }); + + if (holdings?.weight < 1 - Math.min(holdings?.count * 0.000015, 0.95)) { + // Skip if data is inaccurate, dependent on holdings count there might be rounding issues return response; } @@ -52,7 +119,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { (response.countries as unknown as Country[]).length === 0 ) { response.countries = []; - for (const [name, value] of Object.entries(result.countries)) { + for (const [name, value] of Object.entries( + holdings?.countries ?? {} + )) { let countryCode: string; for (const [key, country] of Object.entries( @@ -80,7 +149,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { (response.sectors as unknown as Sector[]).length === 0 ) { response.sectors = []; - for (const [name, value] of Object.entries(result.sectors)) { + for (const [name, value] of Object.entries( + holdings?.sectors ?? {} + )) { response.sectors.push({ name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name, weight: value.weight @@ -94,4 +165,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { public getName() { return 'TRACKINSIGHT'; } + + public getTestSymbol() { + return 'QQQ'; + } } diff --git a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.spec.ts b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.spec.ts similarity index 56% rename from apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.spec.ts rename to apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.spec.ts index e18b6b583..8a8ab1f08 100644 --- a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.spec.ts +++ b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.spec.ts @@ -1,7 +1,6 @@ -import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; -import { YahooFinanceService } from './yahoo-finance.service'; +import { YahooFinanceDataEnhancerService } from './yahoo-finance.service'; jest.mock( '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service', @@ -25,42 +24,51 @@ jest.mock( } ); -describe('YahooFinanceService', () => { - let configurationService: ConfigurationService; +describe('YahooFinanceDataEnhancerService', () => { let cryptocurrencyService: CryptocurrencyService; - let yahooFinanceService: YahooFinanceService; + let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService; beforeAll(async () => { - configurationService = new ConfigurationService(); cryptocurrencyService = new CryptocurrencyService(); - yahooFinanceService = new YahooFinanceService( - configurationService, + yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService( cryptocurrencyService ); }); it('convertFromYahooFinanceSymbol', async () => { expect( - await yahooFinanceService.convertFromYahooFinanceSymbol('BRK-B') + await yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol( + 'BRK-B' + ) ).toEqual('BRK-B'); expect( - await yahooFinanceService.convertFromYahooFinanceSymbol('BTC-USD') + await yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol( + 'BTC-USD' + ) ).toEqual('BTCUSD'); expect( - await yahooFinanceService.convertFromYahooFinanceSymbol('EURUSD=X') + await yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol( + 'EURUSD=X' + ) ).toEqual('EURUSD'); }); it('convertToYahooFinanceSymbol', async () => { expect( - await yahooFinanceService.convertToYahooFinanceSymbol('BTCUSD') + await yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol( + 'BTCUSD' + ) ).toEqual('BTC-USD'); expect( - await yahooFinanceService.convertToYahooFinanceSymbol('DOGEUSD') + await yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol( + 'DOGEUSD' + ) ).toEqual('DOGE-USD'); expect( - await yahooFinanceService.convertToYahooFinanceSymbol('USDCHF') + await yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol( + 'USDCHF' + ) ).toEqual('USDCHF=X'); }); }); 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 new file mode 100644 index 000000000..8731e709c --- /dev/null +++ b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts @@ -0,0 +1,322 @@ +import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; +import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; +import { DEFAULT_CURRENCY, UNKNOWN_KEY } from '@ghostfolio/common/config'; +import { isCurrency } from '@ghostfolio/common/helper'; +import { Injectable, Logger } from '@nestjs/common'; +import { + AssetClass, + AssetSubClass, + DataSource, + Prisma, + SymbolProfile +} from '@prisma/client'; +import { countries } from 'countries-list'; +import yahooFinance from 'yahoo-finance2'; +import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface'; + +@Injectable() +export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { + public constructor( + private readonly cryptocurrencyService: CryptocurrencyService + ) {} + + public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) { + let symbol = aYahooFinanceSymbol.replace( + new RegExp(`-${DEFAULT_CURRENCY}$`), + DEFAULT_CURRENCY + ); + + if (symbol.includes('=X') && !symbol.includes(DEFAULT_CURRENCY)) { + symbol = `${DEFAULT_CURRENCY}${symbol}`; + } + + return symbol.replace('=X', ''); + } + + /** + * Converts a symbol to a Yahoo Finance symbol + * + * Currency: USDCHF -> USDCHF=X + * Cryptocurrency: BTCUSD -> BTC-USD + * DOGEUSD -> DOGE-USD + */ + public convertToYahooFinanceSymbol(aSymbol: string) { + if ( + aSymbol.includes(DEFAULT_CURRENCY) && + aSymbol.length > DEFAULT_CURRENCY.length + ) { + if ( + isCurrency( + aSymbol.substring(0, 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}` + ); + } + } + + return aSymbol; + } + + public async enhance({ + response, + symbol + }: { + response: Partial; + symbol: string; + }): Promise> { + if (response.dataSource !== 'YAHOO' && !response.isin) { + return response; + } + + try { + let yahooSymbol: string; + + if (response.dataSource === 'YAHOO') { + yahooSymbol = symbol; + } else { + const { quotes } = await yahooFinance.search(response.isin); + yahooSymbol = quotes[0].symbol; + } + + const { countries, sectors, url } = + await this.getAssetProfile(yahooSymbol); + + if ((countries as unknown as Prisma.JsonArray)?.length > 0) { + response.countries = countries; + } + + if ((sectors as unknown as Prisma.JsonArray)?.length > 0) { + response.sectors = sectors; + } + + if (url) { + response.url = url; + } + } catch (error) { + Logger.error(error, 'YahooFinanceDataEnhancerService'); + } + + return response; + } + + public formatName({ + longName, + quoteType, + shortName, + symbol + }: { + longName: Price['longName']; + quoteType: Price['quoteType']; + shortName: Price['shortName']; + symbol: Price['symbol']; + }) { + let name = longName; + + if (name) { + name = name.replace('&', '&'); + + name = name.replace('Amundi Index Solutions - ', ''); + name = name.replace('iShares ETF (CH) - ', ''); + name = name.replace('iShares III Public Limited Company - ', ''); + name = name.replace('iShares V PLC - ', ''); + name = name.replace('iShares VI Public Limited Company - ', ''); + name = name.replace('iShares VII PLC - ', ''); + name = name.replace('Multi Units Luxembourg - ', ''); + name = name.replace('VanEck ETFs N.V. - ', ''); + name = name.replace('Vaneck Vectors Ucits Etfs Plc - ', ''); + name = name.replace('Vanguard Funds Public Limited Company - ', ''); + name = name.replace('Vanguard Index Funds - ', ''); + name = name.replace('Xtrackers (IE) Plc - ', ''); + } + + if (quoteType === 'FUTURE') { + // "Gold Jun 22" -> "Gold" + name = shortName?.slice(0, -7); + } + + return name || shortName || symbol; + } + + public async getAssetProfile( + aSymbol: string + ): Promise> { + const response: Partial = {}; + + try { + const symbol = this.convertToYahooFinanceSymbol(aSymbol); + const assetProfile = await yahooFinance.quoteSummary(symbol, { + modules: ['price', 'summaryProfile', 'topHoldings'] + }); + + const { assetClass, assetSubClass } = this.parseAssetClass({ + quoteType: assetProfile.price.quoteType, + shortName: assetProfile.price.shortName + }); + + response.assetClass = assetClass; + response.assetSubClass = assetSubClass; + 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 + }); + response.symbol = aSymbol; + + if (assetSubClass === AssetSubClass.MUTUALFUND) { + response.sectors = []; + + for (const sectorWeighting of assetProfile.topHoldings + ?.sectorWeightings ?? []) { + for (const [sector, weight] of Object.entries(sectorWeighting)) { + response.sectors.push({ weight, name: this.parseSector(sector) }); + } + } + } else if ( + assetSubClass === AssetSubClass.STOCK && + assetProfile.summaryProfile?.country + ) { + // Add country if asset is stock and country available + + try { + const [code] = Object.entries(countries).find(([, country]) => { + return country.name === assetProfile.summaryProfile?.country; + }); + + if (code) { + response.countries = [{ code, weight: 1 }]; + } + } catch {} + + if (assetProfile.summaryProfile?.sector) { + response.sectors = [ + { name: assetProfile.summaryProfile?.sector, weight: 1 } + ]; + } + } + + const url = assetProfile.summaryProfile?.website; + if (url) { + response.url = url; + } + } catch (error) { + Logger.error(error, 'YahooFinanceService'); + } + + return response; + } + + public getName() { + return DataSource.YAHOO; + } + + public getTestSymbol() { + return 'AAPL'; + } + + public parseAssetClass({ + quoteType, + shortName + }: { + quoteType: string; + shortName: string; + }): { + assetClass: AssetClass; + assetSubClass: AssetSubClass; + } { + let assetClass: AssetClass; + let assetSubClass: AssetSubClass; + + switch (quoteType?.toLowerCase()) { + case 'cryptocurrency': + assetClass = AssetClass.CASH; + assetSubClass = AssetSubClass.CRYPTOCURRENCY; + break; + case 'equity': + assetClass = AssetClass.EQUITY; + assetSubClass = AssetSubClass.STOCK; + break; + case 'etf': + assetClass = AssetClass.EQUITY; + assetSubClass = AssetSubClass.ETF; + break; + case 'future': + assetClass = AssetClass.COMMODITY; + assetSubClass = AssetSubClass.COMMODITY; + + if ( + shortName?.toLowerCase()?.startsWith('gold') || + shortName?.toLowerCase()?.startsWith('palladium') || + shortName?.toLowerCase()?.startsWith('platinum') || + shortName?.toLowerCase()?.startsWith('silver') + ) { + assetSubClass = AssetSubClass.PRECIOUS_METAL; + } + + break; + case 'mutualfund': + assetClass = AssetClass.EQUITY; + assetSubClass = AssetSubClass.MUTUALFUND; + break; + } + + return { assetClass, assetSubClass }; + } + + private parseSector(aString: string): 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 3fa56e06c..b3a219a50 100644 --- a/apps/api/src/services/data-provider/data-provider.module.ts +++ b/apps/api/src/services/data-provider/data-provider.module.ts @@ -1,30 +1,41 @@ -import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module'; import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; +import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service'; import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service'; -import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service'; +import { FinancialModelingPrepService } from '@ghostfolio/api/services/data-provider/financial-modeling-prep/financial-modeling-prep.service'; import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service'; 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 { PrismaModule } from '@ghostfolio/api/services/prisma.module'; -import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { Module } from '@nestjs/common'; +import { DataEnhancerModule } from './data-enhancer/data-enhancer.module'; +import { YahooFinanceDataEnhancerService } from './data-enhancer/yahoo-finance/yahoo-finance.service'; import { DataProviderService } from './data-provider.service'; @Module({ imports: [ ConfigurationModule, CryptocurrencyModule, + DataEnhancerModule, + MarketDataModule, PrismaModule, + PropertyModule, + RedisCacheModule, SymbolProfileModule ], providers: [ AlphaVantageService, + CoinGeckoService, DataProviderService, EodHistoricalDataService, - GhostfolioScraperApiService, + FinancialModelingPrepService, GoogleSheetsService, ManualService, RapidApiService, @@ -32,8 +43,9 @@ import { DataProviderService } from './data-provider.service'; { inject: [ AlphaVantageService, + CoinGeckoService, EodHistoricalDataService, - GhostfolioScraperApiService, + FinancialModelingPrepService, GoogleSheetsService, ManualService, RapidApiService, @@ -42,23 +54,26 @@ import { DataProviderService } from './data-provider.service'; provide: 'DataProviderInterfaces', useFactory: ( alphaVantageService, + coinGeckoService, eodHistoricalDataService, - ghostfolioScraperApiService, + financialModelingPrepService, googleSheetsService, manualService, rapidApiService, yahooFinanceService ) => [ alphaVantageService, + coinGeckoService, eodHistoricalDataService, - ghostfolioScraperApiService, + financialModelingPrepService, googleSheetsService, manualService, rapidApiService, yahooFinanceService ] - } + }, + YahooFinanceDataEnhancerService ], - exports: [DataProviderService, GhostfolioScraperApiService] + exports: [DataProviderService, YahooFinanceService] }) export class DataProviderModule {} 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 7f10dc3a0..557699495 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -1,30 +1,138 @@ +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { - IDataGatheringItem, IDataProviderHistoricalResponse, IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces'; -import { PrismaService } from '@ghostfolio/api/services/prisma.service'; -import { DATE_FORMAT } from '@ghostfolio/common/helper'; -import { Granularity } from '@ghostfolio/common/types'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { PROPERTY_DATA_SOURCE_MAPPING } from '@ghostfolio/common/config'; +import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper'; +import { UniqueAsset } from '@ghostfolio/common/interfaces'; +import type { Granularity, UserWithSettings } from '@ghostfolio/common/types'; import { Inject, Injectable, Logger } from '@nestjs/common'; import { DataSource, MarketData, SymbolProfile } from '@prisma/client'; import { format, isValid } from 'date-fns'; -import { groupBy, isEmpty } from 'lodash'; +import { groupBy, isEmpty, isNumber } from 'lodash'; @Injectable() export class DataProviderService { + private dataProviderMapping: { [dataProviderName: string]: string }; + public constructor( private readonly configurationService: ConfigurationService, @Inject('DataProviderInterfaces') private readonly dataProviderInterfaces: DataProviderInterface[], - private readonly prismaService: PrismaService - ) {} + private readonly marketDataService: MarketDataService, + private readonly prismaService: PrismaService, + private readonly propertyService: PropertyService, + private readonly redisCacheService: RedisCacheService + ) { + this.initialize(); + } + + public async initialize() { + this.dataProviderMapping = + ((await this.propertyService.getByKey(PROPERTY_DATA_SOURCE_MAPPING)) as { + [dataProviderName: string]: string; + }) ?? {}; + } + + public async checkQuote(dataSource: DataSource) { + const dataProvider = this.getDataProvider(dataSource); + const symbol = dataProvider.getTestSymbol(); + + const quotes = await this.getQuotes({ + items: [ + { + dataSource, + symbol + } + ], + useCache: false + }); + + if (quotes[symbol]?.marketPrice > 0) { + return true; + } + + return false; + } + + public async getAssetProfiles(items: UniqueAsset[]): Promise<{ + [symbol: string]: Partial; + }> { + const response: { + [symbol: string]: Partial; + } = {}; + + const itemsGroupedByDataSource = groupBy(items, ({ dataSource }) => { + return dataSource; + }); + + const promises = []; + + for (const [dataSource, dataGatheringItems] of Object.entries( + itemsGroupedByDataSource + )) { + const symbols = dataGatheringItems.map((dataGatheringItem) => { + return dataGatheringItem.symbol; + }); + + for (const symbol of symbols) { + const promise = Promise.resolve( + this.getDataProvider(DataSource[dataSource]).getAssetProfile(symbol) + ); + + promises.push( + promise.then((symbolProfile) => { + response[symbol] = symbolProfile; + }) + ); + } + } + + await Promise.all(promises); + + return response; + } + + public getDataSourceForExchangeRates(): DataSource { + return DataSource[ + this.configurationService.get('DATA_SOURCE_EXCHANGE_RATES') + ]; + } + + public getDataSourceForImport(): DataSource { + return DataSource[this.configurationService.get('DATA_SOURCE_IMPORT')]; + } + + public async getDividends({ + dataSource, + from, + granularity = 'day', + symbol, + to + }: { + dataSource: DataSource; + from: Date; + granularity: Granularity; + symbol: string; + to: Date; + }) { + return this.getDataProvider(DataSource[dataSource]).getDividends({ + from, + granularity, + symbol, + to + }); + } public async getHistorical( - aItems: IDataGatheringItem[], + aItems: UniqueAsset[], aGranularity: Granularity = 'month', from: Date, to: Date @@ -52,11 +160,11 @@ export class DataProviderService { )}'` : ''; - const dataSources = aItems.map((item) => { - return item.dataSource; + const dataSources = aItems.map(({ dataSource }) => { + return dataSource; }); - const symbols = aItems.map((item) => { - return item.symbol; + const symbols = aItems.map(({ symbol }) => { + return symbol; }); try { @@ -89,7 +197,7 @@ export class DataProviderService { } public async getHistoricalRaw( - aDataGatheringItems: IDataGatheringItem[], + aDataGatheringItems: UniqueAsset[], from: Date, to: Date ): Promise<{ @@ -114,65 +222,70 @@ export class DataProviderService { } } - const allData = await Promise.all(promises); - for (const { data, symbol } of allData) { - result[symbol] = data; + try { + const allData = await Promise.all(promises); + for (const { data, symbol } of allData) { + result[symbol] = data; + } + } catch (error) { + Logger.error(error, 'DataProviderService'); } return result; } - public getPrimaryDataSource(): DataSource { - return DataSource[this.configurationService.get('DATA_SOURCE_PRIMARY')]; - } - - public async getAssetProfiles(items: IDataGatheringItem[]): Promise<{ - [symbol: string]: Partial; + public async getQuotes({ + items, + useCache = true + }: { + items: UniqueAsset[]; + useCache?: boolean; + }): Promise<{ + [symbol: string]: IDataProviderResponse; }> { const response: { - [symbol: string]: Partial; + [symbol: string]: IDataProviderResponse; } = {}; + const startTimeTotal = performance.now(); - const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource); - - const promises = []; - - for (const [dataSource, dataGatheringItems] of Object.entries( - itemsGroupedByDataSource - )) { - const symbols = dataGatheringItems.map((dataGatheringItem) => { - return dataGatheringItem.symbol; - }); + // Get items from cache + const itemsToFetch: UniqueAsset[] = []; - for (const symbol of symbols) { - const promise = Promise.resolve( - this.getDataProvider(DataSource[dataSource]).getAssetProfile(symbol) + for (const { dataSource, symbol } of items) { + if (useCache) { + const quoteString = await this.redisCacheService.get( + this.redisCacheService.getQuoteKey({ dataSource, symbol }) ); - promises.push( - promise.then((symbolProfile) => { - response[symbol] = symbolProfile; - }) - ); + if (quoteString) { + try { + const cachedDataProviderResponse = JSON.parse(quoteString); + response[symbol] = cachedDataProviderResponse; + continue; + } catch {} + } } - } - await Promise.all(promises); + itemsToFetch.push({ dataSource, symbol }); + } - return response; - } + const numberOfItemsInCache = Object.keys(response)?.length; - public async getQuotes(items: IDataGatheringItem[]): Promise<{ - [symbol: string]: IDataProviderResponse; - }> { - const response: { - [symbol: string]: IDataProviderResponse; - } = {}; - const startTimeTotal = performance.now(); + if (numberOfItemsInCache) { + Logger.debug( + `Fetched ${numberOfItemsInCache} quote${ + numberOfItemsInCache > 1 ? 's' : '' + } from cache in ${((performance.now() - startTimeTotal) / 1000).toFixed( + 3 + )} seconds` + ); + } - const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource); + const itemsGroupedByDataSource = groupBy(itemsToFetch, ({ dataSource }) => { + return dataSource; + }); - const promises = []; + const promises: Promise[] = []; for (const [dataSource, dataGatheringItems] of Object.entries( itemsGroupedByDataSource @@ -201,19 +314,51 @@ export class DataProviderService { const promise = Promise.resolve(dataProvider.getQuotes(symbolsChunk)); promises.push( - promise.then((result) => { + promise.then(async (result) => { for (const [symbol, dataProviderResponse] of Object.entries( result )) { response[symbol] = dataProviderResponse; + + this.redisCacheService.set( + this.redisCacheService.getQuoteKey({ + dataSource: DataSource[dataSource], + symbol + }), + JSON.stringify(dataProviderResponse), + this.configurationService.get('CACHE_QUOTES_TTL') + ); } Logger.debug( - `Fetched ${symbolsChunk.length} quotes from ${dataSource} in ${( + `Fetched ${symbolsChunk.length} quote${ + symbolsChunk.length > 1 ? 's' : '' + } from ${dataSource} in ${( (performance.now() - startTimeDataSource) / 1000 ).toFixed(3)} seconds` ); + + try { + this.marketDataService.updateMany({ + data: Object.keys(response) + .filter((symbol) => { + return ( + isNumber(response[symbol].marketPrice) && + response[symbol].marketPrice > 0 + ); + }) + .map((symbol) => { + return { + symbol, + dataSource: response[symbol].dataSource, + date: getStartOfUtcDate(new Date()), + marketPrice: response[symbol].marketPrice, + state: 'INTRADAY' + }; + }) + }); + } catch {} }) ); } @@ -223,7 +368,7 @@ export class DataProviderService { Logger.debug('------------------------------------------------'); Logger.debug( - `Fetched ${items.length} quotes in ${( + `Fetched ${items.length} quote${items.length > 1 ? 's' : ''} in ${( (performance.now() - startTimeTotal) / 1000 ).toFixed(3)} seconds` @@ -233,26 +378,58 @@ export class DataProviderService { return response; } - public async search(aQuery: string): Promise<{ items: LookupItem[] }> { + public async search({ + includeIndices = false, + query, + user + }: { + includeIndices?: boolean; + query: string; + user: UserWithSettings; + }): Promise<{ items: LookupItem[] }> { const promises: Promise<{ items: LookupItem[] }>[] = []; let lookupItems: LookupItem[] = []; - for (const dataSource of this.configurationService.get('DATA_SOURCES')) { + if (query?.length < 2) { + return { items: lookupItems }; + } + + let dataSources = this.configurationService.get('DATA_SOURCES'); + + if ( + this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + user.subscription.type === 'Basic' + ) { + dataSources = dataSources.filter((dataSource) => { + return !this.isPremiumDataSource(DataSource[dataSource]); + }); + } + + for (const dataSource of dataSources) { promises.push( - this.getDataProvider(DataSource[dataSource]).search(aQuery) + this.getDataProvider(DataSource[dataSource]).search({ + includeIndices, + query + }) ); } const searchResults = await Promise.all(promises); - searchResults.forEach((searchResult) => { - lookupItems = lookupItems.concat(searchResult.items); + searchResults.forEach(({ items }) => { + if (items?.length > 0) { + lookupItems = lookupItems.concat(items); + } }); - const filteredItems = lookupItems.filter((lookupItem) => { - // Only allow symbols with supported currency - return lookupItem.currency ? true : false; - }); + const filteredItems = lookupItems + .filter((lookupItem) => { + // Only allow symbols with supported currency + return lookupItem.currency ? true : false; + }) + .sort(({ name: name1 }, { name: name2 }) => { + return name1?.toLowerCase().localeCompare(name2?.toLowerCase()); + }); return { items: filteredItems @@ -261,6 +438,21 @@ export class DataProviderService { private getDataProvider(providerName: DataSource) { for (const dataProviderInterface of this.dataProviderInterfaces) { + if (this.dataProviderMapping[dataProviderInterface.getName()]) { + const mappedDataProviderInterface = this.dataProviderInterfaces.find( + (currentDataProviderInterface) => { + return ( + currentDataProviderInterface.getName() === + this.dataProviderMapping[dataProviderInterface.getName()] + ); + } + ); + + if (mappedDataProviderInterface) { + return mappedDataProviderInterface; + } + } + if (dataProviderInterface.getName() === providerName) { return dataProviderInterface; } @@ -268,4 +460,12 @@ export class DataProviderService { throw new Error('No data provider has been found.'); } + + private isPremiumDataSource(aDataSource: DataSource) { + const premiumDataSources: DataSource[] = [ + DataSource.EOD_HISTORICAL_DATA, + DataSource.FINANCIAL_MODELING_PREP + ]; + return premiumDataSources.includes(aDataSource); + } } 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 c87c6ec3e..307f6127a 100644 --- a/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts +++ b/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts @@ -1,17 +1,26 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { IDataProviderHistoricalResponse, IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces'; -import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; -import { DATE_FORMAT } from '@ghostfolio/common/helper'; +import { + DEFAULT_CURRENCY, + DEFAULT_REQUEST_TIMEOUT +} from '@ghostfolio/common/config'; +import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper'; import { Granularity } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; -import { DataSource, SymbolProfile } from '@prisma/client'; -import bent from 'bent'; -import { format } from 'date-fns'; +import { + AssetClass, + AssetSubClass, + DataSource, + SymbolProfile +} from '@prisma/client'; +import Big from 'big.js'; +import { format, isToday } from 'date-fns'; +import got from 'got'; @Injectable() export class EodHistoricalDataService implements DataProviderInterface { @@ -19,8 +28,7 @@ export class EodHistoricalDataService implements DataProviderInterface { private readonly URL = 'https://eodhistoricaldata.com/api'; public constructor( - private readonly configurationService: ConfigurationService, - private readonly symbolProfileService: SymbolProfileService + private readonly configurationService: ConfigurationService ) { this.apiKey = this.configurationService.get('EOD_HISTORICAL_DATA_API_KEY'); } @@ -32,11 +40,33 @@ export class EodHistoricalDataService implements DataProviderInterface { public async getAssetProfile( aSymbol: string ): Promise> { + const [searchResult] = await this.getSearchResult(aSymbol); + return { - dataSource: this.getName() + assetClass: searchResult?.assetClass, + assetSubClass: searchResult?.assetSubClass, + currency: this.convertCurrency(searchResult?.currency), + dataSource: this.getName(), + isin: searchResult?.isin, + name: searchResult?.name, + symbol: aSymbol }; } + public async getDividends({ + from, + granularity = 'day', + symbol, + to + }: { + from: Date; + granularity: Granularity; + symbol: string; + to: Date; + }) { + return {}; + } + public async getHistorical( aSymbol: string, aGranularity: Granularity = 'day', @@ -45,31 +75,41 @@ export class EodHistoricalDataService implements DataProviderInterface { ): Promise<{ [symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; }> { + const symbol = this.convertToEodSymbol(aSymbol); + try { - const get = bent( - `${this.URL}/eod/${aSymbol}?api_token=${ + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const response = await got( + `${this.URL}/eod/${symbol}?api_token=${ this.apiKey }&fmt=json&from=${format(from, DATE_FORMAT)}&to=${format( to, DATE_FORMAT )}&period={aGranularity}`, - 'GET', - 'json', - 200 - ); - - const response = await get(); + { + // @ts-ignore + signal: abortController.signal + } + ).json(); return response.reduce( (result, historicalItem, index, array) => { - result[aSymbol][historicalItem.date] = { - marketPrice: historicalItem.close, + result[this.convertFromEodSymbol(symbol)][historicalItem.date] = { + marketPrice: this.getConvertedValue({ + symbol: aSymbol, + value: historicalItem.close + }), performance: historicalItem.open - historicalItem.close }; return result; }, - { [aSymbol]: {} } + { [this.convertFromEodSymbol(symbol)]: {} } ); } catch (error) { throw new Error( @@ -94,54 +134,284 @@ export class EodHistoricalDataService implements DataProviderInterface { public async getQuotes( aSymbols: string[] ): Promise<{ [symbol: string]: IDataProviderResponse }> { - if (aSymbols.length <= 0) { + const symbols = aSymbols.map((symbol) => { + return this.convertToEodSymbol(symbol); + }); + + if (symbols.length <= 0) { return {}; } try { - const get = bent( - `${this.URL}/real-time/${aSymbols[0]}?api_token=${ + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const realTimeResponse = await got( + `${this.URL}/real-time/${symbols[0]}?api_token=${ this.apiKey - }&fmt=json&s=${aSymbols.join(',')}`, - 'GET', - 'json', - 200 + }&fmt=json&s=${symbols.join(',')}`, + { + // @ts-ignore + signal: abortController.signal + } + ).json(); + + const quotes = + symbols.length === 1 ? [realTimeResponse] : realTimeResponse; + + const searchResponse = await Promise.all( + symbols + .filter((symbol) => { + return !symbol.endsWith('.FOREX'); + }) + .map((symbol) => { + return this.search({ query: symbol }); + }) + ); + + const lookupItems = searchResponse.flat().map(({ items }) => { + return items[0]; + }); + + const response = quotes.reduce( + ( + result: { [symbol: string]: IDataProviderResponse }, + { close, code, timestamp } + ) => { + const currency = lookupItems.find((lookupItem) => { + return lookupItem.symbol === code; + })?.currency; + + result[this.convertFromEodSymbol(code)] = { + currency: currency ?? DEFAULT_CURRENCY, + dataSource: DataSource.EOD_HISTORICAL_DATA, + marketPrice: close, + marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed' + }; + + return result; + }, + {} ); - const [response, symbolProfiles] = await Promise.all([ - get(), - this.symbolProfileService.getSymbolProfiles( - aSymbols.map((symbol) => { + if (response[`${DEFAULT_CURRENCY}GBP`]) { + response[`${DEFAULT_CURRENCY}GBp`] = { + ...response[`${DEFAULT_CURRENCY}GBP`], + currency: `${DEFAULT_CURRENCY}GBp`, + marketPrice: this.getConvertedValue({ + symbol: `${DEFAULT_CURRENCY}GBp`, + value: response[`${DEFAULT_CURRENCY}GBP`].marketPrice + }) + }; + } + + if (response[`${DEFAULT_CURRENCY}ILS`]) { + response[`${DEFAULT_CURRENCY}ILA`] = { + ...response[`${DEFAULT_CURRENCY}ILS`], + currency: `${DEFAULT_CURRENCY}ILA`, + marketPrice: this.getConvertedValue({ + symbol: `${DEFAULT_CURRENCY}ILA`, + value: response[`${DEFAULT_CURRENCY}ILS`].marketPrice + }) + }; + } + + return response; + } catch (error) { + Logger.error(error, 'EodHistoricalDataService'); + } + + return {}; + } + + public getTestSymbol() { + return 'AAPL.US'; + } + + public async search({ + includeIndices = false, + query + }: { + includeIndices?: boolean; + query: string; + }): Promise<{ items: LookupItem[] }> { + const searchResult = await this.getSearchResult(query); + + return { + items: searchResult + .filter(({ symbol }) => { + return !symbol.endsWith('.FOREX'); + }) + .map( + ({ + assetClass, + assetSubClass, + currency, + dataSource, + name, + symbol + }) => { return { + assetClass, + assetSubClass, + dataSource, + name, symbol, - dataSource: DataSource.EOD_HISTORICAL_DATA + currency: this.convertCurrency(currency) }; - }) + } ) - ]); - - const quotes = aSymbols.length === 1 ? [response] : response; - - return quotes.reduce((result, item, index, array) => { - result[item.code] = { - currency: symbolProfiles.find((symbolProfile) => { - return symbolProfile.symbol === item.code; - })?.currency, - dataSource: DataSource.EOD_HISTORICAL_DATA, - marketPrice: item.close, - marketState: 'delayed' - }; + }; + } + + private convertCurrency(aCurrency: string) { + let currency = aCurrency; + + if (currency === 'GBX') { + currency = 'GBp'; + } + + return currency; + } + + private convertFromEodSymbol(aEodSymbol: string) { + let symbol = aEodSymbol; + + if (symbol.endsWith('.FOREX')) { + symbol = symbol.replace('GBX', 'GBp'); + symbol = symbol.replace('.FOREX', ''); + symbol = `${DEFAULT_CURRENCY}${symbol}`; + } + + return symbol; + } + + /** + * Converts a symbol to a EOD symbol + * + * Currency: USDCHF -> CHF.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) + ) + ) { + return `${aSymbol + .replace('GBp', 'GBX') + .replace(DEFAULT_CURRENCY, '')}.FOREX`; + } + } - return result; - }, {}); + return aSymbol; + } + + private getConvertedValue({ + symbol, + value + }: { + symbol: string; + value: number; + }) { + if (symbol === `${DEFAULT_CURRENCY}GBp`) { + // Convert GPB to GBp (pence) + return new Big(value).mul(100).toNumber(); + } else if (symbol === `${DEFAULT_CURRENCY}ILA`) { + // Convert ILS to ILA + return new Big(value).mul(100).toNumber(); + } + + return value; + } + + private async getSearchResult(aQuery: string): Promise< + (LookupItem & { + assetClass: AssetClass; + assetSubClass: AssetSubClass; + isin: string; + })[] + > { + let searchResult = []; + + try { + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const response = await got( + `${this.URL}/search/${aQuery}?api_token=${this.apiKey}`, + { + // @ts-ignore + signal: abortController.signal + } + ).json(); + + searchResult = response.map( + ({ Code, Currency, Exchange, ISIN: isin, Name: name, Type }) => { + const { assetClass, assetSubClass } = this.parseAssetClass({ + Exchange, + Type + }); + + return { + assetClass, + assetSubClass, + isin, + name, + currency: this.convertCurrency(Currency), + dataSource: this.getName(), + symbol: `${Code}.${Exchange}` + }; + } + ); } catch (error) { Logger.error(error, 'EodHistoricalDataService'); } - return {}; + return searchResult; } - public async search(aQuery: string): Promise<{ items: LookupItem[] }> { - return { items: [] }; + private parseAssetClass({ + Exchange, + Type + }: { + Exchange: string; + Type: string; + }): { + assetClass: AssetClass; + assetSubClass: AssetSubClass; + } { + let assetClass: AssetClass; + let assetSubClass: AssetSubClass; + + switch (Type?.toLowerCase()) { + case 'common stock': + assetClass = AssetClass.EQUITY; + assetSubClass = AssetSubClass.STOCK; + break; + case 'currency': + assetClass = AssetClass.CASH; + + if (Exchange?.toLowerCase() === 'cc') { + assetSubClass = AssetSubClass.CRYPTOCURRENCY; + } + + break; + case 'etf': + assetClass = AssetClass.EQUITY; + assetSubClass = AssetSubClass.ETF; + break; + } + + return { assetClass, assetSubClass }; } } 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 new file mode 100644 index 000000000..4fd1d4ebd --- /dev/null +++ b/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts @@ -0,0 +1,207 @@ +import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { + IDataProviderHistoricalResponse, + IDataProviderResponse +} from '@ghostfolio/api/services/interfaces/interfaces'; +import { + DEFAULT_CURRENCY, + DEFAULT_REQUEST_TIMEOUT +} from '@ghostfolio/common/config'; +import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; +import { DataProviderInfo } from '@ghostfolio/common/interfaces'; +import { Granularity } from '@ghostfolio/common/types'; +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource, SymbolProfile } from '@prisma/client'; +import { format, isAfter, isBefore, isSameDay } from 'date-fns'; +import got from 'got'; + +@Injectable() +export class FinancialModelingPrepService implements DataProviderInterface { + private apiKey: string; + private readonly URL = 'https://financialmodelingprep.com/api/v3'; + + public constructor( + private readonly configurationService: ConfigurationService + ) { + this.apiKey = this.configurationService.get( + 'FINANCIAL_MODELING_PREP_API_KEY' + ); + } + + public canHandle(symbol: string) { + return true; + } + + public async getAssetProfile( + aSymbol: string + ): Promise> { + return { + dataSource: this.getName(), + symbol: aSymbol + }; + } + + public async getDividends({ + from, + granularity = 'day', + symbol, + to + }: { + from: Date; + granularity: Granularity; + symbol: string; + to: Date; + }) { + return {}; + } + + public async getHistorical( + aSymbol: string, + aGranularity: Granularity = 'day', + from: Date, + to: Date + ): Promise<{ + [symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; + }> { + try { + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const { historical } = await got( + `${this.URL}/historical-price-full/${aSymbol}?apikey=${this.apiKey}`, + { + // @ts-ignore + signal: abortController.signal + } + ).json(); + + const result: { + [symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; + } = { + [aSymbol]: {} + }; + + for (const { close, date } of historical) { + if ( + (isSameDay(parseDate(date), from) || + isAfter(parseDate(date), from)) && + isBefore(parseDate(date), to) + ) { + result[aSymbol][date] = { + marketPrice: close + }; + } + } + + return result; + } catch (error) { + throw new Error( + `Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format( + from, + DATE_FORMAT + )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}` + ); + } + } + + public getName(): DataSource { + return DataSource.FINANCIAL_MODELING_PREP; + } + + public async getQuotes( + aSymbols: string[] + ): Promise<{ [symbol: string]: IDataProviderResponse }> { + const results: { [symbol: string]: IDataProviderResponse } = {}; + + if (aSymbols.length <= 0) { + return {}; + } + + try { + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const response = await got( + `${this.URL}/quote/${aSymbols.join(',')}?apikey=${this.apiKey}`, + { + // @ts-ignore + signal: abortController.signal + } + ).json(); + + for (const { price, symbol } of response) { + results[symbol] = { + currency: DEFAULT_CURRENCY, + dataProviderInfo: this.getDataProviderInfo(), + dataSource: DataSource.FINANCIAL_MODELING_PREP, + marketPrice: price, + marketState: 'delayed' + }; + } + } catch (error) { + Logger.error(error, 'FinancialModelingPrepService'); + } + + return results; + } + + public getTestSymbol() { + return 'AAPL'; + } + + public async search({ + includeIndices = false, + query + }: { + includeIndices?: boolean; + query: string; + }): Promise<{ items: LookupItem[] }> { + let items: LookupItem[] = []; + + try { + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const result = await got( + `${this.URL}/search?query=${query}&apikey=${this.apiKey}`, + { + // @ts-ignore + signal: abortController.signal + } + ).json(); + + items = result.map(({ currency, name, symbol }) => { + return { + // TODO: Add assetClass + // TODO: Add assetSubClass + currency, + name, + symbol, + dataSource: this.getName() + }; + }); + } catch (error) { + Logger.error(error, 'FinancialModelingPrepService'); + } + + return { items }; + } + + private getDataProviderInfo(): DataProviderInfo { + return { + name: 'Financial Modeling Prep', + url: 'https://financialmodelingprep.com/developer/docs' + }; + } +} diff --git a/apps/api/src/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service.ts b/apps/api/src/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service.ts deleted file mode 100644 index 8da34410f..000000000 --- a/apps/api/src/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; -import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; -import { - IDataProviderHistoricalResponse, - IDataProviderResponse -} from '@ghostfolio/api/services/interfaces/interfaces'; -import { PrismaService } from '@ghostfolio/api/services/prisma.service'; -import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; -import { - DATE_FORMAT, - extractNumberFromString, - getYesterday -} from '@ghostfolio/common/helper'; -import { Granularity } from '@ghostfolio/common/types'; -import { Injectable, Logger } from '@nestjs/common'; -import { DataSource, SymbolProfile } from '@prisma/client'; -import bent from 'bent'; -import * as cheerio from 'cheerio'; -import { addDays, format, isBefore } from 'date-fns'; - -@Injectable() -export class GhostfolioScraperApiService implements DataProviderInterface { - public constructor( - private readonly prismaService: PrismaService, - private readonly symbolProfileService: SymbolProfileService - ) {} - - public canHandle(symbol: string) { - return true; - } - - public async getAssetProfile( - aSymbol: string - ): Promise> { - return { - dataSource: this.getName() - }; - } - - public async getHistorical( - aSymbol: string, - aGranularity: Granularity = 'day', - from: Date, - to: Date - ): Promise<{ - [symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; - }> { - try { - const symbol = aSymbol; - - const [symbolProfile] = - await this.symbolProfileService.getSymbolProfilesBySymbols([symbol]); - const { defaultMarketPrice, selector, url } = - symbolProfile.scraperConfiguration; - - if (defaultMarketPrice) { - const historical: { - [symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; - } = { - [symbol]: {} - }; - let date = from; - - while (isBefore(date, to)) { - historical[symbol][format(date, DATE_FORMAT)] = { - marketPrice: defaultMarketPrice - }; - - date = addDays(date, 1); - } - - return historical; - } else if (selector === undefined || url === undefined) { - return {}; - } - - const get = bent(url, 'GET', 'string', 200, {}); - - const html = await get(); - const $ = cheerio.load(html); - - const value = extractNumberFromString($(selector).text()); - - return { - [symbol]: { - [format(getYesterday(), DATE_FORMAT)]: { - marketPrice: value - } - } - }; - } catch (error) { - throw new Error( - `Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format( - from, - DATE_FORMAT - )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}` - ); - } - } - - public getName(): DataSource { - return DataSource.GHOSTFOLIO; - } - - public async getQuotes( - aSymbols: string[] - ): Promise<{ [symbol: string]: IDataProviderResponse }> { - const response: { [symbol: string]: IDataProviderResponse } = {}; - - if (aSymbols.length <= 0) { - return response; - } - - try { - const symbolProfiles = - await this.symbolProfileService.getSymbolProfilesBySymbols(aSymbols); - - const marketData = await this.prismaService.marketData.findMany({ - distinct: ['symbol'], - orderBy: { - date: 'desc' - }, - take: aSymbols.length, - where: { - symbol: { - in: aSymbols - } - } - }); - - for (const symbolProfile of symbolProfiles) { - response[symbolProfile.symbol] = { - currency: symbolProfile.currency, - dataSource: this.getName(), - marketPrice: marketData.find((marketDataItem) => { - return marketDataItem.symbol === symbolProfile.symbol; - }).marketPrice, - marketState: 'delayed' - }; - } - - return response; - } catch (error) { - Logger.error(error, 'GhostfolioScraperApiService'); - } - - return {}; - } - - public async search(aQuery: string): Promise<{ items: LookupItem[] }> { - const items = await this.prismaService.symbolProfile.findMany({ - select: { - currency: true, - dataSource: true, - name: true, - symbol: true - }, - where: { - OR: [ - { - dataSource: this.getName(), - name: { - mode: 'insensitive', - startsWith: aQuery - } - }, - { - dataSource: this.getName(), - symbol: { - mode: 'insensitive', - startsWith: aQuery - } - } - ] - } - }); - - return { items }; - } -} 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 cc6af5241..f4b592371 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 @@ -1,12 +1,12 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { IDataProviderHistoricalResponse, IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces'; -import { PrismaService } from '@ghostfolio/api/services/prisma.service'; -import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { Granularity } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; @@ -30,10 +30,25 @@ export class GoogleSheetsService implements DataProviderInterface { aSymbol: string ): Promise> { return { - dataSource: this.getName() + dataSource: this.getName(), + symbol: aSymbol }; } + public async getDividends({ + from, + granularity = 'day', + symbol, + to + }: { + from: Date; + granularity: Granularity; + symbol: string; + to: Date; + }) { + return {}; + } + public async getHistorical( aSymbol: string, aGranularity: Granularity = 'day', @@ -94,8 +109,14 @@ export class GoogleSheetsService implements DataProviderInterface { try { const response: { [symbol: string]: IDataProviderResponse } = {}; - const symbolProfiles = - await this.symbolProfileService.getSymbolProfilesBySymbols(aSymbols); + const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( + aSymbols.map((symbol) => { + return { + symbol, + dataSource: this.getName() + }; + }) + ); const sheet = await this.getSheet({ sheetId: this.configurationService.get('GOOGLE_SHEETS_ID'), @@ -128,9 +149,21 @@ export class GoogleSheetsService implements DataProviderInterface { return {}; } - public async search(aQuery: string): Promise<{ items: LookupItem[] }> { + public getTestSymbol() { + return 'INDEXSP:.INX'; + } + + public async search({ + includeIndices = false, + query + }: { + includeIndices?: boolean; + query: string; + }): Promise<{ items: LookupItem[] }> { const items = await this.prismaService.symbolProfile.findMany({ select: { + assetClass: true, + assetSubClass: true, currency: true, dataSource: true, name: true, @@ -142,14 +175,14 @@ export class GoogleSheetsService implements DataProviderInterface { dataSource: this.getName(), name: { mode: 'insensitive', - startsWith: aQuery + startsWith: query } }, { dataSource: this.getName(), symbol: { mode: 'insensitive', - startsWith: aQuery + startsWith: query } } ] diff --git a/apps/api/src/services/data-provider/interfaces/data-enhancer.interface.ts b/apps/api/src/services/data-provider/interfaces/data-enhancer.interface.ts index 4e5ce8cba..9c6db9196 100644 --- a/apps/api/src/services/data-provider/interfaces/data-enhancer.interface.ts +++ b/apps/api/src/services/data-provider/interfaces/data-enhancer.interface.ts @@ -10,4 +10,6 @@ export interface DataEnhancerInterface { }): Promise>; getName(): string; + + getTestSymbol(): string; } diff --git a/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts b/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts index 6719f3099..2a16cc24c 100644 --- a/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts +++ b/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts @@ -11,6 +11,18 @@ export interface DataProviderInterface { getAssetProfile(aSymbol: string): Promise>; + getDividends({ + from, + granularity, + symbol, + to + }: { + from: Date; + granularity: Granularity; + symbol: string; + to: Date; + }): Promise<{ [date: string]: IDataProviderHistoricalResponse }>; + getHistorical( aSymbol: string, aGranularity: Granularity, @@ -28,5 +40,13 @@ export interface DataProviderInterface { aSymbols: string[] ): Promise<{ [symbol: string]: IDataProviderResponse }>; - search(aQuery: string): Promise<{ items: LookupItem[] }>; + getTestSymbol(): string; + + search({ + includeIndices, + query + }: { + includeIndices?: boolean; + query: string; + }): Promise<{ items: LookupItem[] }>; } 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 edcdd2cde..5c84a9c92 100644 --- a/apps/api/src/services/data-provider/manual/manual.service.ts +++ b/apps/api/src/services/data-provider/manual/manual.service.ts @@ -4,26 +4,56 @@ import { IDataProviderHistoricalResponse, IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config'; +import { + DATE_FORMAT, + extractNumberFromString, + getYesterday +} from '@ghostfolio/common/helper'; import { Granularity } from '@ghostfolio/common/types'; -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { DataSource, SymbolProfile } from '@prisma/client'; +import * as cheerio from 'cheerio'; +import { isUUID } from 'class-validator'; +import { addDays, format, isBefore } from 'date-fns'; +import got from 'got'; @Injectable() export class ManualService implements DataProviderInterface { - public constructor() {} + public constructor( + private readonly prismaService: PrismaService, + private readonly symbolProfileService: SymbolProfileService + ) {} public canHandle(symbol: string) { - return false; + return true; } public async getAssetProfile( aSymbol: string ): Promise> { return { - dataSource: this.getName() + dataSource: this.getName(), + symbol: aSymbol }; } + public async getDividends({ + from, + granularity = 'day', + symbol, + to + }: { + from: Date; + granularity: Granularity; + symbol: string; + to: Date; + }) { + return {}; + } + public async getHistorical( aSymbol: string, aGranularity: Granularity = 'day', @@ -32,7 +62,71 @@ export class ManualService implements DataProviderInterface { ): Promise<{ [symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; }> { - return {}; + try { + const symbol = aSymbol; + + const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles( + [{ symbol, dataSource: this.getName() }] + ); + const { + defaultMarketPrice, + headers = {}, + selector, + url + } = symbolProfile.scraperConfiguration ?? {}; + + if (defaultMarketPrice) { + const historical: { + [symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; + } = { + [symbol]: {} + }; + let date = from; + + while (isBefore(date, to)) { + historical[symbol][format(date, DATE_FORMAT)] = { + marketPrice: defaultMarketPrice + }; + + date = addDays(date, 1); + } + + return historical; + } else if (selector === undefined || url === undefined) { + return {}; + } + + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const { body } = await got(url, { + headers, + // @ts-ignore + signal: abortController.signal + }); + + const $ = cheerio.load(body); + + const value = extractNumberFromString($(selector).text()); + + return { + [symbol]: { + [format(getYesterday(), DATE_FORMAT)]: { + marketPrice: value + } + } + }; + } catch (error) { + throw new Error( + `Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format( + from, + DATE_FORMAT + )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}` + ); + } } public getName(): DataSource { @@ -42,10 +136,96 @@ export class ManualService implements DataProviderInterface { public async getQuotes( aSymbols: string[] ): Promise<{ [symbol: string]: IDataProviderResponse }> { + const response: { [symbol: string]: IDataProviderResponse } = {}; + + if (aSymbols.length <= 0) { + return response; + } + + try { + const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( + aSymbols.map((symbol) => { + return { symbol, dataSource: this.getName() }; + }) + ); + + const marketData = await this.prismaService.marketData.findMany({ + distinct: ['symbol'], + orderBy: { + date: 'desc' + }, + take: aSymbols.length, + where: { + symbol: { + in: aSymbols + } + } + }); + + for (const symbolProfile of symbolProfiles) { + response[symbolProfile.symbol] = { + currency: symbolProfile.currency, + dataSource: this.getName(), + marketPrice: marketData.find((marketDataItem) => { + return marketDataItem.symbol === symbolProfile.symbol; + })?.marketPrice, + marketState: 'delayed' + }; + } + + return response; + } catch (error) { + Logger.error(error, 'ManualService'); + } + return {}; } - public async search(aQuery: string): Promise<{ items: LookupItem[] }> { - return { items: [] }; + public getTestSymbol() { + return undefined; + } + + public async search({ + includeIndices = false, + query + }: { + includeIndices?: boolean; + query: string; + }): Promise<{ items: LookupItem[] }> { + let items = await this.prismaService.symbolProfile.findMany({ + select: { + assetClass: true, + assetSubClass: true, + currency: true, + dataSource: true, + name: true, + symbol: true + }, + where: { + OR: [ + { + dataSource: this.getName(), + name: { + mode: 'insensitive', + startsWith: query + } + }, + { + dataSource: this.getName(), + symbol: { + mode: 'insensitive', + startsWith: query + } + } + ] + } + }); + + items = items.filter(({ symbol }) => { + // Remove UUID symbols (activities of type ITEM) + return !isUUID(symbol); + }); + + return { items }; } } 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 5aace3d24..7743d7805 100644 --- a/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts +++ b/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts @@ -1,24 +1,25 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { IDataProviderHistoricalResponse, IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces'; -import { PrismaService } from '@ghostfolio/api/services/prisma.service'; -import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config'; -import { DATE_FORMAT, getToday, getYesterday } from '@ghostfolio/common/helper'; +import { + DEFAULT_REQUEST_TIMEOUT, + ghostfolioFearAndGreedIndexSymbol +} from '@ghostfolio/common/config'; +import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import { Granularity } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; import { DataSource, SymbolProfile } from '@prisma/client'; -import bent from 'bent'; -import { format, subMonths, subWeeks, subYears } from 'date-fns'; +import { format } from 'date-fns'; +import got from 'got'; @Injectable() export class RapidApiService implements DataProviderInterface { public constructor( - private readonly configurationService: ConfigurationService, - private readonly prismaService: PrismaService + private readonly configurationService: ConfigurationService ) {} public canHandle(symbol: string) { @@ -29,10 +30,25 @@ export class RapidApiService implements DataProviderInterface { aSymbol: string ): Promise> { return { - dataSource: this.getName() + dataSource: this.getName(), + symbol: aSymbol }; } + public async getDividends({ + from, + granularity = 'day', + symbol, + to + }: { + from: Date; + granularity: Granularity; + symbol: string; + to: Date; + }) { + return {}; + } + public async getHistorical( aSymbol: string, aGranularity: Granularity = 'day', @@ -47,41 +63,6 @@ export class RapidApiService implements DataProviderInterface { if (symbol === ghostfolioFearAndGreedIndexSymbol) { const fgi = await this.getFearAndGreedIndex(); - try { - // Rebuild historical data - // TODO: can be removed after all data from the last year has been gathered - // (introduced on 27.03.2021) - - await this.prismaService.marketData.create({ - data: { - symbol, - dataSource: this.getName(), - date: subWeeks(getToday(), 1), - marketPrice: fgi.oneWeekAgo.value - } - }); - - await this.prismaService.marketData.create({ - data: { - symbol, - dataSource: this.getName(), - date: subMonths(getToday(), 1), - marketPrice: fgi.oneMonthAgo.value - } - }); - - await this.prismaService.marketData.create({ - data: { - symbol, - dataSource: this.getName(), - date: subYears(getToday(), 1), - marketPrice: fgi.oneYearAgo.value - } - }); - - /////////////////////////////////////////////////////////////////////////// - } catch {} - return { [ghostfolioFearAndGreedIndexSymbol]: { [format(getYesterday(), DATE_FORMAT)]: { @@ -135,7 +116,17 @@ export class RapidApiService implements DataProviderInterface { return {}; } - public async search(aQuery: string): Promise<{ items: LookupItem[] }> { + public getTestSymbol() { + return undefined; + } + + public async search({ + includeIndices = false, + query + }: { + includeIndices?: boolean; + query: string; + }): Promise<{ items: LookupItem[] }> { return { items: [] }; } @@ -147,19 +138,25 @@ export class RapidApiService implements DataProviderInterface { oneYearAgo: { value: number; valueText: string }; }> { try { - const get = bent( + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const { fgi } = await got( `https://fear-and-greed-index.p.rapidapi.com/v1/fgi`, - 'GET', - 'json', - 200, { - useQueryString: true, - 'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com', - 'x-rapidapi-key': this.configurationService.get('RAPID_API_API_KEY') + headers: { + useQueryString: 'true', + 'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com', + 'x-rapidapi-key': this.configurationService.get('RAPID_API_API_KEY') + }, + // @ts-ignore + signal: abortController.signal } - ); + ).json(); - const { fgi } = await get(); return fgi; } catch (error) { Logger.error(error, 'RapidApiService'); diff --git a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts index 64d318ac8..c7c0ebbc8 100644 --- a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts +++ b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts @@ -1,167 +1,101 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; +import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { IDataProviderHistoricalResponse, IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces'; -import { UNKNOWN_KEY } from '@ghostfolio/common/config'; -import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper'; +import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; +import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { Granularity } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; -import { - AssetClass, - AssetSubClass, - DataSource, - SymbolProfile -} from '@prisma/client'; +import { DataSource, SymbolProfile } from '@prisma/client'; import Big from 'big.js'; -import { countries } from 'countries-list'; import { addDays, format, isSameDay } from 'date-fns'; import yahooFinance from 'yahoo-finance2'; -import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface'; +import { Quote } from 'yahoo-finance2/dist/esm/src/modules/quote'; @Injectable() export class YahooFinanceService implements DataProviderInterface { - private baseCurrency: string; - public constructor( - private readonly configurationService: ConfigurationService, - private readonly cryptocurrencyService: CryptocurrencyService - ) { - this.baseCurrency = this.configurationService.get('BASE_CURRENCY'); - } + private readonly cryptocurrencyService: CryptocurrencyService, + private readonly yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService + ) {} public canHandle(symbol: string) { return true; } - public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) { - let symbol = aYahooFinanceSymbol.replace( - new RegExp(`-${this.baseCurrency}$`), - this.baseCurrency - ); - - if (symbol.includes('=X') && !symbol.includes(this.baseCurrency)) { - symbol = `${this.baseCurrency}${symbol}`; - } - - return symbol.replace('=X', ''); - } - - /** - * Converts a symbol to a Yahoo Finance symbol - * - * Currency: USDCHF -> USDCHF=X - * Cryptocurrency: BTCUSD -> BTC-USD - * DOGEUSD -> DOGE-USD - */ - public convertToYahooFinanceSymbol(aSymbol: string) { - if ( - aSymbol.includes(this.baseCurrency) && - aSymbol.length > this.baseCurrency.length - ) { - if ( - isCurrency( - aSymbol.substring(0, aSymbol.length - this.baseCurrency.length) - ) - ) { - return `${aSymbol}=X`; - } else if ( - this.cryptocurrencyService.isCryptocurrency( - aSymbol.replace( - new RegExp(`-${this.baseCurrency}$`), - this.baseCurrency - ) - ) - ) { - // Add a dash before the last three characters - // BTCUSD -> BTC-USD - // DOGEUSD -> DOGE-USD - // SOL1USD -> SOL1-USD - return aSymbol.replace( - new RegExp(`-?${this.baseCurrency}$`), - `-${this.baseCurrency}` - ); - } - } - - return aSymbol; - } - public async getAssetProfile( aSymbol: string ): Promise> { - const response: Partial = {}; - - try { - const symbol = this.convertToYahooFinanceSymbol(aSymbol); - const assetProfile = await yahooFinance.quoteSummary(symbol, { - modules: ['price', 'summaryProfile', 'topHoldings'] - }); - - const { assetClass, assetSubClass } = this.parseAssetClass( - assetProfile.price - ); - - response.assetClass = assetClass; - response.assetSubClass = assetSubClass; - 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 - }); - response.symbol = aSymbol; + const { assetClass, assetSubClass, currency, name } = + await this.yahooFinanceDataEnhancerService.getAssetProfile(aSymbol); + + return { + assetClass, + assetSubClass, + currency, + name, + dataSource: this.getName(), + symbol: aSymbol + }; + } - if (assetSubClass === AssetSubClass.MUTUALFUND) { - response.sectors = []; + public async getDividends({ + from, + granularity = 'day', + symbol, + to + }: { + from: Date; + granularity: Granularity; + symbol: string; + to: Date; + }) { + if (isSameDay(from, to)) { + to = addDays(to, 1); + } - for (const sectorWeighting of assetProfile.topHoldings - ?.sectorWeightings ?? []) { - for (const [sector, weight] of Object.entries(sectorWeighting)) { - response.sectors.push({ weight, name: this.parseSector(sector) }); - } + try { + const historicalResult = await yahooFinance.historical( + this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol( + symbol + ), + { + events: 'dividends', + interval: granularity === 'month' ? '1mo' : '1d', + period1: format(from, DATE_FORMAT), + period2: format(to, DATE_FORMAT) } - } else if ( - assetSubClass === AssetSubClass.STOCK && - assetProfile.summaryProfile?.country - ) { - // Add country if asset is stock and country available - - try { - const [code] = Object.entries(countries).find(([, country]) => { - return country.name === assetProfile.summaryProfile?.country; - }); + ); - if (code) { - response.countries = [{ code, weight: 1 }]; - } - } catch {} + const response: { + [date: string]: IDataProviderHistoricalResponse; + } = {}; - if (assetProfile.summaryProfile?.sector) { - response.sectors = [ - { name: assetProfile.summaryProfile?.sector, weight: 1 } - ]; - } + for (const historicalItem of historicalResult) { + response[format(historicalItem.date, DATE_FORMAT)] = { + marketPrice: this.getConvertedValue({ + symbol, + value: historicalItem.dividends + }) + }; } - const url = assetProfile.summaryProfile?.website; - if (url) { - response.url = url; - } + return response; } catch (error) { - throw new Error( - `Could not get asset profile for ${aSymbol} (${this.getName()}): [${ - error.name - }] ${error.message}` + 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' ); - } - return response; + return {}; + } } public async getHistorical( @@ -176,11 +110,11 @@ export class YahooFinanceService implements DataProviderInterface { to = addDays(to, 1); } - const yahooFinanceSymbol = this.convertToYahooFinanceSymbol(aSymbol); - try { const historicalResult = await yahooFinance.historical( - yahooFinanceSymbol, + this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol( + aSymbol + ), { interval: '1d', period1: format(from, DATE_FORMAT), @@ -192,25 +126,14 @@ export class YahooFinanceService implements DataProviderInterface { [symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; } = {}; - // Convert symbol back - const symbol = this.convertFromYahooFinanceSymbol(yahooFinanceSymbol); - - response[symbol] = {}; + response[aSymbol] = {}; for (const historicalItem of historicalResult) { - let marketPrice = historicalItem.close; - - if (symbol === `${this.baseCurrency}GBp`) { - // Convert GPB to GBp (pence) - marketPrice = new Big(marketPrice).mul(100).toNumber(); - } else if (symbol === `${this.baseCurrency}ILA`) { - // Convert ILS to ILA - marketPrice = new Big(marketPrice).mul(100).toNumber(); - } - - response[symbol][format(historicalItem.date, DATE_FORMAT)] = { - marketPrice, - performance: historicalItem.open - historicalItem.close + response[aSymbol][format(historicalItem.date, DATE_FORMAT)] = { + marketPrice: this.getConvertedValue({ + symbol: aSymbol, + value: historicalItem.close + }) }; } @@ -239,18 +162,38 @@ export class YahooFinanceService implements DataProviderInterface { if (aSymbols.length <= 0) { return {}; } + const yahooFinanceSymbols = aSymbols.map((symbol) => - this.convertToYahooFinanceSymbol(symbol) + this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(symbol) ); try { const response: { [symbol: string]: IDataProviderResponse } = {}; - const quotes = await yahooFinance.quote(yahooFinanceSymbols); + let quotes: Pick< + Quote, + 'currency' | 'marketState' | 'regularMarketPrice' | 'symbol' + >[] = []; + + try { + quotes = await yahooFinance.quote(yahooFinanceSymbols); + } catch (error) { + Logger.error(error, 'YahooFinanceService'); + + Logger.warn( + 'Fallback to yahooFinance.quoteSummary()', + 'YahooFinanceService' + ); + + quotes = await this.getQuotesWithQuoteSummary(yahooFinanceSymbols); + } for (const quote of quotes) { // Convert symbols back - const symbol = this.convertFromYahooFinanceSymbol(quote.symbol); + const symbol = + this.yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol( + quote.symbol + ); response[symbol] = { currency: quote.currency, @@ -264,35 +207,50 @@ export class YahooFinanceService implements DataProviderInterface { }; if ( - symbol === `${this.baseCurrency}GBP` && - yahooFinanceSymbols.includes(`${this.baseCurrency}GBp=X`) + symbol === `${DEFAULT_CURRENCY}GBP` && + yahooFinanceSymbols.includes(`${DEFAULT_CURRENCY}GBp=X`) ) { // Convert GPB to GBp (pence) - response[`${this.baseCurrency}GBp`] = { + response[`${DEFAULT_CURRENCY}GBp`] = { ...response[symbol], currency: 'GBp', - marketPrice: new Big(response[symbol].marketPrice) - .mul(100) - .toNumber() + marketPrice: this.getConvertedValue({ + symbol: `${DEFAULT_CURRENCY}GBp`, + value: response[symbol].marketPrice + }) }; } else if ( - symbol === `${this.baseCurrency}ILS` && - yahooFinanceSymbols.includes(`${this.baseCurrency}ILA=X`) + symbol === `${DEFAULT_CURRENCY}ILS` && + yahooFinanceSymbols.includes(`${DEFAULT_CURRENCY}ILA=X`) ) { // Convert ILS to ILA - response[`${this.baseCurrency}ILA`] = { + response[`${DEFAULT_CURRENCY}ILA`] = { ...response[symbol], currency: 'ILA', - marketPrice: new Big(response[symbol].marketPrice) - .mul(100) - .toNumber() + marketPrice: this.getConvertedValue({ + symbol: `${DEFAULT_CURRENCY}ILA`, + value: response[symbol].marketPrice + }) + }; + } else if ( + symbol === `${DEFAULT_CURRENCY}ZAR` && + yahooFinanceSymbols.includes(`${DEFAULT_CURRENCY}ZAc=X`) + ) { + // Convert ZAR to ZAc (cents) + response[`${DEFAULT_CURRENCY}ZAc`] = { + ...response[symbol], + currency: 'ZAc', + marketPrice: this.getConvertedValue({ + symbol: `${DEFAULT_CURRENCY}ZAc`, + value: response[symbol].marketPrice + }) }; } } - if (yahooFinanceSymbols.includes(`${this.baseCurrency}USX=X`)) { + if (yahooFinanceSymbols.includes(`${DEFAULT_CURRENCY}USX=X`)) { // Convert USD to USX (cent) - response[`${this.baseCurrency}USX`] = { + response[`${DEFAULT_CURRENCY}USX`] = { currency: 'USX', dataSource: this.getName(), marketPrice: new Big(1).mul(100).toNumber(), @@ -308,11 +266,27 @@ export class YahooFinanceService implements DataProviderInterface { } } - public async search(aQuery: string): Promise<{ items: LookupItem[] }> { + public getTestSymbol() { + return 'AAPL'; + } + + public async search({ + includeIndices = false, + query + }: { + includeIndices?: boolean; + query: string; + }): Promise<{ items: LookupItem[] }> { const items: LookupItem[] = []; try { - const searchResult = await yahooFinance.search(aQuery); + const quoteTypes = ['EQUITY', 'ETF', 'FUTURE', 'MUTUALFUND']; + + if (includeIndices) { + quoteTypes.push('INDEX'); + } + + const searchResult = await yahooFinance.search(query); const quotes = searchResult.quotes .filter((quote) => { @@ -324,18 +298,18 @@ export class YahooFinanceService implements DataProviderInterface { (quoteType === 'CRYPTOCURRENCY' && this.cryptocurrencyService.isCryptocurrency( symbol.replace( - new RegExp(`-${this.baseCurrency}$`), - this.baseCurrency + new RegExp(`-${DEFAULT_CURRENCY}$`), + DEFAULT_CURRENCY ) )) || - ['EQUITY', 'ETF', 'FUTURE', 'MUTUALFUND'].includes(quoteType) + quoteTypes.includes(quoteType) ); }) .filter(({ quoteType, symbol }) => { if (quoteType === 'CRYPTOCURRENCY') { // Only allow cryptocurrencies in base currency to avoid having redundancy in the database. // Transactions need to be converted manually to the base currency before - return symbol.includes(this.baseCurrency); + return symbol.includes(DEFAULT_CURRENCY); } else if (quoteType === 'FUTURE') { // Allow GC=F, but not MGC=F return symbol.length === 4; @@ -355,15 +329,24 @@ export class YahooFinanceService implements DataProviderInterface { return currentQuote.symbol === marketDataItem.symbol; }); - const symbol = this.convertFromYahooFinanceSymbol( - marketDataItem.symbol - ); + const symbol = + this.yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol( + marketDataItem.symbol + ); + + const { assetClass, assetSubClass } = + this.yahooFinanceDataEnhancerService.parseAssetClass({ + quoteType: quote.quoteType, + shortName: quote.shortname + }); items.push({ + assetClass, + assetSubClass, symbol, currency: marketDataItem.currency, dataSource: this.getName(), - name: this.formatName({ + name: this.yahooFinanceDataEnhancerService.formatName({ longName: quote.longname, quoteType: quote.quoteType, shortName: quote.shortname, @@ -378,122 +361,46 @@ export class YahooFinanceService implements DataProviderInterface { return { items }; } - private formatName({ - longName, - quoteType, - shortName, - symbol + private getConvertedValue({ + symbol, + value }: { - longName: Price['longName']; - quoteType: Price['quoteType']; - shortName: Price['shortName']; - symbol: Price['symbol']; + symbol: string; + value: number; }) { - let name = longName; - - if (name) { - name = name.replace('iShares ETF (CH) - ', ''); - name = name.replace('iShares III Public Limited Company - ', ''); - name = name.replace('iShares VI Public Limited Company - ', ''); - name = name.replace('iShares VII PLC - ', ''); - name = name.replace('Multi Units Luxembourg - ', ''); - name = name.replace('VanEck ETFs N.V. - ', ''); - name = name.replace('Vaneck Vectors Ucits Etfs Plc - ', ''); - name = name.replace('Vanguard Funds Public Limited Company - ', ''); - name = name.replace('Vanguard Index Funds - ', ''); - name = name.replace('Xtrackers (IE) Plc - ', ''); + if (symbol === `${DEFAULT_CURRENCY}GBp`) { + // Convert GPB to GBp (pence) + return new Big(value).mul(100).toNumber(); + } else if (symbol === `${DEFAULT_CURRENCY}ILA`) { + // Convert ILS to ILA + return new Big(value).mul(100).toNumber(); + } else if (symbol === `${DEFAULT_CURRENCY}ZAc`) { + // Convert ZAR to ZAc (cents) + return new Big(value).mul(100).toNumber(); } - if (quoteType === 'FUTURE') { - // "Gold Jun 22" -> "Gold" - name = shortName?.slice(0, -6); - } - - return name || shortName || symbol; + return value; } - private parseAssetClass(aPrice: Price): { - assetClass: AssetClass; - assetSubClass: AssetSubClass; - } { - let assetClass: AssetClass; - let assetSubClass: AssetSubClass; - - switch (aPrice?.quoteType?.toLowerCase()) { - case 'cryptocurrency': - assetClass = AssetClass.CASH; - assetSubClass = AssetSubClass.CRYPTOCURRENCY; - break; - case 'equity': - assetClass = AssetClass.EQUITY; - assetSubClass = AssetSubClass.STOCK; - break; - case 'etf': - assetClass = AssetClass.EQUITY; - assetSubClass = AssetSubClass.ETF; - break; - case 'future': - assetClass = AssetClass.COMMODITY; - assetSubClass = AssetSubClass.COMMODITY; - - if ( - aPrice?.shortName?.toLowerCase()?.startsWith('gold') || - aPrice?.shortName?.toLowerCase()?.startsWith('palladium') || - aPrice?.shortName?.toLowerCase()?.startsWith('platinum') || - aPrice?.shortName?.toLowerCase()?.startsWith('silver') - ) { - assetSubClass = AssetSubClass.PRECIOUS_METAL; - } - - break; - case 'mutualfund': - assetClass = AssetClass.EQUITY; - assetSubClass = AssetSubClass.MUTUALFUND; - break; - } - - return { assetClass, assetSubClass }; - } + private async getQuotesWithQuoteSummary(aYahooFinanceSymbols: string[]) { + const quoteSummaryPromises = aYahooFinanceSymbols.map((symbol) => { + return yahooFinance.quoteSummary(symbol).catch(() => { + Logger.error( + `Could not get quote summary for ${symbol}`, + 'YahooFinanceService' + ); + return null; + }); + }); - private parseSector(aString: string): 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; - } + const quoteSummaryItems = await Promise.all(quoteSummaryPromises); - return sector; + return quoteSummaryItems + .filter((item) => { + return item !== null; + }) + .map(({ price }) => { + return price; + }); } } diff --git a/apps/api/src/services/exchange-rate-data.service.ts b/apps/api/src/services/exchange-rate-data.service.ts deleted file mode 100644 index 60a7e0e56..000000000 --- a/apps/api/src/services/exchange-rate-data.service.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config'; -import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; -import { Injectable, Logger } from '@nestjs/common'; -import { format } from 'date-fns'; -import { isNumber, uniq } from 'lodash'; - -import { ConfigurationService } from './configuration.service'; -import { DataProviderService } from './data-provider/data-provider.service'; -import { IDataGatheringItem } from './interfaces/interfaces'; -import { PrismaService } from './prisma.service'; -import { PropertyService } from './property/property.service'; - -@Injectable() -export class ExchangeRateDataService { - private baseCurrency: string; - private currencies: string[] = []; - private currencyPairs: IDataGatheringItem[] = []; - private exchangeRates: { [currencyPair: string]: number } = {}; - - public constructor( - private readonly configurationService: ConfigurationService, - private readonly dataProviderService: DataProviderService, - private readonly prismaService: PrismaService, - private readonly propertyService: PropertyService - ) {} - - public getCurrencies() { - return this.currencies?.length > 0 ? this.currencies : [this.baseCurrency]; - } - - public getCurrencyPairs() { - return this.currencyPairs; - } - - public async initialize() { - this.baseCurrency = this.configurationService.get('BASE_CURRENCY'); - this.currencies = await this.prepareCurrencies(); - this.currencyPairs = []; - this.exchangeRates = {}; - - for (const { - currency1, - currency2, - dataSource - } of this.prepareCurrencyPairs(this.currencies)) { - this.currencyPairs.push({ - dataSource, - symbol: `${currency1}${currency2}` - }); - } - - await this.loadCurrencies(); - } - - public async loadCurrencies() { - const result = await this.dataProviderService.getHistorical( - this.currencyPairs, - 'day', - getYesterday(), - getYesterday() - ); - - if (Object.keys(result).length !== this.currencyPairs.length) { - // Load currencies directly from data provider as a fallback - // if historical data is not fully available - const historicalData = await this.dataProviderService.getQuotes( - this.currencyPairs.map(({ dataSource, symbol }) => { - return { dataSource, symbol }; - }) - ); - - Object.keys(historicalData).forEach((key) => { - result[key] = { - [format(getYesterday(), DATE_FORMAT)]: { - marketPrice: historicalData[key].marketPrice - } - }; - }); - } - - const resultExtended = result; - - Object.keys(result).forEach((pair) => { - const [currency1, currency2] = pair.match(/.{1,3}/g); - const [date] = Object.keys(result[pair]); - - // Calculate the opposite direction - resultExtended[`${currency2}${currency1}`] = { - [date]: { - marketPrice: 1 / result[pair][date].marketPrice - } - }; - }); - - Object.keys(resultExtended).forEach((symbol) => { - const [currency1, currency2] = symbol.match(/.{1,3}/g); - const date = format(getYesterday(), DATE_FORMAT); - - this.exchangeRates[symbol] = resultExtended[symbol]?.[date]?.marketPrice; - - if (!this.exchangeRates[symbol]) { - // Not found, calculate indirectly via base currency - this.exchangeRates[symbol] = - resultExtended[`${currency1}${this.baseCurrency}`]?.[date] - ?.marketPrice * - resultExtended[`${this.baseCurrency}${currency2}`]?.[date] - ?.marketPrice; - - // Calculate the opposite direction - this.exchangeRates[`${currency2}${currency1}`] = - 1 / this.exchangeRates[symbol]; - } - }); - } - - public toCurrency( - aValue: number, - aFromCurrency: string, - aToCurrency: string - ) { - if (aValue === 0) { - return 0; - } - - let factor = 1; - - if (aFromCurrency !== aToCurrency) { - if (this.exchangeRates[`${aFromCurrency}${aToCurrency}`]) { - factor = this.exchangeRates[`${aFromCurrency}${aToCurrency}`]; - } else { - // Calculate indirectly via base currency - const factor1 = - this.exchangeRates[`${aFromCurrency}${this.baseCurrency}`]; - const factor2 = - this.exchangeRates[`${this.baseCurrency}${aToCurrency}`]; - - factor = factor1 * factor2; - - this.exchangeRates[`${aFromCurrency}${aToCurrency}`] = factor; - } - } - - if (isNumber(factor) && !isNaN(factor)) { - return factor * aValue; - } - - // Fallback with error, if currencies are not available - Logger.error( - `No exchange rate has been found for ${aFromCurrency}${aToCurrency}`, - 'ExchangeRateDataService' - ); - return aValue; - } - - private async prepareCurrencies(): Promise { - let currencies: string[] = []; - - ( - await this.prismaService.account.findMany({ - distinct: ['currency'], - orderBy: [{ currency: 'asc' }], - select: { currency: true }, - where: { - currency: { - not: null - } - } - }) - ).forEach((account) => { - currencies.push(account.currency); - }); - - ( - await this.prismaService.symbolProfile.findMany({ - distinct: ['currency'], - orderBy: [{ currency: 'asc' }], - select: { currency: true } - }) - ).forEach((symbolProfile) => { - currencies.push(symbolProfile.currency); - }); - - const customCurrencies = (await this.propertyService.getByKey( - PROPERTY_CURRENCIES - )) as string[]; - - if (customCurrencies?.length > 0) { - currencies = currencies.concat(customCurrencies); - } - - return uniq(currencies).filter(Boolean).sort(); - } - - private prepareCurrencyPairs(aCurrencies: string[]) { - return aCurrencies - .filter((currency) => { - return currency !== this.baseCurrency; - }) - .map((currency) => { - return { - currency1: this.baseCurrency, - currency2: currency, - dataSource: this.dataProviderService.getPrimaryDataSource(), - symbol: `${this.baseCurrency}${currency}` - }; - }); - } -} diff --git a/apps/api/src/services/exchange-rate-data.module.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.module.ts similarity index 58% rename from apps/api/src/services/exchange-rate-data.module.ts rename to apps/api/src/services/exchange-rate-data/exchange-rate-data.module.ts index 8b8eeee28..89b0158db 100644 --- a/apps/api/src/services/exchange-rate-data.module.ts +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.module.ts @@ -1,19 +1,20 @@ -import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { Module } from '@nestjs/common'; -import { PrismaModule } from './prisma.module'; - @Module({ + exports: [ExchangeRateDataService], imports: [ ConfigurationModule, DataProviderModule, + MarketDataModule, PrismaModule, PropertyModule ], - providers: [ExchangeRateDataService], - exports: [ExchangeRateDataService] + providers: [ExchangeRateDataService] }) export class ExchangeRateDataModule {} 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 new file mode 100644 index 000000000..376aa1a6a --- /dev/null +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts @@ -0,0 +1,307 @@ +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { + DEFAULT_CURRENCY, + PROPERTY_CURRENCIES +} from '@ghostfolio/common/config'; +import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; +import { Injectable, Logger } from '@nestjs/common'; +import { format, isToday } from 'date-fns'; +import { isNumber, uniq } from 'lodash'; + +@Injectable() +export class ExchangeRateDataService { + private currencies: string[] = []; + private currencyPairs: IDataGatheringItem[] = []; + private exchangeRates: { [currencyPair: string]: number } = {}; + + public constructor( + private readonly dataProviderService: DataProviderService, + private readonly marketDataService: MarketDataService, + private readonly prismaService: PrismaService, + private readonly propertyService: PropertyService + ) {} + + public getCurrencies() { + return this.currencies?.length > 0 ? this.currencies : [DEFAULT_CURRENCY]; + } + + public getCurrencyPairs() { + return this.currencyPairs; + } + + public hasCurrencyPair(currency1: string, currency2: string) { + return this.currencyPairs.some(({ symbol }) => { + return ( + symbol === `${currency1}${currency2}` || + symbol === `${currency2}${currency1}` + ); + }); + } + + public async initialize() { + this.currencies = await this.prepareCurrencies(); + this.currencyPairs = []; + this.exchangeRates = {}; + + for (const { + currency1, + currency2, + dataSource + } of this.prepareCurrencyPairs(this.currencies)) { + this.currencyPairs.push({ + dataSource, + symbol: `${currency1}${currency2}` + }); + } + + await this.loadCurrencies(); + } + + public async loadCurrencies() { + const result = await this.dataProviderService.getHistorical( + this.currencyPairs, + 'day', + getYesterday(), + getYesterday() + ); + + if (Object.keys(result).length !== this.currencyPairs.length) { + // Load currencies directly from data provider as a fallback + // if historical data is not fully available + const quotes = await this.dataProviderService.getQuotes({ + items: this.currencyPairs.map(({ dataSource, symbol }) => { + return { dataSource, symbol }; + }) + }); + + for (const symbol of Object.keys(quotes)) { + if (isNumber(quotes[symbol].marketPrice)) { + result[symbol] = { + [format(getYesterday(), DATE_FORMAT)]: { + marketPrice: quotes[symbol].marketPrice + } + }; + } + } + } + + const resultExtended = result; + + for (const symbol of Object.keys(result)) { + const [currency1, currency2] = symbol.match(/.{1,3}/g); + const [date] = Object.keys(result[symbol]); + + // Calculate the opposite direction + resultExtended[`${currency2}${currency1}`] = { + [date]: { + marketPrice: 1 / result[symbol][date].marketPrice + } + }; + } + + for (const symbol of Object.keys(resultExtended)) { + const [currency1, currency2] = symbol.match(/.{1,3}/g); + const date = format(getYesterday(), DATE_FORMAT); + + this.exchangeRates[symbol] = resultExtended[symbol]?.[date]?.marketPrice; + + if (!this.exchangeRates[symbol]) { + // Not found, calculate indirectly via base currency + this.exchangeRates[symbol] = + resultExtended[`${currency1}${DEFAULT_CURRENCY}`]?.[date] + ?.marketPrice * + resultExtended[`${DEFAULT_CURRENCY}${currency2}`]?.[date] + ?.marketPrice; + + // Calculate the opposite direction + this.exchangeRates[`${currency2}${currency1}`] = + 1 / this.exchangeRates[symbol]; + } + } + } + + public toCurrency( + aValue: number, + aFromCurrency: string, + aToCurrency: string + ) { + if (aValue === 0) { + return 0; + } + + let factor: number; + + if (aFromCurrency === aToCurrency) { + factor = 1; + } else { + if (this.exchangeRates[`${aFromCurrency}${aToCurrency}`]) { + factor = this.exchangeRates[`${aFromCurrency}${aToCurrency}`]; + } else { + // Calculate indirectly via base currency + const factor1 = + this.exchangeRates[`${aFromCurrency}${DEFAULT_CURRENCY}`]; + const factor2 = this.exchangeRates[`${DEFAULT_CURRENCY}${aToCurrency}`]; + + factor = factor1 * factor2; + + this.exchangeRates[`${aFromCurrency}${aToCurrency}`] = factor; + } + } + + if (isNumber(factor) && !isNaN(factor)) { + return factor * aValue; + } + + // Fallback with error, if currencies are not available + Logger.error( + `No exchange rate has been found for ${aFromCurrency}${aToCurrency}`, + 'ExchangeRateDataService' + ); + return aValue; + } + + public async toCurrencyAtDate( + aValue: number, + aFromCurrency: string, + aToCurrency: string, + aDate: Date + ) { + if (aValue === 0) { + return 0; + } + + if (isToday(aDate)) { + return this.toCurrency(aValue, aFromCurrency, aToCurrency); + } + + let factor: number; + + if (aFromCurrency === aToCurrency) { + factor = 1; + } else { + const dataSource = + this.dataProviderService.getDataSourceForExchangeRates(); + const symbol = `${aFromCurrency}${aToCurrency}`; + + const marketData = await this.marketDataService.get({ + dataSource, + symbol, + date: aDate + }); + + if (marketData?.marketPrice) { + factor = marketData?.marketPrice; + } else { + // Calculate indirectly via base currency + + let marketPriceBaseCurrencyFromCurrency: number; + let marketPriceBaseCurrencyToCurrency: number; + + try { + if (aFromCurrency === DEFAULT_CURRENCY) { + marketPriceBaseCurrencyFromCurrency = 1; + } else { + marketPriceBaseCurrencyFromCurrency = ( + await this.marketDataService.get({ + dataSource, + date: aDate, + symbol: `${DEFAULT_CURRENCY}${aFromCurrency}` + }) + )?.marketPrice; + } + } catch {} + + try { + if (aToCurrency === DEFAULT_CURRENCY) { + marketPriceBaseCurrencyToCurrency = 1; + } else { + marketPriceBaseCurrencyToCurrency = ( + await this.marketDataService.get({ + dataSource, + date: aDate, + symbol: `${DEFAULT_CURRENCY}${aToCurrency}` + }) + )?.marketPrice; + } + } catch {} + + // Calculate the opposite direction + factor = + (1 / marketPriceBaseCurrencyFromCurrency) * + marketPriceBaseCurrencyToCurrency; + } + } + + if (isNumber(factor) && !isNaN(factor)) { + return factor * aValue; + } + + Logger.error( + `No exchange rate has been found for ${aFromCurrency}${aToCurrency} at ${format( + aDate, + DATE_FORMAT + )}`, + 'ExchangeRateDataService' + ); + + return undefined; + } + + private async prepareCurrencies(): Promise { + let currencies: string[] = []; + + ( + await this.prismaService.account.findMany({ + distinct: ['currency'], + orderBy: [{ currency: 'asc' }], + select: { currency: true }, + where: { + currency: { + not: null + } + } + }) + ).forEach((account) => { + currencies.push(account.currency); + }); + + ( + await this.prismaService.symbolProfile.findMany({ + distinct: ['currency'], + orderBy: [{ currency: 'asc' }], + select: { currency: true } + }) + ).forEach((symbolProfile) => { + currencies.push(symbolProfile.currency); + }); + + const customCurrencies = (await this.propertyService.getByKey( + PROPERTY_CURRENCIES + )) as string[]; + + if (customCurrencies?.length > 0) { + currencies = currencies.concat(customCurrencies); + } + + return uniq(currencies).filter(Boolean).sort(); + } + + private prepareCurrencyPairs(aCurrencies: string[]) { + return aCurrencies + .filter((currency) => { + return currency !== DEFAULT_CURRENCY; + }) + .map((currency) => { + return { + currency1: DEFAULT_CURRENCY, + currency2: currency, + dataSource: this.dataProviderService.getDataSourceForExchangeRates(), + symbol: `${DEFAULT_CURRENCY}${currency}` + }; + }); + } +} diff --git a/apps/api/src/services/impersonation.service.ts b/apps/api/src/services/impersonation.service.ts deleted file mode 100644 index 8082a8198..000000000 --- a/apps/api/src/services/impersonation.service.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { PrismaService } from './prisma.service'; - -@Injectable() -export class ImpersonationService { - public constructor(private readonly prismaService: PrismaService) {} - - public async validateImpersonationId(aId = '', aUserId: string) { - const accessObject = await this.prismaService.access.findFirst({ - where: { GranteeUser: { id: aUserId }, id: aId } - }); - - return accessObject?.userId; - } -} diff --git a/apps/api/src/services/impersonation.module.ts b/apps/api/src/services/impersonation/impersonation.module.ts similarity index 68% rename from apps/api/src/services/impersonation.module.ts rename to apps/api/src/services/impersonation/impersonation.module.ts index 00be6e68f..506fb7f91 100644 --- a/apps/api/src/services/impersonation.module.ts +++ b/apps/api/src/services/impersonation/impersonation.module.ts @@ -1,5 +1,5 @@ -import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; -import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; +import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { Module } from '@nestjs/common'; @Module({ diff --git a/apps/api/src/services/impersonation/impersonation.service.ts b/apps/api/src/services/impersonation/impersonation.service.ts new file mode 100644 index 000000000..e678356cb --- /dev/null +++ b/apps/api/src/services/impersonation/impersonation.service.ts @@ -0,0 +1,49 @@ +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import type { RequestWithUser } from '@ghostfolio/common/types'; +import { Inject, Injectable } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; + +@Injectable() +export class ImpersonationService { + public constructor( + private readonly prismaService: PrismaService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} + + public async validateImpersonationId(aId = '') { + if (this.request.user) { + const accessObject = await this.prismaService.access.findFirst({ + where: { + GranteeUser: { id: this.request.user.id }, + id: aId + } + }); + + if (accessObject?.userId) { + return accessObject.userId; + } else if ( + hasPermission( + this.request.user.permissions, + permissions.impersonateAllUsers + ) + ) { + return aId; + } + } else { + // Public access + const accessObject = await this.prismaService.access.findFirst({ + where: { + GranteeUser: null, + User: { id: aId } + } + }); + + if (accessObject?.userId) { + return accessObject.userId; + } + } + + return null; + } +} diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index 5ac20b55e..b437668ab 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -3,20 +3,21 @@ import { CleanedEnvAccessors } from 'envalid'; export interface Environment extends CleanedEnvAccessors { ACCESS_TOKEN_SALT: string; ALPHA_VANTAGE_API_KEY: string; - BASE_CURRENCY: string; + BETTER_UPTIME_API_KEY: string; + CACHE_QUOTES_TTL: number; CACHE_TTL: number; - DATA_SOURCE_PRIMARY: string; + DATA_SOURCE_EXCHANGE_RATES: string; + DATA_SOURCE_IMPORT: string; DATA_SOURCES: string[]; ENABLE_FEATURE_BLOG: boolean; - ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; - ENABLE_FEATURE_IMPORT: boolean; ENABLE_FEATURE_READ_ONLY_MODE: boolean; ENABLE_FEATURE_SOCIAL_LOGIN: boolean; ENABLE_FEATURE_STATISTICS: boolean; ENABLE_FEATURE_SUBSCRIPTION: boolean; ENABLE_FEATURE_SYSTEM_MESSAGE: boolean; EOD_HISTORICAL_DATA_API_KEY: string; + FINANCIAL_MODELING_PREP_API_KEY: string; GOOGLE_CLIENT_ID: string; GOOGLE_SECRET: string; GOOGLE_SHEETS_ACCOUNT: string; diff --git a/apps/api/src/services/interfaces/interfaces.ts b/apps/api/src/services/interfaces/interfaces.ts index 1148dd6af..15505db63 100644 --- a/apps/api/src/services/interfaces/interfaces.ts +++ b/apps/api/src/services/interfaces/interfaces.ts @@ -1,4 +1,4 @@ -import { UniqueAsset } from '@ghostfolio/common/interfaces'; +import { DataProviderInfo, UniqueAsset } from '@ghostfolio/common/interfaces'; import { MarketState } from '@ghostfolio/common/types'; import { Account, @@ -23,11 +23,11 @@ export interface IOrder { export interface IDataProviderHistoricalResponse { marketPrice: number; - performance?: number; } export interface IDataProviderResponse { currency: string; + dataProviderInfo?: DataProviderInfo; dataSource: DataSource; marketPrice: number; marketState: MarketState; diff --git a/apps/api/src/services/market-data.module.ts b/apps/api/src/services/market-data/market-data.module.ts similarity index 75% rename from apps/api/src/services/market-data.module.ts rename to apps/api/src/services/market-data/market-data.module.ts index b1a09fa91..32367d5c0 100644 --- a/apps/api/src/services/market-data.module.ts +++ b/apps/api/src/services/market-data/market-data.module.ts @@ -1,4 +1,4 @@ -import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { Module } from '@nestjs/common'; import { MarketDataService } from './market-data.service'; diff --git a/apps/api/src/services/market-data.service.ts b/apps/api/src/services/market-data/market-data.service.ts similarity index 57% rename from apps/api/src/services/market-data.service.ts rename to apps/api/src/services/market-data/market-data.service.ts index 9dd3e4773..414c247aa 100644 --- a/apps/api/src/services/market-data.service.ts +++ b/apps/api/src/services/market-data/market-data.service.ts @@ -1,10 +1,16 @@ import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto'; import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface'; -import { PrismaService } from '@ghostfolio/api/services/prisma.service'; +import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { resetHours } from '@ghostfolio/common/helper'; import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; -import { DataSource, MarketData, Prisma } from '@prisma/client'; +import { + DataSource, + MarketData, + MarketDataState, + Prisma +} from '@prisma/client'; @Injectable() export class MarketDataService { @@ -20,14 +26,13 @@ export class MarketDataService { } public async get({ - date, + dataSource, + date = new Date(), symbol - }: { - date: Date; - symbol: string; - }): Promise { + }: IDataGatheringItem): Promise { return await this.prismaService.marketData.findFirst({ where: { + dataSource, symbol, date: resetHours(date) } @@ -92,7 +97,9 @@ export class MarketDataService { } public async updateMarketData(params: { - data: { dataSource: DataSource } & UpdateMarketDataDto; + data: { + state: MarketDataState; + } & UpdateMarketDataDto; where: Prisma.MarketDataWhereUniqueInput; }): Promise { const { data, where } = params; @@ -100,12 +107,50 @@ export class MarketDataService { return this.prismaService.marketData.upsert({ where, create: { - dataSource: data.dataSource, - date: where.date_symbol.date, + dataSource: where.dataSource_date_symbol.dataSource, + date: where.dataSource_date_symbol.date, marketPrice: data.marketPrice, - symbol: where.date_symbol.symbol + state: data.state, + symbol: where.dataSource_date_symbol.symbol }, - update: { marketPrice: data.marketPrice } + update: { marketPrice: data.marketPrice, state: data.state } }); } + + /** + * Upsert market data by imitating missing upsertMany functionality + * with $transaction + */ + public async updateMany({ + data + }: { + data: Prisma.MarketDataUpdateInput[]; + }): Promise { + const upsertPromises = data.map( + ({ dataSource, date, marketPrice, symbol, state }) => { + return this.prismaService.marketData.upsert({ + create: { + dataSource: dataSource, + date: date, + marketPrice: marketPrice, + state: state, + symbol: symbol + }, + update: { + marketPrice: marketPrice, + state: state + }, + where: { + dataSource_date_symbol: { + dataSource: dataSource, + date: date, + symbol: symbol + } + } + }); + } + ); + + return this.prismaService.$transaction(upsertPromises); + } } diff --git a/apps/api/src/services/prisma.module.ts b/apps/api/src/services/prisma/prisma.module.ts similarity index 63% rename from apps/api/src/services/prisma.module.ts rename to apps/api/src/services/prisma/prisma.module.ts index ee8c8b8c5..7cd76d314 100644 --- a/apps/api/src/services/prisma.module.ts +++ b/apps/api/src/services/prisma/prisma.module.ts @@ -1,4 +1,4 @@ -import { PrismaService } from '@ghostfolio/api/services/prisma.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { Module } from '@nestjs/common'; @Module({ diff --git a/apps/api/src/services/prisma.service.ts b/apps/api/src/services/prisma/prisma.service.ts similarity index 100% rename from apps/api/src/services/prisma.service.ts rename to apps/api/src/services/prisma/prisma.service.ts diff --git a/apps/api/src/services/property/property.module.ts b/apps/api/src/services/property/property.module.ts index fcd89de40..50fba955d 100644 --- a/apps/api/src/services/property/property.module.ts +++ b/apps/api/src/services/property/property.module.ts @@ -1,4 +1,4 @@ -import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { Module } from '@nestjs/common'; import { PropertyService } from './property.service'; diff --git a/apps/api/src/services/property/property.service.ts b/apps/api/src/services/property/property.service.ts index 4760c3a94..cb5902cd5 100644 --- a/apps/api/src/services/property/property.service.ts +++ b/apps/api/src/services/property/property.service.ts @@ -1,5 +1,8 @@ -import { PrismaService } from '@ghostfolio/api/services/prisma.service'; -import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { + PROPERTY_CURRENCIES, + PROPERTY_IS_USER_SIGNUP_ENABLED +} from '@ghostfolio/common/config'; import { Injectable } from '@nestjs/common'; @Injectable() @@ -39,6 +42,13 @@ export class PropertyService { return properties?.[aKey]; } + public async isUserSignupEnabled() { + return ( + ((await this.getByKey(PROPERTY_IS_USER_SIGNUP_ENABLED)) as boolean) ?? + true + ); + } + public async put({ key, value }: { key: string; value: string }) { return this.prismaService.property.upsert({ create: { key, value }, diff --git a/apps/api/src/services/symbol-profile.module.ts b/apps/api/src/services/symbol-profile/symbol-profile.module.ts similarity index 76% rename from apps/api/src/services/symbol-profile.module.ts rename to apps/api/src/services/symbol-profile/symbol-profile.module.ts index ac1337e87..fb3fc4d62 100644 --- a/apps/api/src/services/symbol-profile.module.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.module.ts @@ -1,4 +1,4 @@ -import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { Module } from '@nestjs/common'; import { SymbolProfileService } from './symbol-profile.service'; diff --git a/apps/api/src/services/symbol-profile.service.ts b/apps/api/src/services/symbol-profile/symbol-profile.service.ts similarity index 75% rename from apps/api/src/services/symbol-profile.service.ts rename to apps/api/src/services/symbol-profile/symbol-profile.service.ts index 62bc38aab..99244c352 100644 --- a/apps/api/src/services/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -1,4 +1,4 @@ -import { PrismaService } from '@ghostfolio/api/services/prisma.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { EnhancedSymbolProfile, @@ -8,25 +8,20 @@ import { import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Injectable } from '@nestjs/common'; -import { - DataSource, - Prisma, - SymbolProfile, - SymbolProfileOverrides -} from '@prisma/client'; +import { Prisma, SymbolProfile, SymbolProfileOverrides } from '@prisma/client'; import { continents, countries } from 'countries-list'; @Injectable() export class SymbolProfileService { public constructor(private readonly prismaService: PrismaService) {} - public async delete({ - dataSource, - symbol - }: { - dataSource: DataSource; - symbol: string; - }) { + public async add( + assetProfile: Prisma.SymbolProfileCreateInput + ): Promise { + return this.prismaService.symbolProfile.create({ data: assetProfile }); + } + + public async delete({ dataSource, symbol }: UniqueAsset) { return this.prismaService.symbolProfile.delete({ where: { dataSource_symbol: { dataSource, symbol } } }); @@ -43,7 +38,19 @@ export class SymbolProfileService { ): Promise { return this.prismaService.symbolProfile .findMany({ - include: { SymbolProfileOverrides: true }, + include: { + _count: { + select: { Order: true } + }, + Order: { + orderBy: { + date: 'asc' + }, + select: { date: true }, + take: 1 + }, + SymbolProfileOverrides: true + }, where: { AND: [ { @@ -69,7 +76,12 @@ export class SymbolProfileService { ): Promise { return this.prismaService.symbolProfile .findMany({ - include: { SymbolProfileOverrides: true }, + include: { + _count: { + select: { Order: true } + }, + SymbolProfileOverrides: true + }, where: { id: { in: symbolProfileIds.map((symbolProfileId) => { @@ -81,40 +93,47 @@ export class SymbolProfileService { .then((symbolProfiles) => this.getSymbols(symbolProfiles)); } - /** - * @deprecated - */ - public async getSymbolProfilesBySymbols( - symbols: string[] - ): Promise { - return this.prismaService.symbolProfile - .findMany({ - include: { SymbolProfileOverrides: true }, - where: { - symbol: { - in: symbols - } - } - }) - .then((symbolProfiles) => this.getSymbols(symbolProfiles)); + public updateSymbolProfile({ + comment, + dataSource, + scraperConfiguration, + symbol, + symbolMapping + }: Prisma.SymbolProfileUpdateInput & UniqueAsset) { + return this.prismaService.symbolProfile.update({ + data: { comment, scraperConfiguration, symbolMapping }, + where: { dataSource_symbol: { dataSource, symbol } } + }); } private getSymbols( symbolProfiles: (SymbolProfile & { + _count: { Order: number }; + Order?: { + date: Date; + }[]; SymbolProfileOverrides: SymbolProfileOverrides; })[] ): EnhancedSymbolProfile[] { return symbolProfiles.map((symbolProfile) => { const item = { ...symbolProfile, + activitiesCount: 0, countries: this.getCountries( symbolProfile?.countries as unknown as Prisma.JsonArray ), + dateOfFirstActivity: undefined, scraperConfiguration: this.getScraperConfiguration(symbolProfile), sectors: this.getSectors(symbolProfile), symbolMapping: this.getSymbolMapping(symbolProfile) }; + item.activitiesCount = symbolProfile._count.Order; + delete item._count; + + item.dateOfFirstActivity = symbolProfile.Order?.[0]?.date; + delete item.Order; + if (item.SymbolProfileOverrides) { item.assetClass = item.SymbolProfileOverrides.assetClass ?? item.assetClass; @@ -177,6 +196,8 @@ export class SymbolProfileService { if (scraperConfiguration) { return { defaultMarketPrice: scraperConfiguration.defaultMarketPrice as number, + headers: + scraperConfiguration.headers as ScraperConfiguration['headers'], selector: scraperConfiguration.selector as string, url: scraperConfiguration.url as string }; diff --git a/apps/api/src/services/tag/tag.module.ts b/apps/api/src/services/tag/tag.module.ts index 32d905884..76ed9fcd6 100644 --- a/apps/api/src/services/tag/tag.module.ts +++ b/apps/api/src/services/tag/tag.module.ts @@ -1,4 +1,4 @@ -import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { Module } from '@nestjs/common'; import { TagService } from './tag.service'; diff --git a/apps/api/src/services/tag/tag.service.ts b/apps/api/src/services/tag/tag.service.ts index 534a6e73d..c02345784 100644 --- a/apps/api/src/services/tag/tag.service.ts +++ b/apps/api/src/services/tag/tag.service.ts @@ -1,4 +1,4 @@ -import { PrismaService } from '@ghostfolio/api/services/prisma.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { Injectable } from '@nestjs/common'; @Injectable() 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 c810dad4a..d4d565cb1 100644 --- a/apps/api/src/services/twitter-bot/twitter-bot.module.ts +++ b/apps/api/src/services/twitter-bot/twitter-bot.module.ts @@ -1,6 +1,6 @@ import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module'; import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; -import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service'; import { Module } from '@nestjs/common'; 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 40e9c9cf2..d3e7fb91c 100644 --- a/apps/api/src/services/twitter-bot/twitter-bot.service.ts +++ b/apps/api/src/services/twitter-bot/twitter-bot.service.ts @@ -1,6 +1,6 @@ import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ghostfolioFearAndGreedIndexDataSource, ghostfolioFearAndGreedIndexSymbol @@ -65,9 +65,8 @@ export class TwitterBotService { status += benchmarkListing; } - const { data: createdTweet } = await this.twitterClient.v2.tweet( - status - ); + const { data: createdTweet } = + await this.twitterClient.v2.tweet(status); Logger.log( `Fear & Greed Index has been tweeted: https://twitter.com/ghostfolio_/status/${createdTweet.id}`, diff --git a/apps/api/tsconfig.app.json b/apps/api/tsconfig.app.json index 44e62fa9f..a0c17b4fa 100644 --- a/apps/api/tsconfig.app.json +++ b/apps/api/tsconfig.app.json @@ -4,7 +4,7 @@ "outDir": "../../dist/out-tsc", "types": ["node"], "emitDecoratorMetadata": true, - "target": "es2015" + "target": "es2021" }, "exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"], "include": ["**/*.ts"] diff --git a/apps/client-e2e/project.json b/apps/client-e2e/project.json index 94276384e..16d13e012 100644 --- a/apps/client-e2e/project.json +++ b/apps/client-e2e/project.json @@ -1,10 +1,11 @@ { + "name": "client-e2e", "$schema": "../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "apps/client-e2e/src", "projectType": "application", "targets": { "e2e": { - "executor": "@nrwl/cypress:cypress", + "executor": "@nx/cypress:cypress", "options": { "cypressConfig": "apps/client-e2e/cypress.json", "tsConfig": "apps/client-e2e/tsconfig.e2e.json", diff --git a/apps/client-e2e/src/plugins/index.js b/apps/client-e2e/src/plugins/index.js index 9067e75a2..63aa33cbe 100644 --- a/apps/client-e2e/src/plugins/index.js +++ b/apps/client-e2e/src/plugins/index.js @@ -11,7 +11,7 @@ // This function is called when a project is opened or re-opened (e.g. due to // the project's config changing) -const { preprocessTypescript } = require('@nrwl/cypress/plugins/preprocessor'); +const { preprocessTypescript } = require('@nx/cypress/plugins/preprocessor'); module.exports = (on, config) => { // `on` is used to hook into various events Cypress emits diff --git a/apps/client/.browserslistrc b/apps/client/.browserslistrc deleted file mode 100644 index 0ccadaf32..000000000 --- a/apps/client/.browserslistrc +++ /dev/null @@ -1,18 +0,0 @@ -# This file is used by the build system to adjust CSS and JS output to support the specified browsers below. -# For additional information regarding the format and rule options, please see: -# https://github.com/browserslist/browserslist#queries - -# For the full list of supported browsers by the Angular framework, please see: -# https://angular.io/guide/browser-support - -# You can see what browsers were selected by your queries by running: -# npx browserslist - -last 1 Chrome version -last 1 Firefox version -last 2 Edge major versions -last 2 Safari major versions -last 2 iOS major versions -Firefox ESR -not IE 9-10 # Angular support for IE 9-10 has been deprecated and will be removed as of Angular v11. To opt-in, remove the 'not' prefix on this line. -not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line. diff --git a/apps/client/jest.config.ts b/apps/client/jest.config.ts index d248ed8e4..04378bdbd 100644 --- a/apps/client/jest.config.ts +++ b/apps/client/jest.config.ts @@ -3,12 +3,7 @@ export default { displayName: 'client', setupFilesAfterEnv: ['/src/test-setup.ts'], - globals: { - 'ts-jest': { - tsconfig: '/tsconfig.spec.json', - stringifyContentPathRegex: '\\.(html|svg)$' - } - }, + globals: {}, coverageDirectory: '../../coverage/apps/client', snapshotSerializers: [ 'jest-preset-angular/build/serializers/no-ng-attributes', @@ -16,7 +11,13 @@ export default { 'jest-preset-angular/build/serializers/html-comment' ], transform: { - '^.+.(ts|mjs|js|html)$': 'jest-preset-angular' + '^.+.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$' + } + ] }, transformIgnorePatterns: ['node_modules/(?!.*.mjs$)'], preset: '../../jest.preset.js' diff --git a/apps/client/ngsw-config.json b/apps/client/ngsw-config.json new file mode 100644 index 000000000..c0f03a135 --- /dev/null +++ b/apps/client/ngsw-config.json @@ -0,0 +1,30 @@ +{ + "$schema": "../../node_modules/@angular/service-worker/config/schema.json", + "index": "/index.html", + "assetGroups": [ + { + "name": "app", + "installMode": "prefetch", + "resources": { + "files": [ + "/favicon.ico", + "/index.html", + "/assets/site.webmanifest", + "/*.css", + "/*.js" + ] + } + }, + { + "name": "assets", + "installMode": "lazy", + "updateMode": "prefetch", + "resources": { + "files": [ + "/assets/**", + "/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)" + ] + } + } + ] +} diff --git a/apps/client/project.json b/apps/client/project.json index dc9a17fed..2e36f7144 100644 --- a/apps/client/project.json +++ b/apps/client/project.json @@ -1,4 +1,5 @@ { + "name": "client", "$schema": "../../node_modules/nx/schemas/project-schema.json", "projectType": "application", "generators": { @@ -10,63 +11,29 @@ "prefix": "gf", "targets": { "build": { - "executor": "@angular-devkit/build-angular:browser", + "executor": "@nx/angular:webpack-browser", "options": { + "localize": true, "outputPath": "dist/apps/client", "index": "apps/client/src/index.html", "main": "apps/client/src/main.ts", "polyfills": "apps/client/src/polyfills.ts", "tsConfig": "apps/client/tsconfig.app.json", - "assets": [ - { - "glob": "assetlinks.json", - "input": "apps/client/src/assets", - "output": "./../.well-known" - }, - { - "glob": "CHANGELOG.md", - "input": "", - "output": "./../assets" - }, - { - "glob": "LICENSE", - "input": "", - "output": "./../assets" - }, - { - "glob": "robots.txt", - "input": "apps/client/src/assets", - "output": "./../" - }, - { - "glob": "sitemap.xml", - "input": "apps/client/src/assets", - "output": "./../" - }, - { - "glob": "**/*", - "input": "node_modules/ionicons/dist/ionicons", - "output": "./../ionicons" - }, - { - "glob": "**/*.js", - "input": "node_modules/ionicons/dist/", - "output": "./../" - }, - { - "glob": "**/*", - "input": "apps/client/src/assets", - "output": "./../assets/" - } + "assets": [], + "styles": [ + "apps/client/src/assets/fonts/inter.css", + "apps/client/src/styles/theme.scss", + "apps/client/src/styles.scss" ], - "styles": ["apps/client/src/styles.scss"], "scripts": ["node_modules/marked/marked.min.js"], "vendorChunk": true, "extractLicenses": false, "buildOptimizer": false, "sourceMap": true, "optimization": false, - "namedChunks": true + "namedChunks": true, + "serviceWorker": true, + "ngswConfigPath": "apps/client/ngsw-config.json" }, "configurations": { "development-de": { @@ -81,6 +48,10 @@ "baseHref": "/es/", "localize": ["es"] }, + "development-fr": { + "baseHref": "/fr/", + "localize": ["fr"] + }, "development-it": { "baseHref": "/it/", "localize": ["it"] @@ -89,6 +60,14 @@ "baseHref": "/nl/", "localize": ["nl"] }, + "development-pt": { + "baseHref": "/pt/", + "localize": ["pt"] + }, + "development-tr": { + "baseHref": "/tr/", + "localize": ["tr"] + }, "production": { "fileReplacements": [ { @@ -120,8 +99,51 @@ "outputs": ["{options.outputPath}"], "defaultConfiguration": "" }, + "copy-assets": { + "executor": "nx:run-commands", + "options": { + "commands": [ + { + "command": "shx mkdir -p dist/apps/client" + }, + { + "command": "shx cp -r apps/client/src/assets dist/apps/client" + }, + { + "command": "shx cp -r apps/client/src/assets/.well-known dist/apps/client" + }, + { + "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" + }, + { + "command": "shx cp apps/client/src/assets/site.webmanifest dist/apps/client" + }, + { + "command": "shx cp node_modules/ionicons/dist/index.js dist/apps/client" + }, + { + "command": "shx cp node_modules/ionicons/dist/ionicons.js dist/apps/client" + }, + { + "command": "shx cp -r node_modules/ionicons/dist/ionicons dist/apps/client/ionicons" + }, + { + "command": "shx cp CHANGELOG.md dist/apps/client/assets" + }, + { + "command": "shx cp LICENSE dist/apps/client/assets" + } + ] + } + }, "serve": { - "executor": "@angular-devkit/build-angular:dev-server", + "executor": "@nx/angular:webpack-dev-server", "options": { "browserTarget": "client:build", "proxyConfig": "apps/client/proxy.conf.json" @@ -136,12 +158,21 @@ "development-es": { "browserTarget": "client:build:development-es" }, + "development-fr": { + "browserTarget": "client:build:development-fr" + }, "development-it": { "browserTarget": "client:build:development-it" }, "development-nl": { "browserTarget": "client:build:development-nl" }, + "development-pt": { + "browserTarget": "client:build:development-pt" + }, + "development-tr": { + "browserTarget": "client:build:development-tr" + }, "production": { "browserTarget": "client:build:production" } @@ -156,8 +187,11 @@ "targetFiles": [ "messages.de.xlf", "messages.es.xlf", + "messages.fr.xlf", "messages.it.xlf", - "messages.nl.xlf" + "messages.nl.xlf", + "messages.pt.xlf", + "messages.tr.xlf" ] } }, @@ -168,7 +202,7 @@ } }, "test": { - "executor": "@nrwl/jest:jest", + "executor": "@nx/jest:jest", "options": { "jestConfig": "apps/client/jest.config.ts", "passWithNoTests": true @@ -186,6 +220,10 @@ "baseHref": "/es/", "translation": "apps/client/src/locales/messages.es.xlf" }, + "fr": { + "baseHref": "/fr/", + "translation": "apps/client/src/locales/messages.fr.xlf" + }, "it": { "baseHref": "/it/", "translation": "apps/client/src/locales/messages.it.xlf" @@ -193,6 +231,14 @@ "nl": { "baseHref": "/nl/", "translation": "apps/client/src/locales/messages.nl.xlf" + }, + "pt": { + "baseHref": "/pt/", + "translation": "apps/client/src/locales/messages.pt.xlf" + }, + "tr": { + "baseHref": "/tr/", + "translation": "apps/client/src/locales/messages.tr.xlf" } }, "sourceLocale": "en" diff --git a/apps/client/proxy.conf.json b/apps/client/proxy.conf.json index 9ad19ddba..94a448238 100644 --- a/apps/client/proxy.conf.json +++ b/apps/client/proxy.conf.json @@ -1,14 +1,14 @@ { "/api": { - "target": "http://localhost:3333", + "target": "http://0.0.0.0:3333", "secure": false }, "/assets": { - "target": "http://localhost:3333", + "target": "http://0.0.0.0:3333", "secure": false }, "/ionicons": { - "target": "http://localhost:3333", + "target": "http://0.0.0.0:3333", "secure": false } } diff --git a/apps/client/src/app/adapter/custom-date-adapter.ts b/apps/client/src/app/adapter/custom-date-adapter.ts index af57c567a..663c91b72 100644 --- a/apps/client/src/app/adapter/custom-date-adapter.ts +++ b/apps/client/src/app/adapter/custom-date-adapter.ts @@ -2,7 +2,7 @@ import { Platform } from '@angular/cdk/platform'; import { Inject, forwardRef } from '@angular/core'; import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core'; import { getDateFormatString } from '@ghostfolio/common/helper'; -import { format, parse } from 'date-fns'; +import { addYears, format, getYear, parse } from 'date-fns'; export class CustomDateAdapter extends NativeDateAdapter { public constructor( @@ -31,6 +31,16 @@ export class CustomDateAdapter extends NativeDateAdapter { * Parses a date from a provided value */ public parse(aValue: string): Date { - return parse(aValue, getDateFormatString(this.locale), new Date()); + let date = parse(aValue, getDateFormatString(this.locale), new Date()); + + if (getYear(date) < 1900) { + if (getYear(date) > Number(format(new Date(), 'yy')) + 1) { + date = addYears(date, 1900); + } else { + date = addYears(date, 2000); + } + } + + return date; } } diff --git a/apps/client/src/app/app-routing.module.ts b/apps/client/src/app/app-routing.module.ts index 856230eb6..f82bad864 100644 --- a/apps/client/src/app/app-routing.module.ts +++ b/apps/client/src/app/app-routing.module.ts @@ -4,31 +4,29 @@ import { PageTitleStrategy } from '@ghostfolio/client/services/page-title.strate import { ModulePreloadService } from './core/module-preload.service'; +export const paths = { + about: $localize`about`, + faq: $localize`faq`, + features: $localize`features`, + license: $localize`license`, + markets: $localize`markets`, + pricing: $localize`pricing`, + privacyPolicy: $localize`privacy-policy`, + register: $localize`register`, + resources: $localize`resources` +}; + const routes: Routes = [ { - path: 'about', + path: paths.about, loadChildren: () => import('./pages/about/about-page.module').then((m) => m.AboutPageModule) }, - { - path: 'about/changelog', - loadChildren: () => - import('./pages/about/changelog/changelog-page.module').then( - (m) => m.ChangelogPageModule - ) - }, - { - path: 'about/privacy-policy', - loadChildren: () => - import('./pages/about/privacy-policy/privacy-policy-page.module').then( - (m) => m.PrivacyPolicyPageModule - ) - }, { path: 'account', loadChildren: () => - import('./pages/account/account-page.module').then( - (m) => m.AccountPageModule + import('./pages/user-account/user-account-page.module').then( + (m) => m.UserAccountPageModule ) }, { @@ -53,67 +51,18 @@ const routes: Routes = [ loadChildren: () => import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule) }, - { - path: 'blog/2021/07/hallo-ghostfolio', - loadChildren: () => - import( - './pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.module' - ).then((m) => m.HalloGhostfolioPageModule) - }, - { - path: 'blog/2021/07/hello-ghostfolio', - loadChildren: () => - import( - './pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module' - ).then((m) => m.HelloGhostfolioPageModule) - }, - { - path: 'blog/2022/01/ghostfolio-first-months-in-open-source', - loadChildren: () => - import( - './pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module' - ).then((m) => m.FirstMonthsInOpenSourcePageModule) - }, - { - path: 'blog/2022/07/ghostfolio-meets-internet-identity', - loadChildren: () => - import( - './pages/blog/2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.module' - ).then((m) => m.GhostfolioMeetsInternetIdentityPageModule) - }, - { - path: 'blog/2022/07/how-do-i-get-my-finances-in-order', - loadChildren: () => - import( - './pages/blog/2022/07/how-do-i-get-my-finances-in-order/how-do-i-get-my-finances-in-order-page.module' - ).then((m) => m.HowDoIGetMyFinancesInOrderPageModule) - }, - { - path: 'blog/2022/08/500-stars-on-github', - loadChildren: () => - import( - './pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.module' - ).then((m) => m.FiveHundredStarsOnGitHubPageModule) - }, - { - path: 'blog/2022/10/hacktoberfest-2022', - loadChildren: () => - import( - './pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.module' - ).then((m) => m.Hacktoberfest2022PageModule) - }, { path: 'demo', loadChildren: () => import('./pages/demo/demo-page.module').then((m) => m.DemoPageModule) }, { - path: 'faq', + path: paths.faq, loadChildren: () => import('./pages/faq/faq-page.module').then((m) => m.FaqPageModule) }, { - path: 'features', + path: paths.features, loadChildren: () => import('./pages/features/features-page.module').then( (m) => m.FeaturesPageModule @@ -125,12 +74,17 @@ const routes: Routes = [ import('./pages/home/home-page.module').then((m) => m.HomePageModule) }, { - path: 'markets', + path: paths.markets, loadChildren: () => import('./pages/markets/markets-page.module').then( (m) => m.MarketsPageModule ) }, + { + path: 'open', + loadChildren: () => + import('./pages/open/open-page.module').then((m) => m.OpenPageModule) + }, { path: 'p', loadChildren: () => @@ -146,63 +100,21 @@ const routes: Routes = [ ) }, { - path: 'portfolio/activities', - loadChildren: () => - import('./pages/portfolio/transactions/transactions-page.module').then( - (m) => m.TransactionsPageModule - ) - }, - { - path: 'portfolio/allocations', - loadChildren: () => - import('./pages/portfolio/allocations/allocations-page.module').then( - (m) => m.AllocationsPageModule - ) - }, - { - path: 'portfolio/analysis', - loadChildren: () => - import('./pages/portfolio/analysis/analysis-page.module').then( - (m) => m.AnalysisPageModule - ) - }, - { - path: 'portfolio/fire', - loadChildren: () => - import('./pages/portfolio/fire/fire-page.module').then( - (m) => m.FirePageModule - ) - }, - { - path: 'portfolio/holdings', - loadChildren: () => - import('./pages/portfolio/holdings/holdings-page.module').then( - (m) => m.HoldingsPageModule - ) - }, - { - path: 'portfolio/report', - loadChildren: () => - import('./pages/portfolio/report/report-page.module').then( - (m) => m.ReportPageModule - ) - }, - { - path: 'pricing', + path: paths.pricing, loadChildren: () => import('./pages/pricing/pricing-page.module').then( (m) => m.PricingPageModule ) }, { - path: 'register', + path: paths.register, loadChildren: () => import('./pages/register/register-page.module').then( (m) => m.RegisterPageModule ) }, { - path: 'resources', + path: paths.resources, loadChildren: () => import('./pages/resources/resources-page.module').then( (m) => m.ResourcesPageModule @@ -243,9 +155,8 @@ const routes: Routes = [ // Preload all lazy loaded modules with the attribute preload === true { anchorScrolling: 'enabled', - preloadingStrategy: ModulePreloadService, + preloadingStrategy: ModulePreloadService // enableTracing: true // <-- debugging purposes only - relativeLinkResolution: 'legacy' } ) ], diff --git a/apps/client/src/app/app.component.html b/apps/client/src/app/app.component.html index 4525930cf..a52261969 100644 --- a/apps/client/src/app/app.component.html +++ b/apps/client/src/app/app.component.html @@ -1,36 +1,27 @@
- -
- -
-
-
+ + + + +
-