diff --git a/.github/workflows/build-code.yml b/.github/workflows/build-code.yml index 7a8f72ac4..bd5d1efdc 100644 --- a/.github/workflows/build-code.yml +++ b/.github/workflows/build-code.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: node_version: - - 22 + - 22.22.1 steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/.github/workflows/extract-locales.yml b/.github/workflows/extract-locales.yml index c17eac5b6..b8b79b946 100644 --- a/.github/workflows/extract-locales.yml +++ b/.github/workflows/extract-locales.yml @@ -33,8 +33,8 @@ jobs: uses: peter-evans/create-pull-request@v7 with: author: 'github-actions[bot] ' - branch: 'feature/update-locales' + branch: 'task/update-locales' commit-message: 'Update locales' delete-branch: true - title: 'Feature/update locales' + title: 'Task/update locales' token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index fde9874a2..68abade5f 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,8 +1,8 @@ { "recommendations": [ "angular.ng-template", + "esbenp.prettier-vscode", "firsttris.vscode-jest-runner", - "nrwl.angular-console", - "prettier.prettier-vscode" + "nrwl.angular-console" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 36091af85..9bf4d12b5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,4 @@ { - "editor.defaultFormatter": "prettier.prettier-vscode", + "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true } diff --git a/CHANGELOG.md b/CHANGELOG.md index 5097993c3..fb81aeb18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,15 +7,408 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Changed + +- Improved the language localization for Polish (`pl`) + +## 2.250.0 - 2026-03-17 + +### Added + +- Added support for specific calendar year date ranges (`2025`, `2024`, `2023`, etc.) on the portfolio activities page + +### Changed + +- Consolidated the sign-out logic within the user service to unify cookie, state and token clearance +- Improved the language localization for Polish (`pl`) +- Upgraded `@ionic/angular` from version `8.7.3` to `8.8.1` +- Upgraded `replace-in-file` from version `8.3.0` to `8.4.0` +- Upgraded `svgmap` from version `2.14.0` to `2.19.2` +- Pinned the _Node.js_ version in the _Build code_ _GitHub Action_ to ensure environment consistency for tests + +### Fixed + +- Fixed an issue with the detection of the thousand separator for the `de-CH` locale +- Fixed an issue in the _Storybook_ stories of the symbol autocomplete component caused by a circular dependency + +## 2.249.0 - 2026-03-10 + +### Added + +- Integrated _Bull Dashboard_ for a detailed jobs queue view in the admin control panel (experimental) +- Added a debounce to the `PortfolioChangedListener` and `AssetProfileChangedListener` to minimize redundant _Redis_ and database operations + +### Changed + +- Improved the _Storybook_ stories of the value component +- Improved the language localization for Dutch (`nl`) +- Improved the language localization for German (`de`) +- Upgraded `class-validator` from version `0.14.3` to `0.15.1` + +### Fixed + +- Fixed false _Redis_ health check failures by using unique keys and increasing the timeout to 5s + +## 2.248.0 - 2026-03-07 + +### Added + +- Added support for column sorting to the data providers management of the admin control panel + +### Changed + +- Included asset profile data in the endpoint `GET api/v1/portfolio/holdings` +- Included asset profile data in the holdings of the public page +- Reused the value component in the platform management of the admin control panel +- Reused the value component in the tag management of the admin control panel +- Deprecated the `api/v1/order` endpoints in favor of the `api/v1/activities` endpoints +- Upgraded `jsonpath` from version `1.1.1` to `1.2.1` + +### Fixed + +- Fixed an issue in the _FIRE_ calculator to correctly calculate the projected total amount + +## 2.247.0 - 2026-03-04 + +### Changed + +- Upgraded `yahoo-finance2` from version `3.13.0` to `3.13.2` + +## 2.246.0 - 2026-03-03 + +### Changed + +- Removed the deprecated `committedFunds` from the summary of the portfolio details endpoint +- Upgraded `Nx` from version `22.4.5` to `22.5.3` + +### Fixed + +- Fixed an issue where the apply and reset filter buttons remained disabled in the assistant + +## 2.245.0 - 2026-03-01 + +### Changed + +- Excluded the scraper configuration from the import and export functionality +- Excluded the symbol mapping from the import and export functionality +- Improved the language localization for Dutch (`nl`) +- Improved the language localization for Italian (`it`) +- Improved the language localization for Spanish (`es`) + +### Fixed + +- Resolved the data source transformation in the errors of the performance endpoint +- Resolved the data source transformation in the export functionality + +## 2.244.0 - 2026-02-28 + +### Changed + +- Improved the usability of the asset profile details dialog in the admin control panel for currencies +- Removed the deprecated static portfolio analysis rule: _Fees_ (Fee Ratio) +- Refactored queries in the data provider service to use Prisma’s safe query methods + +### Fixed + +- Fixed an exception by adding a fallback for missing market price values on the _X-ray_ page + +## 2.243.0 - 2026-02-23 + +### Changed + +- Improved the language localization for Chinese (`zh`) +- Upgraded `nestjs` from version `11.1.8` to `11.1.14` + +### Fixed + +- Fixed an issue when creating activities of type `FEE`, `INTEREST` or `LIABILITY` + +## 2.242.0 - 2026-02-22 + +### Changed + +- Changed the account field to optional in the create or update activity dialog + +### Fixed + +- Fixed a validation issue for valuables used in the create and import activity logic +- Fixed the page size for presets in the historical market data table of the admin control panel + +## 2.241.0 - 2026-02-21 + +### Changed + +- Improved the usability of the portfolio summary tab on the home page in the _Presenter View_ +- Refreshed the cryptocurrencies list +- Improved the language localization for German (`de`) +- Improved the language localization for Spanish (`es`) + +### Fixed + +- Fixed an issue with `balanceInBaseCurrency` of the accounts in the value redaction interceptor for the impersonation mode +- Fixed an issue with `comment` of the accounts in the value redaction interceptor for the impersonation mode +- Fixed an issue with `dividendInBaseCurrency` of the accounts in the value redaction interceptor for the impersonation mode +- Fixed an issue with `interestInBaseCurrency` of the accounts in the value redaction interceptor for the impersonation mode +- Fixed an issue with `value` of the accounts in the value redaction interceptor for the impersonation mode + +## 2.240.0 - 2026-02-18 + +### Added + +- Added a _No Activities_ preset to the historical market data table of the admin control panel +- Added support for custom cryptocurrencies defined in the database +- Added support for the cryptocurrency _Sky_ + +### Changed + +- Harmonized the validation for the create activity endpoint with the existing import activity logic +- Upgraded `marked` from version `17.0.1` to `17.0.2` +- Upgraded `ngx-markdown` from version `21.0.1` to `21.1.0` + +## 2.239.0 - 2026-02-15 + +### Added + +- Added a new static portfolio analysis rule based on the total investment volume: _Fees_ (Fee Ratio) +- Extended the content of the _Self-Hosting_ section on the Frequently Asked Questions (FAQ) page with information on derived currencies + +### Changed + +- Deprecated the existing static portfolio analysis rule: _Fees_ (Fee Ratio) +- Ignored nested ETFs when fetching top holdings for ETF and mutual fund assets from _Yahoo Finance_ +- Improved the scraper configuration with more detailed error messages +- Improved the language localization for German (`de`) +- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `13.1.0` to `13.2.2` +- Upgraded `cheerio` from version `1.0.0` to `1.2.0` + +### Fixed + +- Fixed the investment value by including currency effects in the portfolio summary tab on the home page +- Added the missing `valueInBaseCurrency` to the response of the import activities endpoint + +## 2.238.0 - 2026-02-12 + +### Changed + +- Upgraded `ngx-skeleton-loader` from version `11.3.0` to `12.0.0` +- Upgraded `twitter-api-v2` from version `1.27.0` to `1.29.0` + +### Fixed + +- Fixed a performance calculation issue by resetting tracking variables when a holding is fully closed +- Fixed an issue in the annualized performance calculation +- Fixed an issue with the exchange rate calculation by expanding the date range to cover the full day (start to end of day) + +## 2.237.0 - 2026-02-08 + +### Changed + +- Removed the deprecated `transactionCount` in the portfolio calculator and service +- Refreshed the cryptocurrencies list +- Upgraded `Nx` from version `22.4.1` to `22.4.5` + +### Fixed + +- Fixed the accounts of the assistant for the impersonation mode +- Fixed the tags of the assistant for the impersonation mode + +## 2.236.0 - 2026-02-05 + +### Changed + +- Removed the deprecated `transactionCount` in the endpoint `GET api/v1/admin` +- Upgraded `stripe` from version `20.1.0` to `20.3.0` + +### Fixed + +- Fixed an exception when fetching the top holdings for ETF and mutual fund assets from _Yahoo Finance_ + +## 2.235.0 - 2026-02-03 + +### Added + +- Added the ability to fetch top holdings for ETF and mutual fund assets from _Yahoo Finance_ +- Added support for the impersonation mode in the endpoint `GET api/v1/account/:id/balances` +- Added an action menu to the user detail dialog in the users section of the admin control panel + +### Changed + +- Optimized the value redaction interceptor for the impersonation mode by introducing `fast-redact` +- Refactored `showTransactions` in favor of `showActivitiesCount` in the accounts table component +- Refactored `transactionCount` in favor of `activitiesCount` in the accounts table component +- Deprecated `transactionCount` in favor of `activitiesCount` in the endpoint `GET api/v1/admin` +- Removed the deprecated `firstBuyDate` in the portfolio calculator +- Upgraded `yahoo-finance2` from version `3.11.2` to `3.13.0` + +## 2.234.0 - 2026-01-30 + +### Changed + +- Improved the usability of the create asset profile dialog in the market data section of the admin control panel +- Improved the language localization for Chinese (`zh`) +- Improved the language localization for German (`de`) +- Improved the language localization for Spanish (`es`) +- Upgraded `angular` from version `21.0.6` to `21.1.1` +- Upgraded `lodash` from version `4.17.21` to `4.17.23` +- Upgraded `Nx` from version `22.3.3` to `22.4.1` +- Upgraded `prettier` from version `3.8.0` to `3.8.1` + +## 2.233.0 - 2026-01-23 + +### Changed + +- Deprecated `firstBuyDate` in favor of `dateOfFirstActivity` in the portfolio calculator +- Deprecated `transactionCount` in favor of `activitiesCount` in the portfolio calculator and service +- Removed the deprecated `firstBuyDate` from the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol` +- Refreshed the cryptocurrencies list +- Upgraded `prettier` from version `3.7.4` to `3.8.0` + +## 2.232.0 - 2026-01-19 + +### Added + +- Extended the analysis page to include the total amount, change and performance with currency effects (experimental) + +### Changed + +- Deprecated `firstBuyDate` in favor of `dateOfFirstActivity` in the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol` +- Improved the language localization for German (`de`) +- Upgraded `countries-list` from version `3.2.0` to `3.2.2` + +## 2.231.0 - 2026-01-17 + +### Changed + +- Removed the deprecated platforms from the info service +- Removed the deprecated activities from the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol` + +### Fixed + +- Fixed a numeric parsing error related to cash positions on the _X-ray_ page +- Fixed the total fee calculation in the holding detail dialog related to activities in a custom currency +- Fixed the total fee calculation in the summary related to activities in a custom currency + +## 2.230.0 - 2026-01-14 + +### Added + +- Set up the language localization for Korean (`ko`) + +### Changed + +- Restored the support for specific calendar year date ranges (`2024`, `2023`, `2022`, etc.) in the holdings table (experimental) + +### Fixed + +- Fixed the total fee calculation in the holding detail dialog related to activities in a custom currency +- Fixed the total fee calculation in the summary related to activities in a custom currency + +## 2.229.0 - 2026-01-11 + +### Changed + +- Set the active sort column in the accounts table component +- Deprecated `activities` in the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol` +- Moved the admin service to `@ghostfolio/ui/services` +- Moved the data service to `@ghostfolio/ui/services` +- Refactored the dividend import +- Refreshed the cryptocurrencies list + +### Fixed + +- Fixed the net worth calculation to prevent the double counting of cash positions +- Fixed the filtering by asset class in the endpoint `GET api/v1/portfolio/holdings` +- Fixed the case-insensitive sorting in the accounts table component +- Fixed the case-insensitive sorting in the benchmark component +- Fixed the case-insensitive sorting in the holdings table component + +## 2.228.0 - 2026-01-03 + +### Added + +- Extended the portfolio holdings to include performance with currency effects for cash positions + +### Changed + +- Integrated the endpoint to get all platforms (`GET api/v1/platforms`) into the create or update account dialog +- Extracted the scraper configuration to a dedicated tab in the asset profile details dialog of the admin control panel +- Improved the language localization for German (`de`) +- Upgraded `@date-fns/utc` from version `2.1.0` to `2.1.1` + +### Fixed + +- Improved the table headers’ alignment of the accounts table on mobile + +## 2.227.0 - 2026-01-02 + +### Changed + +- Initialized the input properties in the _FIRE_ calculator +- Removed the deprecated public _Stripe_ key +- Upgraded `stripe` from version `18.5.0` to `20.1.0` + +### Fixed + +- Fixed the import of `jsonpath` to support REST APIs (`JSON`) via the scraper configuration + +## 2.226.0 - 2026-01-01 + +### Added + +- Extended the content of the _Self-Hosting_ section by information about additional data providers on the Frequently Asked Questions (FAQ) page + +### Changed + +- Upgraded `class-validator` from version `0.14.2` to `0.14.3` +- Upgraded `yahoo-finance2` from version `3.10.2` to `3.11.2` + +## 2.225.0 - 2025-12-31 + +### Added + +- Added a new endpoint to get all platforms (`GET api/v1/platforms`) +- Added the session url to the endpoint response of the _Stripe_ checkout + +### Changed + +- Improved the routing of the user detail dialog in the users section of the admin control panel +- Lifted the asset profile identifier editing restriction for `MANUAL` data sources in the asset profile details dialog of the admin control panel +- Deprecated the public _Stripe_ key +- Improved the language localization for German (`de`) +- Eliminated `ngx-stripe` +- Upgraded `angular` from version `20.2.4` to `21.0.6` +- Upgraded `marked` from version `15.0.4` to `17.0.1` +- Upgraded `ngx-device-detector` from version `10.1.0` to `11.0.0` +- Upgraded `ng-extract-i18n-merge` from `3.1.0` to `3.2.1` +- Upgraded `ngx-markdown` from version `20.0.0` to `21.0.1` +- Upgraded `Nx` from version `21.5.1` to `22.3.3` +- Upgraded `shx` from version `0.3.4` to `0.4.0` +- Upgraded `storybook` from version `9.1.5` to `10.1.10` +- Upgraded `zone.js` from version `0.15.1` to `0.16.0` + +### Fixed + +- Added the missing currency suffix to the cash balance field in the create or update account dialog +- Fixed the time in market display of the portfolio summary tab on the home page for the impersonation mode +- Fixed the delete button in the asset profile details dialog of the admin control panel by providing the missing `watchedByCount` parameter + +## 2.224.2 - 2025-12-20 + ### Added - Included the calendar year boundaries in the portfolio calculations +- Added the ISIN number to the asset profile details dialog of the admin control panel ### Changed +- Restored the support for specific calendar year date ranges (`2024`, `2023`, `2022`, etc.) in the assistant (experimental) - Removed the deprecated _Angular CLI_ decorator (`decorate-angular-cli.js`) - Refreshed the cryptocurrencies list +### Fixed + +- Localized date formatting across the _FIRE_ section + ## 2.223.0 - 2025-12-14 ### Added diff --git a/README.md b/README.md index 822825b57..44212b607 100644 --- a/README.md +++ b/README.md @@ -85,31 +85,32 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c ### Supported Environment Variables -| Name | Type | Default Value | Description | -| ------------------------ | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens | -| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key | -| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key | -| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` | -| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on | -| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) | -| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` | -| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on | -| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database | -| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database | -| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database | -| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ | -| `REDIS_HOST` | `string` | | The host where _Redis_ is running | -| `REDIS_PASSWORD` | `string` | | The password of _Redis_ | -| `REDIS_PORT` | `number` | | The port where _Redis_ is running | -| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | -| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. | +| Name | Type | Default Value | Description | +| --------------------------- | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens | +| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key | +| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key | +| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` | +| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token | +| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on | +| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) | +| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` | +| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on | +| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database | +| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database | +| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database | +| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ | +| `REDIS_HOST` | `string` | | The host where _Redis_ is running | +| `REDIS_PASSWORD` | `string` | | The password of _Redis_ | +| `REDIS_PORT` | `number` | | The port where _Redis_ is running | +| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | +| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. | #### OpenID Connect OIDC (Experimental) | Name | Type | Default Value | Description | | -------------------------- | --------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------- | -| `ENABLE_FEATURE_AUTH_OIDC` | `boolean` (optional) | `false` | Enables _OpenID Connect_ authentication | +| `ENABLE_FEATURE_AUTH_OIDC` | `boolean` (optional) | `false` | Enables authentication via _OpenID Connect_ | | `OIDC_AUTHORIZATION_URL` | `string` (optional) | | Manual override for the OIDC authorization endpoint (falls back to the discovery from the issuer) | | `OIDC_CALLBACK_URL` | `string` (optional) | `${ROOT_URL}/api/auth/oidc/callback` | The OIDC callback URL | | `OIDC_CLIENT_ID` | `string` | | The OIDC client ID | @@ -241,7 +242,7 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/ +
-

- Browser testing via
- - LambdaTest Logo - -

+ + TestMu AI Logo +
## Analytics @@ -331,6 +331,8 @@ If you like to support this project, become a [**Sponsor**](https://github.com/s ## License -© 2021 - 2025 [Ghostfolio](https://ghostfol.io) +© 2021 - 2026 [Ghostfolio](https://ghostfol.io) Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html). + +[^1]: Available with [**Ghostfolio Premium**](https://ghostfol.io/en/pricing). diff --git a/apps/api/src/app/account/account.controller.ts b/apps/api/src/app/account/account.controller.ts index 542b199fd..052720176 100644 --- a/apps/api/src/app/account/account.controller.ts +++ b/apps/api/src/app/account/account.controller.ts @@ -132,12 +132,16 @@ export class AccountController { @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseInterceptors(RedactValuesInResponseInterceptor) public async getAccountBalancesById( + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Param('id') id: string ): Promise { + const impersonationUserId = + await this.impersonationService.validateImpersonationId(impersonationId); + return this.accountBalanceService.getAccountBalances({ filters: [{ id, type: 'ACCOUNT' }], userCurrency: this.request.user.settings.settings.baseCurrency, - userId: this.request.user.id + userId: impersonationUserId || this.request.user.id }); } diff --git a/apps/api/src/app/account/account.service.ts b/apps/api/src/app/account/account.service.ts index 398a89bb9..e1b01a6ed 100644 --- a/apps/api/src/app/account/account.service.ts +++ b/apps/api/src/app/account/account.service.ts @@ -150,15 +150,15 @@ export class AccountService { }); return accounts.map((account) => { - let transactionCount = 0; + let activitiesCount = 0; for (const { isDraft } of account.activities) { if (!isDraft) { - transactionCount += 1; + activitiesCount += 1; } } - const result = { ...account, transactionCount }; + const result = { ...account, activitiesCount }; delete result.activities; diff --git a/apps/api/src/app/order/order.controller.ts b/apps/api/src/app/activities/activities.controller.ts similarity index 78% rename from apps/api/src/app/order/order.controller.ts rename to apps/api/src/app/activities/activities.controller.ts index 73c295f1b..141fd4c82 100644 --- a/apps/api/src/app/order/order.controller.ts +++ b/apps/api/src/app/activities/activities.controller.ts @@ -4,6 +4,7 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; @@ -36,27 +37,32 @@ import { } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; -import { Order as OrderModel, Prisma } from '@prisma/client'; +import { Order, Prisma } from '@prisma/client'; import { parseISO } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; -import { OrderService } from './order.service'; +import { ActivitiesService } from './activities.service'; -@Controller('order') -export class OrderController { +@Controller([ + 'activities', + /** @deprecated */ + 'order' +]) +export class ActivitiesController { public constructor( + private readonly activitiesService: ActivitiesService, private readonly apiService: ApiService, + private readonly dataProviderService: DataProviderService, private readonly dataGatheringService: DataGatheringService, private readonly impersonationService: ImpersonationService, - private readonly orderService: OrderService, @Inject(REQUEST) private readonly request: RequestWithUser ) {} @Delete() - @HasPermission(permissions.deleteOrder) + @HasPermission(permissions.deleteActivity) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseInterceptors(TransformDataSourceInRequestInterceptor) - public async deleteOrders( + public async deleteActivities( @Query('accounts') filterByAccounts?: string, @Query('assetClasses') filterByAssetClasses?: string, @Query('dataSource') filterByDataSource?: string, @@ -71,29 +77,29 @@ export class OrderController { filterByTags }); - return this.orderService.deleteOrders({ + return this.activitiesService.deleteActivities({ filters, userId: this.request.user.id }); } @Delete(':id') - @HasPermission(permissions.deleteOrder) + @HasPermission(permissions.deleteActivity) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) - public async deleteOrder(@Param('id') id: string): Promise { - const order = await this.orderService.order({ + public async deleteActivity(@Param('id') id: string): Promise { + const activity = await this.activitiesService.order({ id, userId: this.request.user.id }); - if (!order) { + if (!activity) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), StatusCodes.FORBIDDEN ); } - return this.orderService.deleteOrder({ + return this.activitiesService.deleteActivity({ id }); } @@ -103,7 +109,7 @@ export class OrderController { @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) - public async getAllOrders( + public async getAllActivities( @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Query('accounts') filterByAccounts?: string, @Query('assetClasses') filterByAssetClasses?: string, @@ -120,7 +126,7 @@ export class OrderController { let startDate: Date; if (dateRange) { - ({ endDate, startDate } = getIntervalFromDateRange(dateRange)); + ({ endDate, startDate } = getIntervalFromDateRange({ dateRange })); } const filters = this.apiService.buildFiltersFromQueryParams({ @@ -135,7 +141,7 @@ export class OrderController { await this.impersonationService.validateImpersonationId(impersonationId); const userCurrency = this.request.user.settings.settings.baseCurrency; - const { activities, count } = await this.orderService.getOrders({ + const { activities, count } = await this.activitiesService.getActivities({ endDate, filters, sortColumn, @@ -156,7 +162,7 @@ export class OrderController { @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) - public async getOrderById( + public async getActivityById( @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Param('id') id: string ): Promise { @@ -164,7 +170,7 @@ export class OrderController { await this.impersonationService.validateImpersonationId(impersonationId); const userCurrency = this.request.user.settings.settings.baseCurrency; - const { activities } = await this.orderService.getOrders({ + const { activities } = await this.activitiesService.getActivities({ userCurrency, includeDrafts: true, userId: impersonationUserId || this.request.user.id, @@ -185,11 +191,34 @@ export class OrderController { return activity; } - @HasPermission(permissions.createOrder) + @HasPermission(permissions.createActivity) @Post() @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseInterceptors(TransformDataSourceInRequestInterceptor) - public async createOrder(@Body() data: CreateOrderDto): Promise { + public async createActivity(@Body() data: CreateOrderDto): Promise { + try { + await this.dataProviderService.validateActivities({ + activitiesDto: [ + { + currency: data.currency, + dataSource: data.dataSource, + symbol: data.symbol, + type: data.type + } + ], + maxActivitiesToImport: 1, + user: this.request.user + }); + } catch (error) { + throw new HttpException( + { + error: getReasonPhrase(StatusCodes.BAD_REQUEST), + message: [error.message] + }, + StatusCodes.BAD_REQUEST + ); + } + const currency = data.currency; const customCurrency = data.customCurrency; const dataSource = data.dataSource; @@ -202,7 +231,7 @@ export class OrderController { delete data.dataSource; - const order = await this.orderService.createOrder({ + const activity = await this.activitiesService.createActivity({ ...data, date: parseISO(data.date), SymbolProfile: { @@ -227,14 +256,14 @@ export class OrderController { userId: this.request.user.id }); - if (dataSource && !order.isDraft) { + if (dataSource && !activity.isDraft) { // Gather symbol data in the background, if data source is set // (not MANUAL) and not draft this.dataGatheringService.gatherSymbols({ dataGatheringItems: [ { dataSource, - date: order.date, + date: activity.date, symbol: data.symbol } ], @@ -242,19 +271,22 @@ export class OrderController { }); } - return order; + return activity; } - @HasPermission(permissions.updateOrder) + @HasPermission(permissions.updateActivity) @Put(':id') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseInterceptors(TransformDataSourceInRequestInterceptor) - public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) { - const originalOrder = await this.orderService.order({ + public async updateActivity( + @Param('id') id: string, + @Body() data: UpdateOrderDto + ) { + const originalActivity = await this.activitiesService.order({ id }); - if (!originalOrder || originalOrder.userId !== this.request.user.id) { + if (!originalActivity || originalActivity.userId !== this.request.user.id) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), StatusCodes.FORBIDDEN @@ -277,7 +309,7 @@ export class OrderController { delete data.dataSource; - return this.orderService.updateOrder({ + return this.activitiesService.updateActivity({ data: { ...data, date, diff --git a/apps/api/src/app/order/order.module.ts b/apps/api/src/app/activities/activities.module.ts similarity index 86% rename from apps/api/src/app/order/order.module.ts rename to apps/api/src/app/activities/activities.module.ts index 9bc837aa6..7476ad66a 100644 --- a/apps/api/src/app/order/order.module.ts +++ b/apps/api/src/app/activities/activities.module.ts @@ -15,12 +15,12 @@ import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/sym import { Module } from '@nestjs/common'; -import { OrderController } from './order.controller'; -import { OrderService } from './order.service'; +import { ActivitiesController } from './activities.controller'; +import { ActivitiesService } from './activities.service'; @Module({ - controllers: [OrderController], - exports: [OrderService], + controllers: [ActivitiesController], + exports: [ActivitiesService], imports: [ ApiModule, CacheModule, @@ -35,6 +35,6 @@ import { OrderService } from './order.service'; TransformDataSourceInRequestModule, TransformDataSourceInResponseModule ], - providers: [AccountBalanceService, AccountService, OrderService] + providers: [AccountBalanceService, AccountService, ActivitiesService] }) -export class OrderModule {} +export class ActivitiesModule {} diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/activities/activities.service.ts similarity index 74% rename from apps/api/src/app/order/order.service.ts rename to apps/api/src/app/activities/activities.service.ts index 001d43b7a..89b9468f8 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -1,7 +1,10 @@ +import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; +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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; @@ -16,6 +19,7 @@ import { import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { ActivitiesResponse, + Activity, AssetProfileIdentifier, EnhancedSymbolProfile, Filter @@ -40,10 +44,12 @@ import { groupBy, uniqBy } from 'lodash'; import { randomUUID } from 'node:crypto'; @Injectable() -export class OrderService { +export class ActivitiesService { public constructor( + private readonly accountBalanceService: AccountBalanceService, private readonly accountService: AccountService, private readonly dataGatheringService: DataGatheringService, + private readonly dataProviderService: DataProviderService, private readonly eventEmitter: EventEmitter2, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly prismaService: PrismaService, @@ -56,7 +62,7 @@ export class OrderService { tags, userId }: { tags: Tag[]; userId: string } & AssetProfileIdentifier) { - const orders = await this.prismaService.order.findMany({ + const activities = await this.prismaService.order.findMany({ where: { userId, SymbolProfile: { @@ -67,7 +73,7 @@ export class OrderService { }); await Promise.all( - orders.map(({ id }) => + activities.map(({ id }) => this.prismaService.order.update({ data: { tags: { @@ -90,7 +96,7 @@ export class OrderService { ); } - public async createOrder( + public async createActivity( data: Prisma.OrderCreateInput & { accountId?: string; assetClass?: AssetClass; @@ -195,7 +201,7 @@ export class OrderService { ? false : isAfter(data.date as Date, endOfToday()); - const order = await this.prismaService.order.create({ + const activity = await this.prismaService.order.create({ data: { ...orderData, account, @@ -229,56 +235,56 @@ export class OrderService { this.eventEmitter.emit( AssetProfileChangedEvent.getName(), new AssetProfileChangedEvent({ - currency: order.SymbolProfile.currency, - dataSource: order.SymbolProfile.dataSource, - symbol: order.SymbolProfile.symbol + currency: activity.SymbolProfile.currency, + dataSource: activity.SymbolProfile.dataSource, + symbol: activity.SymbolProfile.symbol }) ); this.eventEmitter.emit( PortfolioChangedEvent.getName(), new PortfolioChangedEvent({ - userId: order.userId + userId: activity.userId }) ); - return order; + return activity; } - public async deleteOrder( + public async deleteActivity( where: Prisma.OrderWhereUniqueInput ): Promise { - const order = await this.prismaService.order.delete({ + const activity = await this.prismaService.order.delete({ where }); const [symbolProfile] = await this.symbolProfileService.getSymbolProfilesByIds([ - order.symbolProfileId + activity.symbolProfileId ]); if (symbolProfile.activitiesCount === 0) { - await this.symbolProfileService.deleteById(order.symbolProfileId); + await this.symbolProfileService.deleteById(activity.symbolProfileId); } this.eventEmitter.emit( PortfolioChangedEvent.getName(), new PortfolioChangedEvent({ - userId: order.userId + userId: activity.userId }) ); - return order; + return activity; } - public async deleteOrders({ + public async deleteActivities({ filters, userId }: { filters?: Filter[]; userId: string; }): Promise { - const { activities } = await this.getOrders({ + const { activities } = await this.getActivities({ filters, userId, includeDrafts: true, @@ -317,7 +323,135 @@ export class OrderService { return count; } - public async getLatestOrder({ dataSource, symbol }: AssetProfileIdentifier) { + /** + * Generates synthetic activities for cash holdings based on account balance history. + * Treat currencies as assets with a fixed unit price of 1.0 (in their own currency) to allow + * performance tracking based on exchange rate fluctuations. + * + * @param cashDetails - The cash balance details. + * @param filters - Optional filters to apply. + * @param userCurrency - The base currency of the user. + * @param userId - The ID of the user. + * @returns A response containing the list of synthetic cash activities. + */ + public async getCashActivities({ + cashDetails, + filters = [], + userCurrency, + userId + }: { + cashDetails: CashDetails; + filters?: Filter[]; + userCurrency: string; + userId: string; + }): Promise { + const filtersByAssetClass = filters.filter(({ type }) => { + return type === 'ASSET_CLASS'; + }); + + if ( + filtersByAssetClass.length > 0 && + !filtersByAssetClass.find(({ id }) => { + return id === AssetClass.LIQUIDITY; + }) + ) { + // If asset class filters are present and none of them is liquidity, return an empty response + return { + activities: [], + count: 0 + }; + } + + const activities: Activity[] = []; + + for (const account of cashDetails.accounts) { + const { balances } = await this.accountBalanceService.getAccountBalances({ + userCurrency, + userId, + filters: [{ id: account.id, type: 'ACCOUNT' }] + }); + + let currentBalance = 0; + let currentBalanceInBaseCurrency = 0; + + for (const balanceItem of balances) { + const syntheticActivityTemplate: Activity = { + userId, + accountId: account.id, + accountUserId: account.userId, + comment: account.name, + createdAt: new Date(balanceItem.date), + currency: account.currency, + date: new Date(balanceItem.date), + fee: 0, + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + id: balanceItem.id, + isDraft: false, + quantity: 1, + SymbolProfile: { + activitiesCount: 0, + assetClass: AssetClass.LIQUIDITY, + assetSubClass: AssetSubClass.CASH, + countries: [], + createdAt: new Date(balanceItem.date), + currency: account.currency, + dataSource: + this.dataProviderService.getDataSourceForExchangeRates(), + holdings: [], + id: account.currency, + isActive: true, + name: account.currency, + sectors: [], + symbol: account.currency, + updatedAt: new Date(balanceItem.date) + }, + symbolProfileId: account.currency, + type: ActivityType.BUY, + unitPrice: 1, + unitPriceInAssetProfileCurrency: 1, + updatedAt: new Date(balanceItem.date), + valueInBaseCurrency: 0, + value: 0 + }; + + if (currentBalance < balanceItem.value) { + // BUY + activities.push({ + ...syntheticActivityTemplate, + quantity: balanceItem.value - currentBalance, + type: ActivityType.BUY, + value: balanceItem.value - currentBalance, + valueInBaseCurrency: + balanceItem.valueInBaseCurrency - currentBalanceInBaseCurrency + }); + } else if (currentBalance > balanceItem.value) { + // SELL + activities.push({ + ...syntheticActivityTemplate, + quantity: currentBalance - balanceItem.value, + type: ActivityType.SELL, + value: currentBalance - balanceItem.value, + valueInBaseCurrency: + currentBalanceInBaseCurrency - balanceItem.valueInBaseCurrency + }); + } + + currentBalance = balanceItem.value; + currentBalanceInBaseCurrency = balanceItem.valueInBaseCurrency; + } + } + + return { + activities, + count: activities.length + }; + } + + public async getLatestActivity({ + dataSource, + symbol + }: AssetProfileIdentifier) { return this.prismaService.order.findFirst({ orderBy: { date: 'desc' @@ -328,7 +462,7 @@ export class OrderService { }); } - public async getOrders({ + public async getActivities({ endDate, filters, includeDrafts = false, @@ -610,22 +744,52 @@ export class OrderService { return { activities, count }; } + /** + * Retrieves all activities required for the portfolio calculator, including both standard asset activities + * and optional synthetic activities representing cash activities. + */ @LogPerformance - public async getOrdersForPortfolioCalculator({ + public async getActivitiesForPortfolioCalculator({ filters, userCurrency, - userId + userId, + withCash = false }: { + /** Optional filters to apply to the activities. */ filters?: Filter[]; + /** The base currency of the user. */ userCurrency: string; + /** The ID of the user. */ userId: string; + /** Whether to include cash activities in the result. */ + withCash?: boolean; }) { - return this.getOrders({ + const activities = await this.getActivities({ filters, userCurrency, userId, withExcludedAccountsAndActivities: false // TODO }); + + if (withCash) { + const cashDetails = await this.accountService.getCashDetails({ + filters, + userId, + currency: userCurrency + }); + + const cashActivities = await this.getCashActivities({ + cashDetails, + filters, + userCurrency, + userId + }); + + activities.activities.push(...cashActivities.activities); + activities.count += cashActivities.count; + } + + return activities; } public async getStatisticsByCurrency( @@ -656,7 +820,7 @@ export class OrderService { }); } - public async updateOrder({ + public async updateActivity({ data, where }: { @@ -721,7 +885,7 @@ export class OrderService { data: { tags: { set: [] } } }); - const order = await this.prismaService.order.update({ + const activity = await this.prismaService.order.update({ where, data: { ...data, @@ -735,11 +899,11 @@ export class OrderService { this.eventEmitter.emit( PortfolioChangedEvent.getName(), new PortfolioChangedEvent({ - userId: order.userId + userId: activity.userId }) ); - return order; + return activity; } private async orders(params: { diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 24467c732..69b619625 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -172,7 +172,7 @@ export class AdminController { let date: Date; if (dateRange) { - const { startDate } = getIntervalFromDateRange(dateRange); + const { startDate } = getIntervalFromDateRange({ dateRange }); date = startDate; } @@ -247,14 +247,17 @@ export class AdminController { @Param('symbol') symbol: string ): Promise<{ price: number }> { try { - const price = await this.manualService.test(data.scraperConfiguration); + const price = await this.manualService.test({ + symbol, + scraperConfiguration: data.scraperConfiguration + }); if (price) { return { price }; } throw new Error( - `Could not parse the current market price for ${symbol} (${dataSource})` + `Could not parse the market price for ${symbol} (${dataSource})` ); } catch (error) { Logger.error(error, 'AdminController'); diff --git a/apps/api/src/app/admin/admin.module.ts b/apps/api/src/app/admin/admin.module.ts index 598b68f17..960a36629 100644 --- a/apps/api/src/app/admin/admin.module.ts +++ b/apps/api/src/app/admin/admin.module.ts @@ -1,4 +1,4 @@ -import { OrderModule } from '@ghostfolio/api/app/order/order.module'; +import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; @@ -20,6 +20,7 @@ import { QueueModule } from './queue/queue.module'; @Module({ imports: [ + ActivitiesModule, ApiModule, BenchmarkModule, ConfigurationModule, @@ -28,7 +29,6 @@ import { QueueModule } from './queue/queue.module'; DemoModule, ExchangeRateDataModule, MarketDataModule, - OrderModule, PrismaModule, PropertyModule, QueueModule, diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index 705085a48..d2bf6e411 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -1,4 +1,4 @@ -import { OrderService } from '@ghostfolio/api/app/order/order.service'; +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { environment } from '@ghostfolio/api/environments/environment'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; @@ -55,12 +55,12 @@ import { groupBy } from 'lodash'; @Injectable() export class AdminService { public constructor( + private readonly activitiesService: ActivitiesService, private readonly benchmarkService: BenchmarkService, private readonly configurationService: ConfigurationService, private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly marketDataService: MarketDataService, - private readonly orderService: OrderService, private readonly prismaService: PrismaService, private readonly propertyService: PropertyService, private readonly symbolProfileService: SymbolProfileService @@ -138,11 +138,11 @@ export class AdminService { public async get(): Promise { const dataSources = Object.values(DataSource); - const [enabledDataSources, settings, transactionCount, userCount] = + const [activitiesCount, enabledDataSources, settings, userCount] = await Promise.all([ + this.prismaService.order.count(), this.dataProviderService.getDataSources(), this.propertyService.get(), - this.prismaService.order.count(), this.countUsersWithAnalytics() ]); @@ -182,9 +182,9 @@ export class AdminService { ).filter(Boolean); return { + activitiesCount, dataProviders, settings, - transactionCount, userCount, version: environment.version }; @@ -225,6 +225,10 @@ export class AdminService { presetId === 'ETF_WITHOUT_SECTORS' ) { filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }]; + } else if (presetId === 'NO_ACTIVITIES') { + where.activities = { + none: {} + }; } const searchQuery = filters.find(({ type }) => { @@ -466,10 +470,12 @@ export class AdminService { let currency: EnhancedSymbolProfile['currency'] = '-'; let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity']; - if (isCurrency(getCurrencyFromSymbol(symbol))) { + const isCurrencyAssetProfile = isCurrency(getCurrencyFromSymbol(symbol)); + + if (isCurrencyAssetProfile) { currency = getCurrencyFromSymbol(symbol); ({ activitiesCount, dateOfFirstActivity } = - await this.orderService.getStatisticsByCurrency(currency)); + await this.activitiesService.getStatisticsByCurrency(currency)); } const [[assetProfile], marketData] = await Promise.all([ @@ -504,6 +510,8 @@ export class AdminService { dataSource, dateOfFirstActivity, symbol, + assetClass: isCurrencyAssetProfile ? AssetClass.LIQUIDITY : undefined, + assetSubClass: isCurrencyAssetProfile ? AssetSubClass.CASH : undefined, isActive: true } }; @@ -790,7 +798,7 @@ export class AdminService { if (isCurrency(getCurrencyFromSymbol(symbol))) { currency = getCurrencyFromSymbol(symbol); ({ activitiesCount, dateOfFirstActivity } = - await this.orderService.getStatisticsByCurrency(currency)); + await this.activitiesService.getStatisticsByCurrency(currency)); } const lastMarketPrice = lastMarketPriceMap.get( diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 5ec148558..8ebe05928 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -1,4 +1,5 @@ import { EventsModule } from '@ghostfolio/api/events/events.module'; +import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.middleware'; import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { CronModule } from '@ghostfolio/api/services/cron/cron.module'; @@ -10,10 +11,13 @@ import { PropertyModule } from '@ghostfolio/api/services/property/property.modul import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { + BULL_BOARD_ROUTE, DEFAULT_LANGUAGE_CODE, SUPPORTED_LANGUAGE_CODES } from '@ghostfolio/common/config'; +import { ExpressAdapter } from '@bull-board/express'; +import { BullBoardModule } from '@bull-board/nestjs'; import { BullModule } from '@nestjs/bull'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; @@ -25,6 +29,7 @@ import { join } from 'node:path'; import { AccessModule } from './access/access.module'; import { AccountModule } from './account/account.module'; +import { ActivitiesModule } from './activities/activities.module'; import { AdminModule } from './admin/admin.module'; import { AppController } from './app.controller'; import { AssetModule } from './asset/asset.module'; @@ -37,6 +42,7 @@ import { AssetsModule } from './endpoints/assets/assets.module'; import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module'; import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module'; import { MarketDataModule } from './endpoints/market-data/market-data.module'; +import { PlatformsModule } from './endpoints/platforms/platforms.module'; import { PublicModule } from './endpoints/public/public.module'; import { SitemapModule } from './endpoints/sitemap/sitemap.module'; import { TagsModule } from './endpoints/tags/tags.module'; @@ -47,7 +53,6 @@ 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'; @@ -61,6 +66,7 @@ import { UserModule } from './user/user.module'; AdminModule, AccessModule, AccountModule, + ActivitiesModule, AiModule, ApiKeysModule, AssetModule, @@ -68,6 +74,29 @@ import { UserModule } from './user/user.module'; AuthDeviceModule, AuthModule, BenchmarksModule, + ...(process.env.ENABLE_FEATURE_BULL_BOARD === 'true' + ? [ + BullBoardModule.forRoot({ + adapter: ExpressAdapter, + boardOptions: { + uiConfig: { + boardLogo: { + height: 0, + path: '', + width: 0 + }, + boardTitle: 'Job Queues', + favIcon: { + alternative: '/assets/favicon-32x32.png', + default: '/assets/favicon-32x32.png' + } + } + }, + middleware: BullBoardAuthMiddleware, + route: BULL_BOARD_ROUTE + }) + ] + : []), BullModule.forRoot({ redis: { db: parseInt(process.env.REDIS_DB ?? '0', 10), @@ -93,8 +122,8 @@ import { UserModule } from './user/user.module'; InfoModule, LogoModule, MarketDataModule, - OrderModule, PlatformModule, + PlatformsModule, PortfolioModule, PortfolioSnapshotQueueModule, PrismaModule, @@ -103,7 +132,12 @@ import { UserModule } from './user/user.module'; RedisCacheModule, ScheduleModule.forRoot(), ServeStaticModule.forRoot({ - exclude: ['/.well-known/*wildcard', '/api/*wildcard', '/sitemap.xml'], + exclude: [ + `${BULL_BOARD_ROUTE}/*wildcard`, + '/.well-known/*wildcard', + '/api/*wildcard', + '/sitemap.xml' + ], rootPath: join(__dirname, '..', 'client'), serveStaticOptions: { setHeaders: (res) => { diff --git a/apps/api/src/app/endpoints/ai/ai.module.ts b/apps/api/src/app/endpoints/ai/ai.module.ts index 8a441fde7..eab4ecf8b 100644 --- a/apps/api/src/app/endpoints/ai/ai.module.ts +++ b/apps/api/src/app/endpoints/ai/ai.module.ts @@ -1,6 +1,6 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service'; -import { OrderModule } from '@ghostfolio/api/app/order/order.module'; +import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; @@ -29,6 +29,7 @@ import { AiService } from './ai.service'; @Module({ controllers: [AiController], imports: [ + ActivitiesModule, ApiModule, BenchmarkModule, ConfigurationModule, @@ -37,7 +38,6 @@ import { AiService } from './ai.service'; I18nModule, ImpersonationModule, MarketDataModule, - OrderModule, PortfolioSnapshotQueueModule, PrismaModule, PropertyModule, diff --git a/apps/api/src/app/endpoints/assets/assets.controller.ts b/apps/api/src/app/endpoints/assets/assets.controller.ts index a314b3f19..397686d8c 100644 --- a/apps/api/src/app/endpoints/assets/assets.controller.ts +++ b/apps/api/src/app/endpoints/assets/assets.controller.ts @@ -4,6 +4,7 @@ import { interpolate } from '@ghostfolio/common/helper'; import { Controller, Get, + OnModuleInit, Param, Res, Version, @@ -14,12 +15,14 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; @Controller('assets') -export class AssetsController { +export class AssetsController implements OnModuleInit { private webManifest = ''; public constructor( public readonly configurationService: ConfigurationService - ) { + ) {} + + public onModuleInit() { try { this.webManifest = readFileSync( join(__dirname, 'assets', 'site.webmanifest'), diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts index 629d90928..970925777 100644 --- a/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts @@ -126,10 +126,10 @@ export class BenchmarksController { @Query('tags') filterByTags?: string, @Query('withExcludedAccounts') withExcludedAccountsParam = 'false' ): Promise { - const { endDate, startDate } = getIntervalFromDateRange( + const { endDate, startDate } = getIntervalFromDateRange({ dateRange, - new Date(startDateString) - ); + startDate: new Date(startDateString) + }); const filters = this.apiService.buildFiltersFromQueryParams({ filterByAccounts, diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts index 8bdf79035..2bcd6177d 100644 --- a/apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts @@ -1,6 +1,6 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service'; -import { OrderModule } from '@ghostfolio/api/app/order/order.module'; +import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; @@ -32,6 +32,7 @@ import { BenchmarksService } from './benchmarks.service'; @Module({ controllers: [BenchmarksController], imports: [ + ActivitiesModule, ApiModule, ConfigurationModule, DataProviderModule, @@ -39,7 +40,6 @@ import { BenchmarksService } from './benchmarks.service'; I18nModule, ImpersonationModule, MarketDataModule, - OrderModule, PortfolioSnapshotQueueModule, PrismaModule, PropertyModule, diff --git a/apps/api/src/app/endpoints/market-data/market-data.controller.ts b/apps/api/src/app/endpoints/market-data/market-data.controller.ts index 987d34918..0dae82d2c 100644 --- a/apps/api/src/app/endpoints/market-data/market-data.controller.ts +++ b/apps/api/src/app/endpoints/market-data/market-data.controller.ts @@ -2,6 +2,8 @@ import { AdminService } from '@ghostfolio/api/app/admin/admin.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; +import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { @@ -28,7 +30,8 @@ import { Param, Post, Query, - UseGuards + UseGuards, + UseInterceptors } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; @@ -86,6 +89,8 @@ export class MarketDataController { @Get(':dataSource/:symbol') @UseGuards(AuthGuard('jwt')) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getMarketDataBySymbol( @Param('dataSource') dataSource: DataSource, @Param('symbol') symbol: string diff --git a/apps/api/src/app/endpoints/market-data/market-data.module.ts b/apps/api/src/app/endpoints/market-data/market-data.module.ts index a8b355de3..d5d64673d 100644 --- a/apps/api/src/app/endpoints/market-data/market-data.module.ts +++ b/apps/api/src/app/endpoints/market-data/market-data.module.ts @@ -1,5 +1,7 @@ import { AdminModule } from '@ghostfolio/api/app/admin/admin.module'; import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; +import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; +import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { MarketDataModule as MarketDataServiceModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; @@ -13,7 +15,9 @@ import { MarketDataController } from './market-data.controller'; AdminModule, MarketDataServiceModule, SymbolModule, - SymbolProfileModule + SymbolProfileModule, + TransformDataSourceInRequestModule, + TransformDataSourceInResponseModule ] }) export class MarketDataModule {} diff --git a/apps/api/src/app/endpoints/platforms/platforms.controller.ts b/apps/api/src/app/endpoints/platforms/platforms.controller.ts new file mode 100644 index 000000000..92ba77297 --- /dev/null +++ b/apps/api/src/app/endpoints/platforms/platforms.controller.ts @@ -0,0 +1,24 @@ +import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { PlatformsResponse } from '@ghostfolio/common/interfaces'; +import { permissions } from '@ghostfolio/common/permissions'; + +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Controller('platforms') +export class PlatformsController { + public constructor(private readonly platformService: PlatformService) {} + + @Get() + @HasPermission(permissions.readPlatforms) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getPlatforms(): Promise { + const platforms = await this.platformService.getPlatforms({ + orderBy: { name: 'asc' } + }); + + return { platforms }; + } +} diff --git a/apps/api/src/app/endpoints/platforms/platforms.module.ts b/apps/api/src/app/endpoints/platforms/platforms.module.ts new file mode 100644 index 000000000..21d0edf69 --- /dev/null +++ b/apps/api/src/app/endpoints/platforms/platforms.module.ts @@ -0,0 +1,11 @@ +import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module'; + +import { Module } from '@nestjs/common'; + +import { PlatformsController } from './platforms.controller'; + +@Module({ + controllers: [PlatformsController], + imports: [PlatformModule] +}) +export class PlatformsModule {} diff --git a/apps/api/src/app/endpoints/public/public.controller.ts b/apps/api/src/app/endpoints/public/public.controller.ts index b4ecd37ba..b97640cab 100644 --- a/apps/api/src/app/endpoints/public/public.controller.ts +++ b/apps/api/src/app/endpoints/public/public.controller.ts @@ -1,5 +1,5 @@ import { AccessService } from '@ghostfolio/api/app/access/access.service'; -import { OrderService } from '@ghostfolio/api/app/order/order.service'; +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; @@ -28,9 +28,9 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes'; export class PublicController { public constructor( private readonly accessService: AccessService, + private readonly activitiesService: ActivitiesService, private readonly configurationService: ConfigurationService, private readonly exchangeRateDataService: ExchangeRateDataService, - private readonly orderService: OrderService, private readonly portfolioService: PortfolioService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly userService: UserService @@ -81,7 +81,7 @@ export class PublicController { }) ]); - const { activities } = await this.orderService.getOrders({ + const { activities } = await this.activitiesService.getActivities({ sortColumn: 'date', sortDirection: 'desc', take: 10, @@ -167,6 +167,7 @@ export class PublicController { allocationInPercentage: portfolioPosition.valueInBaseCurrency / totalValue, assetClass: hasDetails ? portfolioPosition.assetClass : undefined, + assetProfile: hasDetails ? portfolioPosition.assetProfile : undefined, countries: hasDetails ? portfolioPosition.countries : [], currency: hasDetails ? portfolioPosition.currency : undefined, dataSource: portfolioPosition.dataSource, diff --git a/apps/api/src/app/endpoints/public/public.module.ts b/apps/api/src/app/endpoints/public/public.module.ts index 19e281dde..e8395228f 100644 --- a/apps/api/src/app/endpoints/public/public.module.ts +++ b/apps/api/src/app/endpoints/public/public.module.ts @@ -1,7 +1,7 @@ import { AccessModule } from '@ghostfolio/api/app/access/access.module'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service'; -import { OrderModule } from '@ghostfolio/api/app/order/order.module'; +import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; @@ -27,13 +27,13 @@ import { PublicController } from './public.controller'; controllers: [PublicController], imports: [ AccessModule, + ActivitiesModule, BenchmarkModule, DataProviderModule, ExchangeRateDataModule, I18nModule, ImpersonationModule, MarketDataModule, - OrderModule, PortfolioSnapshotQueueModule, PrismaModule, RedisCacheModule, diff --git a/apps/api/src/app/export/export.module.ts b/apps/api/src/app/export/export.module.ts index 4f40cc417..6158fe043 100644 --- a/apps/api/src/app/export/export.module.ts +++ b/apps/api/src/app/export/export.module.ts @@ -1,5 +1,5 @@ import { AccountModule } from '@ghostfolio/api/app/account/account.module'; -import { OrderModule } from '@ghostfolio/api/app/order/order.module'; +import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; @@ -14,9 +14,9 @@ import { ExportService } from './export.service'; controllers: [ExportController], imports: [ AccountModule, + ActivitiesModule, ApiModule, MarketDataModule, - OrderModule, TagModule, TransformDataSourceInRequestModule ], diff --git a/apps/api/src/app/export/export.service.ts b/apps/api/src/app/export/export.service.ts index d07b199be..4f2fb3309 100644 --- a/apps/api/src/app/export/export.service.ts +++ b/apps/api/src/app/export/export.service.ts @@ -1,5 +1,5 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service'; -import { OrderService } from '@ghostfolio/api/app/order/order.service'; +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { environment } from '@ghostfolio/api/environments/environment'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service'; @@ -17,8 +17,8 @@ import { groupBy, uniqBy } from 'lodash'; export class ExportService { public constructor( private readonly accountService: AccountService, + private readonly activitiesService: ActivitiesService, private readonly marketDataService: MarketDataService, - private readonly orderService: OrderService, private readonly tagService: TagService ) {} @@ -38,7 +38,7 @@ export class ExportService { }); const platformsMap: { [platformId: string]: Platform } = {}; - let { activities } = await this.orderService.getOrders({ + let { activities } = await this.activitiesService.getActivities({ filters, userId, includeDrafts: true, @@ -182,10 +182,8 @@ export class ExportService { isActive, isin, name, - scraperConfiguration, sectors, symbol, - symbolMapping, url }) => { return { @@ -204,11 +202,8 @@ export class ExportService { isin, marketData: marketDataByAssetProfile[id], name, - scraperConfiguration: - scraperConfiguration as unknown as Prisma.JsonArray, sectors: sectors as unknown as Prisma.JsonArray, symbol, - symbolMapping, url }; } diff --git a/apps/api/src/app/import/import.controller.ts b/apps/api/src/app/import/import.controller.ts index 2681444df..d5724bef2 100644 --- a/apps/api/src/app/import/import.controller.ts +++ b/apps/api/src/app/import/import.controller.ts @@ -38,7 +38,7 @@ export class ImportController { @Post() @UseGuards(AuthGuard('jwt'), HasPermissionGuard) - @HasPermission(permissions.createOrder) + @HasPermission(permissions.createActivity) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async import( @@ -103,6 +103,7 @@ export class ImportController { const activities = await this.importService.getDividends({ dataSource, symbol, + userCurrency: this.request.user.settings.settings.baseCurrency, userId: this.request.user.id }); diff --git a/apps/api/src/app/import/import.module.ts b/apps/api/src/app/import/import.module.ts index 88990af2e..ca9b5667b 100644 --- a/apps/api/src/app/import/import.module.ts +++ b/apps/api/src/app/import/import.module.ts @@ -1,11 +1,12 @@ import { AccountModule } from '@ghostfolio/api/app/account/account.module'; +import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.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 { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; +import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; @@ -24,13 +25,14 @@ import { ImportService } from './import.service'; controllers: [ImportController], imports: [ AccountModule, + ActivitiesModule, + ApiModule, CacheModule, ConfigurationModule, DataGatheringModule, DataProviderModule, ExchangeRateDataModule, MarketDataModule, - OrderModule, PlatformModule, PortfolioModule, PrismaModule, diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 2deef1c44..b82f763a0 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -1,9 +1,10 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service'; -import { OrderService } from '@ghostfolio/api/app/order/order.service'; +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ApiService } from '@ghostfolio/api/services/api/api.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 { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; @@ -25,13 +26,13 @@ import { } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { - AccountWithPlatform, + AccountWithValue, OrderWithAccount, UserWithSettings } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; -import { DataSource, Prisma, SymbolProfile } from '@prisma/client'; +import { DataSource, Prisma } from '@prisma/client'; import { Big } from 'big.js'; import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns'; import { omit, uniqBy } from 'lodash'; @@ -43,11 +44,12 @@ import { ImportDataDto } from './import-data.dto'; export class ImportService { public constructor( private readonly accountService: AccountService, - private readonly configurationService: ConfigurationService, + private readonly activitiesService: ActivitiesService, + private readonly apiService: ApiService, private readonly dataGatheringService: DataGatheringService, private readonly dataProviderService: DataProviderService, + private readonly exchangeRateDataService: ExchangeRateDataService, private readonly marketDataService: MarketDataService, - private readonly orderService: OrderService, private readonly platformService: PlatformService, private readonly portfolioService: PortfolioService, private readonly symbolProfileService: SymbolProfileService, @@ -57,8 +59,12 @@ export class ImportService { public async getDividends({ dataSource, symbol, + userCurrency, userId - }: AssetProfileIdentifier & { userId: string }): Promise { + }: AssetProfileIdentifier & { + userCurrency: string; + userId: string; + }): Promise { try { const holding = await this.portfolioService.getHolding({ dataSource, @@ -71,36 +77,45 @@ export class ImportService { return []; } - const { activities, firstBuyDate, historicalData } = holding; + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByDataSource: dataSource, + filterBySymbol: 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 { dateOfFirstActivity, historicalData } = holding; - const accounts = activities - .filter(({ account }) => { - return !!account; - }) - .map(({ account }) => { - return account; - }); + const [{ accounts }, { activities }, [assetProfile], dividends] = + await Promise.all([ + this.portfolioService.getAccountsWithAggregations({ + filters, + userId, + withExcludedAccounts: true + }), + this.activitiesService.getActivities({ + filters, + userCurrency, + userId, + startDate: parseDate(dateOfFirstActivity) + }), + this.symbolProfileService.getSymbolProfiles([ + { + dataSource, + symbol + } + ]), + await this.dataProviderService.getDividends({ + dataSource, + symbol, + from: parseDate(dateOfFirstActivity), + granularity: 'day', + to: new Date() + }) + ]); const account = this.isUniqueAccount(accounts) ? accounts[0] : undefined; return await Promise.all( - Object.entries(dividends).map(async ([dateString, { marketPrice }]) => { + Object.entries(dividends).map(([dateString, { marketPrice }]) => { const quantity = historicalData.find((historicalDataItem) => { return historicalDataItem.date === dateString; @@ -378,7 +393,7 @@ export class ImportService { } } - const assetProfiles = await this.validateActivities({ + const assetProfiles = await this.dataProviderService.validateActivities({ activitiesDto, assetProfilesWithMarketDataDto, maxActivitiesToImport, @@ -533,7 +548,7 @@ export class ImportService { continue; } - order = await this.orderService.createOrder({ + order = await this.activitiesService.createActivity({ comment, currency, date, @@ -575,10 +590,18 @@ export class ImportService { const value = new Big(quantity).mul(unitPrice).toNumber(); + const valueInBaseCurrency = this.exchangeRateDataService.toCurrencyAtDate( + value, + currency ?? assetProfile.currency, + userCurrency, + date + ); + activities.push({ ...order, error, value, + valueInBaseCurrency: await valueInBaseCurrency, // @ts-ignore SymbolProfile: assetProfile }); @@ -622,7 +645,7 @@ export class ImportService { userId: string; }): Promise[]> { const { activities: existingActivities } = - await this.orderService.getOrders({ + await this.activitiesService.getActivities({ userCurrency, userId, includeDrafts: true, @@ -695,141 +718,13 @@ export class ImportService { ); } - private isUniqueAccount(accounts: AccountWithPlatform[]) { + private isUniqueAccount(accounts: AccountWithValue[]) { const uniqueAccountIds = new Set(); - for (const account of accounts) { - uniqueAccountIds.add(account.id); + for (const { id } of accounts) { + uniqueAccountIds.add(id); } return uniqueAccountIds.size === 1; } - - private async validateActivities({ - activitiesDto, - assetProfilesWithMarketDataDto, - maxActivitiesToImport, - user - }: { - activitiesDto: Partial[]; - assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles']; - maxActivitiesToImport: number; - user: UserWithSettings; - }) { - if (activitiesDto?.length > maxActivitiesToImport) { - throw new Error(`Too many activities (${maxActivitiesToImport} at most)`); - } - - const assetProfiles: { - [assetProfileIdentifier: string]: Partial; - } = {}; - const dataSources = await this.dataProviderService.getDataSources(); - - for (const [ - index, - { currency, dataSource, symbol, type } - ] of activitiesDto.entries()) { - if (!dataSources.includes(dataSource)) { - throw new Error( - `activities.${index}.dataSource ("${dataSource}") is not valid` - ); - } - - if ( - this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - user.subscription.type === 'Basic' - ) { - const dataProvider = this.dataProviderService.getDataProvider( - DataSource[dataSource] - ); - - if (dataProvider.getDataProviderInfo().isPremium) { - throw new Error( - `activities.${index}.dataSource ("${dataSource}") is not valid` - ); - } - } - - if (!assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]) { - if (['FEE', 'INTEREST', 'LIABILITY'].includes(type)) { - // Skip asset profile validation for FEE, INTEREST, and LIABILITY - // as these activity types don't require asset profiles - const assetProfileInImport = assetProfilesWithMarketDataDto?.find( - (profile) => { - return ( - profile.dataSource === dataSource && profile.symbol === symbol - ); - } - ); - - assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] = { - currency, - dataSource, - symbol, - name: assetProfileInImport?.name - }; - - continue; - } - - let assetProfile: Partial = { currency }; - - try { - assetProfile = ( - await this.dataProviderService.getAssetProfiles([ - { dataSource, symbol } - ]) - )?.[symbol]; - } catch {} - - if (!assetProfile?.name) { - const assetProfileInImport = assetProfilesWithMarketDataDto?.find( - (profile) => { - return ( - profile.dataSource === dataSource && profile.symbol === symbol - ); - } - ); - - if (assetProfileInImport) { - // Merge all fields of custom asset profiles into the validation object - Object.assign(assetProfile, { - assetClass: assetProfileInImport.assetClass, - assetSubClass: assetProfileInImport.assetSubClass, - comment: assetProfileInImport.comment, - countries: assetProfileInImport.countries, - currency: assetProfileInImport.currency, - cusip: assetProfileInImport.cusip, - dataSource: assetProfileInImport.dataSource, - figi: assetProfileInImport.figi, - figiComposite: assetProfileInImport.figiComposite, - figiShareClass: assetProfileInImport.figiShareClass, - holdings: assetProfileInImport.holdings, - isActive: assetProfileInImport.isActive, - isin: assetProfileInImport.isin, - name: assetProfileInImport.name, - scraperConfiguration: assetProfileInImport.scraperConfiguration, - sectors: assetProfileInImport.sectors, - symbol: assetProfileInImport.symbol, - symbolMapping: assetProfileInImport.symbolMapping, - url: assetProfileInImport.url - }); - } - } - - if (!['FEE', 'INTEREST', 'LIABILITY'].includes(type)) { - if (!assetProfile?.name) { - throw new Error( - `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` - ); - } - } - - assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] = - assetProfile; - } - } - - return assetProfiles; - } } diff --git a/apps/api/src/app/info/info.service.ts b/apps/api/src/app/info/info.service.ts index 3802e3ef4..9b4a4d597 100644 --- a/apps/api/src/app/info/info.service.ts +++ b/apps/api/src/app/info/info.service.ts @@ -1,4 +1,3 @@ -import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; @@ -38,7 +37,6 @@ export class InfoService { private readonly configurationService: ConfigurationService, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly jwtService: JwtService, - private readonly platformService: PlatformService, private readonly propertyService: PropertyService, private readonly redisCacheService: RedisCacheService, private readonly subscriptionService: SubscriptionService, @@ -93,7 +91,6 @@ export class InfoService { (await this.propertyService.getByKey( PROPERTY_COUNTRIES_OF_SUBSCRIBERS )) ?? []; - info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY'); } if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) { @@ -104,16 +101,12 @@ export class InfoService { benchmarks, demoAuthToken, isUserSignupEnabled, - platforms, statistics, subscriptionOffer ] = await Promise.all([ this.benchmarkService.getBenchmarkAssetProfiles(), this.getDemoAuthToken(), this.propertyService.isUserSignupEnabled(), - this.platformService.getPlatforms({ - orderBy: { name: 'asc' } - }), this.getStatistics(), this.subscriptionService.getSubscriptionOffer({ key: 'default' }) ]); @@ -128,7 +121,6 @@ export class InfoService { demoAuthToken, globalPermissions, isReadOnlyMode, - platforms, statistics, subscriptionOffer, baseCurrency: DEFAULT_CURRENCY, diff --git a/apps/api/src/app/platform/platform.controller.ts b/apps/api/src/app/platform/platform.controller.ts index 2d4a1d413..ebf03e3a9 100644 --- a/apps/api/src/app/platform/platform.controller.ts +++ b/apps/api/src/app/platform/platform.controller.ts @@ -25,7 +25,7 @@ export class PlatformController { public constructor(private readonly platformService: PlatformService) {} @Get() - @HasPermission(permissions.readPlatforms) + @HasPermission(permissions.readPlatformsWithAccountCount) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async getPlatforms() { return this.platformService.getPlatformsWithAccountCount(); diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index ee4219b58..d57b85d8c 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -39,6 +39,7 @@ import { GroupBy } from '@ghostfolio/common/types'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Logger } from '@nestjs/common'; +import { AssetSubClass } from '@prisma/client'; import { Big } from 'big.js'; import { plainToClass } from 'class-transformer'; import { @@ -52,6 +53,7 @@ import { isBefore, isWithinInterval, min, + startOfDay, startOfYear, subDays } from 'date-fns'; @@ -119,6 +121,7 @@ export abstract class PortfolioCalculator { ({ date, feeInAssetProfileCurrency, + feeInBaseCurrency, quantity, SymbolProfile, tags = [], @@ -141,6 +144,7 @@ export abstract class PortfolioCalculator { type, date: format(date, DATE_FORMAT), fee: new Big(feeInAssetProfileCurrency), + feeInBaseCurrency: new Big(feeInBaseCurrency), quantity: new Big(quantity), unitPrice: new Big(unitPriceInAssetProfileCurrency) }; @@ -154,13 +158,13 @@ export abstract class PortfolioCalculator { this.redisCacheService = redisCacheService; this.userId = userId; - const { endDate, startDate } = getIntervalFromDateRange( - 'max', - subDays(dateOfFirstActivity, 1) - ); + const { endDate, startDate } = getIntervalFromDateRange({ + dateRange: 'max', + startDate: subDays(dateOfFirstActivity, 1) + }); - this.endDate = endDate; - this.startDate = startDate; + this.endDate = endOfDay(endDate); + this.startDate = startOfDay(startDate); this.computeTransactionPoints(); @@ -203,13 +207,19 @@ export abstract class PortfolioCalculator { let totalInterestWithCurrencyEffect = new Big(0); let totalLiabilitiesWithCurrencyEffect = new Big(0); - for (const { currency, dataSource, symbol } of transactionPoints[ - firstIndex - 1 - ].items) { - dataGatheringItems.push({ - dataSource, - symbol - }); + for (const { + assetSubClass, + currency, + dataSource, + symbol + } of transactionPoints[firstIndex - 1].items) { + // Gather data for all assets except CASH + if (assetSubClass !== 'CASH') { + dataGatheringItems.push({ + dataSource, + symbol + }); + } currencies[symbol] = currency; } @@ -227,7 +237,7 @@ export abstract class PortfolioCalculator { const exchangeRatesByCurrency = await this.exchangeRateDataService.getExchangeRatesByCurrency({ currencies: Array.from(new Set(Object.values(currencies))), - endDate: endOfDay(this.endDate), + endDate: this.endDate, startDate: this.startDate, targetCurrency: this.currency }); @@ -329,12 +339,6 @@ export abstract class PortfolioCalculator { } = {}; for (const item of lastTransactionPoint.items) { - const feeInBaseCurrency = item.fee.mul( - exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[ - lastTransactionPoint.date - ] ?? 1 - ); - const marketPriceInBaseCurrency = ( marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice ).mul( @@ -383,29 +387,36 @@ export abstract class PortfolioCalculator { hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors; - valuesBySymbol[item.symbol] = { - currentValues, - currentValuesWithCurrencyEffect, - investmentValuesAccumulated, - investmentValuesAccumulatedWithCurrencyEffect, - investmentValuesWithCurrencyEffect, - netPerformanceValues, - netPerformanceValuesWithCurrencyEffect, - timeWeightedInvestmentValues, - timeWeightedInvestmentValuesWithCurrencyEffect - }; + const includeInTotalAssetValue = + item.assetSubClass !== AssetSubClass.CASH; + + if (includeInTotalAssetValue) { + valuesBySymbol[item.symbol] = { + currentValues, + currentValuesWithCurrencyEffect, + investmentValuesAccumulated, + investmentValuesAccumulatedWithCurrencyEffect, + investmentValuesWithCurrencyEffect, + netPerformanceValues, + netPerformanceValuesWithCurrencyEffect, + timeWeightedInvestmentValues, + timeWeightedInvestmentValuesWithCurrencyEffect + }; + } positions.push({ - feeInBaseCurrency, + includeInTotalAssetValue, timeWeightedInvestment, timeWeightedInvestmentWithCurrencyEffect, - dividend: totalDividend, - dividendInBaseCurrency: totalDividendInBaseCurrency, + activitiesCount: item.activitiesCount, averagePrice: item.averagePrice, currency: item.currency, dataSource: item.dataSource, + dateOfFirstActivity: item.dateOfFirstActivity, + dividend: totalDividend, + dividendInBaseCurrency: totalDividendInBaseCurrency, fee: item.fee, - firstBuyDate: item.firstBuyDate, + feeInBaseCurrency: item.feeInBaseCurrency, grossPerformance: !hasErrors ? (grossPerformance ?? null) : null, grossPerformancePercentage: !hasErrors ? (grossPerformancePercentage ?? null) @@ -420,9 +431,8 @@ export abstract class PortfolioCalculator { investment: totalInvestment, investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect, marketPrice: - marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? null, - marketPriceInBaseCurrency: - marketPriceInBaseCurrency?.toNumber() ?? null, + marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? 1, + marketPriceInBaseCurrency: marketPriceInBaseCurrency?.toNumber() ?? 1, netPerformance: !hasErrors ? (netPerformance ?? null) : null, netPerformancePercentage: !hasErrors ? (netPerformancePercentage ?? null) @@ -436,7 +446,6 @@ export abstract class PortfolioCalculator { quantity: item.quantity, symbol: item.symbol, tags: item.tags, - transactionCount: item.transactionCount, valueInBaseCurrency: new Big(marketPriceInBaseCurrency).mul( item.quantity ) @@ -876,7 +885,7 @@ export abstract class PortfolioCalculator { // Make sure some key dates are present for (const dateRange of ['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd']) { const { endDate: dateRangeEnd, startDate: dateRangeStart } = - getIntervalFromDateRange(dateRange); + getIntervalFromDateRange({ dateRange }); if ( !isBefore(dateRangeStart, startDate) && @@ -925,6 +934,7 @@ export abstract class PortfolioCalculator { for (const { date, fee, + feeInBaseCurrency, quantity, SymbolProfile, tags, @@ -933,6 +943,7 @@ export abstract class PortfolioCalculator { } of this.activities) { let currentTransactionPointItem: TransactionPointSymbol; + const assetSubClass = SymbolProfile.assetSubClass; const currency = SymbolProfile.currency; const dataSource = SymbolProfile.dataSource; const factor = getFactor(type); @@ -977,37 +988,42 @@ export abstract class PortfolioCalculator { } currentTransactionPointItem = { + assetSubClass, currency, dataSource, investment, skipErrors, symbol, + activitiesCount: oldAccumulatedSymbol.activitiesCount + 1, averagePrice: newQuantity.eq(0) ? new Big(0) : investment.div(newQuantity).abs(), + dateOfFirstActivity: oldAccumulatedSymbol.dateOfFirstActivity, dividend: new Big(0), fee: oldAccumulatedSymbol.fee.plus(fee), - firstBuyDate: oldAccumulatedSymbol.firstBuyDate, + feeInBaseCurrency: + oldAccumulatedSymbol.feeInBaseCurrency.plus(feeInBaseCurrency), includeInHoldings: oldAccumulatedSymbol.includeInHoldings, quantity: newQuantity, - tags: oldAccumulatedSymbol.tags.concat(tags), - transactionCount: oldAccumulatedSymbol.transactionCount + 1 + tags: oldAccumulatedSymbol.tags.concat(tags) }; } else { currentTransactionPointItem = { + assetSubClass, currency, dataSource, fee, + feeInBaseCurrency, skipErrors, symbol, tags, + activitiesCount: 1, averagePrice: unitPrice, + dateOfFirstActivity: date, dividend: new Big(0), - firstBuyDate: date, includeInHoldings: INVESTMENT_ACTIVITY_TYPES.includes(type), investment: unitPrice.mul(quantity).mul(factor), - quantity: quantity.mul(factor), - transactionCount: 1 + quantity: quantity.mul(factor) }; } diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts index f0e2f6488..9a93d0419 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts @@ -87,6 +87,7 @@ describe('PortfolioCalculator', () => { ...activityDummyData, date: new Date('2021-11-22'), feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, quantity: 2, SymbolProfile: { ...symbolProfileDummyData, @@ -102,6 +103,7 @@ describe('PortfolioCalculator', () => { ...activityDummyData, date: new Date('2021-11-30'), feeInAssetProfileCurrency: 1.65, + feeInBaseCurrency: 1.65, quantity: 2, SymbolProfile: { ...symbolProfileDummyData, @@ -131,20 +133,26 @@ describe('PortfolioCalculator', () => { groupBy: 'month' }); + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + expect(portfolioSnapshot).toMatchObject({ currentValueInBaseCurrency: new Big('595.6'), errors: [], hasErrors: false, positions: [ { + activitiesCount: 2, averagePrice: new Big('139.75'), currency: 'CHF', dataSource: 'YAHOO', + dateOfFirstActivity: '2021-11-22', dividend: new Big('0'), dividendInBaseCurrency: new Big('0'), fee: new Big('3.2'), feeInBaseCurrency: new Big('3.2'), - firstBuyDate: '2021-11-22', grossPerformance: new Big('36.6'), grossPerformancePercentage: new Big('0.07706261539956593567'), grossPerformancePercentageWithCurrencyEffect: new Big( @@ -170,7 +178,6 @@ describe('PortfolioCalculator', () => { timeWeightedInvestmentWithCurrencyEffect: new Big( '474.93846153846153846154' ), - transactionCount: 2, valueInBaseCurrency: new Big('595.6') } ], @@ -187,6 +194,7 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentage: 0.07032490039195362, netPerformanceInPercentageWithCurrencyEffect: 0.07032490039195362, netPerformanceWithCurrencyEffect: 33.4, + totalInvestment: 559, totalInvestmentValueWithCurrencyEffect: 559 }) ); @@ -200,6 +208,10 @@ describe('PortfolioCalculator', () => { { date: '2021-11-01', investment: 559 }, { date: '2021-12-01', investment: 0 } ]); + + expect(investmentsByYear).toEqual([ + { date: '2021-01-01', investment: 559 } + ]); }); }); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts index 10b1fabd3..c876d0db1 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts @@ -87,6 +87,7 @@ describe('PortfolioCalculator', () => { ...activityDummyData, date: new Date('2021-11-22'), feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, quantity: 2, SymbolProfile: { ...symbolProfileDummyData, @@ -102,6 +103,7 @@ describe('PortfolioCalculator', () => { ...activityDummyData, date: new Date('2021-11-30'), feeInAssetProfileCurrency: 1.65, + feeInBaseCurrency: 1.65, quantity: 1, SymbolProfile: { ...symbolProfileDummyData, @@ -117,6 +119,7 @@ describe('PortfolioCalculator', () => { ...activityDummyData, date: new Date('2021-11-30'), feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, quantity: 1, SymbolProfile: { ...symbolProfileDummyData, @@ -146,20 +149,26 @@ describe('PortfolioCalculator', () => { groupBy: 'month' }); + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + expect(portfolioSnapshot).toMatchObject({ currentValueInBaseCurrency: new Big('0'), errors: [], hasErrors: false, positions: [ { + activitiesCount: 3, averagePrice: new Big('0'), currency: 'CHF', dataSource: 'YAHOO', + dateOfFirstActivity: '2021-11-22', dividend: new Big('0'), dividendInBaseCurrency: new Big('0'), fee: new Big('3.2'), feeInBaseCurrency: new Big('3.2'), - firstBuyDate: '2021-11-22', grossPerformance: new Big('-12.6'), grossPerformancePercentage: new Big('-0.04408677396780965649'), grossPerformancePercentageWithCurrencyEffect: new Big( @@ -183,7 +192,6 @@ describe('PortfolioCalculator', () => { timeWeightedInvestmentWithCurrencyEffect: new Big( '285.80000000000000396627' ), - transactionCount: 3, valueInBaseCurrency: new Big('0') } ], @@ -200,6 +208,7 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentage: -0.05528341497550734703, netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703, netPerformanceWithCurrencyEffect: -15.8, + totalInvestment: 0, totalInvestmentValueWithCurrencyEffect: 0 }) ); @@ -213,6 +222,10 @@ describe('PortfolioCalculator', () => { { date: '2021-11-01', investment: 0 }, { date: '2021-12-01', investment: 0 } ]); + + expect(investmentsByYear).toEqual([ + { date: '2021-01-01', investment: 0 } + ]); }); }); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts index 32cd9f7d4..ae921d6d9 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts @@ -87,6 +87,7 @@ describe('PortfolioCalculator', () => { ...activityDummyData, date: new Date('2021-11-22'), feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, quantity: 2, SymbolProfile: { ...symbolProfileDummyData, @@ -102,6 +103,7 @@ describe('PortfolioCalculator', () => { ...activityDummyData, date: new Date('2021-11-30'), feeInAssetProfileCurrency: 1.65, + feeInBaseCurrency: 1.65, quantity: 2, SymbolProfile: { ...symbolProfileDummyData, @@ -131,20 +133,26 @@ describe('PortfolioCalculator', () => { groupBy: 'month' }); + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + expect(portfolioSnapshot).toMatchObject({ currentValueInBaseCurrency: new Big('0'), errors: [], hasErrors: false, positions: [ { + activitiesCount: 2, averagePrice: new Big('0'), currency: 'CHF', dataSource: 'YAHOO', + dateOfFirstActivity: '2021-11-22', dividend: new Big('0'), dividendInBaseCurrency: new Big('0'), fee: new Big('3.2'), feeInBaseCurrency: new Big('3.2'), - firstBuyDate: '2021-11-22', grossPerformance: new Big('-12.6'), grossPerformancePercentage: new Big('-0.0440867739678096571'), grossPerformancePercentageWithCurrencyEffect: new Big( @@ -168,7 +176,6 @@ describe('PortfolioCalculator', () => { tags: [], timeWeightedInvestment: new Big('285.8'), timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'), - transactionCount: 2, valueInBaseCurrency: new Big('0') } ], @@ -185,6 +192,7 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentage: -0.05528341497550734703, netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703, netPerformanceWithCurrencyEffect: -15.8, + totalInvestment: 0, totalInvestmentValueWithCurrencyEffect: 0 }) ); @@ -198,6 +206,10 @@ describe('PortfolioCalculator', () => { { date: '2021-11-01', investment: 0 }, { date: '2021-12-01', investment: 0 } ]); + + expect(investmentsByYear).toEqual([ + { date: '2021-01-01', investment: 0 } + ]); }); }); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts index bfa4d06f3..6207f1417 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts @@ -87,6 +87,7 @@ describe('PortfolioCalculator', () => { ...activityDummyData, date: new Date('2021-11-30'), feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, quantity: 2, SymbolProfile: { ...symbolProfileDummyData, @@ -122,20 +123,26 @@ describe('PortfolioCalculator', () => { groupBy: 'month' }); + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + expect(portfolioSnapshot).toMatchObject({ currentValueInBaseCurrency: new Big('297.8'), errors: [], hasErrors: false, positions: [ { + activitiesCount: 1, averagePrice: new Big('136.6'), currency: 'CHF', dataSource: 'YAHOO', + dateOfFirstActivity: '2021-11-30', dividend: new Big('0'), dividendInBaseCurrency: new Big('0'), fee: new Big('1.55'), feeInBaseCurrency: new Big('1.55'), - firstBuyDate: '2021-11-30', grossPerformance: new Big('24.6'), grossPerformancePercentage: new Big('0.09004392386530014641'), grossPerformancePercentageWithCurrencyEffect: new Big( @@ -165,7 +172,6 @@ describe('PortfolioCalculator', () => { tags: [], timeWeightedInvestment: new Big('273.2'), timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'), - transactionCount: 1, valueInBaseCurrency: new Big('297.8') } ], @@ -186,6 +192,7 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentage: 0.08437042459736457, netPerformanceInPercentageWithCurrencyEffect: 0.08437042459736457, netPerformanceWithCurrencyEffect: 23.05, + totalInvestment: 273.2, totalInvestmentValueWithCurrencyEffect: 273.2 }) ); @@ -198,6 +205,10 @@ describe('PortfolioCalculator', () => { { date: '2021-11-01', investment: 273.2 }, { date: '2021-12-01', investment: 0 } ]); + + expect(investmentsByYear).toEqual([ + { date: '2021-01-01', investment: 273.2 } + ]); }); it.only('with BALN.SW buy (with unit price lower than closing price)', async () => { @@ -208,6 +219,7 @@ describe('PortfolioCalculator', () => { ...activityDummyData, date: new Date('2021-11-30'), feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, quantity: 2, SymbolProfile: { ...symbolProfileDummyData, @@ -247,6 +259,7 @@ describe('PortfolioCalculator', () => { ...activityDummyData, date: new Date('2021-11-30'), feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, quantity: 2, SymbolProfile: { ...symbolProfileDummyData, diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts index 84ea6c251..774c1d2f6 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts @@ -110,6 +110,7 @@ describe('PortfolioCalculator', () => { ...activity, date: parseDate(activity.date), feeInAssetProfileCurrency: 4.46, + feeInBaseCurrency: 3.94, SymbolProfile: { ...symbolProfileDummyData, currency: 'USD', diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts index 32b3f05c2..055356325 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts @@ -98,6 +98,7 @@ describe('PortfolioCalculator', () => { ...activity, date: parseDate(activity.date), feeInAssetProfileCurrency: 4.46, + feeInBaseCurrency: 4.46, SymbolProfile: { ...symbolProfileDummyData, currency: 'USD', @@ -131,6 +132,11 @@ describe('PortfolioCalculator', () => { groupBy: 'month' }); + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + expect(portfolioSnapshot.historicalData[0]).toEqual({ date: '2021-12-11', investmentValueWithCurrencyEffect: 0, @@ -189,14 +195,15 @@ describe('PortfolioCalculator', () => { hasErrors: false, positions: [ { + activitiesCount: 1, averagePrice: new Big('44558.42'), currency: 'USD', dataSource: 'YAHOO', + dateOfFirstActivity: '2021-12-12', dividend: new Big('0'), dividendInBaseCurrency: new Big('0'), fee: new Big('4.46'), feeInBaseCurrency: new Big('4.46'), - firstBuyDate: '2021-12-12', grossPerformance: new Big('-1458.72'), grossPerformancePercentage: new Big('-0.03273724696701543726'), grossPerformancePercentageWithCurrencyEffect: new Big( @@ -220,7 +227,6 @@ describe('PortfolioCalculator', () => { tags: [], timeWeightedInvestment: new Big('44558.42'), timeWeightedInvestmentWithCurrencyEffect: new Big('44558.42'), - transactionCount: 1, valueInBaseCurrency: new Big('43099.7') } ], @@ -244,6 +250,11 @@ describe('PortfolioCalculator', () => { { date: '2021-12-01', investment: 44558.42 }, { date: '2022-01-01', investment: 0 } ]); + + expect(investmentsByYear).toEqual([ + { date: '2021-01-01', investment: 44558.42 }, + { date: '2022-01-01', investment: 0 } + ]); }); }); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts index 0c111fab2..11765fc49 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts @@ -100,6 +100,7 @@ describe('PortfolioCalculator', () => { ...activityDummyData, date: new Date('2015-01-01'), feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, quantity: 2, SymbolProfile: { ...symbolProfileDummyData, @@ -115,6 +116,7 @@ describe('PortfolioCalculator', () => { ...activityDummyData, date: new Date('2017-12-31'), feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, quantity: 1, SymbolProfile: { ...symbolProfileDummyData, @@ -144,6 +146,11 @@ describe('PortfolioCalculator', () => { groupBy: 'month' }); + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + expect(portfolioSnapshot).toMatchObject({ currentValueInBaseCurrency: new Big('13298.425356'), errors: [], @@ -151,14 +158,15 @@ describe('PortfolioCalculator', () => { hasErrors: false, positions: [ { + activitiesCount: 2, averagePrice: new Big('320.43'), currency: 'USD', dataSource: 'YAHOO', + dateOfFirstActivity: '2015-01-01', dividend: new Big('0'), dividendInBaseCurrency: new Big('0'), fee: new Big('0'), feeInBaseCurrency: new Big('0'), - firstBuyDate: '2015-01-01', grossPerformance: new Big('27172.74').mul(0.97373), grossPerformancePercentage: new Big('0.4241983590271396608571'), grossPerformancePercentageWithCurrencyEffect: new Big( @@ -186,7 +194,6 @@ describe('PortfolioCalculator', () => { timeWeightedInvestmentWithCurrencyEffect: new Big( '636.79389574611155533947' ), - transactionCount: 2, valueInBaseCurrency: new Big('13298.425356') } ], @@ -203,6 +210,7 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentage: 42.41983590271396609433, netPerformanceInPercentageWithCurrencyEffect: 41.64017412624815597854, netPerformanceWithCurrencyEffect: 26516.208701400000064086, + totalInvestment: 318.542667299999967957, totalInvestmentValueWithCurrencyEffect: 318.542667299999967957 }) ); @@ -251,6 +259,13 @@ describe('PortfolioCalculator', () => { { date: '2017-12-01', investment: -318.54266729999995 }, { date: '2018-01-01', investment: 0 } ]); + + expect(investmentsByYear).toEqual([ + { date: '2015-01-01', investment: 637.0853345999999 }, + { date: '2016-01-01', investment: 0 }, + { date: '2017-01-01', investment: -318.54266729999995 }, + { date: '2018-01-01', investment: 0 } + ]); }); }); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts index 618dc805c..6a45f79c6 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts @@ -98,6 +98,7 @@ describe('PortfolioCalculator', () => { ...activity, date: parseDate(activity.date), feeInAssetProfileCurrency: activity.fee, + feeInBaseCurrency: activity.fee, SymbolProfile: { ...symbolProfileDummyData, currency: 'USD', diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts index 716ec7a59..64882061f 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts @@ -98,6 +98,7 @@ describe('PortfolioCalculator', () => { ...activity, date: parseDate(activity.date), feeInAssetProfileCurrency: 4.46, + feeInBaseCurrency: 4.46, SymbolProfile: { ...symbolProfileDummyData, currency: 'USD', @@ -131,6 +132,11 @@ describe('PortfolioCalculator', () => { groupBy: 'month' }); + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + expect(portfolioSnapshot.historicalData[0]).toEqual({ date: '2021-12-11', investmentValueWithCurrencyEffect: 0, @@ -189,14 +195,15 @@ describe('PortfolioCalculator', () => { hasErrors: false, positions: [ { + activitiesCount: 1, averagePrice: new Big('44558.42'), currency: 'USD', dataSource: 'YAHOO', + dateOfFirstActivity: '2021-12-12', dividend: new Big('0'), dividendInBaseCurrency: new Big('0'), fee: new Big('4.46'), feeInBaseCurrency: new Big('4.46'), - firstBuyDate: '2021-12-12', grossPerformance: new Big('-1458.72'), grossPerformancePercentage: new Big('-0.03273724696701543726'), grossPerformancePercentageWithCurrencyEffect: new Big( @@ -220,7 +227,6 @@ describe('PortfolioCalculator', () => { tags: [], timeWeightedInvestment: new Big('44558.42'), timeWeightedInvestmentWithCurrencyEffect: new Big('44558.42'), - transactionCount: 1, valueInBaseCurrency: new Big('43099.7') } ], @@ -244,6 +250,11 @@ describe('PortfolioCalculator', () => { { date: '2021-12-01', investment: 44558.42 }, { date: '2022-01-01', investment: 0 } ]); + + expect(investmentsByYear).toEqual([ + { date: '2021-01-01', investment: 44558.42 }, + { date: '2022-01-01', investment: 0 } + ]); }); }); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts new file mode 100644 index 000000000..217a67c49 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts @@ -0,0 +1,289 @@ +import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; +import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; +import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { TimelinePosition } from '@ghostfolio/common/models'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { DataSource } from '@prisma/client'; +import { Big } from 'big.js'; +import { randomUUID } from 'node:crypto'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', + () => { + return { + ExchangeRateDataService: jest.fn().mockImplementation(() => { + return ExchangeRateDataServiceMock; + }) + }; + } +); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let accountBalanceService: AccountBalanceService; + let accountService: AccountService; + let activitiesService: ActivitiesService; + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let dataProviderService: DataProviderService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + accountBalanceService = new AccountBalanceService( + null, + exchangeRateDataService, + null + ); + + accountService = new AccountService( + accountBalanceService, + null, + exchangeRateDataService, + null + ); + + redisCacheService = new RedisCacheService(null, configurationService); + + dataProviderService = new DataProviderService( + configurationService, + null, + null, + null, + null, + redisCacheService + ); + + currentRateService = new CurrentRateService( + null, + dataProviderService, + null, + null + ); + + activitiesService = new ActivitiesService( + accountBalanceService, + accountService, + null, + dataProviderService, + null, + exchangeRateDataService, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('Cash Performance', () => { + it('should calculate performance for cash assets in CHF default currency', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2025-01-01').getTime()); + + const accountId = randomUUID(); + + jest + .spyOn(accountBalanceService, 'getAccountBalances') + .mockResolvedValue({ + balances: [ + { + accountId, + id: randomUUID(), + date: parseDate('2023-12-31'), + value: 1000, + valueInBaseCurrency: 850 + }, + { + accountId, + id: randomUUID(), + date: parseDate('2024-12-31'), + value: 2000, + valueInBaseCurrency: 1800 + } + ] + }); + + jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({ + accounts: [ + { + balance: 2000, + comment: null, + createdAt: parseDate('2023-12-31'), + currency: 'USD', + id: accountId, + isExcluded: false, + name: 'USD', + platformId: null, + updatedAt: parseDate('2023-12-31'), + userId: userDummyData.id + } + ], + balanceInBaseCurrency: 1820 + }); + + jest + .spyOn(dataProviderService, 'getDataSourceForExchangeRates') + .mockReturnValue(DataSource.YAHOO); + + jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({ + activities: [], + count: 0 + }); + + const { activities } = + await activitiesService.getActivitiesForPortfolioCalculator({ + userCurrency: 'CHF', + userId: userDummyData.id, + withCash: true + }); + + jest.spyOn(currentRateService, 'getValues').mockResolvedValue({ + dataProviderInfos: [], + errors: [], + values: [] + }); + + const accountBalanceItems = + await accountBalanceService.getAccountBalanceItems({ + userCurrency: 'CHF', + userId: userDummyData.id + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + accountBalanceItems, + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const position = portfolioSnapshot.positions.find(({ symbol }) => { + return symbol === 'USD'; + }); + + /** + * Investment: 2000 USD * 0.91 = 1820 CHF + * Investment value with currency effect: (1000 USD * 0.85) + (1000 USD * 0.90) = 1750 CHF + * Net performance: (1000 USD * 1.0) - (1000 USD * 1.0) = 0 CHF + * Total account balance: 2000 USD * 0.85 = 1700 CHF (using the exchange rate on 2024-12-31) + * Value in base currency: 2000 USD * 0.91 = 1820 CHF + */ + expect(position).toMatchObject({ + activitiesCount: 2, + averagePrice: new Big(1), + currency: 'USD', + dataSource: DataSource.YAHOO, + dateOfFirstActivity: '2023-12-31', + dividend: new Big(0), + dividendInBaseCurrency: new Big(0), + fee: new Big(0), + feeInBaseCurrency: new Big(0), + grossPerformance: new Big(0), + grossPerformancePercentage: new Big(0), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.08211603004634809014' + ), + grossPerformanceWithCurrencyEffect: new Big(70), + includeInTotalAssetValue: false, + investment: new Big(1820), + investmentWithCurrencyEffect: new Big(1750), + marketPrice: 1, + marketPriceInBaseCurrency: 0.91, + netPerformance: new Big(0), + netPerformancePercentage: new Big(0), + netPerformancePercentageWithCurrencyEffectMap: { + '1d': new Big('0.01111111111111111111'), + '1y': new Big('0.06937181021989792704'), + '5y': new Big('0.0818817546090273363'), + max: new Big('0.0818817546090273363'), + mtd: new Big('0.01111111111111111111'), + wtd: new Big('-0.05517241379310344828'), + ytd: new Big('0.01111111111111111111') + }, + netPerformanceWithCurrencyEffectMap: { + '1d': new Big(20), + '1y': new Big(60), + '5y': new Big(70), + max: new Big(70), + mtd: new Big(20), + wtd: new Big(-80), + ytd: new Big(20) + }, + quantity: new Big(2000), + symbol: 'USD', + timeWeightedInvestment: new Big('912.47956403269754768392'), + timeWeightedInvestmentWithCurrencyEffect: new Big( + '852.45231607629427792916' + ), + valueInBaseCurrency: new Big(1820) + }); + + expect(portfolioSnapshot).toMatchObject({ + hasErrors: false, + totalFeesWithCurrencyEffect: new Big(0), + totalInterestWithCurrencyEffect: new Big(0), + totalLiabilitiesWithCurrencyEffect: new Big(0) + }); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts index aae77c876..a3fbc0758 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts @@ -87,6 +87,7 @@ describe('PortfolioCalculator', () => { ...activityDummyData, date: new Date('2021-09-01'), feeInAssetProfileCurrency: 49, + feeInBaseCurrency: 49, quantity: 0, SymbolProfile: { ...symbolProfileDummyData, @@ -127,6 +128,7 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentage: 0, netPerformanceInPercentageWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0, + totalInvestment: 0, totalInvestmentValueWithCurrencyEffect: 0 }) ); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts index 495728e22..122a9aaed 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts @@ -99,6 +99,7 @@ describe('PortfolioCalculator', () => { ...activityDummyData, date: new Date('2023-01-03'), feeInAssetProfileCurrency: 1, + feeInBaseCurrency: 0.9238, quantity: 1, SymbolProfile: { ...symbolProfileDummyData, @@ -128,20 +129,26 @@ describe('PortfolioCalculator', () => { groupBy: 'month' }); + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + expect(portfolioSnapshot).toMatchObject({ currentValueInBaseCurrency: new Big('103.10483'), errors: [], hasErrors: false, positions: [ { + activitiesCount: 1, averagePrice: new Big('89.12'), currency: 'USD', dataSource: 'YAHOO', + dateOfFirstActivity: '2023-01-03', dividend: new Big('0'), dividendInBaseCurrency: new Big('0'), fee: new Big('1'), feeInBaseCurrency: new Big('0.9238'), - firstBuyDate: '2023-01-03', grossPerformance: new Big('27.33').mul(0.8854), grossPerformancePercentage: new Big('0.3066651705565529623'), grossPerformancePercentageWithCurrencyEffect: new Big( @@ -165,7 +172,6 @@ describe('PortfolioCalculator', () => { tags: [], timeWeightedInvestment: new Big('89.12').mul(0.8854), timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'), - transactionCount: 1, valueInBaseCurrency: new Big('103.10483') } ], @@ -182,6 +188,7 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentage: 0.29544434470377019749, netPerformanceInPercentageWithCurrencyEffect: 0.24112962014285697628, netPerformanceWithCurrencyEffect: 19.851974, + totalInvestment: new Big('89.12').mul(0.8854).toNumber(), totalInvestmentValueWithCurrencyEffect: 82.329056 }) ); @@ -217,6 +224,10 @@ describe('PortfolioCalculator', () => { investment: 0 } ]); + + expect(investmentsByYear).toEqual([ + { date: '2023-01-01', investment: 82.329056 } + ]); }); }); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts new file mode 100644 index 000000000..d5b22e864 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts @@ -0,0 +1,190 @@ +import { + activityDummyData, + loadExportFile, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; +import { join } from 'node:path'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let exportResponse: ExportResponse; + + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeAll(() => { + exportResponse = loadExportFile( + join( + __dirname, + '../../../../../../../test/import/ok/jnug-buy-and-sell-and-buy-and-sell.json' + ) + ); + }); + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with JNUG buy and sell', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2025-12-28').getTime()); + + const activities: Activity[] = exportResponse.activities.map( + (activity) => ({ + ...activityDummyData, + ...activity, + date: parseDate(activity.date), + feeInAssetProfileCurrency: activity.fee, + feeInBaseCurrency: activity.fee, + SymbolProfile: { + ...symbolProfileDummyData, + currency: activity.currency, + dataSource: activity.dataSource, + name: 'Direxion Daily Junior Gold Miners Index Bull 2X Shares', + symbol: activity.symbol + }, + unitPriceInAssetProfileCurrency: activity.unitPrice + }) + ); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: exportResponse.user.settings.currency, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('0'), + errors: [], + hasErrors: false, + positions: [ + { + activitiesCount: 4, + averagePrice: new Big('0'), + currency: 'USD', + dataSource: 'YAHOO', + dateOfFirstActivity: '2025-12-11', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('4'), + feeInBaseCurrency: new Big('4'), + grossPerformance: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) + grossPerformanceWithCurrencyEffect: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) + investment: new Big('0'), + investmentWithCurrencyEffect: new Big('0'), + netPerformance: new Big('39.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4 + netPerformanceWithCurrencyEffectMap: { + max: new Big('39.95') // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4 + }, + marketPrice: 237.8000030517578, + marketPriceInBaseCurrency: 237.8000030517578, + quantity: new Big('0'), + symbol: 'JNUG', + tags: [], + valueInBaseCurrency: new Big('0') + } + ], + totalFeesWithCurrencyEffect: new Big('4'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('0'), + totalInvestmentWithCurrencyEffect: new Big('0'), + totalLiabilitiesWithCurrencyEffect: new Big('0') + }); + + expect(investments).toEqual([ + { date: '2025-12-11', investment: new Big('1885.05') }, + { date: '2025-12-18', investment: new Big('2041.1') }, + { date: '2025-12-28', investment: new Big('0') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2025-12-01', investment: 0 } + ]); + + expect(investmentsByYear).toEqual([ + { date: '2025-01-01', investment: 0 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts index 1fd88dacc..acbf6a66b 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts @@ -87,6 +87,7 @@ describe('PortfolioCalculator', () => { ...activityDummyData, date: new Date('2023-01-01'), // Date in future feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, quantity: 1, SymbolProfile: { ...symbolProfileDummyData, diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts index 4c8ccdcf5..baa6ae1ed 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts @@ -80,6 +80,7 @@ describe('PortfolioCalculator', () => { ...activityDummyData, date: new Date('2024-03-08'), feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, quantity: 0.3333333333333333, SymbolProfile: { ...symbolProfileDummyData, @@ -94,8 +95,9 @@ describe('PortfolioCalculator', () => { { ...activityDummyData, date: new Date('2024-03-13'), - quantity: 0.6666666666666666, feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 0.6666666666666666, SymbolProfile: { ...symbolProfileDummyData, currency: 'USD', @@ -109,8 +111,9 @@ describe('PortfolioCalculator', () => { { ...activityDummyData, date: new Date('2024-03-14'), - quantity: 1, feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, SymbolProfile: { ...symbolProfileDummyData, currency: 'USD', diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts index 0331e163e..e7eff6682 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts @@ -87,6 +87,7 @@ describe('PortfolioCalculator', () => { ...activityDummyData, date: new Date('2021-09-16'), feeInAssetProfileCurrency: 19, + feeInBaseCurrency: 19, quantity: 1, SymbolProfile: { ...symbolProfileDummyData, @@ -102,6 +103,7 @@ describe('PortfolioCalculator', () => { ...activityDummyData, date: new Date('2021-11-16'), feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, quantity: 1, SymbolProfile: { ...symbolProfileDummyData, @@ -129,13 +131,14 @@ describe('PortfolioCalculator', () => { hasErrors: false, positions: [ { + activitiesCount: 2, averagePrice: new Big('298.58'), currency: 'USD', dataSource: 'YAHOO', + dateOfFirstActivity: '2021-09-16', dividend: new Big('0.62'), dividendInBaseCurrency: new Big('0.62'), fee: new Big('19'), - firstBuyDate: '2021-09-16', grossPerformance: new Big('33.25'), grossPerformancePercentage: new Big('0.11136043941322258691'), grossPerformancePercentageWithCurrencyEffect: new Big( @@ -159,8 +162,7 @@ describe('PortfolioCalculator', () => { }, quantity: new Big('1'), symbol: 'MSFT', - tags: [], - transactionCount: 2 + tags: [] } ], totalFeesWithCurrencyEffect: new Big('19'), @@ -172,6 +174,7 @@ describe('PortfolioCalculator', () => { expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( expect.objectContaining({ + totalInvestment: 298.58, totalInvestmentValueWithCurrencyEffect: 298.58 }) ); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts index fdd9e4718..6c47af7ca 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts @@ -93,6 +93,11 @@ describe('PortfolioCalculator', () => { groupBy: 'month' }); + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + expect(portfolioSnapshot).toMatchObject({ currentValueInBaseCurrency: new Big(0), hasErrors: false, @@ -108,6 +113,8 @@ describe('PortfolioCalculator', () => { expect(investments).toEqual([]); expect(investmentsByMonth).toEqual([]); + + expect(investmentsByYear).toEqual([]); }); }); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts index 650944421..3034e3a1f 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts @@ -101,6 +101,7 @@ describe('PortfolioCalculator', () => { ...activity, date: parseDate(activity.date), feeInAssetProfileCurrency: activity.fee, + feeInBaseCurrency: activity.fee, SymbolProfile: { ...symbolProfileDummyData, currency: activity.currency, @@ -128,20 +129,26 @@ describe('PortfolioCalculator', () => { groupBy: 'month' }); + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + expect(portfolioSnapshot).toMatchObject({ currentValueInBaseCurrency: new Big('87.8'), errors: [], hasErrors: false, positions: [ { + activitiesCount: 2, averagePrice: new Big('75.80'), currency: 'CHF', dataSource: 'YAHOO', + dateOfFirstActivity: '2022-03-07', dividend: new Big('0'), dividendInBaseCurrency: new Big('0'), fee: new Big('4.25'), feeInBaseCurrency: new Big('4.25'), - firstBuyDate: '2022-03-07', grossPerformance: new Big('21.93'), grossPerformancePercentage: new Big('0.15113417083448194384'), grossPerformancePercentageWithCurrencyEffect: new Big( @@ -167,7 +174,6 @@ describe('PortfolioCalculator', () => { timeWeightedInvestmentWithCurrencyEffect: new Big( '145.10285714285714285714' ), - transactionCount: 2, valueInBaseCurrency: new Big('87.8') } ], @@ -184,6 +190,7 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentage: 0.12184460284330327256, netPerformanceInPercentageWithCurrencyEffect: 0.12184460284330327256, netPerformanceWithCurrencyEffect: 17.68, + totalInvestment: 75.8, totalInvestmentValueWithCurrencyEffect: 75.8 }) ); @@ -197,6 +204,10 @@ describe('PortfolioCalculator', () => { { date: '2022-03-01', investment: 151.6 }, { date: '2022-04-01', investment: -75.8 } ]); + + expect(investmentsByYear).toEqual([ + { date: '2022-01-01', investment: 75.8 } + ]); }); }); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts index 2e408dc3c..c79fdef58 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts @@ -101,6 +101,7 @@ describe('PortfolioCalculator', () => { ...activity, date: parseDate(activity.date), feeInAssetProfileCurrency: activity.fee, + feeInBaseCurrency: activity.fee, SymbolProfile: { ...symbolProfileDummyData, currency: activity.currency, @@ -128,6 +129,11 @@ describe('PortfolioCalculator', () => { groupBy: 'month' }); + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + expect(portfolioSnapshot.historicalData[0]).toEqual({ date: '2022-03-06', investmentValueWithCurrencyEffect: 0, @@ -187,14 +193,15 @@ describe('PortfolioCalculator', () => { hasErrors: false, positions: [ { + activitiesCount: 2, averagePrice: new Big('0'), currency: 'CHF', dataSource: 'YAHOO', + dateOfFirstActivity: '2022-03-07', dividend: new Big('0'), dividendInBaseCurrency: new Big('0'), fee: new Big('0'), feeInBaseCurrency: new Big('0'), - firstBuyDate: '2022-03-07', grossPerformance: new Big('19.86'), grossPerformancePercentage: new Big('0.13100263852242744063'), grossPerformancePercentageWithCurrencyEffect: new Big( @@ -218,7 +225,6 @@ describe('PortfolioCalculator', () => { tags: [], timeWeightedInvestment: new Big('151.6'), timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), - transactionCount: 2, valueInBaseCurrency: new Big('0') } ], @@ -235,6 +241,7 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentage: 0.13100263852242744063, netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063, netPerformanceWithCurrencyEffect: 19.86, + totalInvestment: 0, totalInvestmentValueWithCurrencyEffect: 0 }) ); @@ -248,6 +255,10 @@ describe('PortfolioCalculator', () => { { date: '2022-03-01', investment: 151.6 }, { date: '2022-04-01', investment: -151.6 } ]); + + expect(investmentsByYear).toEqual([ + { date: '2022-01-01', investment: 0 } + ]); }); }); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts index 3c7c3be4b..e518a5994 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts @@ -87,6 +87,7 @@ describe('PortfolioCalculator', () => { ...activityDummyData, date: new Date('2022-01-01'), feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, quantity: 1, SymbolProfile: { ...symbolProfileDummyData, @@ -115,21 +116,22 @@ describe('PortfolioCalculator', () => { hasErrors: false, positions: [ { + activitiesCount: 1, averagePrice: new Big('500000'), currency: 'USD', dataSource: 'MANUAL', + dateOfFirstActivity: '2022-01-01', dividend: new Big('0'), dividendInBaseCurrency: new Big('0'), fee: new Big('0'), feeInBaseCurrency: new Big('0'), - firstBuyDate: '2022-01-01', grossPerformance: new Big('0'), grossPerformancePercentage: new Big('0'), grossPerformancePercentageWithCurrencyEffect: new Big('0'), grossPerformanceWithCurrencyEffect: new Big('0'), investment: new Big('500000'), investmentWithCurrencyEffect: new Big('500000'), - marketPrice: null, + marketPrice: 1, marketPriceInBaseCurrency: 500000, netPerformance: new Big('0'), netPerformancePercentage: new Big('0'), @@ -144,7 +146,6 @@ describe('PortfolioCalculator', () => { tags: [], timeWeightedInvestment: new Big('500000'), timeWeightedInvestmentWithCurrencyEffect: new Big('500000'), - transactionCount: 1, valueInBaseCurrency: new Big('500000') } ], @@ -161,6 +162,7 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentage: 0, netPerformanceInPercentageWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0, + totalInvestment: 500000, totalInvestmentValueWithCurrencyEffect: 500000 }) ); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts index d4fad7d93..2841e9975 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -13,7 +13,14 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance import { Logger } from '@nestjs/common'; import { Big } from 'big.js'; -import { addMilliseconds, differenceInDays, format, isBefore } from 'date-fns'; +import { + addMilliseconds, + differenceInDays, + eachYearOfInterval, + format, + isBefore, + isThisYear +} from 'date-fns'; import { cloneDeep, sortBy } from 'lodash'; export class RoaiPortfolioCalculator extends PortfolioCalculator { @@ -34,7 +41,11 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { let totalTimeWeightedInvestment = new Big(0); let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0); - for (const currentPosition of positions) { + for (const currentPosition of positions.filter( + ({ includeInTotalAssetValue }) => { + return includeInTotalAssetValue; + } + )) { if (currentPosition.feeInBaseCurrency) { totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus( currentPosition.feeInBaseCurrency @@ -188,6 +199,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { }) ); + const isCash = orders[0]?.SymbolProfile?.assetSubClass === 'CASH'; + if (orders.length <= 0) { return { currentValues: {}, @@ -244,6 +257,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { // For BUY / SELL activities with a MANUAL data source where no historical market price is available, // the calculation should fall back to using the activity’s unit price. unitPriceAtEndDate = latestActivity.unitPrice; + } else if (isCash) { + unitPriceAtEndDate = new Big(1); } if ( @@ -295,7 +310,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { quantity: new Big(0), SymbolProfile: { dataSource, - symbol + symbol, + assetSubClass: isCash ? 'CASH' : undefined }, type: 'BUY', unitPrice: unitPriceAtStartDate @@ -308,7 +324,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { itemType: 'end', SymbolProfile: { dataSource, - symbol + symbol, + assetSubClass: isCash ? 'CASH' : undefined }, quantity: new Big(0), type: 'BUY', @@ -348,7 +365,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { quantity: new Big(0), SymbolProfile: { dataSource, - symbol + symbol, + assetSubClass: isCash ? 'CASH' : undefined }, type: 'BUY', unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice, @@ -608,6 +626,13 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { totalQuantityFromBuyTransactions ); + if (totalUnits.eq(0)) { + // Reset tracking variables when position is fully closed + totalInvestmentFromBuyTransactions = new Big(0); + totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0); + totalQuantityFromBuyTransactions = new Big(0); + } + if (PortfolioCalculator.ENABLE_LOGGING) { console.log( 'grossPerformanceFromSells', @@ -826,17 +851,16 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { 'max', 'mtd', 'wtd', - 'ytd' - // TODO: - // ...eachYearOfInterval({ end, start }) - // .filter((date) => { - // return !isThisYear(date); - // }) - // .map((date) => { - // return format(date, 'yyyy'); - // }) + 'ytd', + ...eachYearOfInterval({ end, start }) + .filter((date) => { + return !isThisYear(date); + }) + .map((date) => { + return format(date, 'yyyy'); + }) ] as DateRange[]) { - const dateInterval = getIntervalFromDateRange(dateRange); + const dateInterval = getIntervalFromDateRange({ dateRange }); const endDate = dateInterval.endDate; let startDate = dateInterval.startDate; 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 4b4b8f00e..8e027f971 100644 --- a/apps/api/src/app/portfolio/current-rate.service.mock.ts +++ b/apps/api/src/app/portfolio/current-rate.service.mock.ts @@ -64,6 +64,17 @@ function mockGetValue(symbol: string, date: Date) { return { marketPrice: 0 }; + case 'JNUG': + if (isSameDay(parseDate('2025-12-10'), date)) { + return { marketPrice: 204.5599975585938 }; + } else if (isSameDay(parseDate('2025-12-17'), date)) { + return { marketPrice: 203.9700012207031 }; + } else if (isSameDay(parseDate('2025-12-28'), date)) { + return { marketPrice: 237.8000030517578 }; + } + + return { marketPrice: 0 }; + case 'MSFT': if (isSameDay(parseDate('2021-09-16'), date)) { return { marketPrice: 89.12 }; 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 d8b7482e7..5f2358679 100644 --- a/apps/api/src/app/portfolio/current-rate.service.spec.ts +++ b/apps/api/src/app/portfolio/current-rate.service.spec.ts @@ -114,9 +114,9 @@ describe('CurrentRateService', () => { marketDataService = new MarketDataService(null); currentRateService = new CurrentRateService( + null, dataProviderService, marketDataService, - null, null ); }); diff --git a/apps/api/src/app/portfolio/current-rate.service.ts b/apps/api/src/app/portfolio/current-rate.service.ts index 5d39a54bb..b454b01cd 100644 --- a/apps/api/src/app/portfolio/current-rate.service.ts +++ b/apps/api/src/app/portfolio/current-rate.service.ts @@ -1,4 +1,4 @@ -import { OrderService } from '@ghostfolio/api/app/order/order.service'; +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; @@ -24,9 +24,9 @@ export class CurrentRateService { private static readonly MARKET_DATA_PAGE_SIZE = 50000; public constructor( + private readonly activitiesService: ActivitiesService, private readonly dataProviderService: DataProviderService, private readonly marketDataService: MarketDataService, - private readonly orderService: OrderService, @Inject(REQUEST) private readonly request: RequestWithUser ) {} @@ -129,10 +129,11 @@ export class CurrentRateService { if (!value) { // Fallback to unit price of latest activity - const latestActivity = await this.orderService.getLatestOrder({ - dataSource, - symbol - }); + const latestActivity = + await this.activitiesService.getLatestActivity({ + dataSource, + symbol + }); value = { dataSource, diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts index 06e471d67..42759b521 100644 --- a/apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts @@ -3,7 +3,6 @@ import { Big } from 'big.js'; import { PortfolioOrder } from './portfolio-order.interface'; export interface PortfolioOrderItem extends PortfolioOrder { - feeInBaseCurrency?: Big; feeInBaseCurrencyWithCurrencyEffect?: Big; itemType?: 'end' | 'start'; unitPriceFromMarketData?: Big; diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts index 9362184c7..2dbd68f12 100644 --- a/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts @@ -3,10 +3,11 @@ import { Activity } from '@ghostfolio/common/interfaces'; export interface PortfolioOrder extends Pick { date: string; fee: Big; + feeInBaseCurrency: Big; quantity: Big; SymbolProfile: Pick< Activity['SymbolProfile'], - 'currency' | 'dataSource' | 'name' | 'symbol' | 'userId' + 'assetSubClass' | 'currency' | 'dataSource' | 'name' | 'symbol' | 'userId' >; unitPrice: Big; } diff --git a/apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts b/apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts index f4ceadf3b..7f3f54ff5 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,18 +1,20 @@ -import { DataSource, Tag } from '@prisma/client'; +import { AssetSubClass, DataSource, Tag } from '@prisma/client'; import { Big } from 'big.js'; export interface TransactionPointSymbol { + activitiesCount: number; + assetSubClass: AssetSubClass; averagePrice: Big; currency: string; dataSource: DataSource; + dateOfFirstActivity: string; dividend: Big; fee: Big; - firstBuyDate: string; + feeInBaseCurrency: Big; includeInHoldings: boolean; investment: Big; quantity: Big; skipErrors: boolean; symbol: string; tags?: Tag[]; - transactionCount: number; } diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 03796dad6..9c41aecb9 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -1,4 +1,4 @@ -import { OrderService } from '@ghostfolio/api/app/order/order.service'; +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { @@ -63,10 +63,10 @@ import { UpdateHoldingTagsDto } from './update-holding-tags.dto'; @Controller('portfolio') export class PortfolioController { public constructor( + private readonly activitiesService: ActivitiesService, private readonly apiService: ApiService, private readonly configurationService: ConfigurationService, private readonly impersonationService: ImpersonationService, - private readonly orderService: OrderService, private readonly portfolioService: PortfolioService, @Inject(REQUEST) private readonly request: RequestWithUser ) {} @@ -187,7 +187,6 @@ export class PortfolioController { portfolioSummary = nullifyValuesInObject(summary, [ 'cash', - 'committedFunds', 'currentNetWorth', 'currentValueInBaseCurrency', 'dividendInBaseCurrency', @@ -195,15 +194,18 @@ export class PortfolioController { 'excludedAccountsAndActivities', 'fees', 'filteredValueInBaseCurrency', + 'fireWealth', 'grossPerformance', 'grossPerformanceWithCurrencyEffect', 'interestInBaseCurrency', 'items', 'liabilities', + 'liabilitiesInBaseCurrency', 'netPerformance', 'netPerformanceWithCurrencyEffect', 'totalBuy', 'totalInvestment', + 'totalInvestmentValueWithCurrencyEffect', 'totalSell', 'totalValueInBaseCurrency' ]); @@ -318,9 +320,9 @@ export class PortfolioController { await this.impersonationService.validateImpersonationId(impersonationId); const userCurrency = this.request.user.settings.settings.baseCurrency; - const { endDate, startDate } = getIntervalFromDateRange(dateRange); + const { endDate, startDate } = getIntervalFromDateRange({ dateRange }); - const { activities } = await this.orderService.getOrders({ + const { activities } = await this.activitiesService.getActivities({ endDate, filters, startDate, @@ -329,7 +331,7 @@ export class PortfolioController { types: ['DIVIDEND'] }); - let dividends = await this.portfolioService.getDividends({ + let dividends = this.portfolioService.getDividends({ activities, groupBy }); @@ -637,7 +639,7 @@ export class PortfolioController { return report; } - @HasPermission(permissions.updateOrder) + @HasPermission(permissions.updateActivity) @Put('holding/:dataSource/:symbol/tags') @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) diff --git a/apps/api/src/app/portfolio/portfolio.module.ts b/apps/api/src/app/portfolio/portfolio.module.ts index 6dd5811a3..65a9b71aa 100644 --- a/apps/api/src/app/portfolio/portfolio.module.ts +++ b/apps/api/src/app/portfolio/portfolio.module.ts @@ -1,7 +1,7 @@ import { AccessModule } from '@ghostfolio/api/app/access/access.module'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service'; -import { OrderModule } from '@ghostfolio/api/app/order/order.module'; +import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { PerformanceLoggingModule } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.module'; @@ -34,6 +34,7 @@ import { RulesService } from './rules.service'; exports: [PortfolioService], imports: [ AccessModule, + ActivitiesModule, ApiModule, BenchmarkModule, ConfigurationModule, @@ -43,7 +44,6 @@ import { RulesService } from './rules.service'; I18nModule, ImpersonationModule, MarketDataModule, - OrderModule, PerformanceLoggingModule, PortfolioSnapshotQueueModule, PrismaModule, diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 084c8f4ed..60b413cf9 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -1,7 +1,7 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; -import { OrderService } from '@ghostfolio/api/app/order/order.service'; +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; @@ -13,7 +13,7 @@ import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rul import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/developed-markets'; import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets'; 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 { FeeRatioTotalInvestmentVolume } from '@ghostfolio/api/models/rules/fees/fee-ratio-total-investment-volume'; import { BuyingPower } from '@ghostfolio/api/models/rules/liquidity/buying-power'; import { RegionalMarketClusterRiskAsiaPacific } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/asia-pacific'; import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets'; @@ -105,13 +105,13 @@ export class PortfolioService { public constructor( private readonly accountBalanceService: AccountBalanceService, private readonly accountService: AccountService, + private readonly activitiesService: ActivitiesService, private readonly benchmarkService: BenchmarkService, private readonly calculatorFactory: PortfolioCalculatorFactory, private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly i18nService: I18nService, private readonly impersonationService: ImpersonationService, - private readonly orderService: OrderService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly rulesService: RulesService, private readonly symbolProfileService: SymbolProfileService, @@ -179,9 +179,9 @@ export class PortfolioService { return Promise.all( accounts.map(async (account) => { + let activitiesCount = 0; let dividendInBaseCurrency = 0; let interestInBaseCurrency = 0; - let transactionCount = 0; for (const { currency, @@ -214,7 +214,7 @@ export class PortfolioService { } if (!isDraft) { - transactionCount += 1; + activitiesCount += 1; } } @@ -223,9 +223,9 @@ export class PortfolioService { const result = { ...account, + activitiesCount, dividendInBaseCurrency, interestInBaseCurrency, - transactionCount, valueInBaseCurrency, allocationInPercentage: 0, balanceInBaseCurrency: this.exchangeRateDataService.toCurrency( @@ -262,6 +262,8 @@ export class PortfolioService { withExcludedAccounts }); + let activitiesCount = 0; + const searchQuery = filters.find(({ type }) => { return type === 'SEARCH_QUERY'; })?.id; @@ -281,9 +283,10 @@ export class PortfolioService { let totalDividendInBaseCurrency = new Big(0); let totalInterestInBaseCurrency = new Big(0); let totalValueInBaseCurrency = new Big(0); - let transactionCount = 0; for (const account of accounts) { + activitiesCount += account.activitiesCount; + totalBalanceInBaseCurrency = totalBalanceInBaseCurrency.plus( account.balanceInBaseCurrency ); @@ -296,7 +299,6 @@ export class PortfolioService { totalValueInBaseCurrency = totalValueInBaseCurrency.plus( account.valueInBaseCurrency ); - transactionCount += account.transactionCount; } for (const account of accounts) { @@ -310,7 +312,7 @@ export class PortfolioService { return { accounts, - transactionCount, + activitiesCount, totalBalanceInBaseCurrency: totalBalanceInBaseCurrency.toNumber(), totalDividendInBaseCurrency: totalDividendInBaseCurrency.toNumber(), totalInterestInBaseCurrency: totalInterestInBaseCurrency.toNumber(), @@ -318,13 +320,13 @@ export class PortfolioService { }; } - public async getDividends({ + public getDividends({ activities, groupBy }: { activities: Activity[]; groupBy?: GroupBy; - }): Promise { + }): InvestmentItem[] { let dividends = activities.map(({ currency, date, value }) => { return { date: format(date, DATE_FORMAT), @@ -401,10 +403,10 @@ export class PortfolioService { const user = await this.userService.user({ id: userId }); const userCurrency = this.getUserCurrency(user); - const { endDate, startDate } = getIntervalFromDateRange(dateRange); + const { endDate, startDate } = getIntervalFromDateRange({ dateRange }); const { activities } = - await this.orderService.getOrdersForPortfolioCalculator({ + await this.activitiesService.getActivitiesForPortfolioCalculator({ filters, userCurrency, userId @@ -488,7 +490,7 @@ export class PortfolioService { ); const { activities } = - await this.orderService.getOrdersForPortfolioCalculator({ + await this.activitiesService.getActivitiesForPortfolioCalculator({ filters, userCurrency, userId @@ -522,10 +524,6 @@ export class PortfolioService { return type === 'ACCOUNT'; }) ?? false; - const isFilteredByCash = filters?.some(({ id, type }) => { - return id === AssetClass.LIQUIDITY && type === 'ASSET_CLASS'; - }); - const isFilteredByClosedHoldings = filters?.some(({ id, type }) => { return id === 'CLOSED' && type === 'HOLDING_TYPE'; @@ -557,6 +555,9 @@ export class PortfolioService { assetProfileIdentifiers ); + const cashSymbolProfiles = this.getCashSymbolProfiles(cashDetails); + symbolProfiles.push(...cashSymbolProfiles); + const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {}; for (const symbolProfile of symbolProfiles) { symbolProfileMap[symbolProfile.symbol] = symbolProfile; @@ -568,9 +569,10 @@ export class PortfolioService { } for (const { + activitiesCount, currency, + dateOfFirstActivity, dividend, - firstBuyDate, grossPerformance, grossPerformanceWithCurrencyEffect, grossPerformancePercentage, @@ -584,7 +586,6 @@ export class PortfolioService { quantity, symbol, tags, - transactionCount, valueInBaseCurrency } of positions) { if (isFilteredByClosedHoldings === true) { @@ -611,21 +612,43 @@ export class PortfolioService { } holdings[symbol] = { + activitiesCount, currency, markets, marketsAdvanced, marketPrice, symbol, tags, - transactionCount, allocationInPercentage: filteredValueInBaseCurrency.eq(0) ? 0 : valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(), assetClass: assetProfile.assetClass, + assetProfile: { + assetClass: assetProfile.assetClass, + assetSubClass: assetProfile.assetSubClass, + countries: assetProfile.countries, + currency: assetProfile.currency, + dataSource: assetProfile.dataSource, + holdings: assetProfile.holdings.map( + ({ allocationInPercentage, name }) => { + return { + allocationInPercentage, + name, + valueInBaseCurrency: valueInBaseCurrency + .mul(allocationInPercentage) + .toNumber() + }; + } + ), + name: assetProfile.name, + sectors: assetProfile.sectors, + symbol: assetProfile.symbol, + url: assetProfile.url + }, assetSubClass: assetProfile.assetSubClass, countries: assetProfile.countries, dataSource: assetProfile.dataSource, - dateOfFirstActivity: parseDate(firstBuyDate), + dateOfFirstActivity: parseDate(dateOfFirstActivity), dividend: dividend?.toNumber() ?? 0, grossPerformance: grossPerformance?.toNumber() ?? 0, grossPerformancePercent: grossPerformancePercentage?.toNumber() ?? 0, @@ -661,18 +684,6 @@ export class PortfolioService { }; } - if (filters?.length === 0 || isFilteredByAccount || isFilteredByCash) { - const cashPositions = this.getCashPositions({ - cashDetails, - userCurrency, - value: filteredValueInBaseCurrency - }); - - for (const symbol of Object.keys(cashPositions)) { - holdings[symbol] = cashPositions[symbol]; - } - } - const { accounts, platforms } = await this.getValueOfAccountsAndPlatforms({ activities, filters, @@ -769,7 +780,7 @@ export class PortfolioService { const userCurrency = this.getUserCurrency(user); const { activities } = - await this.orderService.getOrdersForPortfolioCalculator({ + await this.activitiesService.getActivitiesForPortfolioCalculator({ userCurrency, userId }); @@ -802,11 +813,12 @@ export class PortfolioService { } const { + activitiesCount, averagePrice, currency, + dateOfFirstActivity, dividendInBaseCurrency, - fee, - firstBuyDate, + feeInBaseCurrency, grossPerformance, grossPerformancePercentage, grossPerformancePercentageWithCurrencyEffect, @@ -820,8 +832,7 @@ export class PortfolioService { quantity, tags, timeWeightedInvestment, - timeWeightedInvestmentWithCurrencyEffect, - transactionCount + timeWeightedInvestmentWithCurrencyEffect } = holding; const activitiesOfHolding = activities.filter(({ SymbolProfile }) => { @@ -832,7 +843,10 @@ export class PortfolioService { }); const dividendYieldPercent = getAnnualizedPerformancePercent({ - daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)), + daysInMarket: differenceInDays( + new Date(), + parseDate(dateOfFirstActivity) + ), netPerformancePercentage: timeWeightedInvestment.eq(0) ? new Big(0) : dividendInBaseCurrency.div(timeWeightedInvestment) @@ -840,7 +854,10 @@ export class PortfolioService { const dividendYieldPercentWithCurrencyEffect = getAnnualizedPerformancePercent({ - daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)), + daysInMarket: differenceInDays( + new Date(), + parseDate(dateOfFirstActivity) + ), netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq(0) ? new Big(0) : dividendInBaseCurrency.div(timeWeightedInvestmentWithCurrencyEffect) @@ -849,7 +866,7 @@ export class PortfolioService { const historicalData = await this.dataProviderService.getHistorical( [{ dataSource, symbol }], 'day', - parseISO(firstBuyDate), + parseISO(dateOfFirstActivity), new Date() ); @@ -914,7 +931,7 @@ export class PortfolioService { // Add historical entry for buy date, if no historical data available historicalDataArray.push({ averagePrice: activitiesOfHolding[0].unitPriceInAssetProfileCurrency, - date: firstBuyDate, + date: dateOfFirstActivity, marketPrice: activitiesOfHolding[0].unitPriceInAssetProfileCurrency, quantity: activitiesOfHolding[0].quantity }); @@ -927,25 +944,20 @@ export class PortfolioService { ); return { - firstBuyDate, + activitiesCount, + dateOfFirstActivity, marketPrice, marketPriceMax, marketPriceMin, SymbolProfile, tags, - activities: activitiesOfHolding, - activitiesCount: transactionCount, averagePrice: averagePrice.toNumber(), dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0], dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), dividendYieldPercent: dividendYieldPercent.toNumber(), dividendYieldPercentWithCurrencyEffect: dividendYieldPercentWithCurrencyEffect.toNumber(), - feeInBaseCurrency: this.exchangeRateDataService.toCurrency( - fee.toNumber(), - SymbolProfile.currency, - userCurrency - ), + feeInBaseCurrency: feeInBaseCurrency.toNumber(), grossPerformance: grossPerformance?.toNumber(), grossPerformancePercent: grossPerformancePercentage?.toNumber(), grossPerformancePercentWithCurrencyEffect: @@ -998,7 +1010,7 @@ export class PortfolioService { userId, userCurrency }), - this.orderService.getOrdersForPortfolioCalculator({ + this.activitiesService.getActivitiesForPortfolioCalculator({ filters, userCurrency, userId @@ -1017,7 +1029,8 @@ export class PortfolioService { netPerformancePercentage: 0, netPerformancePercentageWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0, - totalInvestment: 0 + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0 } }; } @@ -1034,7 +1047,7 @@ export class PortfolioService { const { errors, hasErrors, historicalData } = await portfolioCalculator.getSnapshot(); - const { endDate, startDate } = getIntervalFromDateRange(dateRange); + const { endDate, startDate } = getIntervalFromDateRange({ dateRange }); const { chart } = await portfolioCalculator.getPerformance({ end: endDate, @@ -1048,6 +1061,7 @@ export class PortfolioService { netPerformanceWithCurrencyEffect, netWorth, totalInvestment, + totalInvestmentValueWithCurrencyEffect, valueWithCurrencyEffect } = chart?.at(-1) ?? { netPerformance: 0, @@ -1068,6 +1082,7 @@ export class PortfolioService { netPerformance, netPerformanceWithCurrencyEffect, totalInvestment, + totalInvestmentValueWithCurrencyEffect, currentNetWorth: netWorth, currentValueInBaseCurrency: valueWithCurrencyEffect, netPerformancePercentage: netPerformanceInPercentage, @@ -1316,11 +1331,11 @@ export class PortfolioService { }), rules: await this.rulesService.evaluate( [ - new FeeRatioInitialInvestment( + new FeeRatioTotalInvestmentVolume( this.exchangeRateDataService, this.i18nService, userSettings.language, - summary.committedFunds, + summary.totalBuy + summary.totalSell, summary.fees ) ], @@ -1356,7 +1371,12 @@ export class PortfolioService { }) { userId = await this.getUserId(impersonationId, userId); - await this.orderService.assignTags({ dataSource, symbol, tags, userId }); + await this.activitiesService.assignTags({ + dataSource, + symbol, + tags, + userId + }); } private getAggregatedMarkets(holdings: Record): { @@ -1548,6 +1568,37 @@ export class PortfolioService { return cashPositions; } + private getCashSymbolProfiles(cashDetails: CashDetails) { + const cashSymbols = [ + ...new Set(cashDetails.accounts.map(({ currency }) => currency)) + ]; + + return cashSymbols.map((currency) => { + const account = cashDetails.accounts.find( + ({ currency: accountCurrency }) => { + return accountCurrency === currency; + } + ); + + return { + currency, + activitiesCount: 0, + assetClass: AssetClass.LIQUIDITY, + assetSubClass: AssetSubClass.CASH, + countries: [], + createdAt: account.createdAt, + dataSource: DataSource.MANUAL, + holdings: [], + id: currency, + isActive: true, + name: currency, + sectors: [], + symbol: currency, + updatedAt: account.updatedAt + }; + }); + } + private getDividendsByGroup({ dividends, groupBy @@ -1644,9 +1695,21 @@ export class PortfolioService { }): PortfolioPosition { return { currency, + activitiesCount: 0, allocationInPercentage: 0, assetClass: AssetClass.LIQUIDITY, assetSubClass: AssetSubClass.CASH, + assetProfile: { + currency, + assetClass: AssetClass.LIQUIDITY, + assetSubClass: AssetSubClass.CASH, + countries: [], + dataSource: undefined, + holdings: [], + name: currency, + sectors: [], + symbol: currency + }, countries: [], dataSource: undefined, dateOfFirstActivity: undefined, @@ -1667,7 +1730,6 @@ export class PortfolioService { sectors: [], symbol: currency, tags: [], - transactionCount: 0, valueInBaseCurrency: balance }; } @@ -1817,7 +1879,7 @@ export class PortfolioService { userId = await this.getUserId(impersonationId, userId); const user = await this.userService.user({ id: userId }); - const { activities } = await this.orderService.getOrders({ + const { activities } = await this.activitiesService.getActivities({ userCurrency, userId, withExcludedAccountsAndActivities: true @@ -1839,8 +1901,11 @@ export class PortfolioService { } } - const { currentValueInBaseCurrency, totalInvestment } = - await portfolioCalculator.getSnapshot(); + const { + currentValueInBaseCurrency, + totalInvestment, + totalInvestmentWithCurrencyEffect + } = await portfolioCalculator.getSnapshot(); const { performance } = await this.getPerformance({ impersonationId, @@ -1854,18 +1919,17 @@ export class PortfolioService { netPerformanceWithCurrencyEffect } = performance; - const dividendInBaseCurrency = - await portfolioCalculator.getDividendInBaseCurrency(); - const totalEmergencyFund = this.getTotalEmergencyFund({ emergencyFundHoldingsValueInBaseCurrency, userSettings: user.settings?.settings as UserSettings }); - const fees = await portfolioCalculator.getFeesInBaseCurrency(); + const dateOfFirstActivity = portfolioCalculator.getStartDate(); - const firstOrderDate = portfolioCalculator.getStartDate(); + const dividendInBaseCurrency = + await portfolioCalculator.getDividendInBaseCurrency(); + const fees = await portfolioCalculator.getFeesInBaseCurrency(); const interest = await portfolioCalculator.getInterestInBaseCurrency(); const liabilities = @@ -1888,8 +1952,6 @@ export class PortfolioService { .plus(emergencyFundHoldingsValueInBaseCurrency) .toNumber(); - const committedFunds = new Big(totalBuy).minus(totalSell); - const totalOfExcludedActivities = this.getSumOfActivityType({ userCurrency, activities: excludedActivities, @@ -1923,7 +1985,7 @@ export class PortfolioService { .minus(liabilities) .toNumber(); - const daysInMarket = differenceInDays(new Date(), firstOrderDate); + const daysInMarket = differenceInDays(new Date(), dateOfFirstActivity); const annualizedPerformancePercent = getAnnualizedPerformancePercent({ daysInMarket, @@ -1942,6 +2004,7 @@ export class PortfolioService { annualizedPerformancePercent, annualizedPerformancePercentWithCurrencyEffect, cash, + dateOfFirstActivity, excludedAccountsAndActivities, netPerformance, netPerformancePercentage, @@ -1952,7 +2015,6 @@ export class PortfolioService { activityCount: activities.filter(({ type }) => { return ['BUY', 'SELL'].includes(type); }).length, - committedFunds: committedFunds.toNumber(), currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(), dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), emergencyFund: { @@ -1983,6 +2045,8 @@ export class PortfolioService { interestInBaseCurrency: interest.toNumber(), liabilitiesInBaseCurrency: liabilities.toNumber(), totalInvestment: totalInvestment.toNumber(), + totalInvestmentValueWithCurrencyEffect: + totalInvestmentWithCurrencyEffect.toNumber(), totalValueInBaseCurrency: netWorth }; } @@ -2157,7 +2221,7 @@ export class PortfolioService { accounts[account?.id || UNKNOWN_KEY] = { balance: 0, currency: account?.currency, - name: account.name, + name: account?.name, valueInBaseCurrency: currentValueOfSymbolInBaseCurrency }; } @@ -2171,7 +2235,7 @@ export class PortfolioService { platforms[account?.platformId || UNKNOWN_KEY] = { balance: 0, currency: account?.currency, - name: account.platform?.name, + name: account?.platform?.name, valueInBaseCurrency: currentValueOfSymbolInBaseCurrency }; } 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 1ea0a6137..619d23fc5 100644 --- a/apps/api/src/app/redis-cache/redis-cache.service.ts +++ b/apps/api/src/app/redis-cache/redis-cache.service.ts @@ -6,7 +6,7 @@ import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager'; import { Inject, Injectable, Logger } from '@nestjs/common'; import Keyv from 'keyv'; import ms from 'ms'; -import { createHash } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; @Injectable() export class RedisCacheService { @@ -75,13 +75,16 @@ export class RedisCacheService { } public async isHealthy() { - const testKey = '__health_check__'; + const HEALTH_CHECK_TIMEOUT = ms('5 seconds'); + + const testKey = `__health_check__${randomUUID().replace(/-/g, '')}`; const testValue = Date.now().toString(); try { await Promise.race([ (async () => { - await this.set(testKey, testValue, ms('1 second')); + await this.set(testKey, testValue, HEALTH_CHECK_TIMEOUT); + const result = await this.get(testKey); if (result !== testValue) { @@ -91,7 +94,7 @@ export class RedisCacheService { new Promise((_, reject) => setTimeout( () => reject(new Error('Redis health check failed: timeout')), - ms('2 seconds') + HEALTH_CHECK_TIMEOUT ) ) ]); diff --git a/apps/api/src/app/subscription/subscription.service.ts b/apps/api/src/app/subscription/subscription.service.ts index 0fad8c8ac..689ee3e6a 100644 --- a/apps/api/src/app/subscription/subscription.service.ts +++ b/apps/api/src/app/subscription/subscription.service.ts @@ -35,7 +35,7 @@ export class SubscriptionService { this.stripe = new Stripe( this.configurationService.get('STRIPE_SECRET_KEY'), { - apiVersion: '2025-08-27.basil' + apiVersion: '2026-01-28.clover' } ); } @@ -100,7 +100,7 @@ export class SubscriptionService { ); return { - sessionId: session.id + sessionUrl: session.url }; } diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index 397ae016b..6346ce43a 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -1,8 +1,11 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { DeleteOwnUserDto, UpdateOwnAccessTokenDto, @@ -28,7 +31,8 @@ import { Param, Post, Put, - UseGuards + UseGuards, + UseInterceptors } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { JwtService } from '@nestjs/jwt'; @@ -43,6 +47,7 @@ import { UserService } from './user.service'; export class UserController { public constructor( private readonly configurationService: ConfigurationService, + private readonly impersonationService: ImpersonationService, private readonly jwtService: JwtService, private readonly prismaService: PrismaService, private readonly propertyService: PropertyService, @@ -107,13 +112,19 @@ export class UserController { @Get() @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(RedactValuesInResponseInterceptor) public async getUser( - @Headers('accept-language') acceptLanguage: string + @Headers('accept-language') acceptLanguage: string, + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string ): Promise { - return this.userService.getUser( - this.request.user, - acceptLanguage?.split(',')?.[0] - ); + const impersonationUserId = + await this.impersonationService.validateImpersonationId(impersonationId); + + return this.userService.getUser({ + impersonationUserId, + locale: acceptLanguage?.split(',')?.[0], + user: this.request.user + }); } @Post() diff --git a/apps/api/src/app/user/user.module.ts b/apps/api/src/app/user/user.module.ts index 8a21b0a55..3f4e898fc 100644 --- a/apps/api/src/app/user/user.module.ts +++ b/apps/api/src/app/user/user.module.ts @@ -1,7 +1,9 @@ -import { OrderModule } from '@ghostfolio/api/app/order/order.module'; +import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; +import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; +import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; 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'; @@ -16,15 +18,17 @@ import { UserService } from './user.service'; controllers: [UserController], exports: [UserService], imports: [ + ActivitiesModule, ConfigurationModule, I18nModule, + ImpersonationModule, JwtModule.register({ secret: process.env.JWT_SECRET_KEY, signOptions: { expiresIn: '30 days' } }), - OrderModule, PrismaModule, PropertyModule, + RedactValuesInResponseModule, SubscriptionModule, TagModule ], diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 3280fbfac..370f5d422 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -1,4 +1,4 @@ -import { OrderService } from '@ghostfolio/api/app/order/order.service'; +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { environment } from '@ghostfolio/api/environments/environment'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; @@ -12,7 +12,7 @@ import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rul import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/developed-markets'; import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets'; 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 { FeeRatioTotalInvestmentVolume } from '@ghostfolio/api/models/rules/fees/fee-ratio-total-investment-volume'; import { BuyingPower } from '@ghostfolio/api/models/rules/liquidity/buying-power'; import { RegionalMarketClusterRiskAsiaPacific } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/asia-pacific'; import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets'; @@ -30,7 +30,7 @@ import { PROPERTY_IS_READ_ONLY_MODE, PROPERTY_SYSTEM_MESSAGE, TAG_ID_EXCLUDE_FROM_ANALYSIS, - locale + locale as defaultLocale } from '@ghostfolio/common/config'; import { User as IUser, @@ -49,16 +49,16 @@ import { Injectable } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { Prisma, Role, User } from '@prisma/client'; import { differenceInDays, subDays } from 'date-fns'; -import { sortBy, without } from 'lodash'; +import { without } from 'lodash'; import { createHmac } from 'node:crypto'; @Injectable() export class UserService { public constructor( + private readonly activitiesService: ActivitiesService, private readonly configurationService: ConfigurationService, private readonly eventEmitter: EventEmitter2, private readonly i18nService: I18nService, - private readonly orderService: OrderService, private readonly prismaService: PrismaService, private readonly propertyService: PropertyService, private readonly subscriptionService: SubscriptionService, @@ -96,10 +96,17 @@ export class UserService { return { accessToken, hashedAccessToken }; } - public async getUser( - { accounts, id, permissions, settings, subscription }: UserWithSettings, - aLocale = locale - ): Promise { + public async getUser({ + impersonationUserId, + locale = defaultLocale, + user + }: { + impersonationUserId: string; + locale?: string; + user: UserWithSettings; + }): Promise { + const { id, permissions, settings, subscription } = user; + const userData = await Promise.all([ this.prismaService.access.findMany({ include: { @@ -108,22 +115,31 @@ export class UserService { orderBy: { alias: 'asc' }, where: { granteeUserId: id } }), + this.prismaService.account.findMany({ + orderBy: { + name: 'asc' + }, + where: { + userId: impersonationUserId || user.id + } + }), this.prismaService.order.count({ - where: { userId: id } + where: { userId: impersonationUserId || user.id } }), this.prismaService.order.findFirst({ orderBy: { date: 'asc' }, - where: { userId: id } + where: { userId: impersonationUserId || user.id } }), - this.tagService.getTagsForUser(id) + this.tagService.getTagsForUser(impersonationUserId || user.id) ]); const access = userData[0]; - const activitiesCount = userData[1]; - const firstActivity = userData[2]; - let tags = userData[3].filter((tag) => { + const accounts = userData[1]; + const activitiesCount = userData[2]; + const firstActivity = userData[3]; + let tags = userData[4].filter((tag) => { return tag.id !== TAG_ID_EXCLUDE_FROM_ANALYSIS; }); @@ -146,7 +162,6 @@ export class UserService { } return { - accounts, activitiesCount, id, permissions, @@ -160,10 +175,13 @@ export class UserService { permissions: accessItem.permissions }; }), + accounts: accounts.sort((a, b) => { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }), dateOfFirstActivity: firstActivity?.date ?? new Date(), settings: { ...(settings.settings as UserSettings), - locale: (settings.settings as UserSettings)?.locale ?? aLocale + locale: (settings.settings as UserSettings)?.locale ?? locale } }; } @@ -358,7 +376,7 @@ export class UserService { undefined, undefined ).getSettings(user.settings.settings), - FeeRatioInitialInvestment: new FeeRatioInitialInvestment( + FeeRatioTotalInvestmentVolume: new FeeRatioTotalInvestmentVolume( undefined, undefined, undefined, @@ -512,13 +530,20 @@ export class UserService { } } - if (!environment.production && hasRole(user, Role.ADMIN)) { - currentPermissions.push(permissions.impersonateAllUsers); + if (hasRole(user, Role.ADMIN)) { + if (this.configurationService.get('ENABLE_FEATURE_BULL_BOARD')) { + currentPermissions.push(permissions.accessAdminControlBullBoard); + } + + if (!environment.production) { + currentPermissions.push(permissions.impersonateAllUsers); + } } - user.accounts = sortBy(user.accounts, ({ name }) => { - return name.toLowerCase(); + user.accounts = user.accounts.sort((a, b) => { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); }); + user.permissions = currentPermissions.sort(); return user; @@ -624,7 +649,7 @@ export class UserService { } catch {} try { - await this.orderService.deleteOrders({ + await this.activitiesService.deleteActivities({ userId: where.id }); } catch {} diff --git a/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json b/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json index 80c07fc64..d00ded6ef 100644 --- a/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json +++ b/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json @@ -4,6 +4,7 @@ "4": "4", "7": "Lucky7", "8": "8", + "21": "2131KOBUSHIDE", "32": "Project 32", "42": "Semantic Layer", "47": "President Trump", @@ -20,8 +21,10 @@ "1337": "EliteCoin", "1717": "1717 Masonic Commemorative Token", "2015": "2015 coin", + "2016": "2016 coin", "2024": "2024", "2025": "2025 TOKEN", + "2026": "2026", "2049": "TOKEN 2049", "2192": "LERNITAS", "4444": "4444 Meme", @@ -55,6 +58,7 @@ "10SET": "Tenset", "1ART": "ArtWallet", "1CAT": "Bitcoin Cats", + "1COIN": "1 coin can change your life", "1CR": "1Credit", "1EARTH": "EarthFund", "1ECO": "1eco", @@ -83,6 +87,7 @@ "1UP": "Uptrennd", "1WO": "1World", "2022M": "2022MOON", + "2026MEMECLUB": "2026", "20EX": "20ex", "21BTC": "21.co Wrapped BTC", "21X": "21X", @@ -113,6 +118,7 @@ "3DES": "3DES", "3DVANCE": "3D Vance", "3FT": "ThreeFold Token", + "3KDS": "3KDS", "3KM": "3 Kingdoms Multiverse", "3P": "Web3Camp", "3RDEYE": "3rd Eye", @@ -146,11 +152,13 @@ "7E": "7ELEVEN", "88MPH": "88mph", "8BIT": "8BIT Coin", + "8BITCOIN": "8-Bit COIN", "8BT": "8 Circuit Studios", "8LNDS": "8Lends", "8PAY": "8Pay", "8X8": "8X8 Protocol", "99BTC": "99 Bitcoins", + "9BIT": "The9bit", "9DOGS": "NINE DOGS", "9GAG": "9GAG", "9MM": "Shigure UI", @@ -175,13 +183,17 @@ "AAC": "Double-A Chain", "AAG": "AAG Ventures", "AAI": "AutoAir AI", + "AALON": "American Airlines Group (Ondo Tokenized)", + "AAPLON": "Apple (Ondo Tokenized)", "AAPLX": "Apple xStock", "AAPX": "AMPnet", + "AARBWBTC": "Aave Arbitrum WBTC", "AARDY": "Baby Aardvark", "AARK": "Aark", "AART": "ALL.ART", "AAST": "AASToken", "AAT": "Agricultural Trade Chain", + "AAVAWBTC": "Aave aWBTC", "AAVE": "Aave", "AAVEE": "AAVE.e (Avalanche Bride)", "AAVEGOTCHIFOMO": "Aavegotchi FOMO", @@ -295,7 +307,8 @@ "ADEL": "Akropolis Delphi", "ADF": "Art de Finance", "ADH": "Adhive", - "ADI": "Aditus", + "ADI": "ADI", + "ADITUS": "Aditus", "ADIX": "Adix Token", "ADK": "Aidos Kuneen", "ADL": "Adel", @@ -408,10 +421,12 @@ "AGS": "Aegis", "AGT": "Alaya Governance Token", "AGURI": "Aguri-Chan", + "AGUSTO": "Agusto", "AGV": "Astra Guild Ventures", "AGVC": "AgaveCoin", "AGVE": "Agave", "AGX": "Agricoin", + "AHARWBTC": "Aave Harmony WBTC", "AHOO": "Ahoolee", "AHT": "AhaToken", "AI": "Sleepless", @@ -429,6 +444,7 @@ "AIAI": "All In AI", "AIAKITA": "AiAkita", "AIAT": "AI Analysis Token", + "AIAV": "AI AVatar", "AIB": "AdvancedInternetBlock", "AIBABYDOGE": "AIBabyDoge", "AIBB": "AiBB", @@ -502,6 +518,7 @@ "AIPAD": "AIPAD", "AIPE": "AI Prediction Ecosystem", "AIPEPE": "AI PEPE KING", + "AIPF": "AI Powered Finance", "AIPG": "AI Power Grid", "AIPIN": "AI PIN", "AIPO": "Aipocalypto", @@ -546,7 +563,7 @@ "AIVV1": "AIVille Governance Token", "AIWALLET": "AiWallet Token", "AIWS": "AIWS", - "AIX": "ALIENX", + "AIX": "Ai Xovia", "AIX9": "AthenaX9", "AIXBT": "aixbt by Virtuals", "AIXCB": "aixCB by Virtuals", @@ -619,6 +636,7 @@ "ALIEN": "AlienCoin", "ALIENPEP": "Alien Pepe", "ALIENS": "Aliens", + "ALIENX": "ALIENX", "ALIF": " ALIF COIN", "ALINK": "Aave LINK v1", "ALIS": "ALISmedia", @@ -626,6 +644,7 @@ "ALITA": "Alita Network", "ALITATOKEN": "Alita Token", "ALIX": "AlinX", + "ALK": "Alkemi Network DAO Token", "ALKI": "Alkimi", "ALKIMI": "ALKIMI", "ALLBI": "ALL BEST ICO", @@ -639,10 +658,12 @@ "ALMANAK": "Almanak", "ALMC": "Awkward Look Monkey Club", "ALME": "Alita", + "ALMEELA": "Almeela", "ALMOND": "Almond", "ALN": "Aluna", "ALNV1": "Aluna v1", "ALOHA": "Aloha", + "ALOKA": "ALOKA", "ALON": "Alon", "ALOR": "The Algorix", "ALOT": "Dexalot", @@ -689,7 +710,9 @@ "AMADEUS": "AMADEUS", "AMAL": "AMAL", "AMAPT": "Amnis Finance", + "AMARA": "AMARA", "AMATEN": "Amaten", + "AMATO": "AMATO", "AMAZINGTEAM": "AmazingTeamDAO", "AMB": "AirDAO", "AMBER": "AmberCoin", @@ -698,6 +721,7 @@ "AMBRX": "Amber xStock", "AMBT": "AMBT Token", "AMC": "AI Meta Coin", + "AMCON": "AMC Entertainment (Ondo Tokenized)", "AMDC": "Allmedi Coin", "AMDG": "AMDG", "AMDX": "AMD xStock", @@ -825,6 +849,7 @@ "AOK": "AOK", "AOL": "AOL (America Online)", "AOP": "Ark Of Panda", + "AOPTWBTC": "Aave Optimism WBTC", "AOS": "AOS", "AOT": "Age of Tanks", "AP": "America Party", @@ -861,6 +886,7 @@ "APOL": "Apollo FTW", "APOLL": "Apollon Limassol", "APOLLO": "Apollo Crypto", + "APOLWBTC": "Aave Polygon WBTC", "APP": "Moon App", "APPA": "Dappad", "APPC": "AppCoins", @@ -914,7 +940,9 @@ "ARAW": "Araw", "ARB": "Arbitrum", "ARBI": "Arbipad", + "ARBINU": "ArbInu", "ARBIT": "Arbit Coin", + "ARBITROVE": "Arbitrove Governance Token", "ARBP": "ARB Protocol", "ARBS": "Arbswap", "ARBT": "ARBITRAGE", @@ -1041,6 +1069,7 @@ "ASBNB": "Astherus Staked BNB", "ASC": "All InX SMART CHAIN", "ASCEND": "Ascend", + "ASCN": "AlphaScan", "ASD": "AscendEX Token", "ASDEX": "AstraDEX", "ASEED": "aUSD SEED (Acala)", @@ -1082,6 +1111,7 @@ "ASTA": "ASTA", "ASTER": "Aster", "ASTERINU": "Aster INU", + "ASTHERUSUSDF": "Astherus USDF", "ASTO": "Altered State Token", "ASTON": "Aston", "ASTONV": "Aston Villa Fan Token", @@ -1133,6 +1163,7 @@ "ATLA": "Atleta Network", "ATLAS": "Star Atlas", "ATLASD": "Atlas DEX", + "ATLASOFUSA": "Atlas", "ATLX": "Atlantis Loans Polygon", "ATM": "Atletico de Madrid Fan Token", "ATMA": "ATMA", @@ -1151,6 +1182,7 @@ "ATON": "Further Network", "ATOPLUS": "ATO+", "ATOR": "ATOR Protocol", + "ATOS": "Atoshi", "ATOZ": "Race Kingdom", "ATP": "Atlas Protocol", "ATPAY": "AtPay", @@ -1173,6 +1205,7 @@ "AUCO": "Advanced United Continent", "AUCTION": "Bounce", "AUDC": "Aussie Digital", + "AUDD": "Australian Digital Dollar", "AUDF": "Forte AUD", "AUDIO": "Audius", "AUDM": "Macropod Stablecoin", @@ -1260,6 +1293,7 @@ "AWARE": "ChainAware.ai", "AWARETOKEN": "AWARE", "AWAX": "AWAX", + "AWBTC": "Aave interest bearing WBTC", "AWC": "Atomic Wallet Coin", "AWE": "AWE Network", "AWK": "Awkward Monkey Base", @@ -1302,6 +1336,7 @@ "AXT": "AIX", "AXYS": "Axys", "AYA": "Aryacoin", + "AYFI": "Aave YFI", "AYNI": "Ayni Gold", "AZ": "Azbit", "AZA": "Kaliza", @@ -1312,8 +1347,10 @@ "AZIT": "Azit", "AZNX": "AstraZeneca xStock", "AZR": "Azure", + "AZTEC": "AZTEC", "AZU": "Azultec", "AZUKI": "Azuki", + "AZUKI2": "AZUKI 2.0", "AZUKIDAO": "AzukiDAO", "AZUM": "Azuma Coin", "AZUR": "Azuro Protocol", @@ -1340,6 +1377,7 @@ "BABI": "Babylons", "BABL": "Babylon Finance", "BABY": "Babylon", + "BABY4": "Baby 4", "BABYANDY": "Baby Andy", "BABYASTER": "Baby Aster", "BABYB": "Baby Bali", @@ -1354,6 +1392,7 @@ "BABYBOME": "Book of Baby Memes", "BABYBOMEOW": "Baby of BOMEOW", "BABYBONK": "Baby Bonk", + "BABYBOOM": "BabyBoomToken", "BABYBOSS": "Baby Boss", "BABYBROC": "Baby Broccoli", "BABYBROCCOL": "Baby Broccoli", @@ -1535,6 +1574,7 @@ "BAOM": "Battle of Memes", "BAOS": "BaoBaoSol", "BAOV1": "BaoToken v1", + "BAP3X": "bAP3X", "BAR": "FC Barcelona Fan Token", "BARA": "Capybara", "BARAKATUH": "Barakatuh", @@ -1599,6 +1639,7 @@ "BB": "BounceBit", "BB1": "Bitbond", "BBADGER": "Badger Sett Badger", + "BBAION": "BigBear.ai Holdings (Ondo Tokenized)", "BBANK": "BlockBank", "BBB": "BitBullBot", "BBBTC": "Big Back Bitcoin", @@ -1632,7 +1673,7 @@ "BBS": "BBSCoin", "BBSNEK": "BabySNEK", "BBSOL": "Bybit Staked SOL", - "BBT": "BabyBoomToken", + "BBT": "BurgerBlastToken", "BBTC": "Binance Wrapped BTC", "BBTF": "Block Buster Tech Inc", "BBUSD": "BounceBit USD", @@ -1678,6 +1719,7 @@ "BCOINM": "Bomb Crypto (MATIC)", "BCOINSOL": "Bomb Crypto (SOL)", "BCOINTON": "Bomb Crypto (TON)", + "BCONG": "BabyCong", "BCOQ": "BLACK COQINU", "BCP": "BlockChainPeople", "BCPAY": "Bitcashpay", @@ -1728,13 +1770,15 @@ "BEAMMW": "Beam", "BEAN": "Bean", "BEANS": "SUNBEANS (BEANS)", - "BEAR": "Bear Inu", + "BEAR": "3X Short Bitcoin Token", "BEARIN": "Bear in Bathrobe", + "BEARINU": "Bear Inu", "BEAST": "MrBeast", "BEAT": "Beat Token", "BEATAI": "eBeat AI", "BEATLES": "JohnLennonC0IN", "BEATS": "Sol Beats", + "BEATSWAP": "BeatSwap", "BEATTOKEN": "BEAT Token", "BEAVER": "beaver", "BEB1M": "BeB", @@ -1816,7 +1860,7 @@ "BES": "battle esports coin", "BESA": "Besa Gaming", "BESHARE": "Beshare Token", - "BEST": "Bitpanda Ecosystem Token", + "BEST": "Best Wallet Token", "BESTC": "BestChain", "BETA": "Beta Finance", "BETACOIN": "BetaCoin", @@ -1851,6 +1895,7 @@ "BFLOKI": "BurnFloki", "BFLY": "Butterfly Protocol", "BFM": "BenefitMine", + "BFR": "Buffer Token", "BFT": "BF Token", "BFTB": "Brazil Fan Token", "BFTC": "BITS FACTOR", @@ -1975,7 +2020,8 @@ "BIPC": "BipCoin", "BIPX": "Bispex", "BIR": "Birake", - "BIRB": "Birb", + "BIRB": "Moonbirds", + "BIRBV1": "Birb", "BIRD": "BIRD", "BIRDCHAIN": "Birdchain", "BIRDD": "BIRD DOG", @@ -2016,6 +2062,7 @@ "BITCOINCONFI": "Bitcoin Confidential", "BITCOINOTE": "BitcoiNote", "BITCOINP": "Bitcoin Private", + "BITCOINSCRYPT": "Bitcoin Scrypt", "BITCOINV": "BitcoinV", "BITCONNECT": "BitConnect Coin", "BITCORE": "BitCore", @@ -2038,6 +2085,7 @@ "BITOK": "BitOKX", "BITONE": "BITONE", "BITORB": "BitOrbit", + "BITPANDA": "Bitpanda Ecosystem Token", "BITRA": "Bitratoken", "BITRADIO": "Bitradio", "BITREWARDS": "BitRewards", @@ -2098,6 +2146,7 @@ "BLACKSALE": "Black Sale", "BLACKST": "Black Stallion", "BLACKSWAN": "BlackSwan AI", + "BLACKWHALE": "The Black Whale", "BLADE": "BladeGames", "BLADEW": "BladeWarrior", "BLAKEBTC": "BlakeBitcoin", @@ -2143,9 +2192,11 @@ "BLOB": "B.O.B the Blob", "BLOBERC20": "Blob", "BLOC": "Blockcloud", - "BLOCK": "Blockasset", + "BLOCK": "Block", + "BLOCKASSET": "Blockasset", "BLOCKB": "Block Browser", "BLOCKBID": "Blockbid", + "BLOCKCHAINTRADED": "Blockchain Traded Fund", "BLOCKF": "Block Farm Club", "BLOCKG": "BlockGames", "BLOCKIFY": "Blockify.Games", @@ -2218,6 +2269,7 @@ "BM": "BitMoon", "BMAGA": "Baby Maga", "BMARS": "Binamars", + "BMAX": "BMAX", "BMB": "Beamable Network Token", "BMBO": "Bamboo Coin", "BMC": "Blackmoon Crypto", @@ -2295,6 +2347,7 @@ "BNPL": "BNPL Pay", "BNR": "BiNeuro", "BNRTX": "BnrtxCoin", + "BNRY": "Binary Coin", "BNS": "BNS token", "BNSAI": "bonsAI Network", "BNSD": "BNSD Finance", @@ -2479,9 +2532,10 @@ "BOSSCOQ": "THE COQFATHER", "BOST": "BoostCoin", "BOSU": "Bosu Inu", - "BOT": "Bot Planet", + "BOT": "HyperBot", "BOTC": "BotChain", "BOTIFY": "BOTIFY", + "BOTPLANET": "Bot Planet", "BOTS": "ArkDAO", "BOTTO": "Botto", "BOTX": "BOTXCOIN", @@ -2495,6 +2549,7 @@ "BOWSC": "BowsCoin", "BOWSER": "Bowser", "BOX": "DeBoxToken", + "BOXABL": "BOXABL", "BOXCAT": "BOXCAT", "BOXETH": "Cat-in-a-Box Ether", "BOXT": "BOX Token", @@ -2516,6 +2571,7 @@ "BPD": "Beautiful Princess Disorder", "BPDAI": "Binance-Peg Dai (Binance Bridge)", "BPDOGE": "Binance-Peg DogeZilla (Binance Bridge)", + "BPEPE": "BABY PEPE", "BPEPEF": "Baby Pepe Floki", "BPET": "BPET", "BPINKY": "BPINKY", @@ -2579,6 +2635,7 @@ "BRETTGOLD": "Brett Gold", "BRETTONETH": "Brett ETH", "BRETTSUI": "Brett (brettsui.com)", + "BREV": "Brevis Token", "BREW": "CafeSwap Token", "BREWERY": "Brewery Consortium Coin", "BREWLABS": "Brewlabs", @@ -2737,6 +2794,7 @@ "BTCAS": "BitcoinAsia", "BTCAT": "Bitcoin Cat", "BTCB": "Bitcoin BEP2", + "BTCBAM": "BitCoin Bam", "BTCBASE": "Bitcoin on Base", "BTCBR": "Bitcoin BR", "BTCBRV1": "Bitcoin BR v1", @@ -2752,6 +2810,7 @@ "BTCHD": "Bitcoin HD", "BTCINU": "Bitcoin Inu", "BTCIX": "BITCOLOJIX", + "BTCJ": "Bitcoin (JustCrypto)", "BTCK": "Bitcoin Turbo Koin", "BTCL": "BTC Lite", "BTCM": "BTCMoon", @@ -2767,10 +2826,10 @@ "BTCR": "BitCurrency", "BTCRED": "Bitcoin Red", "BTCRY": "BitCrystal", - "BTCS": "Bitcoin Scrypt", + "BTCS": "BTCs", "BTCSR": "BTC Strategic Reserve", "BTCST": "BTC Standard Hashrate Token", - "BTCT": "Bitcoin Token", + "BTCTOKEN": "Bitcoin Token", "BTCUS": "Bitcoinus", "BTCV": "Bitcoin Vault", "BTCVB": "BitcoinVB", @@ -2782,9 +2841,10 @@ "BTELEGRAM": "BetterTelegram Token", "BTEV1": "Betero v1", "BTEX": "BTEX", - "BTF": "Blockchain Traded Fund", + "BTF": "Bitfinity Network", "BTFA": "Banana Task Force Ape", "BTG": "Bitcoin Gold", + "BTGON": "B2Gold (Ondo Tokenized)", "BTH": "Bithereum", "BTK": "Bostoken", "BTL": "Bitlocus", @@ -2927,13 +2987,16 @@ "BURRRD": "BURRRD", "BURT": "BURT", "BUSD": "Binance USD", + "BUSD0": "Bond USD0", "BUSDC": "BUSD", "BUSY": "Busy DAO", "BUT": "Bucket Token", "BUTT": "Buttercat", + "BUTTC": "Buttcoin", "BUTTCOIN": "The Next Bitcoin", "BUTTHOLE": "Butthole Coin", "BUTTPLUG": "fartcoin killer", + "BUTWHY": "ButWhy", "BUX": "BUX", "BUXCOIN": "Buxcoin", "BUY": "Burency", @@ -2965,6 +3028,7 @@ "BXA": "Blockchain Exchange Alliance", "BXBT": "BoxBet", "BXC": "BonusCloud", + "BXE": "Banxchange", "BXF": "BlackFort Token", "BXH": "BXH", "BXK": "Bitbook Gambling", @@ -3030,7 +3094,7 @@ "CAG": "Change", "CAGA": "Crypto Asset Governance Alliance", "CAH": "Moon Tropica", - "CAI": "Chasm", + "CAI": "CharacterX", "CAID": "ClearAid", "CAILA": "Caila", "CAIR": "Crypto-AI-Robo.com", @@ -3126,7 +3190,8 @@ "CASPER": "Casper DeFi", "CASPERTOKEN": "Casper Token", "CASPUR": "Caspur Zoomies", - "CAST": "Castello Coin", + "CAST": "CAST ORACLES", + "CASTELLOCOIN": "Castello Coin", "CASTLE": "bitCastle", "CAT": "Simon's Cat", "CATA": "CATAMOTO", @@ -3143,6 +3208,7 @@ "CATCO": "CatCoin", "CATCOIN": "CatCoin", "CATCOINETH": "Catcoin", + "CATCOINIO": "Catcoin", "CATCOINOFSOL": "Cat Coin", "CATCOINV2": "CatCoin Cash", "CATDOG": "Cat-Dog", @@ -3187,6 +3253,7 @@ "CATW": "Cat wif Hands", "CATWARRIOR": "Cat warrior", "CATWIF": "CatWifHat", + "CATWIFM": "catwifmask", "CATX": "CAT.trade Protocol", "CATZ": "CatzCoin", "CAU": "Canxium", @@ -3236,6 +3303,7 @@ "CBX": "CropBytes", "CBXRP": "Coinbase Wrapped XRP", "CBY": "Carbify", + "CBYTE": "CBYTE", "CC": "Canton Coin", "CC10": "Cryptocurrency Top 10 Tokens Index", "CCA": "CCA", @@ -3310,6 +3378,7 @@ "CENTRA": "Centra", "CENTS": "Centience", "CENX": "Centcex", + "CEO": "CEO", "CEODOGE": "CEO DOGE", "CERBER": "CERBEROGE", "CERE": "Cere Network", @@ -3384,12 +3453,14 @@ "CHARGED": "GoCharge Tech", "CHARIZARD": "Charizard Inu", "CHARL": "Charlie", + "CHARLI": "Charlie Trump Dog", "CHARLIE": "Charlie Kirk", "CHARM": "Charm Coin", "CHARS": "CHARS", "CHART": "BetOnChart", "CHARTA": "CHARTAI", "CHARTIQ": "ChartIQ", + "CHAS": "Chasm", "CHASH": "CleverHash", "CHAT": "Solchat", "CHATAI": "ChatAI Token", @@ -3496,6 +3567,7 @@ "CHR": "Chroma", "CHRETT": "Chinese BRETT", "CHRISPUMP": "Christmas Pump", + "CHRONOEFFE": "Chronoeffector", "CHRP": "Chirpley", "CHS": "Chainsquare", "CHSB": "SwissBorg", @@ -3519,6 +3591,7 @@ "CIC": "Crazy Internet Coin", "CICHAIN": "CIChain", "CIF": "Crypto Improvement Fund", + "CIFRON": "Cipher Mining (Ondo Tokenized)", "CIG": "cig", "CIM": "COINCOME", "CIN": "CinderCoin", @@ -3545,6 +3618,7 @@ "CIX100": "Cryptoindex", "CJ": "CryptoJacks", "CJC": "CryptoJournal", + "CJL": "Cjournal", "CJR": "Conjure", "CJT": "ConnectJob Token", "CKB": "Nervos Network", @@ -3564,6 +3638,8 @@ "CLASH": "GeorgePlaysClashRoyale", "CLASHUB": "Clashub", "CLASS": "Class Coin", + "CLAWD": "clawd.atg.eth", + "CLAWNCH": "CLAWNCH", "CLAY": "Clayton", "CLAYN": "Clay Nation", "CLB": "Cloudbric", @@ -3588,7 +3664,8 @@ "CLIN": "Clinicoin", "CLINK": "cLINK", "CLINT": "Clinton", - "CLIPPY": "CLIPPY", + "CLIPPY": "Clippy", + "CLIPPYETH": "CLIPPY", "CLIPS": "Clips", "CLIQ": "DefiCliq", "CLIST": "Chainlist", @@ -3650,6 +3727,7 @@ "CMPT": "Spatial Computing", "CMPV2": "Caduceus Protocol", "CMQ": "Communique", + "CMR": "U.S Critical Mineral Reserve", "CMS": "COMSA", "CMSN": "The Commission", "CMT": "CyberMiles", @@ -3695,7 +3773,8 @@ "COC": "Coin of the champions", "COCAINE": "THE GOOD STUFF", "COCK": "Shibacock", - "COCO": "COCO COIN", + "COCO": "coco", + "COCOCOIN": "COCO COIN", "COCONUT": "Coconut", "COCOR": "Cocoro", "COCORO": "Cocoro", @@ -3726,8 +3805,10 @@ "COI": "Coinnec", "COINAI": "Coinbase AI Agent", "COINB": "Coinbidex", + "COINBANK": "CoinBank", "COINBT": "CoinBot", "COINBUCK": "Coinbuck", + "COINCOLLECT": "CoinCollect", "COINDEALTOKEN": "CoinDeal Token", "COINDEFI": "Coin", "COINDEPO": "CoinDepo Token", @@ -3737,6 +3818,7 @@ "COINLION": "CoinLion", "COINM": "CoinMarketPrime", "COINONAT": "Coinonat", + "COINRADR": "CoinRadr", "COINSCOPE": "Coinscope", "COINSL": "CoinsLoot", "COINVEST": "Coinvest", @@ -3750,11 +3832,12 @@ "COLA": "Cola", "COLISEUM": "Coliseum", "COLL": "Collateral Pay", + "COLLAB": "Collab.Land", "COLLAR": "PolyPup Finance", "COLLAT": "Collaterize", "COLLE": "Collective Care", "COLLEA": "Colle AI", - "COLLECT": "CoinCollect", + "COLLECT": "Collect on Fanable", "COLLG": "Collateral Pay Governance", "COLON": "Colon", "COLR": "colR Coin", @@ -3813,6 +3896,7 @@ "COPIUM": "Copium", "COPPER": "COPPER", "COPS": "Cops Finance", + "COPXON": "Global X Copper Miners ETF (Ondo Tokenized)", "COPYCAT": "Copycat Finance", "COQ": "Coq Inu", "COR": "Coreto", @@ -3890,6 +3974,7 @@ "CPLO": "Cpollo", "CPM": "Crypto Pump Meme", "CPN": "CompuCoin", + "CPNGON": "Coupang (Ondo Tokenized)", "CPO": "Cryptopolis", "CPOO": "Cockapoo", "CPOOL": "Clearpool", @@ -3922,6 +4007,7 @@ "CRAPPY": "CrappyBird", "CRASH": "Solana Crash", "CRASHBOYS": "CRASHBOYS", + "CRAT": "CratD2C", "CRAVE": "CraveCoin", "CRAYRABBIT": "CrazyRabbit", "CRAZ": "CRAZY FLOKI", @@ -3970,6 +4056,7 @@ "CREPECOIN": "Crepe Coin", "CRES": "Cresio", "CRESV1": "Cresio v1", + "CRETA": "Creta World", "CREV": "CryptoRevolution", "CREVA": "Creva Coin", "CREW": "CREW INU", @@ -4356,6 +4443,7 @@ "DANJ": "Danjuan Cat", "DANK": "DarkKush", "DANKDOGE": "Dank Doge", + "DANKDOGEAI": "DankDogeAI", "DANNY": "Degen Danny", "DAO": "DAO Maker", "DAO1": "DAO1", @@ -4552,6 +4640,7 @@ "DEFIL": "DeFIL", "DEFILAB": "Defi", "DEFISCALE": "DeFiScale", + "DEFISSI": "DEFI.ssi", "DEFIT": "Digital Fitness", "DEFLA": "Defla", "DEFLCT": "Deflect", @@ -4778,8 +4867,10 @@ "DINGER": "Dinger Token", "DINGO": "Dingocoin", "DINNER": "Trump Dinner", - "DINO": "DinoLFG", + "DINO": "DINO", + "DINOLFG": "DinoLFG", "DINOS": "Dinosaur Inu", + "DINOSOL": "DINOSOL", "DINOSWAP": "DinoSwap", "DINT": "DinarTether", "DINU": "Dogey-Inu", @@ -4840,6 +4931,7 @@ "DLXV": "Delta-X", "DLY": "Daily Finance", "DLYCOP": "Daily COP", + "DM": "Dumb Money", "DMA": "Dragoma", "DMAGA": "Dark MAGA", "DMAIL": "DMAIL Network", @@ -4863,6 +4955,7 @@ "DMTR": "Dimitra", "DMX": "Dymmax", "DMZ": "DeMon Token", + "DN": "DeepNode", "DN8": "Pldgr", "DNA": "Metaverse", "DNAPEPE": "DNA PEPE", @@ -4872,6 +4965,7 @@ "DNFLX": "Netflix Tokenized Stock Defichain", "DNFT": "DareNFT", "DNN": "DNN Token", + "DNNON": "Denison Mines (Ondo Tokenized)", "DNO": "Denaro", "DNODE": "DecentraNode", "DNOTES": "Dnotes", @@ -4947,6 +5041,7 @@ "DOGEIN": "Doge In Glasses", "DOGEINU": "Doge Inu", "DOGEIUS": "DOGEIUS", + "DOGEJ": "Dogecoin (JustCrypto)", "DOGEKING": "DogeKing", "DOGELEGION": "DOGE LEGION", "DOGEM": "Doge Matrix", @@ -4974,6 +5069,7 @@ "DOGEZILLA": "DogeZilla", "DOGEZILLAV1": "DogeZilla v1", "DOGG": "Doggo", + "DOGGO": "DOGGO", "DOGGS": "Doggensnout", "DOGGY": "Doggy", "DOGGYCOIN": "DOGGY", @@ -5035,7 +5131,8 @@ "DONKEY": "donkey", "DONNIEFIN": "Donnie Finance", "DONS": "The Dons", - "DONT": "Donald Trump (dont.cash)", + "DONT": "DisclaimerCoin", + "DONTCASH": "DONT", "DONU": "Donu", "DONUT": "Donut", "DONUTS": "The Simpsons", @@ -5390,6 +5487,7 @@ "ECET": "Evercraft Ecotechnologies", "ECG": "EcoSmart", "ECH": "EthereCash", + "ECHELON": "Echelon Token", "ECHO": "Echo", "ECHOBOT": "ECHO BOT", "ECHOD": "EchoDEX", @@ -5487,6 +5585,7 @@ "EGGC": "EggCoin", "EGGMAN": "Eggman Inu", "EGGP": "Eggplant Finance", + "EGGT": "Egg N Partners", "EGGY": "EGGY", "EGI": "eGame", "EGL": "The Eagle Of Truth", @@ -5507,6 +5606,7 @@ "EHASH": "EHash", "EHIVE": "eHive", "EHRT": "Eight Hours Token", + "EICOIN": "EICOIN", "EIFI": "EIFI FINANCE", "EIGEN": "EigenLayer", "EIGENP": "Eigenpie", @@ -5591,6 +5691,7 @@ "ELONTRUMP": "ELON TRUMP", "ELP": "Ellerium", "ELS": "Ethlas", + "ELSA": "Elsa", "ELT": "Element Black", "ELTC2": "eLTC", "ELTCOIN": "ELTCOIN", @@ -5661,6 +5762,7 @@ "ENEDEX": "Enedex", "ENERGYLEDGER": "Energy Ledger", "ENERGYX": "Safe Energy", + "ENEXSPACE": "ENEX", "ENF": "enfineo", "ENG": "Enigma", "ENGT": "Engagement Token", @@ -5683,6 +5785,7 @@ "ENTER": "EnterCoin", "ENTR": "EnterDAO", "ENTRC": "ENTER COIN", + "ENTROPY": "Entropy", "ENTRP": "Hut34 Project", "ENTRY": "ENTRY", "ENTS": "Ents", @@ -5692,7 +5795,7 @@ "ENVIENTA": "Envienta", "ENVION": "Envion", "ENVOY": "Envoy A.I", - "ENX": "ENEX", + "ENX": "Enigma", "EOC": "EveryonesCoin", "EON": "Exscudo", "EONC": "Dimension", @@ -5711,6 +5814,7 @@ "EPETS": "Etherpets", "EPIC": "Epic Chain", "EPICCASH": "Epic Cash", + "EPICV1": "Ethernity Chain", "EPIK": "EPIK Token", "EPIKO": "Epiko", "EPIX": "Byepix", @@ -5743,6 +5847,7 @@ "ERA7": "Era Token", "ERASWAP": "Era Swap Token", "ERB": "ERBCoin", + "ERBB": "Exchange Request for Bitbon", "ERC": "EuropeCoin", "ERC20": "ERC20", "ERC20V1": "ERC20 v1", @@ -5781,7 +5886,7 @@ "ESGC": "ESG Chain", "ESH": "Switch", "ESHIB": "Euro Shiba Inu", - "ESIM": "EvoSimGame", + "ESIM": "DEPINSIM Token", "ESM": "EL SALVADOR MEME", "ESN": "Ethersocial", "ESNC": "Galaxy Arena Metaverse", @@ -5832,6 +5937,7 @@ "ETHEREM": "Etherempires", "ETHEREUMMEME": "Solana Ethereum Meme", "ETHEREUMP": "ETHEREUMPLUS", + "ETHEREUMSCRYPT": "EthereumScrypt", "ETHERINC": "EtherInc", "ETHERKING": "Ether Kingdoms Token", "ETHERNITY": "Ethernity Chain", @@ -5842,6 +5948,7 @@ "ETHFI": "Ether.fi", "ETHI": "Ethical Finance", "ETHIX": "EthicHub", + "ETHJ": "Ethereum (JustCrypto)", "ETHM": "Ethereum Meta", "ETHO": "The Etho Protocol", "ETHOS": "Ethos Project", @@ -5853,7 +5960,7 @@ "ETHPR": "Ethereum Premium", "ETHPY": "Etherpay", "ETHR": "Ethereal", - "ETHS": "EthereumScrypt", + "ETHS": "Ethscriptions", "ETHSHIB": "Eth Shiba", "ETHV": "Ethverse", "ETHW": "Ethereum PoW", @@ -5914,6 +6021,7 @@ "EURU": "Upper Euro", "EURX": "eToro Euro", "EUSD": "Egoras Dollar", + "EUSX": "eUSX", "EUT": "EarnUp Token", "EUTBL": "Spiko EU T-Bills Money Market Fund", "EV": "EVAI", @@ -5952,6 +6060,7 @@ "EVOC": "EVOCPLUS", "EVOL": "EVOL NETWORK", "EVOS": "EVOS", + "EVOSIM": "EvoSimGame", "EVOVERSES": "EvoVerses", "EVR": "Everus", "EVRICE": "Evrice", @@ -6026,6 +6135,7 @@ "F2C": "Ftribe Fighters", "F2K": "Farm2Kitchen", "F3": "Friend3", + "F5": "F5-promoT5", "F7": "Five7", "F9": "Falcon Nine", "FAB": "FABRK Token", @@ -6066,6 +6176,7 @@ "FAN": "Fanadise", "FAN360": "Fan360", "FANC": "fanC", + "FANCYTHATTOKEN": "Fancy That", "FAND": "Fandomdao", "FANG": "FANG Token", "FANS": "Fantasy Cash", @@ -6140,6 +6251,7 @@ "FCTC": "FaucetCoin", "FCTR": "FactorDAO", "FDC": "Fidance", + "FDGC": "FINTECH DIGITAL GOLD COIN", "FDLS": "FIDELIS", "FDM": "Fandom", "FDO": "Firdaos", @@ -6211,6 +6323,7 @@ "FIC": "Filecash", "FID": "Fidira", "FIDA": "Bonfida", + "FIDD": "Fidelity Digital Dollar", "FIDLE": "Fidlecoin", "FIDO": "FIDO", "FIDU": "Fidu", @@ -6221,7 +6334,7 @@ "FIFTY": "FIFTYONEFIFTY", "FIG": "FlowCom", "FIGH": "FIGHT FIGHT FIGHT", - "FIGHT": "Fight to MAGA", + "FIGHT2MAGA": "Fight to MAGA", "FIGHTMAGA": "FIGHT MAGA", "FIGHTPEPE": "FIGHT PEPE", "FIGHTRUMP": "FIGHT TRUMP", @@ -6287,6 +6400,7 @@ "FK": "FK Coin", "FKBIDEN": "Fkbiden", "FKGARY": "Fuck Gary Gensler", + "FKH": "Flying Ketamine Horse", "FKPEPE": "Fuck Pepe", "FKR": "Flicker", "FKRPRO": "FlickerPro", @@ -6412,7 +6526,7 @@ "FNB": "FNB protocol", "FNC": "Fancy Games", "FNCT": "Financie Token", - "FNCY": "Fancy That", + "FNCY": "FNCY", "FND": "Rare FND", "FNDZ": "FNDZ Token", "FNF": "FunFi", @@ -6440,7 +6554,10 @@ "FOFARIO": "Fofar", "FOFO": "FOFO", "FOFOTOKEN": "FOFO Token", + "FOG": "FOGnet", "FOGE": "Fat Doge", + "FOGO": "Fogo", + "FOGV1": "FOGnet v1", "FOIN": "Foin", "FOL": "Folder Protocol", "FOLD": "Manifold Finance", @@ -6466,6 +6583,7 @@ "FORA": "UFORIKA", "FORCE": "TriForce Tokens", "FORCEC": "Force Coin", + "FORDON": "Ford Motor (Ondo Tokenized)", "FORE": "FORE Protocol", "FOREFRONT": "Forefront", "FOREST": "Forest", @@ -6595,6 +6713,7 @@ "FRR": "Frontrow", "FRSP": "Forkspot", "FRST": "FirstCoin", + "FRT": "FORT Token", "FRTC": "FART COIN", "FRTN": "EbisusBay Fortune", "FRTS": "Fruits", @@ -6709,6 +6828,7 @@ "FWB": "Friends With Benefits Pro", "FWBV1": "Friends With Benefits Pro v1", "FWC": "Qatar 2022", + "FWCL": "Legends", "FWH": "FigureWifHat", "FWOG": "Fwog", "FWT": "Freeway Token", @@ -6824,6 +6944,7 @@ "GASP": "GASP", "GASPCOIN": "gAsp", "GASS": "Gasspas", + "GAST": "Gas Town", "GASTRO": "GastroCoin", "GAT": "Gather", "GATA": "Gata", @@ -7042,6 +7163,7 @@ "GIGASWAP": "GigaSwap", "GIGGLE": "Giggle Fund", "GIGGLEACADEMY": "Giggle Academy", + "GIGL": "GIGGLE PANDA", "GIGS": "Climate101", "GIGX": "GigXCoin", "GIKO": "Giko Cat", @@ -7131,6 +7253,7 @@ "GMDP": "GMD Protocol", "GME": "GameStop", "GMEE": "GAMEE", + "GMEON": "GameStop (Ondo Tokenized)", "GMEPEPE": "GAMESTOP PEPE", "GMETHERFRENS": "GM", "GMETRUMP": "GME TRUMP", @@ -7209,6 +7332,7 @@ "GOFINDXR": "Gofind XR", "GOFX": "GooseFX", "GOG": "Guild of Guardians", + "GOGE": "GOLD DOGE", "GOGLZ": "GOGGLES", "GOGLZV1": "GOGGLES v1", "GOGO": "GOGO Finance", @@ -7319,10 +7443,12 @@ "GPU": "Node AI", "GPUCOIN": "GPU Coin", "GPUINU": "GPU Inu", + "GPUNET": "GPUnet", "GPX": "GPEX", "GQ": "Galactic Quadrant", "GR": "GROM", "GRAB": "GRABWAY", + "GRABON": "Grab Holdings (Ondo Tokenized)", "GRACY": "Gracy", "GRAI": "Gravita Protocol", "GRAIL": "Camelot Token", @@ -7331,6 +7457,7 @@ "GRAND": "Grand Theft Ape", "GRANDCOIN": "GrandCoin", "GRANDMA": "Grandma", + "GRANT": "GrantiX Token", "GRAPE": "GrapeCoin", "GRAPHGRAIAI": "GraphGrail AI", "GRASS": "Grass", @@ -7455,6 +7582,7 @@ "GTAN": "Giant Token", "GTAVI": "GTAVI", "GTBOT": "Gaming-T-Bot", + "GTBTC": "Gate Wrapped BTC", "GTC": "Gitcoin", "GTCC": "GTC COIN", "GTCOIN": "Game Tree", @@ -7517,6 +7645,7 @@ "GVT": "Genesis Vision", "GW": "Gyrowin", "GWD": "GreenWorld", + "GWEI": "ETHGas", "GWGW": "GoWrap", "GWT": "Galaxy War", "GX": "GameX", @@ -7558,6 +7687,7 @@ "HACHIKO": "Hachiko Inu Token", "HACHIONB": "Hachi On Base", "HACK": "HACK", + "HADES": "Hades", "HAEDAL": "Haedal Protocol", "HAGGIS": "New Born Haggis Pygmy Hippo", "HAHA": "Hasaki", @@ -7572,6 +7702,7 @@ "HALF": "0.5X Long Bitcoin Token", "HALFP": "Half Pizza", "HALFSHIT": "0.5X Long Shitcoin Index Token", + "HALIS": "Halis", "HALLO": "Halloween Coin", "HALLOWEEN": "HALLOWEEN", "HALO": "Halo Coin", @@ -7772,6 +7903,7 @@ "HIENS3": "hiENS3", "HIENS4": "hiENS4", "HIFI": "Hifi Finance", + "HIFIDENZA": "hiFIDENZA", "HIFLUF": "hiFLUF", "HIFRIENDS": "hiFRIENDS", "HIGAZERS": "hiGAZERS", @@ -7811,6 +7943,7 @@ "HITBTC": "HitBTC Token", "HITOP": "Hitop", "HIUNDEAD": "hiUNDEAD", + "HIVALHALLA": "hiVALHALLA", "HIVE": "Hive", "HIVP": "HiveSwap", "HIX": "HELIX Orange", @@ -7829,13 +7962,13 @@ "HLG": "Holograph", "HLINK": "Chainlink (Harmony One Bridge)", "HLM": "Helium", - "HLN": "Holonus", + "HLN": "Ēnosys", "HLO": "Halo", "HLOV1": "Halo v1", "HLP": "Purpose Coin", "HLPR": "HELPER COIN", "HLPT": "HLP Token", - "HLS": "Halis", + "HLS": "Helios", "HLT": "HyperLoot", "HLTC": "Huobi LTC", "HLX": "Helex", @@ -7894,6 +8027,7 @@ "HOLDON4": "HoldOn4DearLife", "HOLDS": "Holdstation", "HOLO": "Holoworld", + "HOLON": "Holonus", "HOLY": "Holy Trinity", "HOM": "Homeety", "HOME": "Home", @@ -7916,6 +8050,7 @@ "HONOR": "HonorLand", "HONX": "Honeywell xStock", "HOODOG": "Hoodog", + "HOODON": "Robinhood Markets (Ondo Tokenized)", "HOODRAT": "Hoodrat Coin", "HOODX": "Robinhood xStock", "HOOF": "Metaderby Hoof", @@ -7984,6 +8119,7 @@ "HSN": "Hyper Speed Network", "HSOL": "Helius Staked SOL", "HSP": "Horse Power", + "HSR": "Hshare", "HSS": "Hashshare", "HST": "Decision Token", "HSUI": "Suicune", @@ -8005,6 +8141,7 @@ "HTN": "Hoosat Network", "HTO": "Heavenland HTO", "HTR": "Hathor", + "HTS": "Home3", "HTT": "Hello Art", "HTX": "HTX", "HTZ": "Hertz Network", @@ -8111,6 +8248,7 @@ "IAI": "inheritance Art", "IAM": "IAME Identity", "IAOMIN": "Yao Ming", + "IAUON": "iShares Gold Trust (Ondo Tokenized)", "IB": "Iron Bank", "IBANK": "iBankCoin", "IBAT": "Battle Infinity", @@ -8179,6 +8317,7 @@ "IDLE": "IDLE", "IDM": "IDM", "IDNA": "Idena", + "IDNG": "IDNGold", "IDO": "Idexo", "IDOL": "MEET48 Token", "IDOLINU": "IDOLINU", @@ -8268,7 +8407,8 @@ "IMS": "Independent Money System", "IMST": "Imsmart", "IMT": "Immortal Token", - "IMU": "imusify", + "IMU": "Immunefi", + "IMUSIFY": "imusify", "IMVR": "ImmVRse", "IMX": "Immutable X", "IN": "INFINIT", @@ -8342,12 +8482,14 @@ "INSPI": "InspireAI", "INSR": "Insurabler", "INSTAMINE": "Instamine Nuggets", + "INSTANTSPONSOR": "Instant Sponsor Token", "INSTAR": "Insights Network", "INSUR": "InsurAce", "INSURANCE": "insurance", "INSURC": "InsurChain Coin", "INSUREDFIN": "Insured Finance", "INT": "Internet Node token", + "INTCON": "Intel (Ondo Tokenized)", "INTCX": "Intel xStock", "INTD": "INTDESTCOIN", "INTE": "InteractWith", @@ -8427,9 +8569,11 @@ "IQN": "IQeon", "IQQ": "Iqoniq", "IQT": "IQ Protocol", + "IR": "Infrared Governance Token", "IRA": "Diligence", "IRC": "IRIS", "IRENA": "Irena Coin Apps", + "IRENON": "IREN (Ondo Tokenized)", "IRIS": "IRIS Network", "IRISTOKEN": "Iris Ecosystem", "IRL": "IrishCoin", @@ -8503,6 +8647,7 @@ "IVZ": "InvisibleCoin", "IW": "iWallet", "IWFT": "İstanbul Wild Cats", + "IWMON": "iShares Russell 2000 ETF (Ondo Tokenized)", "IWT": "IwToken", "IX": "X-Block", "IXC": "IXcoin", @@ -8527,6 +8672,7 @@ "J9BC": "J9CASINO", "JACK": "Jack Token", "JACKPOT": "Solana Jackpot", + "JACKSON": "Jackson", "JACS": "JACS", "JACY": "JACY", "JADE": "Jade Protocol", @@ -8558,6 +8704,7 @@ "JAWN": "Long Jawn Silvers", "JAWS": "AutoShark", "JAY": "Jaypeggers", + "JBC": "Japan Brand Coin", "JBO": "JBOX", "JBOT": "JACKBOT", "JBS": "JumBucks Coin", @@ -8596,6 +8743,7 @@ "JERRYINUCOM": "Jerry Inu", "JES": "Jesus", "JESSE": "jesse", + "JESSECOIN": "jesse", "JEST": "Jester", "JESUS": "Jesus Coin", "JET": "Jet Protocol", @@ -8603,6 +8751,7 @@ "JETCOIN": "Jetcoin", "JETFUEL": "Jetfuel Finance", "JETTON": "JetTon Game", + "JETUSD": "JETUSD", "JEUR": "Jarvis Synthetic Euro", "JEW": "Shekel", "JEWEL": "DeFi Kingdoms", @@ -8614,6 +8763,7 @@ "JFIVE": "Jonny Five", "JFOX": "JuniperFox AI", "JFP": "JUSTICE FOR PEANUT", + "JGGL": "JGGL Token", "JGLP": "Jones GLP", "JGN": "Juggernaut", "JHH": "Jen-Hsun Huang", @@ -8624,6 +8774,7 @@ "JIM": "Jim", "JIN": "JinPeng", "JIND": "JINDO INU", + "JINDO": "JINDOGE", "JINDOGE": "Jindoge", "JIO": "JIO Token", "JITOSOL": "Jito Staked SOL", @@ -8650,6 +8801,7 @@ "JNX": "Janex", "JNY": "JNY", "JOB": "Jobchain", + "JOBCOIN": "buy instead of getting a job", "JOBIESS": "JobIess", "JOBS": "JobsCoin", "JOBSEEK": "JobSeek AI", @@ -8665,7 +8817,8 @@ "JOHNNY": "Johnny The Bull", "JOINCOIN": "JoinCoin", "JOINT": "Joint Ventures", - "JOJO": "JOJO", + "JOJO": "JOJOWORLD", + "JOJOSCLUB": "JOJO", "JOJOTOKEN": "JOJO", "JOK": "JokInTheBox", "JOKER": "JOKER", @@ -8749,6 +8902,7 @@ "JWBTC": "Wrapped Bitcoin (TON Bridge)", "JWIF": "Jerrywifhat", "JWL": "Jewels", + "JWT": "JW Token", "JYAI": "Jerry The Turtle By Matt Furie", "JYC": "Joe-Yo Coin", "K": "Sidekick", @@ -8757,7 +8911,13 @@ "KAAI": "KanzzAI", "KAAS": "KAASY.AI", "KAB": "KABOSU", - "KABOSU": "Kabosu Family", + "KABOSU": "X Meme Dog", + "KABOSUCOIN": "Kabosu", + "KABOSUCOM": "Kabosu", + "KABOSUFAMILY": "Kabosu Family", + "KABOSUTOKEN": "Kabosu", + "KABOSUTOKENETH": "KABOSU", + "KABUTO": "Kabuto", "KABY": "Kaby Arena", "KAC": "KACO Finance", "KACY": "markkacy", @@ -8865,6 +9025,7 @@ "KDC": "Klondike Coin", "KDG": "Kingdom Game 4.0", "KDIA": "KDIA COIN", + "KDK": "Kodiak Token", "KDOE": "Kudoe", "KDOGE": "KingDoge", "KDT": "Kenyan Digital Token", @@ -8926,6 +9087,7 @@ "KGC": "Krypton Galaxy Coin", "KGEN": "KGeN", "KGO": "Kiwigo", + "KGST": "KGST", "KGT": "Kaby Gaming Token", "KHAI": "khai", "KHEOWZOO": "khaokheowzoo", @@ -9055,6 +9217,7 @@ "KNDC": "KanadeCoin", "KNDM": "Kingdom", "KNDX": "Kondux", + "KNEKTED": "Knekted", "KNFT": "KStarNFT", "KNG": "BetKings", "KNGN": "KingN Coin", @@ -9067,13 +9230,14 @@ "KNOW": "KNOW", "KNOX": "KnoxDAO", "KNS": "Kenshi", - "KNT": "Knekted", + "KNT": "KayakNet", "KNTO": "Kento", "KNTQ": "Kinetiq Governance Token", "KNU": "Keanu", "KNUT": "Knut From Zoo", "KNUXX": "Knuxx Bully of ETH", "KNW": "Knowledge", + "KO": "Kyuzo's Friends", "KOAI": "KOI", "KOALA": "KOALA", "KOBAN": "KOBAN", @@ -9089,6 +9253,7 @@ "KOII": "Koii", "KOIN": "Koinos", "KOINB": "KoinBülteni Token", + "KOINDEX": "KOIN", "KOINETWORK": "Koi Network", "KOIP": "KoiPond", "KOJI": "Koji", @@ -9548,16 +9713,18 @@ "LIQUIDIUM": "LIQUIDIUM•TOKEN", "LIR": "Let it Ride", "LIS": "Realis Network", - "LISA": "Lisa Simpson", + "LISA": "LISA Token", "LISAS": "Lisa Simpson", + "LISASIMPSONCLUB": "Lisa Simpson", "LIST": "KList Protocol", "LISTA": "Lista DAO", "LISTEN": "Listen", "LISUSD": "lisUSD", - "LIT": "Litentry", + "LIT": "Lighter", "LITE": "Lite USD", "LITEBTC": "LiteBitcoin", "LITENETT": "Litenett", + "LITENTRY": "Litentry", "LITH": "Lithium Finance", "LITHIUM": "Lithium", "LITHO": "Lithosphere", @@ -9566,7 +9733,7 @@ "LITTLEGUY": "just a little guy", "LITTLEMANYU": "Little Manyu", "LIV": "LiviaCoin", - "LIVE": "TRONbetLive", + "LIVE": "SecondLive", "LIVENCOIN": "LivenPay", "LIVESEY": "Dr. Livesey", "LIVESTARS": "Live Stars", @@ -9689,7 +9856,7 @@ "LORY": "Yield Parrot", "LOS": "Lord Of SOL", "LOST": "Lost Worlds", - "LOT": "Lukki Operating Token", + "LOT": "League of Traders", "LOTES": "Loteo", "LOTEU": "Loteo", "LOTT": "Beauty bakery lott", @@ -9732,12 +9899,13 @@ "LQT": "Lifty", "LQTY": "Liquity", "LRC": "Loopring", + "LRCV1": "Loopring v1", "LRDS": "BLOCKLORDS", "LRG": "Largo Coin", "LRN": "Loopring [NEO]", "LRT": "LandRocker", "LSC": "LS Coin", - "LSD": "Pontem Liquidswap", + "LSD": "LSD", "LSDOGE": "LSDoge", "LSETH": "Liquid Staked ETH", "LSHARE": "LSHARE", @@ -9764,6 +9932,7 @@ "LTCC": "Listerclassic Coin", "LTCD": "LitecoinDark", "LTCH": "Litecoin Cash", + "LTCJ": "Litecoin (JustCrypto)", "LTCP": "LitecoinPro", "LTCR": "LiteCreed", "LTCU": "LiteCoin Ultra", @@ -9815,6 +9984,7 @@ "LUFFYV1": "Luffy v1", "LUIGI": "Luigi Inu", "LUIS": "Tongue Cat", + "LUKKI": "Lukki Operating Token", "LULU": "LULU", "LUM": "Luminous", "LUMA": "LUMA Token", @@ -9901,6 +10071,7 @@ "M0": "M by M^0", "M1": "SupplyShock", "M2O": "M2O Token", + "M3H": "MehVerseCoin", "M3M3": "M3M3", "M87": "MESSIER", "MA": "Mind-AI", @@ -9919,6 +10090,8 @@ "MADOG": "MarvelDoge", "MADP": "Mad Penguin", "MADPEPE": "Mad Pepe", + "MADU": "Nicolas Maduro", + "MADURO": "MADURO", "MAECENAS": "Maecenas", "MAEP": "Maester Protocol", "MAF": "MetaMAFIA", @@ -9950,6 +10123,7 @@ "MAGICK": "Cosmic Universe Magick", "MAGICV": "Magicverse", "MAGIK": "Magik Finance", + "MAGMA": "MAGMA", "MAGN": "Magnate Finance", "MAGNE": "Magnetix", "MAGNET": "Yield Magnet", @@ -10007,8 +10181,7 @@ "MANUSAI": "Manus AI Agent", "MANYU": "Manyu", "MANYUDOG": "MANYU", - "MAO": "MAO", - "MAOMEME": "Mao", + "MAO": "Mao", "MAOW": "MAOW", "MAP": "MAP Protocol", "MAPC": "MapCoin", @@ -10018,6 +10191,7 @@ "MAPS": "MAPS", "MAPU": "MatchAwards Platform Utility Token", "MAR3": "Mar3 AI", + "MARAON": "MARA Holdings (Ondo Tokenized)", "MARCO": "MELEGA", "MARCUS": "Marcus Cesar Inu", "MARE": "Mare Finance", @@ -10044,6 +10218,7 @@ "MARSMI": "MarsMi", "MARSO": "Marso.Tech", "MARSRISE": "MarsRise", + "MARSTOKEN": "Mars Token", "MARSUPILAMI": "MARSUPILAMI INU", "MARSW": "Marswap", "MART": "ArtMeta", @@ -10071,7 +10246,7 @@ "MASTERMIX": "Master MIX Token", "MASTERTRADER": "MasterTraderCoin", "MASYA": "MASYA", - "MAT": "My Master Wa", + "MAT": "Matchain", "MATA": "Ninneko", "MATAR": "MATAR AI", "MATCH": "Matching Game", @@ -10179,6 +10354,7 @@ "MCIV": "Mars Civ Project", "MCL": "McLaren F1", "MCLB": "Millennium Club Coin", + "MCM": "Mochimo", "MCN": "mCoin", "MCO": "Crypto.com", "MCO2": "Moss Carbon Credit", @@ -10198,6 +10374,7 @@ "MCU": "MediChain", "MCUSD": "Moola Celo USD", "MCV": "MCV Token", + "MCX": "MachiX Token", "MD": "MetaDeck", "MDA": "Moeda", "MDAI": "MindAI", @@ -10248,6 +10425,7 @@ "MEF": "MEFLEX", "MEFA": "Metaverse Face", "MEFAI": "META FINANCIAL AI", + "MEFI": "Meo Finance", "MEGA": "MegaFlash", "MEGABOT": "Megabot", "MEGAD": "Mega Dice Casino", @@ -10286,6 +10464,7 @@ "MEMEAI": "Meme Ai", "MEMEBRC": "MEME", "MEMECOIN": "just memecoin", + "MEMECOINDAOAI": "MemeCoinDAO", "MEMECUP": "Meme Cup", "MEMEETF": "Meme ETF", "MEMEFI": "MemeFi", @@ -10297,7 +10476,7 @@ "MEMEMUSK": "MEME MUSK", "MEMENTO": "MEMENTO•MORI (Runes)", "MEMERUNE": "MEME•ECONOMICS", - "MEMES": "MemeCoinDAO", + "MEMES": "memes will continue", "MEMESAI": "Memes AI", "MEMESQUAD": "Meme Squad", "MEMET": "MEMETOON", @@ -10366,6 +10545,7 @@ "METANIAV1": "METANIAGAMES", "METANO": "Metano", "METAPK": "Metapocket", + "METAPLACE": "Metaplace", "METAQ": "MetaQ", "METAS": "Metaseer", "METAT": "MetaTrace", @@ -10464,6 +10644,7 @@ "MICRO": "Micro GPT", "MICRODOGE": "MicroDoge", "MICROMINES": "Micromines", + "MICROVISION": "MicroVisionChain", "MIDAI": "Midway AI", "MIDAS": "Midas", "MIDASDOLLAR": "Midas Dollar Share", @@ -10484,7 +10665,7 @@ "MIININGNFT": "MiningNFT", "MIKE": "Mike", "MIKS": "MIKS COIN", - "MIL": "Milllionaire Coin", + "MIL": "Mil", "MILA": "MILADY MEME TOKEN", "MILC": "Micro Licensing Coin", "MILE": "milestoneBased", @@ -10497,6 +10678,7 @@ "MILLI": "Million", "MILLIM": "Millimeter", "MILLIMV1": "Millimeter v1", + "MILLLIONAIRECOIN": "Milllionaire Coin", "MILLY": "milly", "MILO": "Milo Inu", "MILOCEO": "Milo CEO", @@ -10510,6 +10692,7 @@ "MIMO": "MIMO Parallel Governance Token", "MIN": "MINDOL", "MINA": "Mina Protocol", + "MINAR": "Miner Arena", "MINC": "MinCoin", "MIND": "Morpheus Labs", "MINDBODY": "Mind Body Soul", @@ -10766,6 +10949,7 @@ "MOLK": "Mobilink Token", "MOLLARS": "MollarsToken", "MOLLY": "Molly", + "MOLT": "Moltbook", "MOM": "Mother of Memes", "MOMA": "Mochi Market", "MOMIJI": "MAGA Momiji", @@ -10781,6 +10965,7 @@ "MONAV": "Monavale", "MONB": "MonbaseCoin", "MONDO": "mondo", + "MONEROAI": "Monero AI", "MONEROCHAN": "Monerochan", "MONET": "Claude Monet Memeory Coin", "MONETA": "Moneta", @@ -10889,6 +11074,7 @@ "MOTIONCOIN": "Motion", "MOTO": "Motocoin", "MOUND": "Mound Token", + "MOUNTA": "Mountain Protocol", "MOUTAI": "Moutai", "MOV": "MovieCoin", "MOVD": "MOVE Network", @@ -10911,7 +11097,7 @@ "MPAA": "MPAA", "MPAD": "MultiPad", "MPAY": "Menapay", - "MPC": "Metaplace", + "MPC": "Partisia Blockchain", "MPD": "Metapad", "MPG": "Max Property Group", "MPH": "Morpher", @@ -10970,6 +11156,7 @@ "MSCT": "MUSE ENT NFT", "MSD": "MSD", "MSFT": "Microsoft 6900", + "MSFTON": "Microsoft (Ondo Tokenized)", "MSFTX": "Microsoft xStock", "MSG": "MsgSender", "MSGO": "MetaSetGO", @@ -10993,6 +11180,7 @@ "MSTRX": "MicroStrategy xStock", "MSU": "MetaSoccer", "MSUSHI": "Sushi (Multichain)", + "MSVP": "MetaSoilVerseProtocol", "MSWAP": "MoneySwap", "MT": "Mint Token", "MTA": "Meta", @@ -11010,6 +11198,7 @@ "MTHB": "MTHAIBAHT", "MTHD": "Method Finance", "MTHN": "MTH Network", + "MTHT": "MetaHint", "MTIK": "MatikaToken", "MTIX": "Matrix Token", "MTK": "Moya Token", @@ -11072,6 +11261,7 @@ "MUNITY": "Metahorse Unity", "MUNK": "Dramatic Chipmunk", "MUNSUN": "MUNSUN", + "MUON": "Micron Technology (Ondo Tokenized)", "MURA": "Murasaki", "MURATIAI": "MuratiAI", "MUSCAT": "MusCat", @@ -11119,9 +11309,11 @@ "MWD": "MEW WOOF DAO", "MWETH": "Moonwell Flagship ETH (Morpho Vault)", "MWH": "Melania Wif Hat", + "MWT": "Mountain Wolf Token", "MWXT": "MWX Token", "MX": "MX Token", - "MXC": "Machine Xchange Coin", + "MXC": "MXC Token", + "MXCV1": "Machine Xchange Coin v1", "MXD": "Denarius", "MXGP": "MXGP Fan Token", "MXM": "Maximine", @@ -11144,10 +11336,12 @@ "MYL": "MyLottoCoin", "MYLINX": "Linx", "MYLO": "MYLOCAT", + "MYMASTERWAR": "My Master Wa", "MYNE": "ITSMYNE", "MYO": "Mycro", "MYOBU": "Myōbu", "MYRA": "Mytheria", + "MYRC": "MYRC", "MYRE": "Myre", "MYRIA": "Myria", "MYRO": "Myro", @@ -11175,6 +11369,7 @@ "N3": "Network3", "N3DR": "NeorderDAO ", "N3ON": "N3on", + "N4T": "Nobel For Trump", "N64": "N64", "N7": "Number7", "N8V": "NativeCoin", @@ -11389,6 +11584,7 @@ "NEWSTOKENS": "NewsTokens", "NEWT": "Newton Protocol", "NEWTON": "Newtonium", + "NEWYORKCOIN": "NewYorkCoin", "NEX": "Nash Exchange", "NEXA": "Nexa", "NEXAI": "NexAI", @@ -11416,6 +11612,7 @@ "NFCR": "NFCore", "NFD": "Feisty Doge NFT", "NFE": "Edu3Labs", + "NFLXON": "Netflix (Ondo Tokenized)", "NFLXX": "Netflix xStock", "NFM": "NFMart", "NFN": "Nafen", @@ -11453,6 +11650,7 @@ "NGL": "Entangle", "NGM": "e-Money", "NGMI": "NGMI Coin", + "NGNT": "Naira Token", "NGTG": "NUGGET TRAP", "NHCT": "Nano Healthcare Token", "NHI": "Non Human Intelligence", @@ -11464,6 +11662,7 @@ "NIC": "NewInvestCoin", "NICE": "Nice", "NICEC": "NiceCoin", + "NIETZSCHEAN": "Nietzschean Penguin", "NIF": "Unifty", "NIFT": "Niftify", "NIFTSY": "Envelop", @@ -11495,6 +11694,7 @@ "NINU": "Nvidia Inu", "NIOB": "Niob Finance", "NIOCTIB": "nioctiB", + "NIOON": "NIO (Ondo Tokenized)", "NIOX": "Autonio", "NIOXV1": "Autonio v1", "NIOXV2": "Autonio v2", @@ -11550,6 +11750,7 @@ "NOBS": "No BS Crypto", "NOC": "Nono Coin", "NOCHILL": "AVAX HAS NO CHILL", + "NOCK": "Nockchain", "NODE": "NodeOps", "NODELYAI": "NodelyAI", "NODESYNAPSE": "NodeSynapse", @@ -11595,7 +11796,9 @@ "NOTDOG": "NOTDOG", "NOTE": "Republic Note", "NOTECANTO": "Note", - "NOTHING": "NOTHING", + "NOTHING": "Youll own nothing & be happy", + "NOTHINGCASH": "NOTHING", + "NOTIFAI": "NotifAi News", "NOTINU": "NOTCOIN INU", "NOTIONAL": "Notional Finance", "NOV": "Novara Calcio Fan Token", @@ -11611,6 +11814,7 @@ "NPER": "NPER", "NPICK": "NPICK BLOCK", "NPLC": "Plus Coin", + "NPLCV1": "PlusCoin v1", "NPM": "Neptune Mutual", "NPRO": "NPRO", "NPT": "Neopin", @@ -11624,6 +11828,7 @@ "NRCH": "EnreachDAO", "NRFB": "NuriFootBall", "NRG": "Energi", + "NRGE": "New Resources Generation Energy", "NRGY": "NRGY Defi", "NRK": "Nordek", "NRM": "Neuromachine", @@ -11703,6 +11908,7 @@ "NVA": "Neeva Defi", "NVB": "NovaBank", "NVC": "NovaCoin", + "NVDAON": "NVIDIA (Ondo Tokenized)", "NVDAX": "NVIDIA xStock", "NVDX": "Nodvix", "NVG": "NightVerse Game", @@ -11722,6 +11928,7 @@ "NWIF": "neirowifhat", "NWP": "NWPSolution", "NWS": "Nodewaves", + "NXA": "NEXA Agent", "NXC": "Nexium", "NXD": "Nexus Dubai", "NXDT": "NXD Next", @@ -11743,7 +11950,7 @@ "NYANDOGE": "NyanDOGE International", "NYANTE": "Nyantereum International", "NYBBLE": "Nybble", - "NYC": "NewYorkCoin", + "NYC": "NYC", "NYCREC": "NYCREC", "NYE": "NewYork Exchange", "NYEX": "Nyerium", @@ -11766,7 +11973,8 @@ "OAS": "Oasis City", "OASC": "Oasis City", "OASI": "Oasis Metaverse", - "OASIS": "Oasis", + "OASIS": "OASIS", + "OASISPLATFORM": "Oasis", "OAT": "OAT Network", "OATH": "OATH Protocol", "OAX": "Oax", @@ -11798,6 +12006,7 @@ "OCE": "OceanEX Token", "OCEAN": "Ocean Protocol", "OCEANT": "Poseidon Foundation", + "OCEANV1": "Ocean Protocol v1", "OCH": "Orchai", "OCICAT": "OciCat", "OCL": "Oceanlab", @@ -11945,7 +12154,7 @@ "OMNIXIO": "OMNIX", "OMNOM": "Doge Eat Doge", "OMNOMN": "Omega Network", - "OMT": "Mars Token", + "OMT": "Oracle Meta Technologies", "OMV1": "OM Token (v1)", "OMX": "Project Shivom", "OMZ": "Open Meta City", @@ -11955,6 +12164,7 @@ "ONCH": "OnchainPoints.xyz", "ONDO": "Ondo", "ONDOAI": "Ondo DeFAI", + "ONDSON": "Ondas Holdings (Ondo Tokenized)", "ONE": "Harmony", "ONEROOT": "OneRoot Network", "ONES": "OneSwap DAO", @@ -11983,12 +12193,14 @@ "ONUS": "ONUS", "ONX": "OnX.finance", "OOB": "Oobit", + "OOBV1": "Oobit", "OOE": "OpenOcean", "OOFP": "OOFP", "OOGI": "OOGI", "OOKI": "Ooki", "OOKS": "Onooks", "OOM": "OomerBot", + "OOOO": "oooo", "OOPS": "OOPS", "OORC": "Orbit Bridge Klaytn Orbit Chain", "OORT": "OORT", @@ -12010,6 +12222,7 @@ "OPENCUSTODY": "Open Custody Protocol", "OPENDAO": "OpenDAO", "OPENGO": "OPEN Governance Token", + "OPENON": "Opendoor Technologies (Ondo Tokenized)", "OPENP": "Open Platform", "OPENRI": "Open Rights Exchange", "OPENSOURCE": "Open Source Network", @@ -12174,7 +12387,8 @@ "OWB": "OWB", "OWC": "Oduwa", "OWD": "Owlstand", - "OWL": "OWL Token", + "OWL": "Owlto", + "OWLTOKEN": "OWL Token", "OWN": "OTHERWORLD", "OWNDATA": "OWNDATA", "OWNLY": "Ownly", @@ -12198,6 +12412,7 @@ "OZG": "Ozagold", "OZK": "OrdiZK", "OZMPC": "Ozempic", + "OZNI": "Ni Token", "OZO": "Ozone Chain", "OZONE": "Ozone metaverse", "OZONEC": "Ozonechain", @@ -12207,6 +12422,7 @@ "P202": "Project 202", "P2P": "Sentinel", "P2PS": "P2P Solutions Foundation", + "P2PV1": "Sentinel", "P33L": "THE P33L", "P3D": "3DPass", "P404": "Potion 404", @@ -12222,6 +12438,7 @@ "PACOCA": "Pacoca", "PACP": "PAC Protocol", "PACT": "impactMarket", + "PACTTOKEN": "PACT community token", "PACTV1": "impactMarket v1", "PAD": "NearPad", "PAF": "Pacific", @@ -12265,6 +12482,7 @@ "PANGEA": "PANGEA", "PANIC": "PanicSwap", "PANO": "PanoVerse", + "PANTHER": "Panther Protocol", "PANTOS": "Pantos", "PAO": "South Pao", "PAPA": "Papa Bear", @@ -12377,10 +12595,12 @@ "PCN": "PeepCoin", "PCNT": "Playcent", "PCO": "Pecunio", + "PCOCK": "PulseChain Peacock", "PCOIN": "Pioneer Coin", "PCR": "Paycer Protocol", "PCS": "Pabyosi Coin", "PCSP": "GenomicDao G-Stroke", + "PCT": "PET CASH TOKEN", "PCW": "Power Crypto World", "PCX": "ChainX", "PD": "PUDEL", @@ -12391,6 +12611,7 @@ "PDD": "PDDOLLAR", "PDEX": "Polkadex", "PDF": "Port of DeFi Network", + "PDI": "Phuture DeFi Index", "PDJT": "President Donald J. Trump", "PDOG": "Polkadog", "PDOGE": "PolkaDoge", @@ -12453,6 +12674,7 @@ "PENDY": "Pendy", "PENG": "Peng", "PENGCOIN": "PENG", + "PENGO": "Petro Penguins", "PENGU": "Pudgy Penguins", "PENGUAI": "PENGU AI", "PENGUI": "Penguiana", @@ -12513,6 +12735,7 @@ "PEPEMO": "PepeMo", "PEPEMOON": "PEPEMOON", "PEPEMUSK": "pepemusk", + "PEPENODE": "PEPENODE", "PEPEOFSOL": "Pepe of Solana", "PEPEPI": "PEPEPi", "PEPER": "Baby Pepe", @@ -12548,6 +12771,7 @@ "PERC": "Perion", "PERCY": "Percy Verence", "PERI": "PERI Finance", + "PERKSCOIN": "PerksCoin ", "PERL": "PERL.eco", "PERMIAN": "Permian", "PERP": "Perpetual Protocol", @@ -12575,7 +12799,10 @@ "PEUSD": "peg-eUSD", "PEW": "pepe in a memes world", "PEX": "Pexcoin", + "PF": "Purple Frog", + "PFEON": "Pfizer (Ondo Tokenized)", "PFEX": "Pfizer xStock", + "PFF": "PumpFunFloki", "PFI": "PrimeFinance", "PFID": "Pofid Dao", "PFL": "Professional Fighters League Fan Token", @@ -12671,6 +12898,7 @@ "PIKAM": "Pikamoon", "PIKE": "Pike Token", "PIKO": "Pinnako", + "PIKZ": "PIKZ", "PILLAR": "PillarFi", "PILOT": "Unipilot", "PIM": "PIM", @@ -12765,7 +12993,7 @@ "PLAYCOIN": "PlayCoin", "PLAYFUN": "PLAYFUN", "PLAYKEY": "Playkey", - "PLAYSOL": "Play Solana", + "PLAYSOLANA": "Play Solana", "PLAYTOKEN": "Play Token", "PLB": "Paladeum", "PLBT": "Polybius", @@ -12808,12 +13036,14 @@ "PLSX": "PulseX", "PLT": "Poollotto.finance", "PLTC": "PlatonCoin", + "PLTRON": "Palantir Technologies (Ondo Tokenized)", "PLTRX": "Palantir xStock", "PLTX": "PlutusX", "PLTXYZ": "Add.xyz", "PLU": "Pluton", "PLUG": "PL^Gnet", "PLUGCN": "Plug Chain", + "PLUGON": "Plug Power (Ondo Tokenized)", "PLUME": "Plume", "PLUP": "PoolUp", "PLURA": "PluraCoin", @@ -12838,7 +13068,7 @@ "PMOON": "Pookimoon", "PMPY": "Prometheum Prodigy", "PMR": "Pomerium Utility Token", - "PMT": "POWER MARKET", + "PMT": "Public Masterpiece Token", "PMTN": "Peer Mountain", "PMX": "Phillip Morris xStock", "PNB": "Pink BNB", @@ -12908,7 +13138,7 @@ "POLLEN": "Beraborrow", "POLLUK": "Jasse Polluk", "POLLUX": "Pollux Coin", - "POLLY": "Polynetica", + "POLLY": "Polly Penguin", "POLNX": "eToro Polish Zloty", "POLO": "NftyPlay", "POLS": "Polkastarter", @@ -12917,6 +13147,7 @@ "POLY": "Polymath Network", "POLYCUB": "PolyCub", "POLYDOGE": "PolyDoge", + "POLYN": "Polynetica", "POLYPAD": "PolyPad", "POLYX": "Polymesh", "POM": "Proof Of Memes", @@ -12929,6 +13160,7 @@ "PONKE": "Ponke", "PONKEBNB": "Ponke BNB", "PONKEI": "Chinese Ponkei the Original", + "PONTEM": "Pontem Liquidswap", "PONYO": "Ponyo Impact", "PONZI": "Ponzi", "PONZIO": "Ponzio The Cat", @@ -13001,7 +13233,9 @@ "POUW": "Pouwifhat", "POW": "PowBlocks", "POWELL": "Jerome Powell", - "POWER": "Powerloom Token", + "POWER": "Power", + "POWERLOOM": "Powerloom Token", + "POWERMARKET": "POWER MARKET", "POWR": "Power Ledger", "POWSCHE": "Powsche", "POX": "Monkey Pox", @@ -13042,6 +13276,7 @@ "PREAI": "Predict Crypto", "PREC": "Precipitate.AI", "PRED": "Predictcoin", + "PREDIC": "PredicTools", "PREM": "Premium", "PREME": "PREME Token", "PREMIA": "Premia", @@ -13050,6 +13285,7 @@ "PRESI": "Turbo Trump", "PRESID": "President Ron DeSantis", "PRESIDEN": "President Elon", + "PRESSX": "PressX", "PRFT": "Proof Suite Token", "PRG": "Paragon", "PRI": "PRIVATEUM INITIATIVE", @@ -13149,6 +13385,7 @@ "PSWAP": "Polkaswap", "PSY": "PsyOptions", "PSYOP": "PSYOP", + "PSYOPANIME": "PsyopAnime", "PT": "Phemex", "PTA": "PentaCoin", "PTAS": "La Peseta", @@ -13247,8 +13484,9 @@ "PUX": "pukkamex", "PVC": "PVC Meta", "PVFYBO": "JRVGCUPVSC", - "PVP": "PvP", + "PVP": "Pvpfun", "PVPCHAIN": "PVPChain", + "PVPGAME": "PvP", "PVT": "Punkvism Token", "PVU": "Plant vs Undead Token", "PWAR": "PolkaWar", @@ -13261,14 +13499,16 @@ "PWR": "MaxxChain", "PWRC": "PWR Coin", "PWT": "PANDAINU", - "PX": "PXcoin", + "PX": "Not Pixel", "PXB": "PixelBit", "PXC": "PhoenixCoin", + "PXCOIN": "PXcoin", "PXG": "PlayGame", "PXI": "Prime-X1", "PXL": "PIXEL", "PXP": "PointPay", "PXT": "Pixer Eternity", + "PYBOBO": "Capybobo", "PYC": "PayCoin", "PYE": "CreamPYE", "PYI": "PYRIN", @@ -13278,6 +13518,7 @@ "PYME": "PymeDAO", "PYN": "Paynetic", "PYP": "PayPro", + "PYPLON": "PayPal (Ondo Tokenized)", "PYQ": "PolyQuity", "PYR": "Vulcan Forged", "PYRAM": "Pyram Token", @@ -13314,6 +13555,7 @@ "QBX": "qiibee foundation", "QBZ": "QUEENBEE", "QC": "Qcash", + "QCAD": "QCAD", "QCH": "QChi", "QCN": "Quazar Coin", "QCO": "Qravity", @@ -13346,6 +13588,7 @@ "QNX": "QueenDex Coin", "QOBI": "Qobit", "QOM": "Shiba Predator", + "QONE": "QONE", "QOOB": "QOOBER", "QORA": "QoraCoin", "QORPO": "QORPO WORLD", @@ -13385,6 +13628,7 @@ "QUA": "Quantum Tech", "QUAC": "QUACK", "QUACK": "Rich Quack", + "QUADRANS": "QuadransToken", "QUAI": "Quai Network", "QUAIN": "QUAIN", "QUAM": "Quam Network", @@ -13446,7 +13690,7 @@ "RADAR": "DappRadar", "RADI": "RadicalCoin", "RADIO": "RadioShack", - "RADR": "CoinRadr", + "RADR": "RADR", "RADX": "Radx AI", "RAFF": "Ton Raffles", "RAFFLES": "Degen Raffles", @@ -13461,6 +13705,7 @@ "RAIF": "RAI Finance", "RAIIN": "Raiin", "RAIL": "Railgun", + "RAILS": "Rails Token", "RAIN": "Rain", "RAINBOW": "Rainbow Token", "RAINC": "RainCheck", @@ -13470,6 +13715,7 @@ "RAIREFLEX": "Rai Reflex Index", "RAISE": "Raise Token", "RAIT": "Rabbitgame", + "RAITOKEN": "RAI", "RAIZER": "RAIZER", "RAK": "Rake Finance", "RAKE": "Rake Coin", @@ -13497,16 +13743,18 @@ "RATOTHERAT": "Rato The Rat", "RATS": "Rats", "RATWIF": "RatWifHat", - "RAVE": "Ravendex", + "RAVE": "RaveDAO", "RAVELOUS": "Ravelous", "RAVEN": "Raven Protocol", "RAVENCOINC": "Ravencoin Classic", + "RAVENDEX": "Ravendex", "RAWDOG": "RawDog", "RAWG": "RAWG", "RAY": "Raydium", "RAYS": "Rays Network", "RAZE": "Raze Network", "RAZOR": "Razor Network", + "RAZORCOIN": "RazorCoin", "RB": "REBorn", "RBBT": "RabbitCoin", "RBC": "Rubic", @@ -13588,6 +13836,7 @@ "REALYN": "Real", "REAP": "ReapChain", "REAPER": "Grim Finance", + "REAT": "REAT", "REAU": "Vira-lata Finance", "REBD": "REBORN", "REBL": "REBL", @@ -13810,7 +14059,8 @@ "RIVUS": "RivusDAO", "RIYA": "Etheriya", "RIZ": "Rivalz Network", - "RIZE": "Rizespor Token", + "RIZE": "RIZE", + "RIZESPOR": "Rizespor Token", "RIZO": "HahaYes", "RIZOLOL": "Rizo", "RIZZ": "Rizz", @@ -13852,10 +14102,12 @@ "RNDR": "Render Token", "RNDX": "Round X", "RNEAR": "Near (Rainbow Bridge)", + "RNGR": "Ranger", "RNS": "RenosCoin", "RNT": "REAL NIGGER TATE", "RNTB": "BitRent", "RNX": "ROONEX", + "ROA": "ROA CORE", "ROAD": "ROAD", "ROAM": "Roam Token", "ROAR": "Alpha DEX", @@ -13889,6 +14141,7 @@ "ROK": "Rockchain", "ROKM": "Rocket Ma", "ROKO": "Roko", + "ROLL": "Roll", "ROLLSROYCE": "RollsRoyce", "ROLS": "RollerSwap", "ROM": "ROMCOIN", @@ -13993,6 +14246,7 @@ "RTM": "Raptoreum", "RTR": "Restore The Republic", "RTT": "Restore Truth Token", + "RTX": "RateX", "RU": "RIFI United", "RUBB": "Rubber Ducky Cult", "RUBCASH": "RUBCASH", @@ -14080,7 +14334,7 @@ "RYT": "Real Yield Token", "RYU": "The Blue Dragon", "RYZ": "Anryze", - "RZR": "RazorCoin", + "RZR": "Rezor", "RZTO": "RZTO Token", "RZUSD": "RZUSD", "RedFlokiCEO": "Red Floki CEO", @@ -14142,6 +14396,7 @@ "SAKAI": "Sakai Vault", "SAKATA": "Sakata Inu", "SAKE": "SakeToken", + "SAKURACOIN": "Sakuracoin", "SAL": "Salvium", "SALD": "Salad", "SALE": "DxSale Network", @@ -14283,7 +14538,7 @@ "SCOIN": "ShinCoin", "SCONE": "Sportcash One", "SCOOBY": "Scooby coin", - "SCOR": "Scorista", + "SCOR": "Scor", "SCORE": "Scorecoin", "SCOT": "Scotcoin", "SCOTT": "Scottish", @@ -14345,6 +14600,7 @@ "SEAM": "Seamless Protocol", "SEAMLESS": "SeamlessSwap", "SEAN": "Starfish Finance", + "SEAS": "Seasons", "SEAT": "Seamans Token", "SEATLABNFT": "SeatlabNFT", "SEBA": "Seba", @@ -14377,6 +14633,7 @@ "SELFIEC": "Selfie Cat", "SELFT": "SelfToken", "SELLC": "Sell Token", + "SELO": "SELO+", "SEM": "Semux", "SEN": "Sentaro", "SENA": "Ethena Staked ENA", @@ -14393,7 +14650,7 @@ "SENSOR": "Sensor Protocol", "SENSOV1": "SENSO v1", "SENSUS": "Sensus", - "SENT": "Sentinel", + "SENT": "Sentient", "SENTAI": "SentAI", "SENTI": "Sentinel Bot Ai", "SENTIS": "Sentism AI Token", @@ -14419,6 +14676,7 @@ "SETH": "sETH", "SETH2": "sETH2", "SETHER": "Sether", + "SETHH": "Staked ETH Harbour", "SETS": "Sensitrust", "SEUR": "Synth sEUR", "SEW": "simpson in a memes world", @@ -14461,6 +14719,7 @@ "SGB": "Songbird", "SGDX": "eToro Singapore Dollar", "SGE": "Society of Galactic Exploration", + "SGI": "SmartGolfToken", "SGLY": "Singularity", "SGN": "Signals Network", "SGO": "SafuuGO", @@ -14482,7 +14741,6 @@ "SHANG": "Shanghai Inu", "SHAR": "Shark Cat", "SHARBI": "SHARBI", - "SHARD": "ShardCoin", "SHARDS": "WorldShards", "SHARE": "Seigniorage Shares", "SHARECHAIN": "ShareChain", @@ -14530,6 +14788,7 @@ "SHIBACASH": "ShibaCash", "SHIBADOG": "Shiba San", "SHIBAI": "AiShiba", + "SHIBAINU": "SHIBA INU", "SHIBAKEN": "Shibaken Finance", "SHIBAMOM": "Shiba Mom", "SHIBANCE": "Shibance Token", @@ -14580,6 +14839,7 @@ "SHIRO": "Shiro Neko", "SHIROSOL": "Shiro Neko (shirosol.online)", "SHIRYOINU": "Shiryo-Inu", + "SHISA": "SHISA", "SHISHA": "Shisha Coin", "SHIT": "I will poop it NFT", "SHITC": "Shitcoin", @@ -14599,6 +14859,7 @@ "SHOKI": "Shoki", "SHON": "ShonToken", "SHONG": "Shong Inu", + "SHOOK": "SHOOK", "SHOOT": "Mars Battle", "SHOOTER": "Top Down Survival Shooter", "SHOP": "Shoppi Coin", @@ -14628,9 +14889,11 @@ "SHUFFLE": "SHUFFLE!", "SHVR": "Shivers", "SHX": "Stronghold Token", + "SHXV1": "Stronghold Token v1", "SHY": "Shytoshi Kusama", "SHYTCOIN": "ShytCoin", "SI": "Siren", + "SI14": "Si14", "SIACLASSIC": "SiaClassic", "SIB": "SibCoin", "SIBA": "SibaInu", @@ -14646,6 +14909,7 @@ "SIFT": "Smart Investment Fund Token", "SIFU": "SIFU", "SIG": "Signal", + "SIGHT": "Empire of Sight", "SIGM": "Sigma", "SIGMA": "SIGMA", "SIGN": "Sign", @@ -14654,6 +14918,7 @@ "SIGNMETA": "Sign Token", "SIGT": "Signatum", "SIGU": "Singular", + "SIH": "Salient Investment Holding", "SIKA": "SikaSwap", "SIL": "SIL Finance Token V2", "SILENTIS": "Silentis", @@ -14691,6 +14956,7 @@ "SINE": "Sinelock", "SING": "SingularFarm", "SINGLE": "Single Finance", + "SINGULARRY": "SINGULARRY", "SINK": "Let that sink in", "SINS": "SafeInsure", "SINSO": "SINSO", @@ -14747,7 +15013,7 @@ "SKO": "Sugar Kingdom Odyssey", "SKOP": "Skulls of Pepe Token", "SKPEPE": "Sheikh Pepe", - "SKR": "Sakuracoin", + "SKR": "Seeker", "SKRB": "Sakura Bloom", "SKRIMP": "Skrimples", "SKRP": "Skraps", @@ -14804,6 +15070,7 @@ "SLOKI": "Super Floki", "SLOP": "Slop", "SLORK": "SLORK", + "SLOT": "Alphaslot", "SLOTH": "Sloth", "SLOTHA": "Slothana", "SLP": "Smooth Love Potion", @@ -14817,6 +15084,7 @@ "SLUGDENG": "SLUG DENG", "SLUMBO": "SLUMBO", "SLVLUSD": "Staked Level USD", + "SLVON": "iShares Silver Trust (Ondo Tokenized)", "SLVX": "eToro Silver", "SLX": "SLIMEX", "SMA": "Soma Network", @@ -14831,6 +15099,7 @@ "SMARTLOX": "SmartLOX", "SMARTM": "SmartMesh", "SMARTMEME": "SmartMEME", + "SMARTMFG": "Smart MFG", "SMARTNFT": "SmartNFT", "SMARTO": "smARTOFGIVING", "SMARTSHARE": "Smartshare", @@ -14840,6 +15109,7 @@ "SMBR": "Sombra", "SMBSWAP": "SimbCoin Swap", "SMC": "SmartCoin", + "SMCION": "Super Micro Computer (Ondo Tokenized)", "SMCW": "Space Misfits", "SMD": "SMD Coin", "SMETA": "StarkMeta", @@ -14899,6 +15169,7 @@ "SNAP": "SnapEx", "SNAPCAT": "Snapcat", "SNAPKERO": "SNAP", + "SNAPON": "Snap (Ondo Tokenized)", "SNB": "SynchroBitcoin", "SNC": "SunContract", "SNCT": "SnakeCity", @@ -14942,7 +15213,7 @@ "SNPT": "SNPIT TOKEN", "SNRG": "Synergy", "SNRK": "Snark Launch", - "SNS": "Synesis One", + "SNS": "Solana Name Service", "SNST": "Smooth Network Solutions Token", "SNSY": "Sensay", "SNT": "Status Network Token", @@ -14975,11 +15246,13 @@ "SOETH": "Wrapped Ethereum (Sollet)", "SOFAC": "SofaCat", "SOFI": "RAI Finance", + "SOFION": "SoFi Technologies (Ondo Tokenized)", "SOFTCO": "SOFT COQ INU", "SOFTT": "Wrapped FTT (Sollet)", "SOGNI": "Sogni AI", "SOGUR": "Sogur Currency", "SOH": "Stohn Coin", + "SOHMV1": "Staked Olympus v1", "SOHOT": "SOHOTRN", "SOIL": "Soil", "SOILCOIN": "SoilCoin", @@ -15031,6 +15304,7 @@ "SOLIDSEX": "SOLIDsex: Tokenized veSOLID", "SOLINK": "Wrapped Chainlink (Sollet)", "SOLITO": "SOLITO", + "SOLKABOSU": "Kabosu", "SOLKIT": "Solana Kit", "SOLLY": "Solly", "SOLM": "SolMix", @@ -15040,6 +15314,7 @@ "SOLNAV": "SOLNAV AI", "SOLNIC": "Solnic", "SOLO": "Sologenic", + "SOLOM": "Solomon", "SOLOR": "Solordi", "SOLP": "SolPets", "SOLPAD": "Solpad Finance", @@ -15122,7 +15397,7 @@ "SP8DE": "Sp8de", "SPA": "Sperax", "SPAC": "SPACE DOGE", - "SPACE": "MicroVisionChain", + "SPACE": "Spacecoin", "SPACECOIN": "SpaceCoin", "SPACED": "SPACE DRAGON", "SPACEHAMSTER": "Space Hamster", @@ -15207,6 +15482,7 @@ "SPO": "Spores Network", "SPOK": "Spock", "SPOL": "Starterpool", + "SPON": "Spheron Network", "SPONG": "Spongebob", "SPONGE": "Sponge", "SPONGEBOB": "Spongebob Squarepants", @@ -15215,6 +15491,7 @@ "SPOOL": "Spool DAO Token", "SPORE": "Spore", "SPORT": "SportsCoin", + "SPORTFUN": "Sport.fun", "SPORTS": "ZenSports", "SPORTSFIX": "SportsFix", "SPORTSP": "SportsPie", @@ -15243,6 +15520,7 @@ "SPX6969": "SPX 6969", "SPXC": "SpaceXCoin", "SPY": "Smarty Pay", + "SPYON": "SPDR S&P 500 ETF (Ondo Tokenized)", "SPYRO": "SPYRO", "SPYX": "SP500 xStock", "SQ3": "Squad3", @@ -15251,6 +15529,7 @@ "SQG": "Squid Token", "SQGROW": "SquidGrow", "SQL": "Squall Coin", + "SQQQON": "ProShares UltraPro Short QQQ (Ondo Tokenized)", "SQR": "Magic Square", "SQRL": "Squirrel Swap", "SQT": "SubQuery Network", @@ -15268,6 +15547,8 @@ "SQUIDGROWV1": "SquidGrow v1", "SQUIDV1": "Squid Game v1", "SQUIDW": "Squidward Coin", + "SQUIG": "Squiggle DAO Token", + "SQUIGDAO": "SquiggleDAO ERC20", "SQUIRT": "SQUIRTLE", "SQUOGE": "DogeSquatch", "SR30": "SatsRush", @@ -15332,21 +15613,24 @@ "STACS": "STACS Token", "STAFIRETH": "StaFi Staked ETH", "STAGE": "Stage", + "STAI": "StereoAI", "STAK": "Jigstack", "STAKE": "xDai Chain", "STAKEDETH": "StakeHound Staked Ether", + "STAKERDAOWXTZ": "Wrapped Tezos", "STALIN": "StalinCoin", "STAMP": "SafePost", "STAN": "Stank Memes", "STANDARD": "Stakeborg DAO", "STAPT": "Ditto Staked Aptos", - "STAR": "StarHeroes", + "STAR": "Starpower Network Token", "STAR10": "Ronaldinho Coin", "STARAMBA": "Staramba", "STARBASE": "Starbase", "STARC": "StarChain", "STARDOGE": "StarDOGE", "STARGATEAI": "Stargate AI Agent", + "STARHEROES": "StarHeroes", "STARL": "StarLink", "STARLAUNCH": "StarLaunch", "STARLY": "Starly", @@ -15422,6 +15706,7 @@ "STIMA": "STIMA", "STING": "Sting", "STINJ": "Stride Staked INJ", + "STIPS": "Stips", "STITCH": "Stitch", "STIX": "STIX", "STJUNO": "Stride Staked JUNO", @@ -15597,8 +15882,10 @@ "SUPERBONK": "SUPER BONK", "SUPERC": "SuperCoin", "SUPERCAT": "SUPERCAT", + "SUPERCYCLE": "Crypto SuperCycle", "SUPERDAPP": "SuperDapp", "SUPERF": "SUPER FLOKI", + "SUPERFL": "Superfluid", "SUPERGROK": "SuperGrok", "SUPEROETHB": "Super OETH", "SUPERT": "Super Trump", @@ -15732,6 +16019,7 @@ "SYNCO": "Synco", "SYND": "Syndicate", "SYNDOG": "Synthesizer Dog", + "SYNESIS": "Synesis One", "SYNK": "Synk", "SYNLEV": "SynLev", "SYNO": "Synonym Finance", @@ -15832,6 +16120,7 @@ "TATSU": "Taτsu", "TAU": "Lamden Tau", "TAUC": "Taurus Coin", + "TAUD": "TrueAUD", "TAUM": "Orbitau Taureum", "TAUR": "Marnotaur", "TAVA": "ALTAVA", @@ -15855,6 +16144,7 @@ "TBILL": "OpenEden T-Bills", "TBILLV1": "OpenEden T-Bills v1", "TBIS": "TBIS token", + "TBK": "TBK Token", "TBL": "Tombola", "TBLLX": "TBLL xStock", "TBR": "Tuebor", @@ -15902,7 +16192,8 @@ "TDROP": "ThetaDrop", "TDS": "TokenDesk", "TDX": "Tidex Token", - "TEA": "TeaDAO", + "TEA": "TeaFi Token", + "TEADAO": "TeaDAO", "TEAM": "TeamUP", "TEARS": "Liberals Tears", "TEC": "TeCoin", @@ -15933,7 +16224,7 @@ "TEMM": "TEM MARKET", "TEMP": "Tempus", "TEMPLE": "TempleDAO", - "TEN": "Tokenomy", + "TEN": "TEN", "TEND": "Tendies", "TENDIE": "TendieSwap", "TENET": "TENET", @@ -15947,8 +16238,8 @@ "TEQ": "Teq Network", "TER": "TerraNovaCoin", "TERA": "TERA", + "TERA2": "Terareum", "TERADYNE": "Teradyne", - "TERAR": "Terareum", "TERAV1": "Terareum v1", "TERAWATT": "Terawatt", "TERM": "Terminal of Simpson", @@ -15975,6 +16266,7 @@ "TETSUO": "Tetsuo Coin", "TETU": "TETU", "TEVA": "Tevaera", + "TEVI": "TEVI Coin", "TEW": "Trump in a memes world", "TEX": "Terrax", "TF47": "Trump Force 47", @@ -15988,7 +16280,7 @@ "TFT": "The Famous Token", "TFUEL": "Theta Fuel", "TGAME": "TrueGame", - "TGC": "TigerCoin", + "TGC": "TG.Casino", "TGCC": "TheGCCcoin", "TGPT": "Trading GPT", "TGRAM": "TG20 TGram", @@ -16026,7 +16318,9 @@ "THEOS": "Theos", "THEP": "The Protocol", "THEPLAY": "PLAY", + "THEREALCHAIN": "REAL", "THERESAMAY": "Theresa May Coin", + "THEROS": "THEROS", "THES": "The Standard Protocol (USDS)", "THESTANDARD": "Standard Token", "THETA": "Theta Network", @@ -16051,6 +16345,7 @@ "THOR": "THORSwap", "THOREUM": "Thoreum V3", "THP": "TurboHigh Performance", + "THQ": "Theoriq Token", "THR": "Thorecoin", "THREE": "Three Protocol Token ", "THRT": "ThriveToken", @@ -16078,6 +16373,7 @@ "TIG": "Tigereum", "TIGER": "TIGER", "TIGERC": "TigerCash", + "TIGERCOIN": "TigerCoin", "TIGERCV1": "TigerCash v1", "TIGERMOON": "TigerMoon", "TIGERSHARK": "Tiger Shark", @@ -16109,9 +16405,10 @@ "TIPS": "FedoraCoin", "TIPSX": "WisdomTree TIPS Digital Fund", "TIPSY": "TipsyCoin", - "TIT": "TittieCoin", + "TIT": "TITANIUM", "TITA": "Titan Hunters", "TITAN": "SATOSHI•RUNE•TITAN (Runes)", + "TITANCOIN": "Titan Coin", "TITANO": "Titano", "TITANSWAP": "TitanSwap", "TITANX": "TitanX", @@ -16120,6 +16417,7 @@ "TITI": "TiTi Protocol", "TITN": "Titan", "TITS": "We Love Tits", + "TITTIECOIN": "TittieCoin", "TITTY": "TamaKitty", "TIUSD": "TiUSD", "TIX": "Blocktix", @@ -16134,6 +16432,7 @@ "TKMK": "TOKAMAK", "TKMN": "Tokemon", "TKN": "Token Name Service", + "TKNT": "TKN Token", "TKO": "Tokocrypto", "TKP": "TOKPIE", "TKR": "CryptoInsight", @@ -16147,6 +16446,7 @@ "TLN": "Trustlines Network", "TLOS": "Telos", "TLP": "TulipCoin", + "TLTON": "iShares 20+ Year Treasury Bond ETF (Ondo Tokenized)", "TLW": "TILWIKI", "TMAGA": "THE MAGA MOVEMENT", "TMAI": "Token Metrics AI", @@ -16154,6 +16454,7 @@ "TME": "Timereum", "TMED": "MDsquare", "TMFT": "Turkish Motorcycle Federation", + "TMG": "T-mac DAO", "TMN": "TranslateMe", "TMNG": "TMN Global", "TMNT": "TMNT", @@ -16193,6 +16494,7 @@ "TOKC": "Tokyo Coin", "TOKE": "Tokemak", "TOKEN": "TokenFi", + "TOKENOMY": "Tokenomy", "TOKENPLACE": "Tokenplace", "TOKENSTARS": "TokenStars", "TOKERO": "TOKERO LevelUP Token", @@ -16299,6 +16601,7 @@ "TPV": "TravGoPV", "TPY": "Thrupenny", "TQ": "TonQuestion", + "TQQQON": "ProShares UltraPro QQQ (Ondo Tokenized)", "TQQQX": "TQQQ xStock", "TQRT": "TokoQrt", "TR3": "Tr3zor", @@ -16312,6 +16615,7 @@ "TRADE": "Polytrade", "TRADEBOT": "TradeBot", "TRADECHAIN": "Trade Chain", + "TRADETIDE": "Trade Tide Token", "TRADEX": "TradeX AI", "TRADOOR": "Tradoor", "TRAI": "Trackgood AI", @@ -16357,10 +16661,11 @@ "TRGI": "The Real Golden Inu", "TRHUB": "Tradehub", "TRI": "Triangles Coin", - "TRIA": "Triaconta", + "TRIA": "TRIA", "TRIAS": "Trias", "TRIBE": "Tribe", "TRIBETOKEN": "TribeToken", + "TRIBEX": "Tribe Token", "TRIBL": "Tribal Token", "TRICK": "TrickyCoin", "TRICKLE": "Trickle", @@ -16391,6 +16696,7 @@ "TROLLMODE": "TROLL MODE", "TROLLRUN": "TROLL", "TROLLS": "trolls in a memes world", + "TRONBETLIVE": "TRONbetLive", "TRONDOG": "TronDog", "TRONI": "Tron Inu", "TRONP": "Donald Tronp", @@ -16398,7 +16704,7 @@ "TROP": "Interop", "TROPPY": "TROPPY", "TROSS": "Trossard", - "TROVE": "Arbitrove Governance Token", + "TROVE": "TROVE", "TROY": "Troy", "TRP": "Tronipay", "TRR": "Terran Coin", @@ -16499,8 +16805,10 @@ "TSHARE": "Tomb Shares", "TSHP": "12Ships", "TSL": "Energo", + "TSLAON": "Tesla (Ondo Tokenized)", "TSLAX": "Tesla xStock", "TSLT": "Tamkin", + "TSMON": "Taiwan Semiconductor Manufacturing (Ondo Tokenized)", "TSN": "Tsunami Exchange Token", "TSO": "Thesirion", "TSOTCHKE": "tsotchke", @@ -16520,7 +16828,7 @@ "TTF": "TurboTrix Finance", "TTK": "The Three Kingdoms", "TTM": "Tradetomato", - "TTN": "Titan Coin", + "TTN": "TTN", "TTNT": "TITA Project", "TTT": "TRUMPETTOKEN", "TTTU": "T-Project", @@ -16537,7 +16845,8 @@ "TUKI": "Tuki", "TUKIV1": "Tuki v1", "TULIP": "Tulip Protocol", - "TUNA": "TUNACOIN", + "TUNA": "DefiTuna", + "TUNACOIN": "TUNACOIN", "TUNE": "Bitune", "TUNETRADEX": "TuneTrade", "TUP": "Tenup", @@ -16614,7 +16923,7 @@ "TZKI": "Tsuzuki Inu", "TZPEPE": "Tezos Pepe", "TZU": "Sun Tzu", - "U": "Union", + "U": "United Stables", "U2U": "U2U Network", "U8D": "Universal Dollar", "UA1": "UA1", @@ -16641,6 +16950,7 @@ "UBXT": "UpBots", "UC": "YouLive Coin", "UCA": "UCA Coin", + "UCANFIX": "Ucan fix life in1day", "UCAP": "Unicap.finance", "UCASH": "U.CASH", "UCCOIN": "UC Coin", @@ -16762,6 +17072,7 @@ "UNIL": "UniLayer", "UNIM": "Unicorn Milk", "UNIO": "Unio Coin", + "UNION": "Union", "UNIPOWER": "UniPower", "UNIPT": "Universal Protocol Token", "UNIQ": "Uniqredit", @@ -16838,7 +17149,9 @@ "US": "Talus Token", "USA": "Based USA", "USACOIN": "American Coin", - "USAT": "USAT", + "USAGIBNB": "U", + "USAT": "Tether America USD", + "USATINC": "USAT", "USBT": "Universal Blockchain", "USC": "Ultimate Secure Cash", "USCC": "USC", @@ -16877,16 +17190,20 @@ "USDGLOBI": "Globiance USD Stablecoin", "USDGV1": "USDG v1", "USDGV2": "USDG", - "USDH": "USDH Hubble Stablecoin", + "USDH": "USDH", + "USDHHUBBLE": "USDH Hubble Stablecoin", "USDHL": "Hyper USD", "USDI": "Interest Protocol USDi", "USDJ": "USDJ", "USDK": "USDK", + "USDKG": "USDKG", "USDL": "Lift Dollar", - "USDM": "Mountain Protocol", + "USDM": "USDM", "USDMA": "USD mars", - "USDN": "Neutral AI", + "USDN": "Ultimate Synthetic Delta Neutral", + "USDNEUTRAL": "Neutral AI", "USDO": "USD Open Dollar", + "USDON": "U.S. Dollar Tokenized Currency (Ondo)", "USDP": "Pax Dollar", "USDPLUS": "Overnight.fi USD+", "USDQ": "Quantoz USDQ", @@ -16924,6 +17241,7 @@ "USN": "USN", "USNBT": "NuBits", "USNOTA": "NOTA", + "USOR": "U.S Oil", "USP": "USP Token", "USPEPE": "American pepe", "USPLUS": "Fluent Finance", @@ -16941,11 +17259,13 @@ "USUALX": "USUALx", "USUD": "USUD", "USV": "Universal Store of Value", - "USX": "USX Quantum", + "USX": "USX", + "USXQ": "USX Quantum", "USYC": "Hashnote USYC", "UT": "Ulord", "UTBAI": "UTB.ai", "UTC": "UltraCoin", + "UTED": "United", "UTG": "UltronGlow", "UTH": "Uther", "UTHR": "Utherverse Xaeon", @@ -16968,8 +17288,9 @@ "UUU": "U Network", "UVT": "UvToken", "UW3S": "Utility Web3Shot", - "UWU": "UwU Lend", + "UWU": "Unlimited Wealth Utility", "UWUCOIN": "uwu", + "UWULEND": "UwU Lend", "UX": "Umee", "UXLINK": "UXLINK", "UXLINKV1": "UXLINK v1", @@ -16998,6 +17319,7 @@ "VALU": "Value", "VALUE": "Value Liquidity", "VALYR": "Valyr", + "VAM": "Vitalum", "VAMPIRE": "Vampire Inu", "VAN": "Vanspor Token", "VANA": "Vana", @@ -17094,6 +17416,7 @@ "VEO": "Amoveo", "VER": "VersalNFT", "VERA": "Vera", + "VEREM": "Verified Emeralds", "VERI": "Veritaseum", "VERIC": "VeriCoin", "VERIFY": "Verify", @@ -17154,6 +17477,7 @@ "VIDZ": "PureVidz", "VIEW": "Viewly", "VIG": "TheVig", + "VIGI": "Vigi", "VIK": "VIKTAMA", "VIKITA": "VIKITA", "VIKKY": "VikkyToken", @@ -17206,10 +17530,12 @@ "VIZ": "VIZ Token", "VIZION": "ViZion Protocol", "VIZSLASWAP": "VizslaSwap", + "VK": "VK Token", "VKNF": "VKENAF", "VLC": "Volcano Uni", "VLDY": "Validity", "VLK": "Vulkania", + "VLR": "Velora", "VLS": "Veles", "VLT": "Veltor", "VLTC": "Venus LTC", @@ -17263,6 +17589,7 @@ "VON": "Vameon", "VONE": "Vone", "VONSPEED": "Andrea Von Speed", + "VOOI": "VOOI", "VOOT": "VootCoin", "VOOZ": "Vooz Coin", "VOPO": "VOPO", @@ -17290,6 +17617,7 @@ "VRC": "Virtual Coin", "VRFY": "VERIFY", "VRGW": "Virtual Reality Game World", + "VRGX": "VROOMGO", "VRH": "Versailles Heroes", "VRL": "Virtual X", "VRM": "Verium", @@ -17323,6 +17651,7 @@ "VSYS": "V Systems", "VT": "Virtual Tourist", "VTC": "Vertcoin", + "VTCN": "Versatize Coin", "VTG": "Victory Gem", "VTHO": "VeChainThor", "VTIX": "Vanguard xStock", @@ -17355,6 +17684,7 @@ "VVS": "VVS Finance", "VVV": "Venice Token", "VX": "Visa xStock", + "VXC": "VINX COIN", "VXL": "Voxel X Network", "VXR": "Vox Royale", "VXRP": "Venus XRP", @@ -17367,6 +17697,7 @@ "VYPER": "VYPER.WIN", "VYVO": "Vyvo AI", "VZ": "Vault Zero", + "VZON": "Verizon (Ondo Tokenized)", "VZT": "Vezt", "W": "Wormhole", "W1": "W1", @@ -17424,7 +17755,8 @@ "WANNA": "Wanna Bot", "WANUSDT": "wanUSDT", "WAP": "Wet Ass Pussy", - "WAR": "WeStarter", + "WAR": "WAR", + "WARD": "Warden", "WARP": "WarpCoin", "WARPED": "Warped Games", "WARPIE": "Warpie", @@ -17443,6 +17775,7 @@ "WATCH": "Yieldwatch", "WATER": "Waterfall", "WATERCOIN": "WATER", + "WATLAS": "Wrapped Star Atlas (Portal Bridge)", "WATT": "WATTTON", "WAVAX": "Wrapped AVAX", "WAVES": "Waves", @@ -17492,6 +17825,7 @@ "WCFGV1": "Wrapped Centrifuge", "WCFX": "Wrapped Conflux", "WCG": "World Crypto Gold", + "WCHZ": "Wrapped Chiliz", "WCKB": "Wrapped Nervos Network", "WCOIN": "WCoin", "WCORE": "Wrapped Core", @@ -17524,6 +17858,7 @@ "WEBSIM": "The Css God by Virtuals", "WEBSS": "Websser", "WEC": "Whole Earth Coin", + "WECAN": "Wecan Group", "WECO": "WECOIN", "WED": "Wednesday Inu", "WEEBS": "Weebs", @@ -17561,8 +17896,10 @@ "WEPC": "World Earn & Play Community", "WEPE": "Wall Street Pepe", "WERK": "Werk Family", + "WESHOWTOKEN": "WeShow Token", "WEST": "Waves Enterprise", - "WET": "WeShow Token", + "WESTARTER": "WeStarter", + "WET": "HumidiFi Token", "WETH": "WETH", "WETHV1": "WETH v1", "WETHW": "Wrapped EthereumPoW", @@ -17581,6 +17918,7 @@ "WFIL": "Wrapped Filecoin", "WFLAMA": "WIFLAMA", "WFLOW": "Wrapped Flow", + "WFLR": "Wrapped Flare", "WFO": "WoofOracle", "WFRAGSOL": "Wrapped fragSOL", "WFT": "Windfall Token", @@ -17605,6 +17943,7 @@ "WHATSONPIC": "WhatsOnPic", "WHBAR": "Wrapped HBAR", "WHC": "Whales Club", + "WHCHZ": "Chiliz (Portal Bridge)", "WHEAT": "Wheat Token", "WHEE": "WHEE (Ordinals)", "WHEEL": "Wheelers", @@ -17617,6 +17956,8 @@ "WHISKEY": "WHISKEY", "WHITE": "WhiteRock", "WHITEHEART": "Whiteheart", + "WHITEPEPE": "The White Pepe", + "WHITEWHALE": "The White Whale", "WHL": "WhaleCoin", "WHO": "Truwho", "WHOLE": "Whole Network", @@ -17733,6 +18074,7 @@ "WMNT": "Wrapped Mantle", "WMOXY": "Moxy", "WMT": "World Mobile Token v1", + "WMTON": "Walmart (Ondo Tokenized)", "WMTX": "World Mobile Token", "WMW": "WoopMoney", "WMX": "Wombex Finance", @@ -17827,6 +18169,7 @@ "WPP": "Green Energy Token", "WPR": "WePower", "WQT": "Work Quest", + "WR": "White Rat", "WRC": "Worldcore", "WREACT": "Wrapped REACT", "WRK": "BlockWRK", @@ -17889,6 +18232,7 @@ "WUF": "WUFFI", "WUK": "WUKONG", "WUKONG": "Sun Wukong", + "WULFON": "Terawulf (Ondo Tokenized)", "WULFY": "Wulfy", "WUM": "Unicorn Meat", "WUSD": "Worldwide USD", @@ -17914,7 +18258,6 @@ "WXPL": "Wrapped XPL", "WXRP": "Wrapped XRP", "WXT": "WXT", - "WXTZ": "Wrapped Tezos", "WYAC": "Woman Yelling At Cat", "WYN": "Wynn", "WYNN": "Anita Max Wynn", @@ -17957,6 +18300,7 @@ "XAS": "Asch", "XAT": "ShareAt", "XAUC": "XauCoin", + "XAUH": "Herculis Gold Coin", "XAUM": "Matrixdock Gold", "XAUR": "Xaurum", "XAUT": "Tether Gold", @@ -18005,6 +18349,7 @@ "XCHF": "CryptoFranc", "XCHNG": "Chainge Finance", "XCI": "Cannabis Industry Coin", + "XCL": "Xcellar", "XCLR": "ClearCoin", "XCM": "CoinMetro", "XCN": "Onyxcoin", @@ -18043,7 +18388,8 @@ "XEC": "eCash", "XED": "Exeedme", "XEDO": "XedoAI", - "XEL": "Xel", + "XEL": "XELIS", + "XELCOIN": "Xel", "XELS": "XELS Coin", "XEM": "NEM", "XEN": "XEN Crypto", @@ -18053,6 +18399,7 @@ "XENO": "Xeno", "XENOVERSE": "Xenoverse", "XEP": "Electra Protocol", + "XERA": "XERA", "XERS": "X Project", "XES": "Proxeus", "XET": "Xfinite Entertainment Token", @@ -18171,6 +18518,7 @@ "XP": "Xphere", "XPA": "XPA", "XPARTY": "X Party", + "XPASS": "XPASS Token", "XPAT": "Bitnation Pangea", "XPAY": "Wallet Pay", "XPB": "Pebble Coin", @@ -18232,6 +18580,7 @@ "XRPCV1": "XRP Classic v1", "XRPEPE": "XRPEPE", "XRPH": "XRP Healthcare", + "XRPHEDGE": "1X Short XRP Token", "XRS": "Xrius", "XRT": "Robonomics Network", "XRUN": "XRun", @@ -18361,13 +18710,15 @@ "YEAI": "YE AI Agent", "YEARN": "YearnTogether", "YEC": "Ycash", - "YEE": "Yeeco", + "YEE": "Yee Token", + "YEECO": "Yeeco", "YEED": "Yggdrash", "YEEHAW": "YEEHAW", "YEET": "Yeet", "YEETI": "YEETI 液体", "YEFI": "YeFi", "YEL": "Yel.Finance", + "YELLOWWHALE": "The Yellow Whale", "YELP": "Yelpro", "YEON": "Yeon", "YEPE": "Yellow Pepe", @@ -18497,6 +18848,7 @@ "ZAIF": "Zaif Token", "ZAIFIN": "Zero Collateral Dai", "ZAM": "Zamio", + "ZAMA": "Zama", "ZAMZAM": "ZAMZAM", "ZANO": "Zano", "ZAO": "zkTAO", @@ -18542,8 +18894,7 @@ "ZEBU": "ZEBU", "ZEC": "ZCash", "ZECD": "ZCashDarkCoin", - "ZED": "ZED Token", - "ZEDCOIN": "ZedCoin", + "ZED": "ZedCoins", "ZEDD": "ZedDex", "ZEDTOKEN": "Zed Token", "ZEDX": "ZEDX Сoin", @@ -18569,6 +18920,7 @@ "ZENPROTOCOL": "Zen Protocol", "ZENQ": "Zenqira", "ZENT": "Zentry", + "ZENV1": "Horizen v1", "ZEON": "Zeon Network", "ZEP": "Zeppelin Dao", "ZEPH": "Zephyr Protocol", @@ -18586,12 +18938,14 @@ "ZET2": "Zeta2Coin", "ZETA": "ZetaChain", "ZETH": "Zethan", + "ZETO": "ZeTo", "ZETRIX": "Zetrix", "ZEUM": "Colizeum", "ZEUS": "Zeus Network", "ZEUSPEPES": "Zeus", "ZEX": "Zeta", "ZEXI": "ZEXICON", + "ZEXX": "ZEXXCOIN", "ZEXY": "ZEXY", "ZF": "zkSwap Finance ", "ZFI": "Zyfi", @@ -18638,6 +18992,7 @@ "ZKEVM": "zkEVMChain (BSC)", "ZKEX": "zkExchange", "ZKF": "ZKFair", + "ZKFG": "ZKFG", "ZKGPT": "ZKGPT", "ZKGROK": "ZKGROK", "ZKGUN": "zkGUN", @@ -18649,7 +19004,7 @@ "ZKLAB": "zkSync Labs", "ZKLK": "ZkLock", "ZKML": "zKML", - "ZKP": "Panther Protocol", + "ZKP": "zkPass", "ZKPAD": "zkLaunchpad", "ZKPEPE": "ZKPEPEs", "ZKS": "ZKSpace", @@ -18693,6 +19048,7 @@ "ZOOM": "ZoomCoin", "ZOOMER": "Zoomer Coin", "ZOON": "CryptoZoon", + "ZOOSTORY": "ZOO", "ZOOT": "Zoo Token", "ZOOTOPIA": "Zootopia", "ZORA": "Zora", @@ -18720,8 +19076,9 @@ "ZSD": "Zephyr Protocol Stable Dollar", "ZSE": "ZSEcoin", "ZSH": "Ziesha", + "ZSWAP": "ZygoSwap", "ZT": "ZBG Token", - "ZTC": "ZeTo", + "ZTC": "Zenchain", "ZTG": "Zeitgeist", "ZTK": "Zefi", "ZTX": "ZTX", @@ -18769,5 +19126,14 @@ "vXDEFI": "vXDEFI", "wsOHM": "Wrapped Staked Olympus", "修仙": "修仙", - "币安人生": "币安人生" + "分红狗头": "分红狗头", + "哭哭马": "哭哭马", + "安": "安", + "币安人生": "币安人生", + "恶俗企鹅": "恶俗企鹅", + "我踏马来了": "我踏马来了", + "狗屎": "狗屎", + "老子": "老子", + "雪球": "雪球", + "黑马": "黑马" } diff --git a/apps/api/src/assets/cryptocurrencies/custom.json b/apps/api/src/assets/cryptocurrencies/custom.json index 814aeec34..a26fc33df 100644 --- a/apps/api/src/assets/cryptocurrencies/custom.json +++ b/apps/api/src/assets/cryptocurrencies/custom.json @@ -4,6 +4,7 @@ "LUNA1": "Terra", "LUNA2": "Terra", "SGB1": "Songbird", + "SKY33038": "Sky", "SMURFCAT": "Real Smurf Cat", "TON11419": "Toncoin", "UNI1": "Uniswap", diff --git a/apps/api/src/events/asset-profile-changed.event.ts b/apps/api/src/events/asset-profile-changed.event.ts index 46a8c5db4..c08fe59f1 100644 --- a/apps/api/src/events/asset-profile-changed.event.ts +++ b/apps/api/src/events/asset-profile-changed.event.ts @@ -8,4 +8,16 @@ export class AssetProfileChangedEvent { public static getName(): string { return 'assetProfile.changed'; } + + public getCurrency() { + return this.data.currency; + } + + public getDataSource() { + return this.data.dataSource; + } + + public getSymbol() { + return this.data.symbol; + } } diff --git a/apps/api/src/events/asset-profile-changed.listener.ts b/apps/api/src/events/asset-profile-changed.listener.ts index ad80ee4a5..cc70edad6 100644 --- a/apps/api/src/events/asset-profile-changed.listener.ts +++ b/apps/api/src/events/asset-profile-changed.listener.ts @@ -1,29 +1,74 @@ -import { OrderService } from '@ghostfolio/api/app/order/order.service'; +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; +import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { Injectable, Logger } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; +import { DataSource } from '@prisma/client'; +import ms from 'ms'; import { AssetProfileChangedEvent } from './asset-profile-changed.event'; @Injectable() export class AssetProfileChangedListener { + private static readonly DEBOUNCE_DELAY = ms('5 seconds'); + + private debounceTimers = new Map(); + public constructor( + private readonly activitiesService: ActivitiesService, private readonly configurationService: ConfigurationService, private readonly dataGatheringService: DataGatheringService, private readonly dataProviderService: DataProviderService, - private readonly exchangeRateDataService: ExchangeRateDataService, - private readonly orderService: OrderService + private readonly exchangeRateDataService: ExchangeRateDataService ) {} @OnEvent(AssetProfileChangedEvent.getName()) - public async handleAssetProfileChanged(event: AssetProfileChangedEvent) { + public handleAssetProfileChanged(event: AssetProfileChangedEvent) { + const currency = event.getCurrency(); + const dataSource = event.getDataSource(); + const symbol = event.getSymbol(); + + const key = getAssetProfileIdentifier({ + dataSource, + symbol + }); + + const existingTimer = this.debounceTimers.get(key); + + if (existingTimer) { + clearTimeout(existingTimer); + } + + this.debounceTimers.set( + key, + setTimeout(() => { + this.debounceTimers.delete(key); + + void this.processAssetProfileChanged({ + currency, + dataSource, + symbol + }); + }, AssetProfileChangedListener.DEBOUNCE_DELAY) + ); + } + + private async processAssetProfileChanged({ + currency, + dataSource, + symbol + }: { + currency: string; + dataSource: DataSource; + symbol: string; + }) { Logger.log( - `Asset profile of ${event.data.symbol} (${event.data.dataSource}) has changed`, + `Asset profile of ${symbol} (${dataSource}) has changed`, 'AssetProfileChangedListener' ); @@ -31,16 +76,16 @@ export class AssetProfileChangedListener { this.configurationService.get( 'ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES' ) === false || - event.data.currency === DEFAULT_CURRENCY + currency === DEFAULT_CURRENCY ) { return; } const existingCurrencies = this.exchangeRateDataService.getCurrencies(); - if (!existingCurrencies.includes(event.data.currency)) { + if (!existingCurrencies.includes(currency)) { Logger.log( - `New currency ${event.data.currency} has been detected`, + `New currency ${currency} has been detected`, 'AssetProfileChangedListener' ); @@ -48,13 +93,13 @@ export class AssetProfileChangedListener { } const { dateOfFirstActivity } = - await this.orderService.getStatisticsByCurrency(event.data.currency); + await this.activitiesService.getStatisticsByCurrency(currency); if (dateOfFirstActivity) { await this.dataGatheringService.gatherSymbol({ dataSource: this.dataProviderService.getDataSourceForExchangeRates(), date: dateOfFirstActivity, - symbol: `${DEFAULT_CURRENCY}${event.data.currency}` + symbol: `${DEFAULT_CURRENCY}${currency}` }); } } diff --git a/apps/api/src/events/events.module.ts b/apps/api/src/events/events.module.ts index ece67ebe0..772766945 100644 --- a/apps/api/src/events/events.module.ts +++ b/apps/api/src/events/events.module.ts @@ -1,4 +1,4 @@ -import { OrderModule } from '@ghostfolio/api/app/order/order.module'; +import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; @@ -12,11 +12,11 @@ import { PortfolioChangedListener } from './portfolio-changed.listener'; @Module({ imports: [ + ActivitiesModule, ConfigurationModule, DataGatheringModule, DataProviderModule, ExchangeRateDataModule, - OrderModule, RedisCacheModule ], providers: [AssetProfileChangedListener, PortfolioChangedListener] diff --git a/apps/api/src/events/portfolio-changed.listener.ts b/apps/api/src/events/portfolio-changed.listener.ts index d12b9558d..f8e2a9229 100644 --- a/apps/api/src/events/portfolio-changed.listener.ts +++ b/apps/api/src/events/portfolio-changed.listener.ts @@ -2,22 +2,44 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s import { Injectable, Logger } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; +import ms from 'ms'; import { PortfolioChangedEvent } from './portfolio-changed.event'; @Injectable() export class PortfolioChangedListener { + private static readonly DEBOUNCE_DELAY = ms('5 seconds'); + + private debounceTimers = new Map(); + public constructor(private readonly redisCacheService: RedisCacheService) {} @OnEvent(PortfolioChangedEvent.getName()) handlePortfolioChangedEvent(event: PortfolioChangedEvent) { + const userId = event.getUserId(); + + const existingTimer = this.debounceTimers.get(userId); + + if (existingTimer) { + clearTimeout(existingTimer); + } + + this.debounceTimers.set( + userId, + setTimeout(() => { + this.debounceTimers.delete(userId); + + void this.processPortfolioChanged({ userId }); + }, PortfolioChangedListener.DEBOUNCE_DELAY) + ); + } + + private async processPortfolioChanged({ userId }: { userId: string }) { Logger.log( - `Portfolio of user '${event.getUserId()}' has changed`, + `Portfolio of user '${userId}' has changed`, 'PortfolioChangedListener' ); - this.redisCacheService.removePortfolioSnapshotsByUserId({ - userId: event.getUserId() - }); + await this.redisCacheService.removePortfolioSnapshotsByUserId({ userId }); } } diff --git a/apps/api/src/helper/object.helper.spec.ts b/apps/api/src/helper/object.helper.spec.ts index 433490325..ba8760c70 100644 --- a/apps/api/src/helper/object.helper.spec.ts +++ b/apps/api/src/helper/object.helper.spec.ts @@ -1,47 +1,61 @@ -import { redactAttributes } from './object.helper'; +import { DEFAULT_REDACTED_PATHS } from '@ghostfolio/common/config'; + +import { query, redactPaths } from './object.helper'; + +describe('query', () => { + it('should get market price from stock API response', () => { + const object = { + currency: 'USD', + market: { + previousClose: 273.04, + price: 271.86 + }, + symbol: 'AAPL' + }; + + const result = query({ + object, + pathExpression: '$.market.price' + })[0]; + + expect(result).toBe(271.86); + }); +}); describe('redactAttributes', () => { it('should redact provided attributes', () => { - expect(redactAttributes({ object: {}, options: [] })).toStrictEqual({}); + expect(redactPaths({ object: {}, paths: [] })).toStrictEqual({}); - expect( - redactAttributes({ object: { value: 1000 }, options: [] }) - ).toStrictEqual({ value: 1000 }); + expect(redactPaths({ object: { value: 1000 }, paths: [] })).toStrictEqual({ + value: 1000 + }); expect( - redactAttributes({ + redactPaths({ object: { value: 1000 }, - options: [{ attribute: 'value', valueMap: { '*': null } }] + paths: ['value'] }) ).toStrictEqual({ value: null }); expect( - redactAttributes({ + redactPaths({ object: { value: 'abc' }, - options: [{ attribute: 'value', valueMap: { abc: 'xyz' } }] + paths: ['value'], + valueMap: { abc: 'xyz' } }) ).toStrictEqual({ value: 'xyz' }); expect( - redactAttributes({ + redactPaths({ object: { data: [{ value: 'a' }, { value: 'b' }] }, - options: [{ attribute: 'value', valueMap: { a: 1, b: 2 } }] + paths: ['data[*].value'], + valueMap: { a: 1, b: 2 } }) ).toStrictEqual({ data: [{ value: 1 }, { value: 2 }] }); - expect( - redactAttributes({ - object: { value1: 'a', value2: 'b' }, - options: [ - { attribute: 'value1', valueMap: { a: 'x' } }, - { attribute: 'value2', valueMap: { '*': 'y' } } - ] - }) - ).toStrictEqual({ value1: 'x', value2: 'y' }); - console.time('redactAttributes execution time'); expect( - redactAttributes({ + redactPaths({ object: { accounts: { '2e937c05-657c-4de9-8fb3-0813a2245f26': { @@ -97,6 +111,7 @@ describe('redactAttributes', () => { hasError: false, holdings: { 'AAPL.US': { + activitiesCount: 1, currency: 'USD', markets: { UNKNOWN: 0, @@ -116,7 +131,6 @@ describe('redactAttributes', () => { marketPrice: 220.79, symbol: 'AAPL.US', tags: [], - transactionCount: 1, allocationInPercentage: 0.044900865255793135, assetClass: 'EQUITY', assetSubClass: 'STOCK', @@ -149,6 +163,7 @@ describe('redactAttributes', () => { valueInPercentage: 0.0694356974830054 }, 'ALV.DE': { + activitiesCount: 2, currency: 'EUR', markets: { UNKNOWN: 0, @@ -168,7 +183,6 @@ describe('redactAttributes', () => { marketPrice: 296.5, symbol: 'ALV.DE', tags: [], - transactionCount: 2, allocationInPercentage: 0.026912563036519527, assetClass: 'EQUITY', assetSubClass: 'STOCK', @@ -196,6 +210,7 @@ describe('redactAttributes', () => { valueInPercentage: 0.04161818652826481 }, AMZN: { + activitiesCount: 1, currency: 'USD', markets: { UNKNOWN: 0, @@ -215,7 +230,6 @@ describe('redactAttributes', () => { marketPrice: 187.99, symbol: 'AMZN', tags: [], - transactionCount: 1, allocationInPercentage: 0.07646101417126275, assetClass: 'EQUITY', assetSubClass: 'STOCK', @@ -248,6 +262,7 @@ describe('redactAttributes', () => { valueInPercentage: 0.11824101426541227 }, bitcoin: { + activitiesCount: 1, currency: 'USD', markets: { UNKNOWN: 36985.0332704, @@ -273,7 +288,6 @@ describe('redactAttributes', () => { userId: null } ], - transactionCount: 1, allocationInPercentage: 0.15042891393226654, assetClass: 'LIQUIDITY', assetSubClass: 'CRYPTOCURRENCY', @@ -299,6 +313,7 @@ describe('redactAttributes', () => { valueInPercentage: 0.232626620912395 }, BONDORA_GO_AND_GROW: { + activitiesCount: 5, currency: 'EUR', markets: { UNKNOWN: 2231.644722160232, @@ -324,7 +339,6 @@ describe('redactAttributes', () => { userId: null } ], - transactionCount: 5, allocationInPercentage: 0.009076749759365777, assetClass: 'FIXED_INCOME', assetSubClass: 'BOND', @@ -350,6 +364,7 @@ describe('redactAttributes', () => { valueInPercentage: 0.014036487867880205 }, FRANKLY95P: { + activitiesCount: 6, currency: 'CHF', markets: { UNKNOWN: 0, @@ -375,7 +390,6 @@ describe('redactAttributes', () => { userId: null } ], - transactionCount: 6, allocationInPercentage: 0.09095764645669335, assetClass: 'EQUITY', assetSubClass: 'ETF', @@ -474,6 +488,7 @@ describe('redactAttributes', () => { valueInPercentage: 0.14065892911313693 }, MSFT: { + activitiesCount: 1, currency: 'USD', markets: { UNKNOWN: 0, @@ -493,7 +508,6 @@ describe('redactAttributes', () => { marketPrice: 428.02, symbol: 'MSFT', tags: [], - transactionCount: 1, allocationInPercentage: 0.05222646409742627, assetClass: 'EQUITY', assetSubClass: 'STOCK', @@ -526,6 +540,7 @@ describe('redactAttributes', () => { valueInPercentage: 0.08076416659271518 }, TSLA: { + activitiesCount: 1, currency: 'USD', markets: { UNKNOWN: 0, @@ -545,7 +560,6 @@ describe('redactAttributes', () => { marketPrice: 260.46, symbol: 'TSLA', tags: [], - transactionCount: 1, allocationInPercentage: 0.1589050142378352, assetClass: 'EQUITY', assetSubClass: 'STOCK', @@ -578,6 +592,7 @@ describe('redactAttributes', () => { valueInPercentage: 0.2457342510950259 }, VTI: { + activitiesCount: 5, currency: 'USD', markets: { UNKNOWN: 0, @@ -597,7 +612,6 @@ describe('redactAttributes', () => { marketPrice: 282.05, symbol: 'VTI', tags: [], - transactionCount: 5, allocationInPercentage: 0.057358979326040366, assetClass: 'EQUITY', assetSubClass: 'ETF', @@ -750,6 +764,7 @@ describe('redactAttributes', () => { valueInPercentage: 0.08870120238725339 }, 'VWRL.SW': { + activitiesCount: 5, currency: 'CHF', markets: { UNKNOWN: 0, @@ -769,7 +784,6 @@ describe('redactAttributes', () => { marketPrice: 117.62, symbol: 'VWRL.SW', tags: [], - transactionCount: 5, allocationInPercentage: 0.09386983901959013, assetClass: 'EQUITY', assetSubClass: 'ETF', @@ -1158,6 +1172,7 @@ describe('redactAttributes', () => { valueInPercentage: 0.145162408515095 }, 'XDWD.DE': { + activitiesCount: 1, currency: 'EUR', markets: { UNKNOWN: 0, @@ -1177,7 +1192,6 @@ describe('redactAttributes', () => { marketPrice: 105.72, symbol: 'XDWD.DE', tags: [], - transactionCount: 1, allocationInPercentage: 0.03598477442100562, assetClass: 'EQUITY', assetSubClass: 'ETF', @@ -1436,6 +1450,7 @@ describe('redactAttributes', () => { valueInPercentage: 0.055647656152211074 }, USD: { + activitiesCount: 0, currency: 'USD', allocationInPercentage: 0.20291717628620132, assetClass: 'LIQUIDITY', @@ -1458,7 +1473,6 @@ describe('redactAttributes', () => { sectors: [], symbol: 'USD', tags: [], - transactionCount: 0, valueInBaseCurrency: 49890, valueInPercentage: 0.3137956381563603 } @@ -1526,7 +1540,6 @@ describe('redactAttributes', () => { netPerformanceWithCurrencyEffect: null, totalBuy: null, totalSell: null, - committedFunds: null, currentValueInBaseCurrency: null, dividendInBaseCurrency: null, emergencyFund: null, @@ -1540,38 +1553,12 @@ describe('redactAttributes', () => { items: null, liabilities: null, totalInvestment: null, + totalInvestmentValueWithCurrencyEffect: null, totalValueInBaseCurrency: null, currentNetWorth: null } }, - options: [ - 'balance', - 'balanceInBaseCurrency', - 'comment', - 'convertedBalance', - 'dividendInBaseCurrency', - 'fee', - 'feeInBaseCurrency', - 'grossPerformance', - 'grossPerformanceWithCurrencyEffect', - 'investment', - 'netPerformance', - 'netPerformanceWithCurrencyEffect', - 'quantity', - 'symbolMapping', - 'totalBalanceInBaseCurrency', - 'totalValueInBaseCurrency', - 'unitPrice', - 'value', - 'valueInBaseCurrency' - ].map((attribute) => { - return { - attribute, - valueMap: { - '*': null - } - }; - }) + paths: DEFAULT_REDACTED_PATHS }) ).toStrictEqual({ accounts: { @@ -1628,6 +1615,7 @@ describe('redactAttributes', () => { hasError: false, holdings: { 'AAPL.US': { + activitiesCount: 1, currency: 'USD', markets: { UNKNOWN: 0, @@ -1647,7 +1635,6 @@ describe('redactAttributes', () => { marketPrice: 220.79, symbol: 'AAPL.US', tags: [], - transactionCount: 1, allocationInPercentage: 0.044900865255793135, assetClass: 'EQUITY', assetSubClass: 'STOCK', @@ -1661,7 +1648,7 @@ describe('redactAttributes', () => { ], dataSource: 'EOD_HISTORICAL_DATA', dateOfFirstActivity: '2021-11-30T23:00:00.000Z', - dividend: 0, + dividend: null, grossPerformance: null, grossPerformancePercent: 0.3183066634822068, grossPerformancePercentWithCurrencyEffect: 0.3183066634822068, @@ -1680,6 +1667,7 @@ describe('redactAttributes', () => { valueInPercentage: 0.0694356974830054 }, 'ALV.DE': { + activitiesCount: 2, currency: 'EUR', markets: { UNKNOWN: 0, @@ -1699,7 +1687,6 @@ describe('redactAttributes', () => { marketPrice: 296.5, symbol: 'ALV.DE', tags: [], - transactionCount: 2, allocationInPercentage: 0.026912563036519527, assetClass: 'EQUITY', assetSubClass: 'STOCK', @@ -1708,7 +1695,7 @@ describe('redactAttributes', () => { ], dataSource: 'YAHOO', dateOfFirstActivity: '2021-04-22T22:00:00.000Z', - dividend: 192, + dividend: null, grossPerformance: null, grossPerformancePercent: 0.3719230057375532, grossPerformancePercentWithCurrencyEffect: 0.2650716044872953, @@ -1727,6 +1714,7 @@ describe('redactAttributes', () => { valueInPercentage: 0.04161818652826481 }, AMZN: { + activitiesCount: 1, currency: 'USD', markets: { UNKNOWN: 0, @@ -1746,7 +1734,6 @@ describe('redactAttributes', () => { marketPrice: 187.99, symbol: 'AMZN', tags: [], - transactionCount: 1, allocationInPercentage: 0.07646101417126275, assetClass: 'EQUITY', assetSubClass: 'STOCK', @@ -1760,7 +1747,7 @@ describe('redactAttributes', () => { ], dataSource: 'YAHOO', dateOfFirstActivity: '2018-09-30T22:00:00.000Z', - dividend: 0, + dividend: null, grossPerformance: null, grossPerformancePercent: 0.8594552890963852, grossPerformancePercentWithCurrencyEffect: 0.8594552890963852, @@ -1779,6 +1766,7 @@ describe('redactAttributes', () => { valueInPercentage: 0.11824101426541227 }, bitcoin: { + activitiesCount: 1, currency: 'USD', markets: { UNKNOWN: 36985.0332704, @@ -1804,14 +1792,13 @@ describe('redactAttributes', () => { userId: null } ], - transactionCount: 1, allocationInPercentage: 0.15042891393226654, assetClass: 'LIQUIDITY', assetSubClass: 'CRYPTOCURRENCY', countries: [], dataSource: 'COINGECKO', dateOfFirstActivity: '2017-08-15T22:00:00.000Z', - dividend: 0, + dividend: null, grossPerformance: null, grossPerformancePercent: 17.4925166352, grossPerformancePercentWithCurrencyEffect: 17.4925166352, @@ -1830,6 +1817,7 @@ describe('redactAttributes', () => { valueInPercentage: 0.232626620912395 }, BONDORA_GO_AND_GROW: { + activitiesCount: 5, currency: 'EUR', markets: { UNKNOWN: 2231.644722160232, @@ -1855,14 +1843,13 @@ describe('redactAttributes', () => { userId: null } ], - transactionCount: 5, allocationInPercentage: 0.009076749759365777, assetClass: 'FIXED_INCOME', assetSubClass: 'BOND', countries: [], dataSource: 'MANUAL', dateOfFirstActivity: '2021-01-31T23:00:00.000Z', - dividend: 11.45, + dividend: null, grossPerformance: null, grossPerformancePercent: 0, grossPerformancePercentWithCurrencyEffect: -0.06153834320225245, @@ -1881,6 +1868,7 @@ describe('redactAttributes', () => { valueInPercentage: 0.014036487867880205 }, FRANKLY95P: { + activitiesCount: 6, currency: 'CHF', markets: { UNKNOWN: 0, @@ -1906,7 +1894,6 @@ describe('redactAttributes', () => { userId: null } ], - transactionCount: 6, allocationInPercentage: 0.09095764645669335, assetClass: 'EQUITY', assetSubClass: 'ETF', @@ -1966,7 +1953,7 @@ describe('redactAttributes', () => { ], dataSource: 'MANUAL', dateOfFirstActivity: '2021-03-31T22:00:00.000Z', - dividend: 0, + dividend: null, grossPerformance: null, grossPerformancePercent: 0.27579517683678895, grossPerformancePercentWithCurrencyEffect: 0.458553421589667, @@ -1985,6 +1972,7 @@ describe('redactAttributes', () => { valueInPercentage: 0.14065892911313693 }, MSFT: { + activitiesCount: 1, currency: 'USD', markets: { UNKNOWN: 0, @@ -2004,7 +1992,6 @@ describe('redactAttributes', () => { marketPrice: 428.02, symbol: 'MSFT', tags: [], - transactionCount: 1, allocationInPercentage: 0.05222646409742627, assetClass: 'EQUITY', assetSubClass: 'STOCK', @@ -2018,7 +2005,7 @@ describe('redactAttributes', () => { ], dataSource: 'YAHOO', dateOfFirstActivity: '2023-01-02T23:00:00.000Z', - dividend: 0, + dividend: null, grossPerformance: null, grossPerformancePercent: 0.7865431171216295, grossPerformancePercentWithCurrencyEffect: 0.7865431171216295, @@ -2037,6 +2024,7 @@ describe('redactAttributes', () => { valueInPercentage: 0.08076416659271518 }, TSLA: { + activitiesCount: 1, currency: 'USD', markets: { UNKNOWN: 0, @@ -2056,7 +2044,6 @@ describe('redactAttributes', () => { marketPrice: 260.46, symbol: 'TSLA', tags: [], - transactionCount: 1, allocationInPercentage: 0.1589050142378352, assetClass: 'EQUITY', assetSubClass: 'STOCK', @@ -2070,7 +2057,7 @@ describe('redactAttributes', () => { ], dataSource: 'YAHOO', dateOfFirstActivity: '2017-01-02T23:00:00.000Z', - dividend: 0, + dividend: null, grossPerformance: null, grossPerformancePercent: 17.184314638161936, grossPerformancePercentWithCurrencyEffect: 17.184314638161936, @@ -2089,6 +2076,7 @@ describe('redactAttributes', () => { valueInPercentage: 0.2457342510950259 }, VTI: { + activitiesCount: 5, currency: 'USD', markets: { UNKNOWN: 0, @@ -2108,7 +2096,6 @@ describe('redactAttributes', () => { marketPrice: 282.05, symbol: 'VTI', tags: [], - transactionCount: 5, allocationInPercentage: 0.057358979326040366, assetClass: 'EQUITY', assetSubClass: 'ETF', @@ -2152,7 +2139,7 @@ describe('redactAttributes', () => { ], dataSource: 'YAHOO', dateOfFirstActivity: '2019-02-28T23:00:00.000Z', - dividend: 0, + dividend: null, grossPerformance: null, grossPerformancePercent: 0.8832083851170418, grossPerformancePercentWithCurrencyEffect: 0.8832083851170418, @@ -2261,6 +2248,7 @@ describe('redactAttributes', () => { valueInPercentage: 0.08870120238725339 }, 'VWRL.SW': { + activitiesCount: 5, currency: 'CHF', markets: { UNKNOWN: 0, @@ -2280,7 +2268,6 @@ describe('redactAttributes', () => { marketPrice: 117.62, symbol: 'VWRL.SW', tags: [], - transactionCount: 5, allocationInPercentage: 0.09386983901959013, assetClass: 'EQUITY', assetSubClass: 'ETF', @@ -2547,7 +2534,7 @@ describe('redactAttributes', () => { ], dataSource: 'YAHOO', dateOfFirstActivity: '2018-02-28T23:00:00.000Z', - dividend: 0, + dividend: null, grossPerformance: null, grossPerformancePercent: 0.3683200415015591, grossPerformancePercentWithCurrencyEffect: 0.5806366182968891, @@ -2661,6 +2648,7 @@ describe('redactAttributes', () => { valueInPercentage: 0.145162408515095 }, 'XDWD.DE': { + activitiesCount: 1, currency: 'EUR', markets: { UNKNOWN: 0, @@ -2680,7 +2668,6 @@ describe('redactAttributes', () => { marketPrice: 105.72, symbol: 'XDWD.DE', tags: [], - transactionCount: 1, allocationInPercentage: 0.03598477442100562, assetClass: 'EQUITY', assetSubClass: 'ETF', @@ -2826,7 +2813,7 @@ describe('redactAttributes', () => { ], dataSource: 'YAHOO', dateOfFirstActivity: '2021-08-18T22:00:00.000Z', - dividend: 0, + dividend: null, grossPerformance: null, grossPerformancePercent: 0.3474381850624522, grossPerformancePercentWithCurrencyEffect: 0.28744846894552306, @@ -2939,12 +2926,13 @@ describe('redactAttributes', () => { valueInPercentage: 0.055647656152211074 }, USD: { + activitiesCount: 0, currency: 'USD', allocationInPercentage: 0.20291717628620132, assetClass: 'LIQUIDITY', assetSubClass: 'CASH', countries: [], - dividend: 0, + dividend: null, grossPerformance: null, grossPerformancePercent: 0, grossPerformancePercentWithCurrencyEffect: 0, @@ -2961,7 +2949,6 @@ describe('redactAttributes', () => { sectors: [], symbol: 'USD', tags: [], - transactionCount: 0, valueInBaseCurrency: null, valueInPercentage: 0.3137956381563603 } @@ -3029,7 +3016,6 @@ describe('redactAttributes', () => { netPerformanceWithCurrencyEffect: null, totalBuy: null, totalSell: null, - committedFunds: null, currentValueInBaseCurrency: null, dividendInBaseCurrency: null, emergencyFund: null, @@ -3043,6 +3029,7 @@ describe('redactAttributes', () => { items: null, liabilities: null, totalInvestment: null, + totalInvestmentValueWithCurrencyEffect: null, totalValueInBaseCurrency: null, currentNetWorth: null } diff --git a/apps/api/src/helper/object.helper.ts b/apps/api/src/helper/object.helper.ts index a5854e9d9..350d5fe04 100644 --- a/apps/api/src/helper/object.helper.ts +++ b/apps/api/src/helper/object.helper.ts @@ -1,5 +1,6 @@ -import { Big } from 'big.js'; -import { cloneDeep, isArray, isObject } from 'lodash'; +import fastRedact from 'fast-redact'; +import jsonpath from 'jsonpath'; +import { cloneDeep, isObject } from 'lodash'; export function hasNotDefinedValuesInObject(aObject: Object): boolean { for (const key in aObject) { @@ -31,60 +32,39 @@ export function nullifyValuesInObjects(aObjects: T[], keys: string[]): T[] { }); } -export function redactAttributes({ - isFirstRun = true, +export function query({ object, - options + pathExpression +}: { + object: object; + pathExpression: string; +}) { + return jsonpath.query(object, pathExpression); +} + +export function redactPaths({ + object, + paths, + valueMap }: { - isFirstRun?: boolean; object: any; - options: { attribute: string; valueMap: { [key: string]: any } }[]; + paths: fastRedact.RedactOptions['paths']; + valueMap?: { [key: string]: any }; }): any { - if (!object || !options?.length) { - return object; - } - - // Create deep clone - const redactedObject = isFirstRun - ? JSON.parse(JSON.stringify(object)) - : 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, - isFirstRun: false, - object: currentObject - }); - } - ); - } else if ( - isObject(redactedObject[property]) && - !(redactedObject[property] instanceof Big) - ) { - // Recursively call the function on the nested object - redactedObject[property] = redactAttributes({ - options, - isFirstRun: false, - object: redactedObject[property] - }); + const redact = fastRedact({ + paths, + censor: (value) => { + if (valueMap) { + if (valueMap[value]) { + return valueMap[value]; + } else { + return value; } + } else { + return null; } } - } + }); - return redactedObject; + return JSON.parse(redact(object)); } diff --git a/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts b/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts index 5ecf7c48d..60b994cac 100644 --- a/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts +++ b/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts @@ -1,5 +1,8 @@ -import { redactAttributes } from '@ghostfolio/api/helper/object.helper'; -import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; +import { redactPaths } from '@ghostfolio/api/helper/object.helper'; +import { + DEFAULT_REDACTED_PATHS, + HEADER_KEY_IMPERSONATION +} from '@ghostfolio/common/config'; import { hasReadRestrictedAccessPermission, isRestrictedView @@ -39,40 +42,9 @@ export class RedactValuesInResponseInterceptor implements NestInterceptor< }) || isRestrictedView(user) ) { - data = redactAttributes({ + data = redactPaths({ object: data, - options: [ - 'balance', - 'balanceInBaseCurrency', - 'comment', - 'convertedBalance', - 'dividendInBaseCurrency', - 'fee', - 'feeInBaseCurrency', - 'grossPerformance', - 'grossPerformanceWithCurrencyEffect', - 'interestInBaseCurrency', - 'investment', - 'netPerformance', - 'netPerformanceWithCurrencyEffect', - 'quantity', - 'symbolMapping', - 'totalBalanceInBaseCurrency', - 'totalDividendInBaseCurrency', - 'totalInterestInBaseCurrency', - 'totalValueInBaseCurrency', - 'unitPrice', - 'unitPriceInAssetProfileCurrency', - 'value', - 'valueInBaseCurrency' - ].map((attribute) => { - return { - attribute, - valueMap: { - '*': null - } - }; - }) + paths: DEFAULT_REDACTED_PATHS }); } diff --git a/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts b/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts index 9af256671..57643f76c 100644 --- a/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts +++ b/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts @@ -1,4 +1,4 @@ -import { redactAttributes } from '@ghostfolio/api/helper/object.helper'; +import { redactPaths } from '@ghostfolio/api/helper/object.helper'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { encodeDataSource } from '@ghostfolio/common/helper'; @@ -58,13 +58,21 @@ export class TransformDataSourceInResponseInterceptor< } } - data = redactAttributes({ + data = redactPaths({ + valueMap, object: data, - options: [ - { - valueMap, - attribute: 'dataSource' - } + paths: [ + 'activities[*].dataSource', + 'activities[*].SymbolProfile.dataSource', + 'benchmarks[*].dataSource', + 'errors[*].dataSource', + 'fearAndGreedIndex.CRYPTOCURRENCIES.dataSource', + 'fearAndGreedIndex.STOCKS.dataSource', + 'holdings[*].assetProfile.dataSource', + 'holdings[*].dataSource', + 'items[*].dataSource', + 'SymbolProfile.dataSource', + 'watchlist[*].dataSource' ] }); } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index a8de3dc5e..f08a09a83 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,4 +1,5 @@ import { + BULL_BOARD_ROUTE, DEFAULT_HOST, DEFAULT_PORT, STORYBOOK_PATH, @@ -14,6 +15,7 @@ import { import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; import type { NestExpressApplication } from '@nestjs/platform-express'; +import cookieParser from 'cookie-parser'; import { NextFunction, Request, Response } from 'express'; import helmet from 'helmet'; @@ -46,6 +48,7 @@ async function bootstrap() { }); app.setGlobalPrefix('api', { exclude: [ + `${BULL_BOARD_ROUTE.substring(1)}{/*wildcard}`, 'sitemap.xml', ...SUPPORTED_LANGUAGE_CODES.map((languageCode) => { // Exclude language-specific routes with an optional wildcard @@ -53,6 +56,7 @@ async function bootstrap() { }) ] }); + app.useGlobalPipes( new ValidationPipe({ forbidNonWhitelisted: true, @@ -64,6 +68,8 @@ async function bootstrap() { // Support 10mb csv/json files for importing activities app.useBodyParser('json', { limit: '10mb' }); + app.use(cookieParser()); + if (configService.get('ENABLE_FEATURE_SUBSCRIPTION') === 'true') { app.use((req: Request, res: Response, next: NextFunction) => { if (req.path.startsWith(STORYBOOK_PATH)) { diff --git a/apps/api/src/middlewares/bull-board-auth.middleware.ts b/apps/api/src/middlewares/bull-board-auth.middleware.ts new file mode 100644 index 000000000..432deb974 --- /dev/null +++ b/apps/api/src/middlewares/bull-board-auth.middleware.ts @@ -0,0 +1,28 @@ +import { BULL_BOARD_COOKIE_NAME } from '@ghostfolio/common/config'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; + +import { ForbiddenException, Injectable, NestMiddleware } from '@nestjs/common'; +import { NextFunction, Request, Response } from 'express'; +import passport from 'passport'; + +@Injectable() +export class BullBoardAuthMiddleware implements NestMiddleware { + public use(req: Request, res: Response, next: NextFunction) { + const token = req.cookies?.[BULL_BOARD_COOKIE_NAME]; + + if (token) { + req.headers.authorization = `Bearer ${token}`; + } + + passport.authenticate('jwt', { session: false }, (error, user) => { + if ( + error || + !hasPermission(user?.permissions, permissions.accessAdminControl) + ) { + next(new ForbiddenException()); + } else { + next(); + } + })(req, res, next); + } +} diff --git a/apps/api/src/models/rule.ts b/apps/api/src/models/rule.ts index 9c27e0018..622375b5b 100644 --- a/apps/api/src/models/rule.ts +++ b/apps/api/src/models/rule.ts @@ -57,7 +57,7 @@ export abstract class Rule implements RuleInterface { previousValue + this.exchangeRateDataService.toCurrency( new Big(currentValue.quantity) - .mul(currentValue.marketPrice) + .mul(currentValue.marketPrice ?? 0) .toNumber(), currentValue.currency, baseCurrency diff --git a/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts b/apps/api/src/models/rules/fees/fee-ratio-total-investment-volume.ts similarity index 78% rename from apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts rename to apps/api/src/models/rules/fees/fee-ratio-total-investment-volume.ts index cb85a73ba..07bf5fa2c 100644 --- a/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts +++ b/apps/api/src/models/rules/fees/fee-ratio-total-investment-volume.ts @@ -3,35 +3,36 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { RuleSettings, UserSettings } from '@ghostfolio/common/interfaces'; -export class FeeRatioInitialInvestment extends Rule { +export class FeeRatioTotalInvestmentVolume extends Rule { private fees: number; - private totalInvestment: number; + private totalInvestmentVolumeInBaseCurrency: number; public constructor( protected exchangeRateDataService: ExchangeRateDataService, private i18nService: I18nService, languageCode: string, - totalInvestment: number, + totalInvestmentVolumeInBaseCurrency: number, fees: number ) { super(exchangeRateDataService, { languageCode, - key: FeeRatioInitialInvestment.name + key: FeeRatioTotalInvestmentVolume.name }); this.fees = fees; - this.totalInvestment = totalInvestment; + this.totalInvestmentVolumeInBaseCurrency = + totalInvestmentVolumeInBaseCurrency; } public evaluate(ruleSettings: Settings) { - const feeRatio = this.totalInvestment - ? this.fees / this.totalInvestment + const feeRatio = this.totalInvestmentVolumeInBaseCurrency + ? this.fees / this.totalInvestmentVolumeInBaseCurrency : 0; if (feeRatio > ruleSettings.thresholdMax) { return { evaluation: this.i18nService.getTranslation({ - id: 'rule.feeRatioInitialInvestment.false', + id: 'rule.feeRatioTotalInvestmentVolume.false', languageCode: this.getLanguageCode(), placeholders: { feeRatio: (ruleSettings.thresholdMax * 100).toFixed(2), @@ -44,7 +45,7 @@ export class FeeRatioInitialInvestment extends Rule { return { evaluation: this.i18nService.getTranslation({ - id: 'rule.feeRatioInitialInvestment.true', + id: 'rule.feeRatioTotalInvestmentVolume.true', languageCode: this.getLanguageCode(), placeholders: { feeRatio: (feeRatio * 100).toPrecision(3), @@ -76,7 +77,7 @@ export class FeeRatioInitialInvestment extends Rule { public getName() { return this.i18nService.getTranslation({ - id: 'rule.feeRatioInitialInvestment', + id: 'rule.feeRatioTotalInvestmentVolume', languageCode: this.getLanguageCode() }); } diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index a91aa6e69..ad8e84a99 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -30,6 +30,7 @@ export class ConfigurationService { API_KEY_FINANCIAL_MODELING_PREP: str({ default: '' }), API_KEY_OPEN_FIGI: str({ default: '' }), API_KEY_RAPID_API: str({ default: '' }), + BULL_BOARD_IS_READ_ONLY: bool({ default: true }), CACHE_QUOTES_TTL: num({ default: ms('1 minute') }), CACHE_TTL: num({ default: CACHE_TTL_NO_CACHE }), DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }), @@ -43,6 +44,7 @@ export class ConfigurationService { ENABLE_FEATURE_AUTH_GOOGLE: bool({ default: false }), ENABLE_FEATURE_AUTH_OIDC: bool({ default: false }), ENABLE_FEATURE_AUTH_TOKEN: bool({ default: true }), + ENABLE_FEATURE_BULL_BOARD: bool({ default: false }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }), ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }), @@ -102,7 +104,6 @@ export class ConfigurationService { ROOT_URL: url({ default: environment.rootUrl }), - STRIPE_PUBLIC_KEY: str({ default: '' }), STRIPE_SECRET_KEY: str({ default: '' }), TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }), TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }), diff --git a/apps/api/src/services/cryptocurrency/cryptocurrency.module.ts b/apps/api/src/services/cryptocurrency/cryptocurrency.module.ts index 8820205eb..e882f4da5 100644 --- a/apps/api/src/services/cryptocurrency/cryptocurrency.module.ts +++ b/apps/api/src/services/cryptocurrency/cryptocurrency.module.ts @@ -1,9 +1,12 @@ +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; + import { Module } from '@nestjs/common'; import { CryptocurrencyService } from './cryptocurrency.service'; @Module({ - providers: [CryptocurrencyService], - exports: [CryptocurrencyService] + exports: [CryptocurrencyService], + imports: [PropertyModule], + providers: [CryptocurrencyService] }) export class CryptocurrencyModule {} diff --git a/apps/api/src/services/cryptocurrency/cryptocurrency.service.ts b/apps/api/src/services/cryptocurrency/cryptocurrency.service.ts index b814fc186..933029ea2 100644 --- a/apps/api/src/services/cryptocurrency/cryptocurrency.service.ts +++ b/apps/api/src/services/cryptocurrency/cryptocurrency.service.ts @@ -1,31 +1,39 @@ -import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { + DEFAULT_CURRENCY, + PROPERTY_CUSTOM_CRYPTOCURRENCIES +} from '@ghostfolio/common/config'; -import { Injectable } from '@nestjs/common'; +import { Injectable, OnModuleInit } from '@nestjs/common'; const cryptocurrencies = require('../../assets/cryptocurrencies/cryptocurrencies.json'); const customCryptocurrencies = require('../../assets/cryptocurrencies/custom.json'); @Injectable() -export class CryptocurrencyService { +export class CryptocurrencyService implements OnModuleInit { private combinedCryptocurrencies: string[]; + public constructor(private readonly propertyService: PropertyService) {} + + public async onModuleInit() { + const customCryptocurrenciesFromDatabase = + await this.propertyService.getByKey>( + PROPERTY_CUSTOM_CRYPTOCURRENCIES + ); + + this.combinedCryptocurrencies = [ + ...Object.keys(cryptocurrencies), + ...Object.keys(customCryptocurrencies), + ...Object.keys(customCryptocurrenciesFromDatabase ?? {}) + ]; + } + public isCryptocurrency(aSymbol = '') { const cryptocurrencySymbol = aSymbol.substring(0, aSymbol.length - 3); return ( aSymbol.endsWith(DEFAULT_CURRENCY) && - this.getCryptocurrencies().includes(cryptocurrencySymbol) + this.combinedCryptocurrencies.includes(cryptocurrencySymbol) ); } - - private getCryptocurrencies() { - if (!this.combinedCryptocurrencies) { - this.combinedCryptocurrencies = [ - ...Object.keys(cryptocurrencies), - ...Object.keys(customCryptocurrencies) - ]; - } - - return this.combinedCryptocurrencies; - } } 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 3cf935b1e..6030e62d4 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 @@ -18,7 +18,7 @@ import { import { Injectable } from '@nestjs/common'; import { DataSource, SymbolProfile } from '@prisma/client'; -import * as Alphavantage from 'alphavantage'; +import Alphavantage from 'alphavantage'; import { format, isAfter, isBefore, parse } from 'date-fns'; import { AlphaVantageHistoricalResponse } from './interfaces/interfaces'; diff --git a/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.spec.ts b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.spec.ts index c37a9fe3e..9335d86d0 100644 --- a/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.spec.ts +++ b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.spec.ts @@ -29,7 +29,7 @@ describe('YahooFinanceDataEnhancerService', () => { let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService; beforeAll(async () => { - cryptocurrencyService = new CryptocurrencyService(); + cryptocurrencyService = new CryptocurrencyService(null); yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService( cryptocurrencyService diff --git a/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts index 65bcd6c06..72136dc04 100644 --- a/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts +++ b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts @@ -135,10 +135,10 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { shortName, symbol }: { - longName: Price['longName']; - quoteType: Price['quoteType']; - shortName: Price['shortName']; - symbol: Price['symbol']; + longName?: Price['longName']; + quoteType?: Price['quoteType']; + shortName?: Price['shortName']; + symbol?: Price['symbol']; }) { let name = longName; @@ -206,17 +206,28 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { ); if (['ETF', 'MUTUALFUND'].includes(assetSubClass)) { - response.sectors = []; - - for (const sectorWeighting of assetProfile.topHoldings - ?.sectorWeightings ?? []) { - for (const [sector, weight] of Object.entries(sectorWeighting)) { - response.sectors.push({ + response.holdings = + assetProfile.topHoldings?.holdings + ?.filter(({ holdingName }) => { + return !holdingName?.includes('ETF'); + }) + ?.map(({ holdingName, holdingPercent }) => { + return { + name: this.formatName({ longName: holdingName }), + weight: holdingPercent + }; + }) ?? []; + + response.sectors = ( + assetProfile.topHoldings?.sectorWeightings ?? [] + ).flatMap((sectorWeighting) => { + return Object.entries(sectorWeighting).map(([sector, weight]) => { + return { name: this.parseSector(sector), weight: weight as number - }); - } - } + }; + }); + }); } else if ( assetSubClass === 'STOCK' && assetProfile.summaryProfile?.country 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 5a088c0e4..a6b12cce2 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -1,3 +1,4 @@ +import { ImportDataDto } from '@ghostfolio/api/app/import/import-data.dto'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; @@ -10,8 +11,10 @@ import { PROPERTY_API_KEY_GHOSTFOLIO, PROPERTY_DATA_SOURCE_MAPPING } from '@ghostfolio/common/config'; +import { CreateOrderDto } from '@ghostfolio/common/dtos'; import { DATE_FORMAT, + getAssetProfileIdentifier, getCurrencyFromSymbol, getStartOfUtcDate, isCurrency, @@ -27,7 +30,7 @@ import { import type { Granularity, UserWithSettings } from '@ghostfolio/common/types'; import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { DataSource, MarketData, SymbolProfile } from '@prisma/client'; +import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client'; import { Big } from 'big.js'; import { eachDayOfInterval, format, isValid } from 'date-fns'; import { groupBy, isEmpty, isNumber, uniqWith } from 'lodash'; @@ -185,6 +188,125 @@ export class DataProviderService implements OnModuleInit { return dataSources.sort(); } + public async validateActivities({ + activitiesDto, + assetProfilesWithMarketDataDto, + maxActivitiesToImport, + user + }: { + activitiesDto: Pick< + Partial, + 'currency' | 'dataSource' | 'symbol' | 'type' + >[]; + assetProfilesWithMarketDataDto?: ImportDataDto['assetProfiles']; + maxActivitiesToImport: number; + user: UserWithSettings; + }) { + if (activitiesDto?.length > maxActivitiesToImport) { + throw new Error(`Too many activities (${maxActivitiesToImport} at most)`); + } + + const assetProfiles: { + [assetProfileIdentifier: string]: Partial; + } = {}; + + const dataSources = await this.getDataSources(); + + for (const [ + index, + { currency, dataSource, symbol, type } + ] of activitiesDto.entries()) { + const activityPath = + maxActivitiesToImport === 1 ? 'activity' : `activities.${index}`; + + if (!dataSources.includes(dataSource)) { + throw new Error( + `${activityPath}.dataSource ("${dataSource}") is not valid` + ); + } + + if ( + this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + user.subscription.type === 'Basic' + ) { + const dataProvider = this.getDataProvider(DataSource[dataSource]); + + if (dataProvider.getDataProviderInfo().isPremium) { + throw new Error( + `${activityPath}.dataSource ("${dataSource}") is not valid` + ); + } + } + + const assetProfileIdentifier = getAssetProfileIdentifier({ + dataSource, + symbol + }); + + if (!assetProfiles[assetProfileIdentifier]) { + if ( + (dataSource === DataSource.MANUAL && type === 'BUY') || + ['FEE', 'INTEREST', 'LIABILITY'].includes(type) + ) { + const assetProfileInImport = assetProfilesWithMarketDataDto?.find( + (assetProfile) => { + return ( + assetProfile.dataSource === dataSource && + assetProfile.symbol === symbol + ); + } + ); + + assetProfiles[assetProfileIdentifier] = { + currency, + dataSource, + symbol, + name: assetProfileInImport?.name ?? symbol + }; + + continue; + } + + let assetProfile: Partial = { currency }; + + try { + assetProfile = ( + await this.getAssetProfiles([ + { + dataSource, + symbol + } + ]) + )?.[symbol]; + } catch {} + + if (!assetProfile?.name) { + const assetProfileInImport = assetProfilesWithMarketDataDto?.find( + (profile) => { + return ( + profile.dataSource === dataSource && profile.symbol === symbol + ); + } + ); + + if (assetProfileInImport) { + Object.assign(assetProfile, assetProfileInImport); + } + } + + if (!assetProfile?.name) { + throw new Error( + `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` + ); + } + + assetProfiles[assetProfileIdentifier] = assetProfile; + } + } + + return assetProfiles; + } + public async getDividends({ dataSource, from, @@ -225,36 +347,35 @@ export class DataProviderService implements OnModuleInit { const granularityQuery = aGranularity === 'month' - ? `AND (date_part('day', date) = 1 OR date >= TIMESTAMP 'yesterday')` - : ''; + ? Prisma.sql`AND (date_part('day', date) = 1 OR date >= TIMESTAMP 'yesterday')` + : Prisma.empty; const rangeQuery = from && to - ? `AND date >= '${format(from, DATE_FORMAT)}' AND date <= '${format( + ? Prisma.sql`AND date >= ${format(from, DATE_FORMAT)}::timestamp AND date <= ${format( to, DATE_FORMAT - )}'` - : ''; + )}::timestamp` + : Prisma.empty; const dataSources = aItems.map(({ dataSource }) => { return dataSource; }); + const symbols = aItems.map(({ symbol }) => { return symbol; }); try { - const queryRaw = ` - SELECT * - FROM "MarketData" - WHERE "dataSource" IN ('${dataSources.join(`','`)}') - AND "symbol" IN ('${symbols.join( - `','` - )}') ${granularityQuery} ${rangeQuery} - ORDER BY date;`; - - const marketDataByGranularity: MarketData[] = - await this.prismaService.$queryRawUnsafe(queryRaw); + const marketDataByGranularity: MarketData[] = await this.prismaService + .$queryRaw` + SELECT * + FROM "MarketData" + WHERE "dataSource"::text IN (${Prisma.join(dataSources)}) + AND "symbol" IN (${Prisma.join(symbols)}) + ${granularityQuery} + ${rangeQuery} + ORDER BY date;`; response = marketDataByGranularity.reduce((r, marketData) => { const { date, marketPrice, symbol } = marketData; 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 f18da49ab..51e65e631 100644 --- a/apps/api/src/services/data-provider/manual/manual.service.ts +++ b/apps/api/src/services/data-provider/manual/manual.service.ts @@ -1,3 +1,4 @@ +import { query } from '@ghostfolio/api/helper/object.helper'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataProviderInterface, @@ -26,7 +27,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { DataSource, SymbolProfile } from '@prisma/client'; import * as cheerio from 'cheerio'; import { addDays, format, isBefore } from 'date-fns'; -import * as jsonpath from 'jsonpath'; @Injectable() export class ManualService implements DataProviderInterface { @@ -105,7 +105,10 @@ export class ManualService implements DataProviderInterface { return {}; } - const value = await this.scrape(symbolProfile.scraperConfiguration); + const value = await this.scrape({ + symbol, + scraperConfiguration: symbolProfile.scraperConfiguration + }); return { [symbol]: { @@ -170,7 +173,10 @@ export class ManualService implements DataProviderInterface { symbolProfilesWithScraperConfigurationAndInstantMode.map( async ({ scraperConfiguration, symbol }) => { try { - const marketPrice = await this.scrape(scraperConfiguration); + const marketPrice = await this.scrape({ + scraperConfiguration, + symbol + }); return { marketPrice, symbol }; } catch (error) { Logger.error( @@ -267,13 +273,23 @@ export class ManualService implements DataProviderInterface { }; } - public async test(scraperConfiguration: ScraperConfiguration) { - return this.scrape(scraperConfiguration); + public async test({ + scraperConfiguration, + symbol + }: { + scraperConfiguration: ScraperConfiguration; + symbol: string; + }) { + return this.scrape({ scraperConfiguration, symbol }); } - private async scrape( - scraperConfiguration: ScraperConfiguration - ): Promise { + private async scrape({ + scraperConfiguration, + symbol + }: { + scraperConfiguration: ScraperConfiguration; + symbol: string; + }): Promise { let locale = scraperConfiguration.locale; const response = await fetch(scraperConfiguration.url, { @@ -283,12 +299,23 @@ export class ManualService implements DataProviderInterface { ) }); + if (!response.ok) { + throw new Error( + `Failed to scrape the market price for ${symbol} (${this.getName()}): ${response.status} ${response.statusText} at ${scraperConfiguration.url}` + ); + } + let value: string; if (response.headers.get('content-type')?.includes('application/json')) { - const data = await response.json(); + const object = await response.json(); - value = String(jsonpath.query(data, scraperConfiguration.selector)[0]); + value = String( + query({ + object, + pathExpression: scraperConfiguration.selector + })[0] + ); } else { const $ = cheerio.load(await response.text()); diff --git a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts index 076375523..742be36b4 100644 --- a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts @@ -1,5 +1,7 @@ -export const ExchangeRateDataServiceMock = { - getExchangeRatesByCurrency: ({ targetCurrency }): Promise => { +import { ExchangeRateDataService } from './exchange-rate-data.service'; + +export const ExchangeRateDataServiceMock: Partial = { + getExchangeRatesByCurrency: ({ targetCurrency }) => { if (targetCurrency === 'CHF') { return Promise.resolve({ CHFCHF: { @@ -14,7 +16,11 @@ export const ExchangeRateDataServiceMock = { '2017-12-31': 0.9787, '2018-01-01': 0.97373, '2023-01-03': 0.9238, - '2023-07-10': 0.8854 + '2023-07-10': 0.8854, + '2023-12-31': 0.85, + '2024-01-01': 0.86, + '2024-12-31': 0.9, + '2025-01-01': 0.91 } }); } else if (targetCurrency === 'EUR') { diff --git a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts index 8c1ba5b41..024bdf4e1 100644 --- a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts @@ -26,6 +26,8 @@ import { import { isNumber } from 'lodash'; import ms from 'ms'; +import { ExchangeRatesByCurrency } from './interfaces/exchange-rate-data.interface'; + @Injectable() export class ExchangeRateDataService { private currencies: string[] = []; @@ -59,7 +61,7 @@ export class ExchangeRateDataService { endDate?: Date; startDate: Date; targetCurrency: string; - }) { + }): Promise { if (!startDate) { return {}; } diff --git a/apps/api/src/services/exchange-rate-data/interfaces/exchange-rate-data.interface.ts b/apps/api/src/services/exchange-rate-data/interfaces/exchange-rate-data.interface.ts new file mode 100644 index 000000000..8e0d2c0d4 --- /dev/null +++ b/apps/api/src/services/exchange-rate-data/interfaces/exchange-rate-data.interface.ts @@ -0,0 +1,5 @@ +export interface ExchangeRatesByCurrency { + [currency: string]: { + [dateString: string]: number; + }; +} diff --git a/apps/api/src/services/i18n/i18n.service.ts b/apps/api/src/services/i18n/i18n.service.ts index cf340d7c6..1cdb811a9 100644 --- a/apps/api/src/services/i18n/i18n.service.ts +++ b/apps/api/src/services/i18n/i18n.service.ts @@ -1,16 +1,16 @@ import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import * as cheerio from 'cheerio'; import { readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; @Injectable() -export class I18nService { +export class I18nService implements OnModuleInit { private localesPath = join(__dirname, 'assets', 'locales'); private translations: { [locale: string]: cheerio.CheerioAPI } = {}; - public constructor() { + public onModuleInit() { this.loadFiles(); } diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index 3c03744f1..9664ae144 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -10,6 +10,7 @@ export interface Environment extends CleanedEnvAccessors { API_KEY_FINANCIAL_MODELING_PREP: string; API_KEY_OPEN_FIGI: string; API_KEY_RAPID_API: string; + BULL_BOARD_IS_READ_ONLY: boolean; CACHE_QUOTES_TTL: number; CACHE_TTL: number; DATA_SOURCE_EXCHANGE_RATES: string; @@ -19,6 +20,7 @@ export interface Environment extends CleanedEnvAccessors { ENABLE_FEATURE_AUTH_GOOGLE: boolean; ENABLE_FEATURE_AUTH_OIDC: boolean; ENABLE_FEATURE_AUTH_TOKEN: boolean; + ENABLE_FEATURE_BULL_BOARD: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean; ENABLE_FEATURE_READ_ONLY_MODE: boolean; @@ -52,7 +54,6 @@ export interface Environment extends CleanedEnvAccessors { REDIS_PORT: number; REQUEST_TIMEOUT: number; ROOT_URL: string; - STRIPE_PUBLIC_KEY: string; STRIPE_SECRET_KEY: string; TWITTER_ACCESS_TOKEN: string; TWITTER_ACCESS_TOKEN_SECRET: string; diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.module.ts b/apps/api/src/services/queues/data-gathering/data-gathering.module.ts index b51823476..f251c8d0c 100644 --- a/apps/api/src/services/queues/data-gathering/data-gathering.module.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.module.ts @@ -9,6 +9,8 @@ import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathe import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config'; +import { BullAdapter } from '@bull-board/api/bullAdapter'; +import { BullBoardModule } from '@bull-board/nestjs'; import { BullModule } from '@nestjs/bull'; import { Module } from '@nestjs/common'; import ms from 'ms'; @@ -17,6 +19,18 @@ import { DataGatheringProcessor } from './data-gathering.processor'; @Module({ imports: [ + ...(process.env.ENABLE_FEATURE_BULL_BOARD === 'true' + ? [ + BullBoardModule.forFeature({ + adapter: BullAdapter, + name: DATA_GATHERING_QUEUE, + options: { + displayName: 'Data Gathering', + readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false' + } + }) + ] + : []), BullModule.registerQueue({ limiter: { duration: ms('4 seconds'), diff --git a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts index 958636334..1260f1cf0 100644 --- a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts +++ b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts @@ -1,5 +1,5 @@ import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module'; -import { OrderModule } from '@ghostfolio/api/app/order/order.module'; +import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; @@ -13,6 +13,8 @@ import { PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE } from '@ghostfolio/common/config'; +import { BullAdapter } from '@bull-board/api/bullAdapter'; +import { BullBoardModule } from '@bull-board/nestjs'; import { BullModule } from '@nestjs/bull'; import { Module } from '@nestjs/common'; @@ -22,6 +24,19 @@ import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor'; exports: [BullModule, PortfolioSnapshotService], imports: [ AccountBalanceModule, + ActivitiesModule, + ...(process.env.ENABLE_FEATURE_BULL_BOARD === 'true' + ? [ + BullBoardModule.forFeature({ + adapter: BullAdapter, + name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE, + options: { + displayName: 'Portfolio Snapshot Computation', + readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false' + } + }) + ] + : []), BullModule.registerQueue({ name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE, settings: { @@ -36,7 +51,6 @@ import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor'; DataProviderModule, ExchangeRateDataModule, MarketDataModule, - OrderModule, RedisCacheModule ], providers: [ diff --git a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts index 75a3a8631..f3aa6e77e 100644 --- a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts +++ b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts @@ -1,5 +1,5 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; -import { OrderService } from '@ghostfolio/api/app/order/order.service'; +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; @@ -23,9 +23,9 @@ import { PortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue export class PortfolioSnapshotProcessor { public constructor( private readonly accountBalanceService: AccountBalanceService, + private readonly activitiesService: ActivitiesService, private readonly calculatorFactory: PortfolioCalculatorFactory, private readonly configurationService: ConfigurationService, - private readonly orderService: OrderService, private readonly redisCacheService: RedisCacheService ) {} @@ -47,10 +47,11 @@ export class PortfolioSnapshotProcessor { ); const { activities } = - await this.orderService.getOrdersForPortfolioCalculator({ + await this.activitiesService.getActivitiesForPortfolioCalculator({ filters: job.data.filters, userCurrency: job.data.userCurrency, - userId: job.data.userId + userId: job.data.userId, + withCash: true }); const accountBalanceItems = diff --git a/apps/api/src/services/symbol-profile/symbol-profile.service.ts b/apps/api/src/services/symbol-profile/symbol-profile.service.ts index c41a59c78..4c2c42589 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -77,7 +77,7 @@ export class SymbolProfileService { .findMany({ include: { _count: { - select: { activities: true } + select: { activities: true, watchedBy: true } }, activities: { orderBy: { @@ -109,7 +109,7 @@ export class SymbolProfileService { .findMany({ include: { _count: { - select: { activities: true } + select: { activities: true, watchedBy: true } }, SymbolProfileOverrides: true }, @@ -184,7 +184,7 @@ export class SymbolProfileService { private enhanceSymbolProfiles( symbolProfiles: (SymbolProfile & { - _count: { activities: number }; + _count: { activities: number; watchedBy?: number }; activities?: { date: Date; }[]; @@ -206,10 +206,12 @@ export class SymbolProfileService { sectors: this.getSectors( symbolProfile?.sectors as unknown as Prisma.JsonArray ), - symbolMapping: this.getSymbolMapping(symbolProfile) + symbolMapping: this.getSymbolMapping(symbolProfile), + watchedByCount: 0 }; item.activitiesCount = symbolProfile._count.activities; + item.watchedByCount = symbolProfile._count.watchedBy ?? 0; delete item._count; item.dateOfFirstActivity = symbolProfile.activities?.[0]?.date; diff --git a/apps/api/src/services/tag/tag.service.ts b/apps/api/src/services/tag/tag.service.ts index eb2d7bfef..f4cbd4cb1 100644 --- a/apps/api/src/services/tag/tag.service.ts +++ b/apps/api/src/services/tag/tag.service.ts @@ -75,12 +75,16 @@ export class TagService { } }); - return tags.map(({ _count, id, name, userId }) => ({ - id, - name, - userId, - isUsed: _count.activities > 0 - })); + return tags + .map(({ _count, id, name, userId }) => ({ + id, + name, + userId, + isUsed: _count.activities > 0 + })) + .sort((a, b) => { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); } public async getTagsWithActivityCount() { 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 ee951820d..b424f7198 100644 --- a/apps/api/src/services/twitter-bot/twitter-bot.service.ts +++ b/apps/api/src/services/twitter-bot/twitter-bot.service.ts @@ -10,19 +10,21 @@ import { resolveMarketCondition } from '@ghostfolio/common/helper'; -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { isWeekend } from 'date-fns'; import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2'; @Injectable() -export class TwitterBotService { +export class TwitterBotService implements OnModuleInit { private twitterClient: TwitterApiReadWrite; public constructor( private readonly benchmarkService: BenchmarkService, private readonly configurationService: ConfigurationService, private readonly symbolService: SymbolService - ) { + ) {} + + public onModuleInit() { this.twitterClient = new TwitterApi({ accessSecret: this.configurationService.get( 'TWITTER_ACCESS_TOKEN_SECRET' diff --git a/apps/api/tsconfig.app.json b/apps/api/tsconfig.app.json index 655120714..d38ef826f 100644 --- a/apps/api/tsconfig.app.json +++ b/apps/api/tsconfig.app.json @@ -4,6 +4,7 @@ "outDir": "../../dist/out-tsc", "types": ["node"], "emitDecoratorMetadata": true, + "moduleResolution": "node10", "target": "es2021", "module": "commonjs" }, diff --git a/apps/api/tsconfig.spec.json b/apps/api/tsconfig.spec.json index 148da8555..934e28503 100644 --- a/apps/api/tsconfig.spec.json +++ b/apps/api/tsconfig.spec.json @@ -3,6 +3,7 @@ "compilerOptions": { "outDir": "../../dist/out-tsc", "module": "commonjs", + "moduleResolution": "node10", "types": ["jest", "node"] }, "include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts", "jest.config.ts"] diff --git a/apps/client/project.json b/apps/client/project.json index 0d3571cdf..38887ca8a 100644 --- a/apps/client/project.json +++ b/apps/client/project.json @@ -26,6 +26,10 @@ "baseHref": "/it/", "translation": "apps/client/src/locales/messages.it.xlf" }, + "ko": { + "baseHref": "/ko/", + "translation": "apps/client/src/locales/messages.ko.xlf" + }, "nl": { "baseHref": "/nl/", "translation": "apps/client/src/locales/messages.nl.xlf" @@ -75,7 +79,6 @@ "ngswConfigPath": "apps/client/ngsw-config.json", "optimization": false, "polyfills": "apps/client/src/polyfills.ts", - "scripts": ["node_modules/marked/marked.min.js"], "serviceWorker": true, "sourceMap": true, "styles": [ @@ -111,6 +114,10 @@ "baseHref": "/it/", "localize": ["it"] }, + "development-ko": { + "baseHref": "/ko/", + "localize": ["ko"] + }, "development-nl": { "baseHref": "/nl/", "localize": ["nl"] @@ -214,6 +221,9 @@ "sslKey": "apps/client/localhost.pem" }, "configurations": { + "development-ca": { + "buildTarget": "client:build:development-ca" + }, "development-de": { "buildTarget": "client:build:development-de" }, @@ -229,6 +239,9 @@ "development-it": { "buildTarget": "client:build:development-it" }, + "development-ko": { + "buildTarget": "client:build:development-ko" + }, "development-nl": { "buildTarget": "client:build:development-nl" }, @@ -265,6 +278,7 @@ "messages.es.xlf", "messages.fr.xlf", "messages.it.xlf", + "messages.ko.xlf", "messages.nl.xlf", "messages.pl.xlf", "messages.pt.xlf", diff --git a/apps/client/proxy.conf.json b/apps/client/proxy.conf.json index a31371d9f..825965b92 100644 --- a/apps/client/proxy.conf.json +++ b/apps/client/proxy.conf.json @@ -1,4 +1,8 @@ { + "/admin/queues": { + "target": "http://0.0.0.0:3333", + "secure": false + }, "/api": { "target": "http://0.0.0.0:3333", "secure": false diff --git a/apps/client/src/app/app.component.ts b/apps/client/src/app/app.component.ts index 12a7b0de9..4e83268a4 100644 --- a/apps/client/src/app/app.component.ts +++ b/apps/client/src/app/app.component.ts @@ -4,17 +4,19 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes'; import { ColorScheme } from '@ghostfolio/common/types'; import { NotificationService } from '@ghostfolio/ui/notifications'; +import { DataService } from '@ghostfolio/ui/services'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + DestroyRef, DOCUMENT, HostBinding, Inject, - OnDestroy, OnInit } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatDialog } from '@angular/material/dialog'; import { Title } from '@angular/platform-browser'; import { @@ -29,16 +31,13 @@ import { DataSource } from '@prisma/client'; import { addIcons } from 'ionicons'; import { openOutline } from 'ionicons/icons'; import { DeviceDetectorService } from 'ngx-device-detector'; -import { Subject } from 'rxjs'; -import { filter, takeUntil } from 'rxjs/operators'; +import { filter } from 'rxjs/operators'; import { GfFooterComponent } from './components/footer/footer.component'; import { GfHeaderComponent } from './components/header/header.component'; import { GfHoldingDetailDialogComponent } from './components/holding-detail-dialog/holding-detail-dialog.component'; import { HoldingDetailDialogParams } from './components/holding-detail-dialog/interfaces/interfaces'; -import { DataService } from './services/data.service'; import { ImpersonationStorageService } from './services/impersonation-storage.service'; -import { TokenStorageService } from './services/token-storage.service'; import { UserService } from './services/user/user.service'; @Component({ @@ -48,7 +47,7 @@ import { UserService } from './services/user/user.service'; styleUrls: ['./app.component.scss'], templateUrl: './app.component.html' }) -export class GfAppComponent implements OnDestroy, OnInit { +export class GfAppComponent implements OnInit { @HostBinding('class.has-info-message') get getHasMessage() { return this.hasInfoMessage; } @@ -69,11 +68,10 @@ export class GfAppComponent implements OnDestroy, OnInit { public showFooter = false; public user: User; - private unsubscribeSubject = new Subject(); - public constructor( private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, + private destroyRef: DestroyRef, private deviceService: DeviceDetectorService, private dialog: MatDialog, @Inject(DOCUMENT) private document: Document, @@ -82,14 +80,13 @@ export class GfAppComponent implements OnDestroy, OnInit { private route: ActivatedRoute, private router: Router, private title: Title, - private tokenStorageService: TokenStorageService, private userService: UserService ) { this.initializeTheme(); this.user = undefined; this.route.queryParams - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((params) => { if ( params['dataSource'] && @@ -112,7 +109,7 @@ export class GfAppComponent implements OnDestroy, OnInit { this.impersonationStorageService .onChangeHasImpersonation() - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((impersonationId) => { this.hasImpersonationId = !!impersonationId; }); @@ -144,18 +141,18 @@ export class GfAppComponent implements OnDestroy, OnInit { if ( (this.currentRoute === internalRoutes.home.path && this.currentSubRoute === - internalRoutes.home.subRoutes.holdings.path) || + internalRoutes.home.subRoutes?.holdings.path) || (this.currentRoute === internalRoutes.portfolio.path && !this.currentSubRoute) || (this.currentRoute === internalRoutes.portfolio.path && this.currentSubRoute === - internalRoutes.portfolio.subRoutes.activities.path) || + internalRoutes.portfolio.subRoutes?.activities.path) || (this.currentRoute === internalRoutes.portfolio.path && this.currentSubRoute === - internalRoutes.portfolio.subRoutes.allocations.path) || + internalRoutes.portfolio.subRoutes?.allocations.path) || (this.currentRoute === internalRoutes.zen.path && this.currentSubRoute === - internalRoutes.home.subRoutes.holdings.path) + internalRoutes.home.subRoutes?.holdings.path) ) { this.hasPermissionToChangeFilters = true; } else { @@ -201,7 +198,7 @@ export class GfAppComponent implements OnDestroy, OnInit { }); this.userService.stateChanged - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((state) => { this.user = state.user; @@ -236,21 +233,15 @@ export class GfAppComponent implements OnDestroy, OnInit { } public onCreateAccount() { - this.tokenStorageService.signOut(); + this.userService.signOut(); } public onSignOut() { - this.tokenStorageService.signOut(); - this.userService.remove(); + this.userService.signOut(); document.location.href = `/${document.documentElement.lang}`; } - public ngOnDestroy() { - this.unsubscribeSubject.next(); - this.unsubscribeSubject.complete(); - } - private initializeTheme(userPreferredColorScheme?: ColorScheme) { const isDarkTheme = userPreferredColorScheme ? userPreferredColorScheme === 'DARK' @@ -274,7 +265,7 @@ export class GfAppComponent implements OnDestroy, OnInit { }) { this.userService .get() - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((user) => { this.user = user; @@ -296,15 +287,21 @@ export class GfAppComponent implements OnDestroy, OnInit { ), hasPermissionToCreateActivity: !this.hasImpersonationId && - hasPermission(this.user?.permissions, permissions.createOrder) && + hasPermission( + this.user?.permissions, + permissions.createActivity + ) && !this.user?.settings?.isRestrictedView, hasPermissionToReportDataGlitch: hasPermission( this.user?.permissions, permissions.reportDataGlitch ), - hasPermissionToUpdateOrder: + hasPermissionToUpdateActivity: !this.hasImpersonationId && - hasPermission(this.user?.permissions, permissions.updateOrder) && + hasPermission( + this.user?.permissions, + permissions.updateActivity + ) && !this.user?.settings?.isRestrictedView, locale: this.user?.settings?.locale }, @@ -314,7 +311,7 @@ export class GfAppComponent implements OnDestroy, OnInit { dialogRef .afterClosed() - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { this.router.navigate([], { queryParams: { diff --git a/apps/client/src/app/components/access-table/access-table.component.ts b/apps/client/src/app/components/access-table/access-table.component.ts index fe2c81199..76548c45d 100644 --- a/apps/client/src/app/components/access-table/access-table.component.ts +++ b/apps/client/src/app/components/access-table/access-table.component.ts @@ -4,7 +4,6 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { NotificationService } from '@ghostfolio/ui/notifications'; import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard'; -import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -36,7 +35,6 @@ import ms from 'ms'; changeDetection: ChangeDetectionStrategy.OnPush, imports: [ ClipboardModule, - CommonModule, IonIcon, MatButtonModule, MatMenuModule, diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts index d8f08ecc2..7a3040391 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts @@ -1,5 +1,4 @@ import { GfInvestmentChartComponent } from '@ghostfolio/client/components/investment-chart/investment-chart.component'; -import { DataService } from '@ghostfolio/client/services/data.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { NUMERICAL_PRECISION_THRESHOLD_6_FIGURES } from '@ghostfolio/common/config'; import { CreateAccountBalanceDto } from '@ghostfolio/common/dtos'; @@ -19,6 +18,7 @@ import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer'; import { GfDialogHeaderComponent } from '@ghostfolio/ui/dialog-header'; import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table'; +import { DataService } from '@ghostfolio/ui/services'; import { GfValueComponent } from '@ghostfolio/ui/value'; import { CommonModule } from '@angular/common'; @@ -26,11 +26,12 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + DestroyRef, Inject, - OnDestroy, OnInit } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog'; @@ -49,8 +50,7 @@ import { } from 'ionicons/icons'; import { isNumber } from 'lodash'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; -import { forkJoin, Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; +import { forkJoin } from 'rxjs'; import { AccountDetailDialogParams } from './interfaces/interfaces'; @@ -77,9 +77,10 @@ import { AccountDetailDialogParams } from './interfaces/interfaces'; styleUrls: ['./account-detail-dialog.component.scss'], templateUrl: 'account-detail-dialog.html' }) -export class GfAccountDetailDialogComponent implements OnDestroy, OnInit { +export class GfAccountDetailDialogComponent implements OnInit { public accountBalances: AccountBalancesResponse['balances']; public activities: OrderWithAccount[]; + public activitiesCount: number; public balance: number; public balancePrecision = 2; public currency: string; @@ -100,22 +101,20 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit { public sortColumn = 'date'; public sortDirection: SortDirection = 'desc'; public totalItems: number; - public transactionCount: number; public user: User; public valueInBaseCurrency: number; - private unsubscribeSubject = new Subject(); - public constructor( private changeDetectorRef: ChangeDetectorRef, @Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams, private dataService: DataService, + private destroyRef: DestroyRef, public dialogRef: MatDialogRef, private router: Router, private userService: UserService ) { this.userService.stateChanged - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((state) => { if (state?.user) { this.user = state.user; @@ -154,7 +153,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit { public onAddAccountBalance(accountBalance: CreateAccountBalanceDto) { this.dataService .postAccountBalance(accountBalance) - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { this.initialize(); }); @@ -163,7 +162,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit { public onDeleteAccountBalance(aId: string) { this.dataService .deleteAccountBalance(aId) - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { this.initialize(); }); @@ -176,7 +175,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit { this.dataService .fetchExport({ activityIds }) - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((data) => { downloadAsFile({ content: data, @@ -212,19 +211,20 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit { private fetchAccount() { this.dataService .fetchAccount(this.data.accountId) - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe( ({ + activitiesCount, balance, currency, dividendInBaseCurrency, interestInBaseCurrency, name, platform, - transactionCount, value, valueInBaseCurrency }) => { + this.activitiesCount = activitiesCount; this.balance = balance; if ( @@ -270,7 +270,6 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit { this.name = name; this.platformName = platform?.name ?? '-'; - this.transactionCount = transactionCount; this.valueInBaseCurrency = valueInBaseCurrency; this.changeDetectorRef.markForCheck(); @@ -287,7 +286,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit { sortColumn: this.sortColumn, sortDirection: this.sortDirection }) - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ activities, count }) => { this.dataSource = new MatTableDataSource(activities); this.totalItems = count; @@ -304,7 +303,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit { forkJoin({ accountBalances: this.dataService .fetchAccountBalances(this.data.accountId) - .pipe(takeUntil(this.unsubscribeSubject)), + .pipe(takeUntilDestroyed(this.destroyRef)), portfolioPerformance: this.dataService .fetchPortfolioPerformance({ filters: [ @@ -317,7 +316,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit { withExcludedAccounts: true, withItems: true }) - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) }).subscribe({ error: () => { this.isLoadingChart = false; @@ -360,7 +359,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit { } ] }) - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ holdings }) => { this.holdings = holdings; @@ -374,9 +373,4 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit { this.fetchChart(); this.fetchPortfolioHoldings(); } - - public ngOnDestroy() { - this.unsubscribeSubject.next(); - this.unsubscribeSubject.complete(); - } } diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html index 07ea17038..e41d3415c 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html @@ -82,7 +82,7 @@ >
- Activities
@@ -102,8 +102,6 @@
Holdings
(); public defaultDateTimeFormat: string; public filterForm: FormGroup; + public displayedColumns = [ 'index', 'type', @@ -93,21 +98,24 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit { 'status', 'actions' ]; + + public hasPermissionToAccessBullBoard = false; public isLoading = false; public statusFilterOptions = QUEUE_JOB_STATUS_LIST; - public user: User; - private unsubscribeSubject = new Subject(); + private user: User; public constructor( private adminService: AdminService, private changeDetectorRef: ChangeDetectorRef, + private destroyRef: DestroyRef, private formBuilder: FormBuilder, private notificationService: NotificationService, + private tokenStorageService: TokenStorageService, private userService: UserService ) { this.userService.stateChanged - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((state) => { if (state?.user) { this.user = state.user; @@ -115,6 +123,11 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit { this.defaultDateTimeFormat = getDateWithTimeFormatString( this.user.settings.locale ); + + this.hasPermissionToAccessBullBoard = hasPermission( + this.user.permissions, + permissions.accessAdminControlBullBoard + ); } }); @@ -126,6 +139,7 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit { chevronUpCircleOutline, ellipsisHorizontal, ellipsisVertical, + openOutline, pauseOutline, playOutline, removeCircleOutline, @@ -139,7 +153,7 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit { }); this.filterForm.valueChanges - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { const currentFilter = this.filterForm.get('status').value; this.fetchJobs(currentFilter ? [currentFilter] : undefined); @@ -151,7 +165,7 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit { public onDeleteJob(aId: string) { this.adminService .deleteJob(aId) - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { this.fetchJobs(); }); @@ -162,7 +176,7 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit { this.adminService .deleteJobs({ status: currentFilter ? [currentFilter] : undefined }) - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { this.fetchJobs(currentFilter ? [currentFilter] : undefined); }); @@ -171,12 +185,24 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit { public onExecuteJob(aId: string) { this.adminService .executeJob(aId) - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { this.fetchJobs(); }); } + public onOpenBullBoard() { + const token = this.tokenStorageService.getToken(); + + document.cookie = [ + `${BULL_BOARD_COOKIE_NAME}=${encodeURIComponent(token)}`, + 'path=/', + 'SameSite=Strict' + ].join('; '); + + window.open(BULL_BOARD_ROUTE, '_blank'); + } + public onViewData(aData: AdminJobs['jobs'][0]['data']) { this.notificationService.alert({ title: JSON.stringify(aData, null, ' ') @@ -189,17 +215,12 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit { }); } - public ngOnDestroy() { - this.unsubscribeSubject.next(); - this.unsubscribeSubject.complete(); - } - private fetchJobs(aStatus?: JobStatus[]) { this.isLoading = true; this.adminService .fetchJobs({ status: aStatus }) - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ jobs }) => { this.dataSource = new MatTableDataSource(jobs); this.dataSource.sort = this.sort; diff --git a/apps/client/src/app/components/admin-jobs/admin-jobs.html b/apps/client/src/app/components/admin-jobs/admin-jobs.html index a82294001..cff80498c 100644 --- a/apps/client/src/app/components/admin-jobs/admin-jobs.html +++ b/apps/client/src/app/components/admin-jobs/admin-jobs.html @@ -1,6 +1,15 @@
+ @if (hasPermissionToAccessBullBoard) { +
+ +
+ } +
diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts index 4f1b60981..d3265946f 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts @@ -1,5 +1,3 @@ -import { AdminService } from '@ghostfolio/client/services/admin.service'; -import { DataService } from '@ghostfolio/client/services/data.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { DEFAULT_PAGE_SIZE, @@ -18,6 +16,7 @@ import { GfSymbolPipe } from '@ghostfolio/common/pipes'; import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter'; import { translate } from '@ghostfolio/ui/i18n'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; +import { AdminService, DataService } from '@ghostfolio/ui/services'; import { GfValueComponent } from '@ghostfolio/ui/value'; import { SelectionModel } from '@angular/cdk/collections'; @@ -27,10 +26,11 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, - OnDestroy, + DestroyRef, OnInit, ViewChild } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatDialog } from '@angular/material/dialog'; @@ -64,7 +64,7 @@ import { import { DeviceDetectorService } from 'ngx-device-detector'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { Subject } from 'rxjs'; -import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators'; +import { distinctUntilChanged } from 'rxjs/operators'; import { AdminMarketDataService } from './admin-market-data.service'; import { GfAssetProfileDialogComponent } from './asset-profile-dialog/asset-profile-dialog.component'; @@ -96,9 +96,7 @@ import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/in styleUrls: ['./admin-market-data.scss'], templateUrl: './admin-market-data.html' }) -export class GfAdminMarketDataComponent - implements AfterViewInit, OnDestroy, OnInit -{ +export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { @ViewChild(MatPaginator) paginator: MatPaginator; @ViewChild(MatSort) sort: MatSort; @@ -141,6 +139,11 @@ export class GfAdminMarketDataComponent id: 'ETF_WITHOUT_SECTORS', label: $localize`ETFs without Sectors`, type: 'PRESET_ID' as Filter['type'] + }, + { + id: 'NO_ACTIVITIES', + label: $localize`No Activities`, + type: 'PRESET_ID' as Filter['type'] } ]; public benchmarks: Partial[]; @@ -162,13 +165,12 @@ export class GfAdminMarketDataComponent public totalItems = 0; public user: User; - private unsubscribeSubject = new Subject(); - public constructor( public adminMarketDataService: AdminMarketDataService, private adminService: AdminService, private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, + private destroyRef: DestroyRef, private deviceService: DeviceDetectorService, private dialog: MatDialog, private route: ActivatedRoute, @@ -205,7 +207,7 @@ export class GfAdminMarketDataComponent this.displayedColumns.push('actions'); this.route.queryParams - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((params) => { if ( params['assetProfileDialog'] && @@ -222,7 +224,7 @@ export class GfAdminMarketDataComponent }); this.userService.stateChanged - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((state) => { if (state?.user) { this.user = state.user; @@ -234,7 +236,7 @@ export class GfAdminMarketDataComponent }); this.filters$ - .pipe(distinctUntilChanged(), takeUntil(this.unsubscribeSubject)) + .pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) .subscribe((filters) => { this.activeFilters = filters; @@ -298,7 +300,7 @@ export class GfAdminMarketDataComponent public onGather7Days() { this.adminService .gather7Days() - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { setTimeout(() => { window.location.reload(); @@ -309,7 +311,7 @@ export class GfAdminMarketDataComponent public onGatherMax() { this.adminService .gatherMax() - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { setTimeout(() => { window.location.reload(); @@ -320,7 +322,7 @@ export class GfAdminMarketDataComponent public onGatherProfileData() { this.adminService .gatherProfileData() - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(); } @@ -330,14 +332,14 @@ export class GfAdminMarketDataComponent }: AssetProfileIdentifier) { this.adminService .gatherProfileDataBySymbol({ dataSource, symbol }) - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(); } public onGatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) { this.adminService .gatherSymbol({ dataSource, symbol }) - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(); } @@ -354,11 +356,6 @@ export class GfAdminMarketDataComponent }); } - public ngOnDestroy() { - this.unsubscribeSubject.next(); - this.unsubscribeSubject.complete(); - } - private loadData( { pageIndex, @@ -375,7 +372,7 @@ export class GfAdminMarketDataComponent this.pageSize = this.activeFilters.length === 1 && this.activeFilters[0].type === 'PRESET_ID' - ? undefined + ? Number.MAX_SAFE_INTEGER : DEFAULT_PAGE_SIZE; if (pageIndex === 0 && this.paginator) { @@ -395,7 +392,7 @@ export class GfAdminMarketDataComponent skip: pageIndex * this.pageSize, take: this.pageSize }) - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ count, marketData }) => { this.totalItems = count; @@ -426,7 +423,7 @@ export class GfAdminMarketDataComponent }) { this.userService .get() - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((user) => { this.user = user; @@ -448,7 +445,7 @@ export class GfAdminMarketDataComponent dialogRef .afterClosed() - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe( (newAssetProfileIdentifier: AssetProfileIdentifier | undefined) => { if (newAssetProfileIdentifier) { @@ -464,7 +461,7 @@ export class GfAdminMarketDataComponent private openCreateAssetProfileDialog() { this.userService .get() - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((user) => { this.user = user; @@ -482,33 +479,28 @@ export class GfAdminMarketDataComponent dialogRef .afterClosed() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(({ dataSource, symbol } = {}) => { - if (dataSource && symbol) { + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((result) => { + if (!result) { + this.router.navigate(['.'], { relativeTo: this.route }); + + return; + } + + const { addAssetProfile, dataSource, symbol } = result; + + if (addAssetProfile && dataSource && symbol) { this.adminService .addAssetProfile({ dataSource, symbol }) - .pipe( - switchMap(() => { - this.isLoading = true; - this.changeDetectorRef.markForCheck(); - - return this.adminService.fetchAdminMarketData({ - filters: this.activeFilters, - take: this.pageSize - }); - }), - takeUntil(this.unsubscribeSubject) - ) - .subscribe(({ marketData }) => { - this.dataSource = new MatTableDataSource(marketData); - this.dataSource.sort = this.sort; - this.isLoading = false; - - this.changeDetectorRef.markForCheck(); + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.loadData(); }); + } else { + this.loadData(); } - this.router.navigate(['.'], { relativeTo: this.route }); + this.onOpenAssetProfileDialog({ dataSource, symbol }); }); }); } diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.service.ts b/apps/client/src/app/components/admin-market-data/admin-market-data.service.ts index eaad32c0e..9528687a8 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.service.ts +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.service.ts @@ -1,4 +1,3 @@ -import { AdminService } from '@ghostfolio/client/services/admin.service'; import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config'; import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { @@ -11,6 +10,7 @@ import { AdminMarketDataItem } from '@ghostfolio/common/interfaces'; import { NotificationService } from '@ghostfolio/ui/notifications'; +import { AdminService } from '@ghostfolio/ui/services'; import { Injectable } from '@angular/core'; import { EMPTY, catchError, finalize, forkJoin } from 'rxjs'; diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss index 5e469970e..73c0c0d74 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss @@ -13,21 +13,5 @@ right: 1rem; top: 0; } - - .mat-expansion-panel { - --mat-expansion-container-background-color: transparent; - - ::ng-deep { - .mat-expansion-panel-body { - padding: 0; - } - } - - .mat-expansion-panel-header { - &:hover { - --mat-expansion-header-hover-state-layer-color: transparent; - } - } - } } } diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts index 3fe944a25..c0af46e22 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts @@ -1,13 +1,15 @@ import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service'; -import { AdminService } from '@ghostfolio/client/services/admin.service'; -import { DataService } from '@ghostfolio/client/services/data.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { ASSET_CLASS_MAPPING, PROPERTY_IS_DATA_GATHERING_ENABLED } from '@ghostfolio/common/config'; import { UpdateAssetProfileDto } from '@ghostfolio/common/dtos'; -import { DATE_FORMAT } from '@ghostfolio/common/helper'; +import { + DATE_FORMAT, + getCurrencyFromSymbol, + isCurrency +} from '@ghostfolio/common/helper'; import { AdminMarketDataDetails, AssetClassSelectorOption, @@ -25,23 +27,23 @@ import { translate } from '@ghostfolio/ui/i18n'; import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; import { NotificationService } from '@ghostfolio/ui/notifications'; import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart'; +import { AdminService, DataService } from '@ghostfolio/ui/services'; import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete'; import { GfValueComponent } from '@ghostfolio/ui/value'; import { TextFieldModule } from '@angular/cdk/text-field'; -import { CommonModule } from '@angular/common'; import { HttpErrorResponse } from '@angular/common/http'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + DestroyRef, ElementRef, Inject, - OnDestroy, OnInit, - ViewChild, - signal + ViewChild } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { AbstractControl, FormBuilder, @@ -61,7 +63,6 @@ import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatExpansionModule } from '@angular/material/expansion'; import { MatInputModule } from '@angular/material/input'; import { MatMenuModule } from '@angular/material/menu'; import { MatSelectModule } from '@angular/material/select'; @@ -80,14 +81,15 @@ import { format } from 'date-fns'; import { StatusCodes } from 'http-status-codes'; import { addIcons } from 'ionicons'; import { + codeSlashOutline, createOutline, ellipsisVertical, readerOutline, serverOutline } from 'ionicons/icons'; import ms from 'ms'; -import { EMPTY, Subject } from 'rxjs'; -import { catchError, takeUntil } from 'rxjs/operators'; +import { EMPTY } from 'rxjs'; +import { catchError } from 'rxjs/operators'; import { AssetProfileDialogParams } from './interfaces/interfaces'; @@ -95,7 +97,6 @@ import { AssetProfileDialogParams } from './interfaces/interfaces'; changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'd-flex flex-column h-100' }, imports: [ - CommonModule, FormsModule, GfCurrencySelectorComponent, GfEntityLogoComponent, @@ -108,7 +109,6 @@ import { AssetProfileDialogParams } from './interfaces/interfaces'; MatButtonModule, MatCheckboxModule, MatDialogModule, - MatExpansionModule, MatInputModule, MatMenuModule, MatSelectModule, @@ -122,7 +122,7 @@ import { AssetProfileDialogParams } from './interfaces/interfaces'; styleUrls: ['./asset-profile-dialog.component.scss'], templateUrl: 'asset-profile-dialog.html' }) -export class GfAssetProfileDialogComponent implements OnDestroy, OnInit { +export class GfAssetProfileDialogComponent implements OnInit { private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format( new Date(), DATE_FORMAT @@ -143,7 +143,6 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit { }); public assetSubClassOptions: AssetClassSelectorOption[] = []; - public assetProfile: AdminMarketDataDetails['assetProfile']; public assetProfileForm = this.formBuilder.group({ @@ -185,12 +184,14 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit { ); public benchmarks: Partial[]; + public canEditAssetProfile = true; public countries: { [code: string]: { name: string; value: number }; }; public currencies: string[] = []; + public dateRangeOptions = [ { label: $localize`Current week` + ' (' + $localize`WTD` + ')', @@ -235,40 +236,36 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit { } ]; - public scraperConfiguationIsExpanded = signal(false); - public sectors: { [name: string]: { name: string; value: number }; }; public user: User; - private unsubscribeSubject = new Subject(); - public constructor( public adminMarketDataService: AdminMarketDataService, private adminService: AdminService, private changeDetectorRef: ChangeDetectorRef, @Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams, private dataService: DataService, + private destroyRef: DestroyRef, public dialogRef: MatDialogRef, private formBuilder: FormBuilder, private notificationService: NotificationService, private snackBar: MatSnackBar, private userService: UserService ) { - addIcons({ createOutline, ellipsisVertical, readerOutline, serverOutline }); - } - - public get canEditAssetProfileIdentifier() { - return ( - this.assetProfile?.assetClass && - !['MANUAL'].includes(this.assetProfile?.dataSource) - ); + addIcons({ + codeSlashOutline, + createOutline, + ellipsisVertical, + readerOutline, + serverOutline + }); } public get canSaveAssetProfileIdentifier() { - return !this.assetProfileForm.dirty; + return !this.assetProfileForm.dirty && this.canEditAssetProfile; } public ngOnInit() { @@ -285,7 +282,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit { this.adminService .fetchAdminData() - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ settings }) => { this.isDataGatheringEnabled = settings[PROPERTY_IS_DATA_GATHERING_ENABLED] === false ? false : true; @@ -294,7 +291,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit { }); this.userService.stateChanged - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((state) => { if (state?.user) { this.user = state.user; @@ -303,7 +300,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit { this.assetProfileForm .get('assetClass') - .valueChanges.pipe(takeUntil(this.unsubscribeSubject)) + .valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((assetClass) => { const assetSubClasses = ASSET_CLASS_MAPPING.get(assetClass) ?? []; @@ -326,12 +323,17 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit { dataSource: this.data.dataSource, symbol: this.data.symbol }) - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ assetProfile, marketData }) => { this.assetProfile = assetProfile; this.assetClassLabel = translate(this.assetProfile?.assetClass); this.assetSubClassLabel = translate(this.assetProfile?.assetSubClass); + + this.canEditAssetProfile = !isCurrency( + getCurrencyFromSymbol(this.data.symbol) + ); + this.countries = {}; this.isBenchmark = this.benchmarks.some(({ id }) => { @@ -398,6 +400,10 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit { url: this.assetProfile?.url ?? '' }); + if (!this.canEditAssetProfile) { + this.assetProfileForm.disable(); + } + this.assetProfileForm.markAsPristine(); this.changeDetectorRef.markForCheck(); @@ -407,7 +413,9 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit { public onCancelEditAssetProfileIdentifierMode() { this.isEditAssetProfileIdentifierMode = false; - this.assetProfileForm.enable(); + if (this.canEditAssetProfile) { + this.assetProfileForm.enable(); + } this.assetProfileIdentifierForm.reset(); } @@ -428,7 +436,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit { }: AssetProfileIdentifier) { this.adminService .gatherProfileDataBySymbol({ dataSource, symbol }) - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(); } @@ -441,7 +449,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit { } & AssetProfileIdentifier) { this.adminService .gatherSymbol({ dataSource, range, symbol }) - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(); } @@ -454,7 +462,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit { public onSetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) { this.dataService .postBenchmark({ dataSource, symbol }) - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { this.dataService.updateInfo(); @@ -513,7 +521,19 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit { if (!scraperConfiguration.selector || !scraperConfiguration.url) { scraperConfiguration = undefined; } - } catch {} + } catch (error) { + console.error($localize`Could not parse scraper configuration`, error); + + this.snackBar.open( + '😞 ' + $localize`Could not parse scraper configuration`, + undefined, + { + duration: ms('3 seconds') + } + ); + + return; + } try { sectors = JSON.parse(this.assetProfileForm.get('sectors').value); @@ -547,7 +567,16 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit { object: assetProfile }); } catch (error) { - console.error(error); + console.error($localize`Could not validate form`, error); + + this.snackBar.open( + '😞 ' + $localize`Could not validate form`, + undefined, + { + duration: ms('3 seconds') + } + ); + return; } @@ -559,8 +588,29 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit { }, assetProfile ) - .subscribe(() => { - this.initialize(); + .subscribe({ + next: () => { + this.snackBar.open( + '✅ ' + $localize`Asset profile has been saved`, + undefined, + { + duration: ms('3 seconds') + } + ); + + this.initialize(); + }, + error: (error) => { + console.error($localize`Could not save asset profile`, error); + + this.snackBar.open( + '😞 ' + $localize`Could not save asset profile`, + undefined, + { + duration: ms('3 seconds') + } + ); + } }); } @@ -614,7 +664,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit { return EMPTY; }), - takeUntil(this.unsubscribeSubject) + takeUntilDestroyed(this.destroyRef) ) .subscribe(() => { const newAssetProfileIdentifier = { @@ -664,7 +714,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit { }); return EMPTY; }), - takeUntil(this.unsubscribeSubject) + takeUntilDestroyed(this.destroyRef) ) .subscribe(({ price }) => { this.notificationService.alert({ @@ -695,7 +745,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit { public onUnsetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) { this.dataService .deleteBenchmark({ dataSource, symbol }) - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { this.dataService.updateInfo(); @@ -705,14 +755,9 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit { }); } - public ngOnDestroy() { - this.unsubscribeSubject.next(); - this.unsubscribeSubject.complete(); - } - public onTriggerSubmitAssetProfileForm() { - if (this.assetProfileForm) { - this.assetProfileFormElement.nativeElement.requestSubmit(); + if (this.assetProfileForm.valid) { + this.onSubmitAssetProfileForm(); } } diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html index 3d855e6e0..6b3141bfe 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html @@ -73,7 +73,8 @@ !adminMarketDataService.hasPermissionToDeleteAssetProfile({ activitiesCount: assetProfile?.activitiesCount, isBenchmark: isBenchmark, - symbol: data.symbol + symbol: data.symbol, + watchedByCount: assetProfile?.watchedByCount }) " (click)=" @@ -186,9 +187,6 @@ mat-button type="button" [disabled]="!canSaveAssetProfileIdentifier" - [ngClass]="{ - 'd-none': !canEditAssetProfileIdentifier - }" (click)="onSetEditAssetProfileIdentifierMode()" > @@ -201,7 +199,14 @@ >Currency
-
+
+ ISIN +
- - - - Scraper Configuration - -
-
- - Default Market Price - - -
-
- - HTTP Request Headers - - -
-
- - Locale - - -
-
- - Mode - - @for (modeValue of modeValues; track modeValue) { - {{ - modeValue.viewValue - }} - } - - -
-
- - - Selector* - - - -
-
- - - Url* - - - -
-
- -
-
-
-
-
- } @if (assetProfile?.dataSource === 'MANUAL') {
@@ -583,6 +467,115 @@
+ @if (assetProfile?.dataSource === 'MANUAL') { + + + +
Scraper Configuration
+
+
+
+
+
+ + Default Market Price + + +
+
+ + HTTP Request Headers + + +
+
+ + Locale + + +
+
+ + Mode + + @for (modeValue of modeValues; track modeValue) { + {{ + modeValue.viewValue + }} + } + + +
+
+ + + Selector* + + + +
+
+ + + Url* + + + +
+
+ +
+
+
+
+
+ }
@@ -591,7 +584,11 @@ Data Gathering diff --git a/apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts b/apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts index 32e1e3309..35887abb4 100644 --- a/apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts +++ b/apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts @@ -1,19 +1,19 @@ -import { AdminService } from '@ghostfolio/client/services/admin.service'; -import { DataService } from '@ghostfolio/client/services/data.service'; import { DEFAULT_CURRENCY, ghostfolioPrefix, PROPERTY_CURRENCIES } from '@ghostfolio/common/config'; +import { AdminService, DataService } from '@ghostfolio/ui/services'; import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, - OnDestroy, + DestroyRef, OnInit } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { AbstractControl, FormBuilder, @@ -32,7 +32,7 @@ import { MatInputModule } from '@angular/material/input'; import { MatRadioModule } from '@angular/material/radio'; import { DataSource } from '@prisma/client'; import { isISO4217CurrencyCode } from 'class-validator'; -import { Subject, switchMap, takeUntil } from 'rxjs'; +import { switchMap } from 'rxjs'; import { CreateAssetProfileDialogMode } from './interfaces/interfaces'; @@ -53,19 +53,19 @@ import { CreateAssetProfileDialogMode } from './interfaces/interfaces'; styleUrls: ['./create-asset-profile-dialog.component.scss'], templateUrl: 'create-asset-profile-dialog.html' }) -export class GfCreateAssetProfileDialogComponent implements OnDestroy, OnInit { +export class GfCreateAssetProfileDialogComponent implements OnInit { public createAssetProfileForm: FormGroup; public ghostfolioPrefix = `${ghostfolioPrefix}_`; public mode: CreateAssetProfileDialogMode; private customCurrencies: string[]; private dataSourceForExchangeRates: DataSource; - private unsubscribeSubject = new Subject(); public constructor( public readonly adminService: AdminService, private readonly changeDetectorRef: ChangeDetectorRef, private readonly dataService: DataService, + private readonly destroyRef: DestroyRef, public readonly dialogRef: MatDialogRef, public readonly formBuilder: FormBuilder ) {} @@ -102,6 +102,7 @@ export class GfCreateAssetProfileDialogComponent implements OnDestroy, OnInit { public onSubmit() { if (this.mode === 'auto') { this.dialogRef.close({ + addAssetProfile: true, dataSource: this.createAssetProfileForm.get('searchSymbol').value.dataSource, symbol: this.createAssetProfileForm.get('searchSymbol').value.symbol @@ -125,13 +126,18 @@ export class GfCreateAssetProfileDialogComponent implements OnDestroy, OnInit { symbol: `${DEFAULT_CURRENCY}${currency}` }); }), - takeUntil(this.unsubscribeSubject) + takeUntilDestroyed(this.destroyRef) ) .subscribe(() => { - this.dialogRef.close(); + this.dialogRef.close({ + addAssetProfile: false, + dataSource: this.dataSourceForExchangeRates, + symbol: `${DEFAULT_CURRENCY}${currency}` + }); }); } else if (this.mode === 'manual') { this.dialogRef.close({ + addAssetProfile: true, dataSource: 'MANUAL', symbol: `${this.ghostfolioPrefix}${this.createAssetProfileForm.get('addSymbol').value}` }); @@ -149,11 +155,6 @@ export class GfCreateAssetProfileDialogComponent implements OnDestroy, OnInit { return false; } - public ngOnDestroy() { - this.unsubscribeSubject.next(); - this.unsubscribeSubject.complete(); - } - private atLeastOneValid(control: AbstractControl): ValidationErrors { const addCurrencyControl = control.get('addCurrency'); const addSymbolControl = control.get('addSymbol'); @@ -184,7 +185,7 @@ export class GfCreateAssetProfileDialogComponent implements OnDestroy, OnInit { private initialize() { this.adminService .fetchAdminData() - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ dataProviders, settings }) => { this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[]; diff --git a/apps/client/src/app/components/admin-overview/admin-overview.component.ts b/apps/client/src/app/components/admin-overview/admin-overview.component.ts index e4be7b062..5d4e5268e 100644 --- a/apps/client/src/app/components/admin-overview/admin-overview.component.ts +++ b/apps/client/src/app/components/admin-overview/admin-overview.component.ts @@ -1,6 +1,4 @@ -import { AdminService } from '@ghostfolio/client/services/admin.service'; import { CacheService } from '@ghostfolio/client/services/cache.service'; -import { DataService } from '@ghostfolio/client/services/data.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { PROPERTY_COUPONS, @@ -20,10 +18,17 @@ import { } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { NotificationService } from '@ghostfolio/ui/notifications'; +import { AdminService, DataService } from '@ghostfolio/ui/services'; import { GfValueComponent } from '@ghostfolio/ui/value'; import { CommonModule } from '@angular/common'; -import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; +import { + ChangeDetectorRef, + Component, + DestroyRef, + OnInit +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; @@ -51,8 +56,6 @@ import { trashOutline } from 'ionicons/icons'; import ms, { StringValue } from 'ms'; -import { Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; @Component({ imports: [ @@ -73,7 +76,8 @@ import { takeUntil } from 'rxjs/operators'; styleUrls: ['./admin-overview.scss'], templateUrl: './admin-overview.html' }) -export class GfAdminOverviewComponent implements OnDestroy, OnInit { +export class GfAdminOverviewComponent implements OnInit { + public activitiesCount: number; public couponDuration: StringValue = '14 days'; public coupons: Coupon[]; public hasPermissionForSubscription: boolean; @@ -84,18 +88,16 @@ export class GfAdminOverviewComponent implements OnDestroy, OnInit { public isDataGatheringEnabled: boolean; public permissions = permissions; public systemMessage: SystemMessage; - public transactionCount: number; public userCount: number; public user: User; public version: string; - private unsubscribeSubject = new Subject(); - public constructor( private adminService: AdminService, private cacheService: CacheService, private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, + private destroyRef: DestroyRef, private notificationService: NotificationService, private snackBar: MatSnackBar, private userService: UserService @@ -103,7 +105,7 @@ export class GfAdminOverviewComponent implements OnDestroy, OnInit { this.info = this.dataService.fetchInfo(); this.userService.stateChanged - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((state) => { if (state?.user) { this.user = state.user; @@ -220,7 +222,7 @@ export class GfAdminOverviewComponent implements OnDestroy, OnInit { confirmFn: () => { this.cacheService .flush() - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { setTimeout(() => { window.location.reload(); @@ -269,7 +271,7 @@ export class GfAdminOverviewComponent implements OnDestroy, OnInit { public onSyncDemoUserAccount() { this.adminService .syncDemoUserAccount() - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { this.snackBar.open( '✅ ' + $localize`Demo user account has been synced.`, @@ -281,21 +283,16 @@ export class GfAdminOverviewComponent implements OnDestroy, OnInit { }); } - public ngOnDestroy() { - this.unsubscribeSubject.next(); - this.unsubscribeSubject.complete(); - } - private fetchAdminData() { this.adminService .fetchAdminData() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(({ settings, transactionCount, userCount, version }) => { + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(({ activitiesCount, settings, userCount, version }) => { + this.activitiesCount = activitiesCount; this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? []; this.isDataGatheringEnabled = settings[PROPERTY_IS_DATA_GATHERING_ENABLED] === false ? false : true; this.systemMessage = settings[PROPERTY_SYSTEM_MESSAGE] as SystemMessage; - this.transactionCount = transactionCount; this.userCount = userCount; this.version = version; @@ -321,7 +318,7 @@ export class GfAdminOverviewComponent implements OnDestroy, OnInit { .putAdminSetting(key, { value: value || value === false ? JSON.stringify(value) : undefined }) - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { setTimeout(() => { window.location.reload(); diff --git a/apps/client/src/app/components/admin-overview/admin-overview.html b/apps/client/src/app/components/admin-overview/admin-overview.html index c47387f37..f0a6ea1d5 100644 --- a/apps/client/src/app/components/admin-overview/admin-overview.html +++ b/apps/client/src/app/components/admin-overview/admin-overview.html @@ -20,11 +20,11 @@
- @if (transactionCount && userCount) { + @if (activitiesCount && userCount) {
- {{ transactionCount / userCount | number: '1.2-2' }} + {{ activitiesCount / userCount | number: '1.2-2' }} per User
} diff --git a/apps/client/src/app/components/admin-platform/admin-platform.component.html b/apps/client/src/app/components/admin-platform/admin-platform.component.html index a5a1430d4..7370a19ae 100644 --- a/apps/client/src/app/components/admin-platform/admin-platform.component.html +++ b/apps/client/src/app/components/admin-platform/admin-platform.component.html @@ -52,12 +52,16 @@ Accounts - {{ element.accountCount }} + - +
-@if (isLoading) { +@if (isLoading()) { (); - @Output() accountToUpdate = new EventEmitter(); - @Output() transferBalance = new EventEmitter(); - - @ViewChild(MatSort) sort: MatSort; - - public dataSource = new MatTableDataSource(); - public displayedColumns = []; - public isLoading = true; - public routeQueryParams: Subscription; - - private unsubscribeSubject = new Subject(); - - public constructor( - private notificationService: NotificationService, - private router: Router - ) { - addIcons({ - arrowRedoOutline, - createOutline, - documentTextOutline, - ellipsisHorizontal, - eyeOffOutline, - trashOutline, - walletOutline - }); - } - - public ngOnChanges() { - this.displayedColumns = ['status', 'account', 'platform']; - - if (this.showTransactions) { - this.displayedColumns.push('transactions'); +export class GfAccountsTableComponent { + public readonly accounts = input.required(); + public readonly activitiesCount = input(); + public readonly baseCurrency = input(); + public readonly hasPermissionToOpenDetails = input(true); + public readonly locale = input(getLocale()); + public readonly showActions = input(); + public readonly showActivitiesCount = input(true); + public readonly showAllocationInPercentage = input(); + public readonly showBalance = input(true); + public readonly showFooter = input(true); + public readonly showValue = input(true); + public readonly showValueInBaseCurrency = input(false); + public readonly totalBalanceInBaseCurrency = input(); + public readonly totalValueInBaseCurrency = input(); + + public readonly accountDeleted = output(); + public readonly accountToUpdate = output(); + public readonly transferBalance = output(); + + public readonly sort = viewChild.required(MatSort); + + protected readonly dataSource = new MatTableDataSource([]); + + protected readonly displayedColumns = computed(() => { + const columns = ['status', 'account', 'platform']; + + if (this.showActivitiesCount()) { + columns.push('activitiesCount'); } - if (this.showBalance) { - this.displayedColumns.push('balance'); + if (this.showBalance()) { + columns.push('balance'); } - if (this.showValue) { - this.displayedColumns.push('value'); + if (this.showValue()) { + columns.push('value'); } - this.displayedColumns.push('currency'); + columns.push('currency'); - if (this.showValueInBaseCurrency) { - this.displayedColumns.push('valueInBaseCurrency'); + if (this.showValueInBaseCurrency()) { + columns.push('valueInBaseCurrency'); } - if (this.showAllocationInPercentage) { - this.displayedColumns.push('allocation'); + if (this.showAllocationInPercentage()) { + columns.push('allocation'); } - this.displayedColumns.push('comment'); + columns.push('comment'); - if (this.showActions) { - this.displayedColumns.push('actions'); + if (this.showActions()) { + columns.push('actions'); } - this.isLoading = true; + return columns; + }); - this.dataSource = new MatTableDataSource(this.accounts); - this.dataSource.sort = this.sort; - this.dataSource.sortingDataAccessor = get; + protected readonly isLoading = computed(() => !this.accounts()); - if (this.accounts) { - this.isLoading = false; - } + private readonly notificationService = inject(NotificationService); + private readonly router = inject(Router); + + public constructor() { + addIcons({ + arrowRedoOutline, + createOutline, + documentTextOutline, + ellipsisHorizontal, + eyeOffOutline, + trashOutline, + walletOutline + }); + + this.dataSource.sortingDataAccessor = getLowercase; + + // Reactive data update + effect(() => { + this.dataSource.data = this.accounts(); + }); + + // Reactive view connection + effect(() => { + this.dataSource.sort = this.sort(); + }); } - public onDeleteAccount(aId: string) { + protected onDeleteAccount(aId: string) { this.notificationService.confirm({ confirmFn: () => { this.accountDeleted.emit(aId); @@ -151,30 +149,25 @@ export class GfAccountsTableComponent implements OnChanges, OnDestroy { }); } - public onOpenAccountDetailDialog(accountId: string) { - if (this.hasPermissionToOpenDetails) { + protected onOpenAccountDetailDialog(accountId: string) { + if (this.hasPermissionToOpenDetails()) { this.router.navigate([], { queryParams: { accountId, accountDetailDialog: true } }); } } - public onOpenComment(aComment: string) { + protected onOpenComment(aComment: string) { this.notificationService.alert({ title: aComment }); } - public onTransferBalance() { + protected onTransferBalance() { this.transferBalance.emit(); } - public onUpdateAccount(aAccount: Account) { + protected onUpdateAccount(aAccount: Account) { this.accountToUpdate.emit(aAccount); } - - public ngOnDestroy() { - this.unsubscribeSubject.next(); - this.unsubscribeSubject.complete(); - } } diff --git a/libs/ui/src/lib/activities-filter/activities-filter.component.html b/libs/ui/src/lib/activities-filter/activities-filter.component.html index fd22ed351..d87ce16ce 100644 --- a/libs/ui/src/lib/activities-filter/activities-filter.component.html +++ b/libs/ui/src/lib/activities-filter/activities-filter.component.html @@ -10,7 +10,7 @@ [removable]="true" (removed)="onRemoveFilter(filter)" > - {{ filter.label | gfSymbol }} + {{ filter.label ?? '' | gfSymbol }} @@ -23,7 +23,7 @@ [matAutocomplete]="autocomplete" [matChipInputFor]="chipList" [matChipInputSeparatorKeyCodes]="separatorKeysCodes" - [placeholder]="placeholder" + [placeholder]="placeholder()" (matChipInputTokenEnd)="onAddFilter($event)" /> @@ -35,7 +35,7 @@ @for (filter of filterGroup.filters; track filter) { - {{ filter.label | gfSymbol }} + {{ filter.label ?? '' | gfSymbol }} } @@ -46,7 +46,7 @@ disabled mat-icon-button matSuffix - [ngClass]="{ 'd-none': !isLoading }" + [ngClass]="{ 'd-none': !isLoading() }" > diff --git a/libs/ui/src/lib/activities-filter/activities-filter.component.ts b/libs/ui/src/lib/activities-filter/activities-filter.component.ts index 34f883c67..6b58e6aec 100644 --- a/libs/ui/src/lib/activities-filter/activities-filter.component.ts +++ b/libs/ui/src/lib/activities-filter/activities-filter.component.ts @@ -7,15 +7,16 @@ import { CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy, Component, + DestroyRef, ElementRef, - EventEmitter, Input, OnChanges, - OnDestroy, - Output, SimpleChanges, - ViewChild + ViewChild, + input, + output } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { MatAutocomplete, @@ -30,8 +31,7 @@ import { IonIcon } from '@ionic/angular/standalone'; import { addIcons } from 'ionicons'; import { closeOutline, searchOutline } from 'ionicons/icons'; import { groupBy } from 'lodash'; -import { BehaviorSubject, Observable, Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; +import { BehaviorSubject } from 'rxjs'; import { translate } from '../i18n'; @@ -53,28 +53,26 @@ import { translate } from '../i18n'; styleUrls: ['./activities-filter.component.scss'], templateUrl: './activities-filter.component.html' }) -export class GfActivitiesFilterComponent implements OnChanges, OnDestroy { +export class GfActivitiesFilterComponent implements OnChanges { @Input() allFilters: Filter[]; - @Input() isLoading: boolean; - @Input() placeholder: string; - @Output() valueChanged = new EventEmitter(); + @ViewChild('autocomplete') protected matAutocomplete: MatAutocomplete; + @ViewChild('searchInput') protected searchInput: ElementRef; - @ViewChild('autocomplete') matAutocomplete: MatAutocomplete; - @ViewChild('searchInput') searchInput: ElementRef; + public readonly isLoading = input.required(); + public readonly placeholder = input.required(); + public readonly valueChanged = output(); - public filterGroups$: Subject = new BehaviorSubject([]); - public filters$: Subject = new BehaviorSubject([]); - public filters: Observable = this.filters$.asObservable(); - public searchControl = new FormControl(undefined); - public selectedFilters: Filter[] = []; - public separatorKeysCodes: number[] = [ENTER, COMMA]; + protected readonly filterGroups$ = new BehaviorSubject([]); + protected readonly searchControl = new FormControl( + null + ); + protected selectedFilters: Filter[] = []; + protected readonly separatorKeysCodes: number[] = [ENTER, COMMA]; - private unsubscribeSubject = new Subject(); - - public constructor() { + public constructor(private destroyRef: DestroyRef) { this.searchControl.valueChanges - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((filterOrSearchTerm) => { if (filterOrSearchTerm) { const searchTerm = @@ -97,41 +95,39 @@ export class GfActivitiesFilterComponent implements OnChanges, OnDestroy { } } - public onAddFilter({ input, value }: MatChipInputEvent) { + public onAddFilter({ chipInput, value }: MatChipInputEvent) { if (value?.trim()) { this.updateFilters(); } // Reset the input value - if (input) { - input.value = ''; + if (chipInput.inputElement) { + chipInput.inputElement.value = ''; } - this.searchControl.setValue(undefined); + this.searchControl.setValue(null); } public onRemoveFilter(aFilter: Filter) { - this.selectedFilters = this.selectedFilters.filter((filter) => { - return filter.id !== aFilter.id; + this.selectedFilters = this.selectedFilters.filter(({ id }) => { + return id !== aFilter.id; }); this.updateFilters(); } public onSelectFilter(event: MatAutocompleteSelectedEvent) { - this.selectedFilters.push( - this.allFilters.find((filter) => { - return filter.id === event.option.value; - }) - ); + const filter = this.allFilters.find(({ id }) => { + return id === event.option.value; + }); + + if (filter) { + this.selectedFilters.push(filter); + } + this.updateFilters(); this.searchInput.nativeElement.value = ''; - this.searchControl.setValue(undefined); - } - - public ngOnDestroy() { - this.unsubscribeSubject.next(); - this.unsubscribeSubject.complete(); + this.searchControl.setValue(null); } private getGroupedFilters(searchTerm?: string) { @@ -139,23 +135,23 @@ export class GfActivitiesFilterComponent implements OnChanges, OnDestroy { this.allFilters .filter((filter) => { // Filter selected filters - return !this.selectedFilters.some((selectedFilter) => { - return selectedFilter.id === filter.id; + return !this.selectedFilters.some(({ id }) => { + return id === filter.id; }); }) .filter((filter) => { if (searchTerm) { // Filter by search term return filter.label - .toLowerCase() + ?.toLowerCase() .includes(searchTerm.toLowerCase()); } return filter; }) - .sort((a, b) => a.label?.localeCompare(b.label)), - (filter) => { - return filter.type; + .sort((a, b) => (a.label ?? '').localeCompare(b.label ?? '')), + ({ type }) => { + return type; } ); diff --git a/libs/ui/src/lib/activities-table/activities-table.component.html b/libs/ui/src/lib/activities-table/activities-table.component.html index b8e1882d4..bdb1e6373 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.html +++ b/libs/ui/src/lib/activities-table/activities-table.component.html @@ -21,7 +21,7 @@