Browse Source

Merge commit '0be83cf033558bc67d4d498e425773b2dd484e6d' into feature/public-access-filter

# Conflicts:
#	apps/api/src/app/endpoints/public/public.controller.ts
#	apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
#	apps/client/src/app/components/user-account-access/user-account-access.component.ts
pull/5848/head
Germán Martín 4 weeks ago
parent
commit
4b1178a112
  1. 2
      .github/workflows/build-code.yml
  2. 2
      .gitignore
  3. 4
      .vscode/extensions.json
  4. 2
      .vscode/settings.json
  5. 412
      CHANGELOG.md
  6. 58
      README.md
  7. 6
      apps/api/src/app/account/account.controller.ts
  8. 6
      apps/api/src/app/account/account.service.ts
  9. 95
      apps/api/src/app/activities/activities.controller.ts
  10. 16
      apps/api/src/app/activities/activities.module.ts
  11. 129
      apps/api/src/app/activities/activities.service.ts
  12. 9
      apps/api/src/app/admin/admin.controller.ts
  13. 8
      apps/api/src/app/admin/admin.module.ts
  14. 30
      apps/api/src/app/admin/admin.service.ts
  15. 9
      apps/api/src/app/admin/queue/queue.module.ts
  16. 24
      apps/api/src/app/admin/queue/queue.service.ts
  17. 42
      apps/api/src/app/app.module.ts
  18. 29
      apps/api/src/app/auth-device/auth-device.controller.ts
  19. 4
      apps/api/src/app/endpoints/ai/ai.module.ts
  20. 7
      apps/api/src/app/endpoints/assets/assets.controller.ts
  21. 6
      apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts
  22. 4
      apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts
  23. 7
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  24. 6
      apps/api/src/app/endpoints/market-data/market-data.module.ts
  25. 4
      apps/api/src/app/endpoints/platforms/platforms.controller.ts
  26. 7
      apps/api/src/app/endpoints/public/public.controller.ts
  27. 4
      apps/api/src/app/endpoints/public/public.module.ts
  28. 4
      apps/api/src/app/endpoints/watchlist/watchlist.module.ts
  29. 5
      apps/api/src/app/export/export.controller.ts
  30. 4
      apps/api/src/app/export/export.module.ts
  31. 16
      apps/api/src/app/export/export.service.ts
  32. 19
      apps/api/src/app/import/import.controller.ts
  33. 10
      apps/api/src/app/import/import.module.ts
  34. 225
      apps/api/src/app/import/import.service.ts
  35. 4
      apps/api/src/app/info/info.module.ts
  36. 182
      apps/api/src/app/info/info.service.ts
  37. 85
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  38. 16
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts
  39. 17
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  40. 16
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts
  41. 17
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts
  42. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts
  43. 15
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts
  44. 19
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  45. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts
  46. 15
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts
  47. 161
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  48. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts
  49. 15
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts
  50. 190
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts
  51. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts
  52. 7
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts
  53. 9
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts
  54. 7
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts
  55. 15
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  56. 15
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  57. 8
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts
  58. 41
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  59. 11
      apps/api/src/app/portfolio/current-rate.service.mock.ts
  60. 2
      apps/api/src/app/portfolio/current-rate.service.spec.ts
  61. 13
      apps/api/src/app/portfolio/current-rate.service.ts
  62. 1
      apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts
  63. 1
      apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts
  64. 5
      apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts
  65. 16
      apps/api/src/app/portfolio/portfolio.controller.ts
  66. 8
      apps/api/src/app/portfolio/portfolio.module.ts
  67. 155
      apps/api/src/app/portfolio/portfolio.service.ts
  68. 11
      apps/api/src/app/redis-cache/redis-cache.service.ts
  69. 3
      apps/api/src/app/subscription/subscription.service.ts
  70. 23
      apps/api/src/app/user/user.controller.ts
  71. 8
      apps/api/src/app/user/user.module.ts
  72. 71
      apps/api/src/app/user/user.service.ts
  73. 794
      apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
  74. 1
      apps/api/src/assets/cryptocurrencies/custom.json
  75. 25
      apps/api/src/environments/environment.prod.ts
  76. 12
      apps/api/src/events/asset-profile-changed.event.ts
  77. 65
      apps/api/src/events/asset-profile-changed.listener.ts
  78. 8
      apps/api/src/events/events.module.ts
  79. 30
      apps/api/src/events/portfolio-changed.listener.ts
  80. 143
      apps/api/src/helper/object.helper.spec.ts
  81. 69
      apps/api/src/helper/object.helper.ts
  82. 42
      apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts
  83. 22
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  84. 6
      apps/api/src/main.ts
  85. 28
      apps/api/src/middlewares/bull-board-auth.middleware.ts
  86. 4
      apps/api/src/models/rule.ts
  87. 7
      apps/api/src/models/rules/account-cluster-risk/current-investment.ts
  88. 7
      apps/api/src/models/rules/account-cluster-risk/single-account.ts
  89. 7
      apps/api/src/models/rules/asset-class-cluster-risk/equity.ts
  90. 7
      apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts
  91. 7
      apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts
  92. 7
      apps/api/src/models/rules/currency-cluster-risk/current-investment.ts
  93. 7
      apps/api/src/models/rules/economic-market-cluster-risk/developed-markets.ts
  94. 7
      apps/api/src/models/rules/economic-market-cluster-risk/emerging-markets.ts
  95. 7
      apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts
  96. 28
      apps/api/src/models/rules/fees/fee-ratio-total-investment-volume.ts
  97. 7
      apps/api/src/models/rules/liquidity/buying-power.ts
  98. 4
      apps/api/src/models/rules/regional-market-cluster-risk/asia-pacific.ts
  99. 4
      apps/api/src/models/rules/regional-market-cluster-risk/emerging-markets.ts
  100. 4
      apps/api/src/models/rules/regional-market-cluster-risk/europe.ts

2
.github/workflows/build-code.yml

@ -13,7 +13,7 @@ jobs:
strategy: strategy:
matrix: matrix:
node_version: node_version:
- 22 - 22.22.1
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4

2
.gitignore

@ -25,6 +25,8 @@ npm-debug.log
# misc # misc
/.angular/cache /.angular/cache
.claude/settings.local.json
.claude/worktrees
.cursor/rules/nx-rules.mdc .cursor/rules/nx-rules.mdc
.env .env
.env.prod .env.prod

4
.vscode/extensions.json

@ -1,8 +1,8 @@
{ {
"recommendations": [ "recommendations": [
"angular.ng-template", "angular.ng-template",
"esbenp.prettier-vscode",
"firsttris.vscode-jest-runner", "firsttris.vscode-jest-runner",
"nrwl.angular-console", "nrwl.angular-console"
"prettier.prettier-vscode"
] ]
} }

2
.vscode/settings.json

@ -1,4 +1,4 @@
{ {
"editor.defaultFormatter": "prettier.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true "editor.formatOnSave": true
} }

412
CHANGELOG.md

@ -5,7 +5,408 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased ## 2.255.0 - 2026-03-20
### Changed
- Sorted the activity types alphabetically on the activities page (experimental)
- Sorted the asset classes of the assistant alphabetically
- Sorted the tags of the assistant alphabetically
- Upgraded `angular` from version `21.1.1` to `21.2.7`
- Upgraded `Nx` from version `22.5.3` to `22.6.4`
- Upgraded `prettier` from version `3.8.1` to `3.8.2`
- Upgraded `svgmap` from version `2.19.2` to `2.19.3`
- Upgraded `yahoo-finance2` from version `3.13.2` to `3.14.0`
### Fixed
- Fixed the missing value column of the accounts table component on mobile
## 2.254.0 - 2026-03-10
### Added
- Added loan as an asset sub class
### Changed
- Extended the asset profile details dialog in the admin control panel to support editing countries for all asset types
- Extended the asset profile details dialog in the admin control panel to support editing sectors for all asset types
- Migrated the data collection for the _Open Startup_ (`/open`) page to the queue design pattern
- Improved the language localization for German (`de`)
- Upgraded `lodash` from version `4.17.23` to `4.18.1`
### Fixed
- Improved the style of the activity type component
## 2.253.0 - 2026-03-06
### Added
- Added support for filtering by activity type on the activities page (experimental)
- Extended the admin control panel by adding a copy-to-clipboard button for the application version
### Changed
- Extended the terms of service for the _Ghostfolio_ SaaS (cloud) to include _Paid Plans_ and _Refund Policy_
- Upgraded `prisma` from version `6.19.0` to `6.19.3`
### Fixed
- Fixed the allocations by account chart on the allocations page in the _Presenter View_
- Fixed the allocations by asset class chart on the allocations page in the _Presenter View_
- Fixed the allocations by currency chart on the allocations page in the _Presenter View_
- Fixed the allocations by ETF provider chart on the allocations page in the _Presenter View_
- Fixed the allocations by platform chart on the allocations page in the _Presenter View_
## 2.252.0 - 2026-03-02
### Added
- Added support for a copy-to-clipboard functionality in the value component
- Extended the holding detail dialog by adding a copy-to-clipboard button for the ISIN number (experimental)
- Extended the holding detail dialog by adding a copy-to-clipboard button for the symbol (experimental)
- Extended the user detail dialog of the admin control panel’s users section by adding a copy-to-clipboard button for the user id
### Changed
- Refreshed the cryptocurrencies list
- Improved the language localization for German (`de`)
- Improved the language localization for Spanish (`es`)
- Upgraded `countries-list` from version `3.2.2` to `3.3.0`
- Upgraded `ng-extract-i18n-merge` from `3.2.1` to `3.3.0`
- Upgraded `stripe` from version `20.3.0` to `20.4.1`
## 2.251.0 - 2026-03-24
### Added
- Added the quantity column to the holdings table of the portfolio holdings page
### Changed
- Hardened the endpoint `DELETE /api/v1/auth-device/:id` by improving the user validation
- Improved the allocations by ETF holding on the allocations page by refining the grouping of the same assets with diverging names (experimental)
- Improved the language localization for Polish (`pl`)
- Upgraded `@trivago/prettier-plugin-sort-imports` from version `5.2.2` to `6.0.2`
### Fixed
- Fixed an issue by adding a missing guard in the public access for portfolio sharing
## 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 ### Added
@ -17,6 +418,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Integrated the endpoint to get all platforms (`GET api/v1/platforms`) into the create or update account dialog - 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 - 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`) - 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 ## 2.227.0 - 2026-01-02
@ -5649,10 +6055,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Removed the alias from the user interface as a preparation to remove it from the `User` database schema - Removed the alias from the user interface as a preparation to remove it from the `User` database schema
- Removed the activities import limit for users with a subscription - Removed the activities import limit for users with a subscription
### Todo
- Rename the environment variable from `MAX_ORDERS_TO_IMPORT` to `MAX_ACTIVITIES_TO_IMPORT`
## 1.169.0 - 14.07.2022 ## 1.169.0 - 14.07.2022
### Added ### Added

58
README.md

@ -85,31 +85,32 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
### Supported Environment Variables ### Supported Environment Variables
| Name | Type | Default Value | Description | | Name | Type | Default Value | Description |
| ------------------------ | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | --------------------------- | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens | | `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_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro 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` | | `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 | | `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token |
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) | | `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` | | `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on | | `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` |
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database | | `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database | | `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database | | `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ | | `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
| `REDIS_HOST` | `string` | | The host where _Redis_ is running | | `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ | | `REDIS_HOST` | `string` | | The host where _Redis_ is running |
| `REDIS_PORT` | `number` | | The port where _Redis_ is running | | `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | | `REDIS_PORT` | `number` | | The port where _Redis_ is running |
| `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. | | `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) #### OpenID Connect OIDC (Experimental)
| Name | Type | Default Value | Description | | 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_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_CALLBACK_URL` | `string` (optional) | `${ROOT_URL}/api/auth/oidc/callback` | The OIDC callback URL |
| `OIDC_CLIENT_ID` | `string` | | The OIDC client ID | | `OIDC_CLIENT_ID` | `string` | | The OIDC client ID |
@ -308,21 +309,20 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r
## Contributing ## Contributing
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you. Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public, including you.
Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22%20no%3Aassignee), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%20no%3Aassignee). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. We would love to hear from you. Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22%20no%3Aassignee), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%20no%3Aassignee). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. We would love to hear from you.
If you like to support this project, become a [**Sponsor**](https://github.com/sponsors/ghostfolio), get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
## Sponsors ## Sponsors
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing), become a [**Sponsor**](https://github.com/sponsors/ghostfolio) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
<br />
<div align="center"> <div align="center">
<p> <a href="https://www.testmuai.com?utm_medium=sponsor&utm_source=ghostfolio" target="_blank" title="TestMu AI - AI Powered Testing Tool">
Browser testing via<br /> <img alt="TestMu AI Logo" height="45" src="https://assets.testmuai.com/resources/images/logos/logo.svg" />
<a href="https://www.lambdatest.com?utm_medium=sponsor&utm_source=ghostfolio" target="_blank" title="LambdaTest - AI Powered Testing Tool"> </a>
<img alt="LambdaTest Logo" height="45" width="250" src="https://www.lambdatest.com/blue-logo.png" />
</a>
</p>
</div> </div>
## Analytics ## Analytics

6
apps/api/src/app/account/account.controller.ts

@ -132,12 +132,16 @@ export class AccountController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountBalancesById( public async getAccountBalancesById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('id') id: string @Param('id') id: string
): Promise<AccountBalancesResponse> { ): Promise<AccountBalancesResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
return this.accountBalanceService.getAccountBalances({ return this.accountBalanceService.getAccountBalances({
filters: [{ id, type: 'ACCOUNT' }], filters: [{ id, type: 'ACCOUNT' }],
userCurrency: this.request.user.settings.settings.baseCurrency, userCurrency: this.request.user.settings.settings.baseCurrency,
userId: this.request.user.id userId: impersonationUserId || this.request.user.id
}); });
} }

6
apps/api/src/app/account/account.service.ts

@ -150,15 +150,15 @@ export class AccountService {
}); });
return accounts.map((account) => { return accounts.map((account) => {
let transactionCount = 0; let activitiesCount = 0;
for (const { isDraft } of account.activities) { for (const { isDraft } of account.activities) {
if (!isDraft) { if (!isDraft) {
transactionCount += 1; activitiesCount += 1;
} }
} }
const result = { ...account, transactionCount }; const result = { ...account, activitiesCount };
delete result.activities; delete result.activities;

95
apps/api/src/app/order/order.controller.ts → 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 { 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 { 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 { 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 { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
@ -36,27 +37,32 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Order as OrderModel, Prisma } from '@prisma/client'; import { Order, Prisma, Type as ActivityType } from '@prisma/client';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { OrderService } from './order.service'; import { ActivitiesService } from './activities.service';
@Controller('order') @Controller([
export class OrderController { 'activities',
/** @deprecated */
'order'
])
export class ActivitiesController {
public constructor( public constructor(
private readonly activitiesService: ActivitiesService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly dataProviderService: DataProviderService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@Delete() @Delete()
@HasPermission(permissions.deleteOrder) @HasPermission(permissions.deleteActivity)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteOrders( public async deleteActivities(
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string, @Query('dataSource') filterByDataSource?: string,
@ -71,29 +77,29 @@ export class OrderController {
filterByTags filterByTags
}); });
return this.orderService.deleteOrders({ return this.activitiesService.deleteActivities({
filters, filters,
userId: this.request.user.id userId: this.request.user.id
}); });
} }
@Delete(':id') @Delete(':id')
@HasPermission(permissions.deleteOrder) @HasPermission(permissions.deleteActivity)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> { public async deleteActivity(@Param('id') id: string): Promise<Order> {
const order = await this.orderService.order({ const activity = await this.activitiesService.order({
id, id,
userId: this.request.user.id userId: this.request.user.id
}); });
if (!order) { if (!activity) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
); );
} }
return this.orderService.deleteOrder({ return this.activitiesService.deleteActivity({
id id
}); });
} }
@ -103,9 +109,10 @@ export class OrderController {
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders( public async getAllActivities(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('activityTypes') filterByTypes?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string, @Query('dataSource') filterByDataSource?: string,
@Query('range') dateRange?: DateRange, @Query('range') dateRange?: DateRange,
@ -120,7 +127,7 @@ export class OrderController {
let startDate: Date; let startDate: Date;
if (dateRange) { if (dateRange) {
({ endDate, startDate } = getIntervalFromDateRange(dateRange)); ({ endDate, startDate } = getIntervalFromDateRange({ dateRange }));
} }
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
@ -133,14 +140,18 @@ export class OrderController {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const types = (filterByTypes?.split(',') as ActivityType[]) ?? [];
const userCurrency = this.request.user.settings.settings.baseCurrency; const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities, count } = await this.orderService.getOrders({ const { activities, count } = await this.activitiesService.getActivities({
endDate, endDate,
filters, filters,
sortColumn, sortColumn,
sortDirection, sortDirection,
startDate, startDate,
types,
userCurrency, userCurrency,
includeDrafts: true, includeDrafts: true,
skip: isNaN(skip) ? undefined : skip, skip: isNaN(skip) ? undefined : skip,
@ -156,7 +167,7 @@ export class OrderController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getOrderById( public async getActivityById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('id') id: string @Param('id') id: string
): Promise<ActivityResponse> { ): Promise<ActivityResponse> {
@ -164,7 +175,7 @@ export class OrderController {
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.settings.settings.baseCurrency; const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities } = await this.orderService.getOrders({ const { activities } = await this.activitiesService.getActivities({
userCurrency, userCurrency,
includeDrafts: true, includeDrafts: true,
userId: impersonationUserId || this.request.user.id, userId: impersonationUserId || this.request.user.id,
@ -185,11 +196,34 @@ export class OrderController {
return activity; return activity;
} }
@HasPermission(permissions.createOrder) @HasPermission(permissions.createActivity)
@Post() @Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> { public async createActivity(@Body() data: CreateOrderDto): Promise<Order> {
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 currency = data.currency;
const customCurrency = data.customCurrency; const customCurrency = data.customCurrency;
const dataSource = data.dataSource; const dataSource = data.dataSource;
@ -202,7 +236,7 @@ export class OrderController {
delete data.dataSource; delete data.dataSource;
const order = await this.orderService.createOrder({ const activity = await this.activitiesService.createActivity({
...data, ...data,
date: parseISO(data.date), date: parseISO(data.date),
SymbolProfile: { SymbolProfile: {
@ -227,14 +261,14 @@ export class OrderController {
userId: this.request.user.id userId: this.request.user.id
}); });
if (dataSource && !order.isDraft) { if (dataSource && !activity.isDraft) {
// Gather symbol data in the background, if data source is set // Gather symbol data in the background, if data source is set
// (not MANUAL) and not draft // (not MANUAL) and not draft
this.dataGatheringService.gatherSymbols({ this.dataGatheringService.gatherSymbols({
dataGatheringItems: [ dataGatheringItems: [
{ {
dataSource, dataSource,
date: order.date, date: activity.date,
symbol: data.symbol symbol: data.symbol
} }
], ],
@ -242,19 +276,22 @@ export class OrderController {
}); });
} }
return order; return activity;
} }
@HasPermission(permissions.updateOrder) @HasPermission(permissions.updateActivity)
@Put(':id') @Put(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) { public async updateActivity(
const originalOrder = await this.orderService.order({ @Param('id') id: string,
@Body() data: UpdateOrderDto
) {
const originalActivity = await this.activitiesService.order({
id id
}); });
if (!originalOrder || originalOrder.userId !== this.request.user.id) { if (!originalActivity || originalActivity.userId !== this.request.user.id) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -277,7 +314,7 @@ export class OrderController {
delete data.dataSource; delete data.dataSource;
return this.orderService.updateOrder({ return this.activitiesService.updateActivity({
data: { data: {
...data, ...data,
date, date,

16
apps/api/src/app/order/order.module.ts → apps/api/src/app/activities/activities.module.ts

@ -10,21 +10,21 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { OrderController } from './order.controller'; import { ActivitiesController } from './activities.controller';
import { OrderService } from './order.service'; import { ActivitiesService } from './activities.service';
@Module({ @Module({
controllers: [OrderController], controllers: [ActivitiesController],
exports: [OrderService], exports: [ActivitiesService],
imports: [ imports: [
ApiModule, ApiModule,
CacheModule, CacheModule,
DataGatheringModule, DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ImpersonationModule, ImpersonationModule,
@ -35,6 +35,6 @@ import { OrderService } from './order.service';
TransformDataSourceInRequestModule, TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule TransformDataSourceInResponseModule
], ],
providers: [AccountBalanceService, AccountService, OrderService] providers: [AccountBalanceService, AccountService, ActivitiesService]
}) })
export class OrderModule {} export class ActivitiesModule {}

129
apps/api/src/app/order/order.service.ts → apps/api/src/app/activities/activities.service.ts

@ -44,7 +44,7 @@ import { groupBy, uniqBy } from 'lodash';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@Injectable() @Injectable()
export class OrderService { export class ActivitiesService {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService, private readonly accountService: AccountService,
@ -62,7 +62,7 @@ export class OrderService {
tags, tags,
userId userId
}: { tags: Tag[]; userId: string } & AssetProfileIdentifier) { }: { tags: Tag[]; userId: string } & AssetProfileIdentifier) {
const orders = await this.prismaService.order.findMany({ const activities = await this.prismaService.order.findMany({
where: { where: {
userId, userId,
SymbolProfile: { SymbolProfile: {
@ -73,7 +73,7 @@ export class OrderService {
}); });
await Promise.all( await Promise.all(
orders.map(({ id }) => activities.map(({ id }) =>
this.prismaService.order.update({ this.prismaService.order.update({
data: { data: {
tags: { tags: {
@ -96,7 +96,7 @@ export class OrderService {
); );
} }
public async createOrder( public async createActivity(
data: Prisma.OrderCreateInput & { data: Prisma.OrderCreateInput & {
accountId?: string; accountId?: string;
assetClass?: AssetClass; assetClass?: AssetClass;
@ -201,7 +201,7 @@ export class OrderService {
? false ? false
: isAfter(data.date as Date, endOfToday()); : isAfter(data.date as Date, endOfToday());
const order = await this.prismaService.order.create({ const activity = await this.prismaService.order.create({
data: { data: {
...orderData, ...orderData,
account, account,
@ -235,56 +235,56 @@ export class OrderService {
this.eventEmitter.emit( this.eventEmitter.emit(
AssetProfileChangedEvent.getName(), AssetProfileChangedEvent.getName(),
new AssetProfileChangedEvent({ new AssetProfileChangedEvent({
currency: order.SymbolProfile.currency, currency: activity.SymbolProfile.currency,
dataSource: order.SymbolProfile.dataSource, dataSource: activity.SymbolProfile.dataSource,
symbol: order.SymbolProfile.symbol symbol: activity.SymbolProfile.symbol
}) })
); );
this.eventEmitter.emit( this.eventEmitter.emit(
PortfolioChangedEvent.getName(), PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({ new PortfolioChangedEvent({
userId: order.userId userId: activity.userId
}) })
); );
return order; return activity;
} }
public async deleteOrder( public async deleteActivity(
where: Prisma.OrderWhereUniqueInput where: Prisma.OrderWhereUniqueInput
): Promise<Order> { ): Promise<Order> {
const order = await this.prismaService.order.delete({ const activity = await this.prismaService.order.delete({
where where
}); });
const [symbolProfile] = const [symbolProfile] =
await this.symbolProfileService.getSymbolProfilesByIds([ await this.symbolProfileService.getSymbolProfilesByIds([
order.symbolProfileId activity.symbolProfileId
]); ]);
if (symbolProfile.activitiesCount === 0) { if (symbolProfile.activitiesCount === 0) {
await this.symbolProfileService.deleteById(order.symbolProfileId); await this.symbolProfileService.deleteById(activity.symbolProfileId);
} }
this.eventEmitter.emit( this.eventEmitter.emit(
PortfolioChangedEvent.getName(), PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({ new PortfolioChangedEvent({
userId: order.userId userId: activity.userId
}) })
); );
return order; return activity;
} }
public async deleteOrders({ public async deleteActivities({
filters, filters,
userId userId
}: { }: {
filters?: Filter[]; filters?: Filter[];
userId: string; userId: string;
}): Promise<number> { }): Promise<number> {
const { activities } = await this.getOrders({ const { activities } = await this.getActivities({
filters, filters,
userId, userId,
includeDrafts: true, includeDrafts: true,
@ -324,24 +324,44 @@ export class OrderService {
} }
/** /**
* Generates synthetic orders for cash holdings based on account balance history. * 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 * 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. * performance tracking based on exchange rate fluctuations.
* *
* @param cashDetails - The cash balance details. * @param cashDetails - The cash balance details.
* @param filters - Optional filters to apply.
* @param userCurrency - The base currency of the user. * @param userCurrency - The base currency of the user.
* @param userId - The ID of the user. * @param userId - The ID of the user.
* @returns A response containing the list of synthetic cash activities. * @returns A response containing the list of synthetic cash activities.
*/ */
public async getCashOrders({ public async getCashActivities({
cashDetails, cashDetails,
filters = [],
userCurrency, userCurrency,
userId userId
}: { }: {
cashDetails: CashDetails; cashDetails: CashDetails;
filters?: Filter[];
userCurrency: string; userCurrency: string;
userId: string; userId: string;
}): Promise<ActivitiesResponse> { }): Promise<ActivitiesResponse> {
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[] = []; const activities: Activity[] = [];
for (const account of cashDetails.accounts) { for (const account of cashDetails.accounts) {
@ -428,7 +448,10 @@ export class OrderService {
}; };
} }
public async getLatestOrder({ dataSource, symbol }: AssetProfileIdentifier) { public async getLatestActivity({
dataSource,
symbol
}: AssetProfileIdentifier) {
return this.prismaService.order.findFirst({ return this.prismaService.order.findFirst({
orderBy: { orderBy: {
date: 'desc' date: 'desc'
@ -439,7 +462,7 @@ export class OrderService {
}); });
} }
public async getOrders({ public async getActivities({
endDate, endDate,
filters, filters,
includeDrafts = false, includeDrafts = false,
@ -606,7 +629,7 @@ export class OrderService {
orderBy = [{ [sortColumn]: sortDirection }]; orderBy = [{ [sortColumn]: sortDirection }];
} }
if (types) { if (types?.length > 0) {
where.type = { in: types }; where.type = { in: types };
} }
@ -722,47 +745,51 @@ export class OrderService {
} }
/** /**
* Retrieves all orders required for the portfolio calculator, including both standard asset orders * Retrieves all activities required for the portfolio calculator, including both standard asset activities
* and synthetic orders representing cash activities. * and optional synthetic activities representing cash activities.
*
* @param filters - Optional filters to apply to the orders.
* @param userCurrency - The base currency of the user.
* @param userId - The ID of the user.
* @returns An object containing the combined list of activities and the total count.
*/ */
@LogPerformance @LogPerformance
public async getOrdersForPortfolioCalculator({ public async getActivitiesForPortfolioCalculator({
filters, filters,
userCurrency, userCurrency,
userId userId,
withCash = false
}: { }: {
/** Optional filters to apply to the activities. */
filters?: Filter[]; filters?: Filter[];
/** The base currency of the user. */
userCurrency: string; userCurrency: string;
/** The ID of the user. */
userId: string; userId: string;
/** Whether to include cash activities in the result. */
withCash?: boolean;
}) { }) {
const nonCashOrders = await this.getOrders({ const activities = await this.getActivities({
filters, filters,
userCurrency, userCurrency,
userId, userId,
withExcludedAccountsAndActivities: false // TODO withExcludedAccountsAndActivities: false // TODO
}); });
const cashDetails = await this.accountService.getCashDetails({ if (withCash) {
filters, const cashDetails = await this.accountService.getCashDetails({
userId, filters,
currency: userCurrency userId,
}); currency: userCurrency
});
const cashOrders = await this.getCashOrders({ const cashActivities = await this.getCashActivities({
cashDetails, cashDetails,
userCurrency, filters,
userId userCurrency,
}); userId
});
return { activities.activities.push(...cashActivities.activities);
activities: [...nonCashOrders.activities, ...cashOrders.activities], activities.count += cashActivities.count;
count: nonCashOrders.count + cashOrders.count }
};
return activities;
} }
public async getStatisticsByCurrency( public async getStatisticsByCurrency(
@ -793,7 +820,7 @@ export class OrderService {
}); });
} }
public async updateOrder({ public async updateActivity({
data, data,
where where
}: { }: {
@ -858,7 +885,7 @@ export class OrderService {
data: { tags: { set: [] } } data: { tags: { set: [] } }
}); });
const order = await this.prismaService.order.update({ const activity = await this.prismaService.order.update({
where, where,
data: { data: {
...data, ...data,
@ -872,11 +899,11 @@ export class OrderService {
this.eventEmitter.emit( this.eventEmitter.emit(
PortfolioChangedEvent.getName(), PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({ new PortfolioChangedEvent({
userId: order.userId userId: activity.userId
}) })
); );
return order; return activity;
} }
private async orders(params: { private async orders(params: {

9
apps/api/src/app/admin/admin.controller.ts

@ -172,7 +172,7 @@ export class AdminController {
let date: Date; let date: Date;
if (dateRange) { if (dateRange) {
const { startDate } = getIntervalFromDateRange(dateRange); const { startDate } = getIntervalFromDateRange({ dateRange });
date = startDate; date = startDate;
} }
@ -247,14 +247,17 @@ export class AdminController {
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<{ price: number }> { ): Promise<{ price: number }> {
try { try {
const price = await this.manualService.test(data.scraperConfiguration); const price = await this.manualService.test({
symbol,
scraperConfiguration: data.scraperConfiguration
});
if (price) { if (price) {
return { price }; return { price };
} }
throw new Error( throw new Error(
`Could not parse the current market price for ${symbol} (${dataSource})` `Could not parse the market price for ${symbol} (${dataSource})`
); );
} catch (error) { } catch (error) {
Logger.error(error, 'AdminController'); Logger.error(error, 'AdminController');

8
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 { 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 { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
@ -9,7 +9,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -20,15 +20,15 @@ import { QueueModule } from './queue/queue.module';
@Module({ @Module({
imports: [ imports: [
ActivitiesModule,
ApiModule, ApiModule,
BenchmarkModule, BenchmarkModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
DemoModule, DemoModule,
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule, MarketDataModule,
OrderModule,
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,
QueueModule, QueueModule,

30
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 { environment } from '@ghostfolio/api/environments/environment';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -55,12 +55,12 @@ import { groupBy } from 'lodash';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
public constructor( public constructor(
private readonly activitiesService: ActivitiesService,
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly orderService: OrderService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
@ -138,11 +138,11 @@ export class AdminService {
public async get(): Promise<AdminData> { public async get(): Promise<AdminData> {
const dataSources = Object.values(DataSource); const dataSources = Object.values(DataSource);
const [enabledDataSources, settings, transactionCount, userCount] = const [activitiesCount, enabledDataSources, settings, userCount] =
await Promise.all([ await Promise.all([
this.prismaService.order.count(),
this.dataProviderService.getDataSources(), this.dataProviderService.getDataSources(),
this.propertyService.get(), this.propertyService.get(),
this.prismaService.order.count(),
this.countUsersWithAnalytics() this.countUsersWithAnalytics()
]); ]);
@ -182,9 +182,9 @@ export class AdminService {
).filter(Boolean); ).filter(Boolean);
return { return {
activitiesCount,
dataProviders, dataProviders,
settings, settings,
transactionCount,
userCount, userCount,
version: environment.version version: environment.version
}; };
@ -225,6 +225,10 @@ export class AdminService {
presetId === 'ETF_WITHOUT_SECTORS' presetId === 'ETF_WITHOUT_SECTORS'
) { ) {
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }]; filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
} else if (presetId === 'NO_ACTIVITIES') {
where.activities = {
none: {}
};
} }
const searchQuery = filters.find(({ type }) => { const searchQuery = filters.find(({ type }) => {
@ -466,10 +470,12 @@ export class AdminService {
let currency: EnhancedSymbolProfile['currency'] = '-'; let currency: EnhancedSymbolProfile['currency'] = '-';
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity']; let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
if (isCurrency(getCurrencyFromSymbol(symbol))) { const isCurrencyAssetProfile = isCurrency(getCurrencyFromSymbol(symbol));
if (isCurrencyAssetProfile) {
currency = getCurrencyFromSymbol(symbol); currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } = ({ activitiesCount, dateOfFirstActivity } =
await this.orderService.getStatisticsByCurrency(currency)); await this.activitiesService.getStatisticsByCurrency(currency));
} }
const [[assetProfile], marketData] = await Promise.all([ const [[assetProfile], marketData] = await Promise.all([
@ -504,6 +510,8 @@ export class AdminService {
dataSource, dataSource,
dateOfFirstActivity, dateOfFirstActivity,
symbol, symbol,
assetClass: isCurrencyAssetProfile ? AssetClass.LIQUIDITY : undefined,
assetSubClass: isCurrencyAssetProfile ? AssetSubClass.CASH : undefined,
isActive: true isActive: true
} }
}; };
@ -617,23 +625,23 @@ export class AdminService {
const symbolProfileOverrides = { const symbolProfileOverrides = {
assetClass: assetClass as AssetClass, assetClass: assetClass as AssetClass,
assetSubClass: assetSubClass as AssetSubClass, assetSubClass: assetSubClass as AssetSubClass,
countries: countries as Prisma.JsonArray,
name: name as string, name: name as string,
sectors: sectors as Prisma.JsonArray,
url: url as string url: url as string
}; };
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput = { const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput = {
comment, comment,
countries,
currency, currency,
dataSource, dataSource,
holdings, holdings,
isActive, isActive,
scraperConfiguration, scraperConfiguration,
sectors,
symbol, symbol,
symbolMapping, symbolMapping,
...(dataSource === 'MANUAL' ...(dataSource === 'MANUAL'
? { assetClass, assetSubClass, name, url } ? { assetClass, assetSubClass, countries, name, sectors, url }
: { : {
SymbolProfileOverrides: { SymbolProfileOverrides: {
upsert: { upsert: {
@ -790,7 +798,7 @@ export class AdminService {
if (isCurrency(getCurrencyFromSymbol(symbol))) { if (isCurrency(getCurrencyFromSymbol(symbol))) {
currency = getCurrencyFromSymbol(symbol); currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } = ({ activitiesCount, dateOfFirstActivity } =
await this.orderService.getStatisticsByCurrency(currency)); await this.activitiesService.getStatisticsByCurrency(currency));
} }
const lastMarketPrice = lastMarketPriceMap.get( const lastMarketPrice = lastMarketPriceMap.get(

9
apps/api/src/app/admin/queue/queue.module.ts

@ -1,5 +1,6 @@
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { StatisticsGatheringQueueModule } from '@ghostfolio/api/services/queues/statistics-gathering/statistics-gathering.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -8,7 +9,11 @@ import { QueueService } from './queue.service';
@Module({ @Module({
controllers: [QueueController], controllers: [QueueController],
imports: [DataGatheringModule, PortfolioSnapshotQueueModule], imports: [
DataGatheringQueueModule,
PortfolioSnapshotQueueModule,
StatisticsGatheringQueueModule
],
providers: [QueueService] providers: [QueueService]
}) })
export class QueueModule {} export class QueueModule {}

24
apps/api/src/app/admin/queue/queue.service.ts

@ -1,7 +1,8 @@
import { import {
DATA_GATHERING_QUEUE, DATA_GATHERING_QUEUE,
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE, PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
QUEUE_JOB_STATUS_LIST QUEUE_JOB_STATUS_LIST,
STATISTICS_GATHERING_QUEUE
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { AdminJobs } from '@ghostfolio/common/interfaces'; import { AdminJobs } from '@ghostfolio/common/interfaces';
@ -15,7 +16,9 @@ export class QueueService {
@InjectQueue(DATA_GATHERING_QUEUE) @InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue, private readonly dataGatheringQueue: Queue,
@InjectQueue(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE) @InjectQueue(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE)
private readonly portfolioSnapshotQueue: Queue private readonly portfolioSnapshotQueue: Queue,
@InjectQueue(STATISTICS_GATHERING_QUEUE)
private readonly statisticsGatheringQueue: Queue
) {} ) {}
public async deleteJob(aId: string) { public async deleteJob(aId: string) {
@ -38,6 +41,7 @@ export class QueueService {
await this.dataGatheringQueue.clean(300, queueStatus); await this.dataGatheringQueue.clean(300, queueStatus);
await this.portfolioSnapshotQueue.clean(300, queueStatus); await this.portfolioSnapshotQueue.clean(300, queueStatus);
await this.statisticsGatheringQueue.clean(300, queueStatus);
} }
} }
@ -58,13 +62,19 @@ export class QueueService {
limit?: number; limit?: number;
status?: JobStatus[]; status?: JobStatus[];
}): Promise<AdminJobs> { }): Promise<AdminJobs> {
const [dataGatheringJobs, portfolioSnapshotJobs] = await Promise.all([ const [dataGatheringJobs, portfolioSnapshotJobs, statisticsGatheringJobs] =
this.dataGatheringQueue.getJobs(status), await Promise.all([
this.portfolioSnapshotQueue.getJobs(status) this.dataGatheringQueue.getJobs(status),
]); this.portfolioSnapshotQueue.getJobs(status),
this.statisticsGatheringQueue.getJobs(status)
]);
const jobsWithState = await Promise.all( const jobsWithState = await Promise.all(
[...dataGatheringJobs, ...portfolioSnapshotJobs] [
...dataGatheringJobs,
...portfolioSnapshotJobs,
...statisticsGatheringJobs
]
.filter((job) => { .filter((job) => {
return job; return job;
}) })

42
apps/api/src/app/app.module.ts

@ -1,4 +1,5 @@
import { EventsModule } from '@ghostfolio/api/events/events.module'; 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 { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { CronModule } from '@ghostfolio/api/services/cron/cron.module'; import { CronModule } from '@ghostfolio/api/services/cron/cron.module';
@ -7,13 +8,16 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { import {
BULL_BOARD_ROUTE,
DEFAULT_LANGUAGE_CODE, DEFAULT_LANGUAGE_CODE,
SUPPORTED_LANGUAGE_CODES SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { ExpressAdapter } from '@bull-board/express';
import { BullBoardModule } from '@bull-board/nestjs';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
@ -25,6 +29,7 @@ import { join } from 'node:path';
import { AccessModule } from './access/access.module'; import { AccessModule } from './access/access.module';
import { AccountModule } from './account/account.module'; import { AccountModule } from './account/account.module';
import { ActivitiesModule } from './activities/activities.module';
import { AdminModule } from './admin/admin.module'; import { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AssetModule } from './asset/asset.module'; import { AssetModule } from './asset/asset.module';
@ -48,7 +53,6 @@ import { HealthModule } from './health/health.module';
import { ImportModule } from './import/import.module'; import { ImportModule } from './import/import.module';
import { InfoModule } from './info/info.module'; import { InfoModule } from './info/info.module';
import { LogoModule } from './logo/logo.module'; import { LogoModule } from './logo/logo.module';
import { OrderModule } from './order/order.module';
import { PlatformModule } from './platform/platform.module'; import { PlatformModule } from './platform/platform.module';
import { PortfolioModule } from './portfolio/portfolio.module'; import { PortfolioModule } from './portfolio/portfolio.module';
import { RedisCacheModule } from './redis-cache/redis-cache.module'; import { RedisCacheModule } from './redis-cache/redis-cache.module';
@ -62,6 +66,7 @@ import { UserModule } from './user/user.module';
AdminModule, AdminModule,
AccessModule, AccessModule,
AccountModule, AccountModule,
ActivitiesModule,
AiModule, AiModule,
ApiKeysModule, ApiKeysModule,
AssetModule, AssetModule,
@ -69,6 +74,29 @@ import { UserModule } from './user/user.module';
AuthDeviceModule, AuthDeviceModule,
AuthModule, AuthModule,
BenchmarksModule, 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({ BullModule.forRoot({
redis: { redis: {
db: parseInt(process.env.REDIS_DB ?? '0', 10), db: parseInt(process.env.REDIS_DB ?? '0', 10),
@ -81,7 +109,7 @@ import { UserModule } from './user/user.module';
ConfigModule.forRoot(), ConfigModule.forRoot(),
ConfigurationModule, ConfigurationModule,
CronModule, CronModule,
DataGatheringModule, DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
EventEmitterModule.forRoot(), EventEmitterModule.forRoot(),
EventsModule, EventsModule,
@ -94,7 +122,6 @@ import { UserModule } from './user/user.module';
InfoModule, InfoModule,
LogoModule, LogoModule,
MarketDataModule, MarketDataModule,
OrderModule,
PlatformModule, PlatformModule,
PlatformsModule, PlatformsModule,
PortfolioModule, PortfolioModule,
@ -105,7 +132,12 @@ import { UserModule } from './user/user.module';
RedisCacheModule, RedisCacheModule,
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
ServeStaticModule.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'), rootPath: join(__dirname, '..', 'client'),
serveStaticOptions: { serveStaticOptions: {
setHeaders: (res) => { setHeaders: (res) => {

29
apps/api/src/app/auth-device/auth-device.controller.ts

@ -2,18 +2,43 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Delete, Param, UseGuards } from '@nestjs/common'; import {
Controller,
Delete,
HttpException,
Inject,
Param,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
@Controller('auth-device') @Controller('auth-device')
export class AuthDeviceController { export class AuthDeviceController {
public constructor(private readonly authDeviceService: AuthDeviceService) {} public constructor(
private readonly authDeviceService: AuthDeviceService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Delete(':id') @Delete(':id')
@HasPermission(permissions.deleteAuthDevice) @HasPermission(permissions.deleteAuthDevice)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAuthDevice(@Param('id') id: string): Promise<void> { public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
const originalAuthDevice = await this.authDeviceService.authDevice({
id,
userId: this.request.user.id
});
if (!originalAuthDevice) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
await this.authDeviceService.deleteAuthDevice({ id }); await this.authDeviceService.deleteAuthDevice({ id });
} }
} }

4
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 { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.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 { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
@ -29,6 +29,7 @@ import { AiService } from './ai.service';
@Module({ @Module({
controllers: [AiController], controllers: [AiController],
imports: [ imports: [
ActivitiesModule,
ApiModule, ApiModule,
BenchmarkModule, BenchmarkModule,
ConfigurationModule, ConfigurationModule,
@ -37,7 +38,6 @@ import { AiService } from './ai.service';
I18nModule, I18nModule,
ImpersonationModule, ImpersonationModule,
MarketDataModule, MarketDataModule,
OrderModule,
PortfolioSnapshotQueueModule, PortfolioSnapshotQueueModule,
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,

7
apps/api/src/app/endpoints/assets/assets.controller.ts

@ -4,6 +4,7 @@ import { interpolate } from '@ghostfolio/common/helper';
import { import {
Controller, Controller,
Get, Get,
OnModuleInit,
Param, Param,
Res, Res,
Version, Version,
@ -14,12 +15,14 @@ import { readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
@Controller('assets') @Controller('assets')
export class AssetsController { export class AssetsController implements OnModuleInit {
private webManifest = ''; private webManifest = '';
public constructor( public constructor(
public readonly configurationService: ConfigurationService public readonly configurationService: ConfigurationService
) { ) {}
public onModuleInit() {
try { try {
this.webManifest = readFileSync( this.webManifest = readFileSync(
join(__dirname, 'assets', 'site.webmanifest'), join(__dirname, 'assets', 'site.webmanifest'),

6
apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts

@ -126,10 +126,10 @@ export class BenchmarksController {
@Query('tags') filterByTags?: string, @Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false' @Query('withExcludedAccounts') withExcludedAccountsParam = 'false'
): Promise<BenchmarkMarketDataDetailsResponse> { ): Promise<BenchmarkMarketDataDetailsResponse> {
const { endDate, startDate } = getIntervalFromDateRange( const { endDate, startDate } = getIntervalFromDateRange({
dateRange, dateRange,
new Date(startDateString) startDate: new Date(startDateString)
); });
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,

4
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 { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.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 { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
@ -32,6 +32,7 @@ import { BenchmarksService } from './benchmarks.service';
@Module({ @Module({
controllers: [BenchmarksController], controllers: [BenchmarksController],
imports: [ imports: [
ActivitiesModule,
ApiModule, ApiModule,
ConfigurationModule, ConfigurationModule,
DataProviderModule, DataProviderModule,
@ -39,7 +40,6 @@ import { BenchmarksService } from './benchmarks.service';
I18nModule, I18nModule,
ImpersonationModule, ImpersonationModule,
MarketDataModule, MarketDataModule,
OrderModule,
PortfolioSnapshotQueueModule, PortfolioSnapshotQueueModule,
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,

7
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 { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; 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 { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
@ -28,7 +30,8 @@ import {
Param, Param,
Post, Post,
Query, Query,
UseGuards UseGuards,
UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@ -86,6 +89,8 @@ export class MarketDataController {
@Get(':dataSource/:symbol') @Get(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getMarketDataBySymbol( public async getMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string

6
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 { AdminModule } from '@ghostfolio/api/app/admin/admin.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.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 { MarketDataModule as MarketDataServiceModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
@ -13,7 +15,9 @@ import { MarketDataController } from './market-data.controller';
AdminModule, AdminModule,
MarketDataServiceModule, MarketDataServiceModule,
SymbolModule, SymbolModule,
SymbolProfileModule SymbolProfileModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
] ]
}) })
export class MarketDataModule {} export class MarketDataModule {}

4
apps/api/src/app/endpoints/platforms/platforms.controller.ts

@ -15,7 +15,9 @@ export class PlatformsController {
@HasPermission(permissions.readPlatforms) @HasPermission(permissions.readPlatforms)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPlatforms(): Promise<PlatformsResponse> { public async getPlatforms(): Promise<PlatformsResponse> {
const platforms = await this.platformService.getPlatforms(); const platforms = await this.platformService.getPlatforms({
orderBy: { name: 'asc' }
});
return { platforms }; return { platforms };
} }

7
apps/api/src/app/endpoints/public/public.controller.ts

@ -1,5 +1,5 @@
import { AccessService } from '@ghostfolio/api/app/access/access.service'; 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 { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { UserService } from '@ghostfolio/api/app/user/user.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'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
@ -32,9 +32,9 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
export class PublicController { export class PublicController {
public constructor( public constructor(
private readonly accessService: AccessService, private readonly accessService: AccessService,
private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly orderService: OrderService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService
@ -178,7 +178,7 @@ export class PublicController {
return false; return false;
}); });
const { activities } = await this.orderService.getOrders({ const { activities } = await this.activitiesService.getActivities({
filters: activityFilters.length > 0 ? activityFilters : undefined, filters: activityFilters.length > 0 ? activityFilters : undefined,
sortColumn: 'date', sortColumn: 'date',
sortDirection: 'desc', sortDirection: 'desc',
@ -269,6 +269,7 @@ export class PublicController {
allocationInPercentage: allocationInPercentage:
portfolioPosition.valueInBaseCurrency / totalValue, portfolioPosition.valueInBaseCurrency / totalValue,
assetClass: hasDetails ? portfolioPosition.assetClass : undefined, assetClass: hasDetails ? portfolioPosition.assetClass : undefined,
assetProfile: hasDetails ? portfolioPosition.assetProfile : undefined,
countries: hasDetails ? portfolioPosition.countries : [], countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined, currency: hasDetails ? portfolioPosition.currency : undefined,
dataSource: portfolioPosition.dataSource, dataSource: portfolioPosition.dataSource,

4
apps/api/src/app/endpoints/public/public.module.ts

@ -1,7 +1,7 @@
import { AccessModule } from '@ghostfolio/api/app/access/access.module'; import { AccessModule } from '@ghostfolio/api/app/access/access.module';
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.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 { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
@ -27,13 +27,13 @@ import { PublicController } from './public.controller';
controllers: [PublicController], controllers: [PublicController],
imports: [ imports: [
AccessModule, AccessModule,
ActivitiesModule,
BenchmarkModule, BenchmarkModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
I18nModule, I18nModule,
ImpersonationModule, ImpersonationModule,
MarketDataModule, MarketDataModule,
OrderModule,
PortfolioSnapshotQueueModule, PortfolioSnapshotQueueModule,
PrismaModule, PrismaModule,
RedisCacheModule, RedisCacheModule,

4
apps/api/src/app/endpoints/watchlist/watchlist.module.ts

@ -5,7 +5,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -17,7 +17,7 @@ import { WatchlistService } from './watchlist.service';
controllers: [WatchlistController], controllers: [WatchlistController],
imports: [ imports: [
BenchmarkModule, BenchmarkModule,
DataGatheringModule, DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
ImpersonationModule, ImpersonationModule,
MarketDataModule, MarketDataModule,

5
apps/api/src/app/export/export.controller.ts

@ -15,6 +15,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Type as ActivityType } from '@prisma/client';
import { ExportService } from './export.service'; import { ExportService } from './export.service';
@ -33,12 +34,15 @@ export class ExportController {
public async export( public async export(
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('activityIds') filterByActivityIds?: string, @Query('activityIds') filterByActivityIds?: string,
@Query('activityTypes') filterByTypes?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string, @Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string, @Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<ExportResponse> { ): Promise<ExportResponse> {
const activityIds = filterByActivityIds?.split(',') ?? []; const activityIds = filterByActivityIds?.split(',') ?? [];
const activityTypes = (filterByTypes?.split(',') as ActivityType[]) ?? [];
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
@ -49,6 +53,7 @@ export class ExportController {
return this.exportService.export({ return this.exportService.export({
activityIds, activityIds,
activityTypes,
filters, filters,
userId: this.request.user.id, userId: this.request.user.id,
userSettings: this.request.user.settings.settings userSettings: this.request.user.settings.settings

4
apps/api/src/app/export/export.module.ts

@ -1,5 +1,5 @@
import { AccountModule } from '@ghostfolio/api/app/account/account.module'; 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 { 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 { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
@ -14,9 +14,9 @@ import { ExportService } from './export.service';
controllers: [ExportController], controllers: [ExportController],
imports: [ imports: [
AccountModule, AccountModule,
ActivitiesModule,
ApiModule, ApiModule,
MarketDataModule, MarketDataModule,
OrderModule,
TagModule, TagModule,
TransformDataSourceInRequestModule TransformDataSourceInRequestModule
], ],

16
apps/api/src/app/export/export.service.ts

@ -1,5 +1,5 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; 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 { environment } from '@ghostfolio/api/environments/environment';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
@ -10,25 +10,27 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Platform, Prisma } from '@prisma/client'; import { Platform, Prisma, Type as ActivityType } from '@prisma/client';
import { groupBy, uniqBy } from 'lodash'; import { groupBy, uniqBy } from 'lodash';
@Injectable() @Injectable()
export class ExportService { export class ExportService {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly activitiesService: ActivitiesService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly orderService: OrderService,
private readonly tagService: TagService private readonly tagService: TagService
) {} ) {}
public async export({ public async export({
activityIds, activityIds,
activityTypes,
filters, filters,
userId, userId,
userSettings userSettings
}: { }: {
activityIds?: string[]; activityIds?: string[];
activityTypes?: ActivityType[];
filters?: Filter[]; filters?: Filter[];
userId: string; userId: string;
userSettings: UserSettings; userSettings: UserSettings;
@ -38,12 +40,13 @@ export class ExportService {
}); });
const platformsMap: { [platformId: string]: Platform } = {}; const platformsMap: { [platformId: string]: Platform } = {};
let { activities } = await this.orderService.getOrders({ let { activities } = await this.activitiesService.getActivities({
filters, filters,
userId, userId,
includeDrafts: true, includeDrafts: true,
sortColumn: 'date', sortColumn: 'date',
sortDirection: 'asc', sortDirection: 'asc',
types: activityTypes,
userCurrency: userSettings?.baseCurrency, userCurrency: userSettings?.baseCurrency,
withExcludedAccountsAndActivities: true withExcludedAccountsAndActivities: true
}); });
@ -182,10 +185,8 @@ export class ExportService {
isActive, isActive,
isin, isin,
name, name,
scraperConfiguration,
sectors, sectors,
symbol, symbol,
symbolMapping,
url url
}) => { }) => {
return { return {
@ -204,11 +205,8 @@ export class ExportService {
isin, isin,
marketData: marketDataByAssetProfile[id], marketData: marketDataByAssetProfile[id],
name, name,
scraperConfiguration:
scraperConfiguration as unknown as Prisma.JsonArray,
sectors: sectors as unknown as Prisma.JsonArray, sectors: sectors as unknown as Prisma.JsonArray,
symbol, symbol,
symbolMapping,
url url
}; };
} }

19
apps/api/src/app/import/import.controller.ts

@ -3,6 +3,7 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { 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 { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { ImportResponse } from '@ghostfolio/common/interfaces'; import { ImportResponse } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -38,7 +39,7 @@ export class ImportController {
@Post() @Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@HasPermission(permissions.createOrder) @HasPermission(permissions.createActivity)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async import( public async import(
@ -62,7 +63,7 @@ export class ImportController {
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Premium' this.request.user.subscription.type === SubscriptionType.Premium
) { ) {
maxActivitiesToImport = Number.MAX_SAFE_INTEGER; maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
} }
@ -100,12 +101,24 @@ export class ImportController {
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<ImportResponse> { ): Promise<ImportResponse> {
let maxActivitiesToImport = this.configurationService.get(
'MAX_ACTIVITIES_TO_IMPORT'
);
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === SubscriptionType.Premium
) {
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
}
const activities = await this.importService.getDividends({ const activities = await this.importService.getDividends({
dataSource, dataSource,
symbol, symbol,
userCurrency: this.request.user.settings.settings.baseCurrency,
userId: this.request.user.id userId: this.request.user.id
}); });
return { activities }; return { activities: activities.slice(0, maxActivitiesToImport) };
} }
} }

10
apps/api/src/app/import/import.module.ts

@ -1,17 +1,18 @@
import { AccountModule } from '@ghostfolio/api/app/account/account.module'; 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 { 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 { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.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 { 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 { 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 { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
@ -24,13 +25,14 @@ import { ImportService } from './import.service';
controllers: [ImportController], controllers: [ImportController],
imports: [ imports: [
AccountModule, AccountModule,
ActivitiesModule,
ApiModule,
CacheModule, CacheModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule, MarketDataModule,
OrderModule,
PlatformModule, PlatformModule,
PortfolioModule, PortfolioModule,
PrismaModule, PrismaModule,

225
apps/api/src/app/import/import.service.ts

@ -1,9 +1,10 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; 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 { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.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 { 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 { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
@ -25,13 +26,13 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { import {
AccountWithPlatform, AccountWithValue,
OrderWithAccount, OrderWithAccount,
UserWithSettings UserWithSettings
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client'; import { DataSource, Prisma } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns'; import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns';
import { omit, uniqBy } from 'lodash'; import { omit, uniqBy } from 'lodash';
@ -43,11 +44,12 @@ import { ImportDataDto } from './import-data.dto';
export class ImportService { export class ImportService {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly configurationService: ConfigurationService, private readonly activitiesService: ActivitiesService,
private readonly apiService: ApiService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly orderService: OrderService,
private readonly platformService: PlatformService, private readonly platformService: PlatformService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
private readonly symbolProfileService: SymbolProfileService, private readonly symbolProfileService: SymbolProfileService,
@ -57,8 +59,12 @@ export class ImportService {
public async getDividends({ public async getDividends({
dataSource, dataSource,
symbol, symbol,
userCurrency,
userId userId
}: AssetProfileIdentifier & { userId: string }): Promise<Activity[]> { }: AssetProfileIdentifier & {
userCurrency: string;
userId: string;
}): Promise<Activity[]> {
try { try {
const holding = await this.portfolioService.getHolding({ const holding = await this.portfolioService.getHolding({
dataSource, dataSource,
@ -71,36 +77,45 @@ export class ImportService {
return []; return [];
} }
const { activities, firstBuyDate, historicalData } = holding; const filters = this.apiService.buildFiltersFromQueryParams({
filterByDataSource: dataSource,
filterBySymbol: symbol
});
const [[assetProfile], dividends] = await Promise.all([ const { dateOfFirstActivity, historicalData } = holding;
this.symbolProfileService.getSymbolProfiles([
{
dataSource,
symbol
}
]),
await this.dataProviderService.getDividends({
dataSource,
symbol,
from: parseDate(firstBuyDate),
granularity: 'day',
to: new Date()
})
]);
const accounts = activities const [{ accounts }, { activities }, [assetProfile], dividends] =
.filter(({ account }) => { await Promise.all([
return !!account; this.portfolioService.getAccountsWithAggregations({
}) filters,
.map(({ account }) => { userId,
return account; 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; const account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
return await Promise.all( return await Promise.all(
Object.entries(dividends).map(async ([dateString, { marketPrice }]) => { Object.entries(dividends).map(([dateString, { marketPrice }]) => {
const quantity = const quantity =
historicalData.find((historicalDataItem) => { historicalData.find((historicalDataItem) => {
return historicalDataItem.date === dateString; return historicalDataItem.date === dateString;
@ -378,7 +393,7 @@ export class ImportService {
} }
} }
const assetProfiles = await this.validateActivities({ const assetProfiles = await this.dataProviderService.validateActivities({
activitiesDto, activitiesDto,
assetProfilesWithMarketDataDto, assetProfilesWithMarketDataDto,
maxActivitiesToImport, maxActivitiesToImport,
@ -533,7 +548,7 @@ export class ImportService {
continue; continue;
} }
order = await this.orderService.createOrder({ order = await this.activitiesService.createActivity({
comment, comment,
currency, currency,
date, date,
@ -575,10 +590,18 @@ export class ImportService {
const value = new Big(quantity).mul(unitPrice).toNumber(); const value = new Big(quantity).mul(unitPrice).toNumber();
const valueInBaseCurrency = this.exchangeRateDataService.toCurrencyAtDate(
value,
currency ?? assetProfile.currency,
userCurrency,
date
);
activities.push({ activities.push({
...order, ...order,
error, error,
value, value,
valueInBaseCurrency: await valueInBaseCurrency,
// @ts-ignore // @ts-ignore
SymbolProfile: assetProfile SymbolProfile: assetProfile
}); });
@ -622,7 +645,7 @@ export class ImportService {
userId: string; userId: string;
}): Promise<Partial<Activity>[]> { }): Promise<Partial<Activity>[]> {
const { activities: existingActivities } = const { activities: existingActivities } =
await this.orderService.getOrders({ await this.activitiesService.getActivities({
userCurrency, userCurrency,
userId, userId,
includeDrafts: true, includeDrafts: true,
@ -695,141 +718,13 @@ export class ImportService {
); );
} }
private isUniqueAccount(accounts: AccountWithPlatform[]) { private isUniqueAccount(accounts: AccountWithValue[]) {
const uniqueAccountIds = new Set<string>(); const uniqueAccountIds = new Set<string>();
for (const account of accounts) { for (const { id } of accounts) {
uniqueAccountIds.add(account.id); uniqueAccountIds.add(id);
} }
return uniqueAccountIds.size === 1; return uniqueAccountIds.size === 1;
} }
private async validateActivities({
activitiesDto,
assetProfilesWithMarketDataDto,
maxActivitiesToImport,
user
}: {
activitiesDto: Partial<CreateOrderDto>[];
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<SymbolProfile>;
} = {};
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<SymbolProfile> = { 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;
}
} }

4
apps/api/src/app/info/info.module.ts

@ -8,7 +8,7 @@ import { ConfigurationModule } from '@ghostfolio/api/services/configuration/conf
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -22,7 +22,7 @@ import { InfoService } from './info.service';
imports: [ imports: [
BenchmarkModule, BenchmarkModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
JwtModule.register({ JwtModule.register({

182
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 { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
@ -8,26 +7,24 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
HEADER_KEY_TOKEN,
PROPERTY_BETTER_UPTIME_MONITOR_ID,
PROPERTY_COUNTRIES_OF_SUBSCRIBERS, PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
PROPERTY_DEMO_USER_ID, PROPERTY_DEMO_USER_ID,
PROPERTY_DOCKER_HUB_PULLS,
PROPERTY_GITHUB_CONTRIBUTORS,
PROPERTY_GITHUB_STARGAZERS,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SLACK_COMMUNITY_USERS, PROPERTY_SLACK_COMMUNITY_USERS,
PROPERTY_UPTIME,
ghostfolioFearAndGreedIndexDataSourceStocks ghostfolioFearAndGreedIndexDataSourceStocks
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import { encodeDataSource } from '@ghostfolio/common/helper';
DATE_FORMAT,
encodeDataSource,
extractNumberFromString
} from '@ghostfolio/common/helper';
import { InfoItem, Statistics } from '@ghostfolio/common/interfaces'; import { InfoItem, Statistics } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import * as cheerio from 'cheerio'; import { subDays } from 'date-fns';
import { format, subDays } from 'date-fns'; import { isNil } from 'lodash';
@Injectable() @Injectable()
export class InfoService { export class InfoService {
@ -38,7 +35,6 @@ export class InfoService {
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly platformService: PlatformService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService, private readonly redisCacheService: RedisCacheService,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService,
@ -103,16 +99,12 @@ export class InfoService {
benchmarks, benchmarks,
demoAuthToken, demoAuthToken,
isUserSignupEnabled, isUserSignupEnabled,
platforms,
statistics, statistics,
subscriptionOffer subscriptionOffer
] = await Promise.all([ ] = await Promise.all([
this.benchmarkService.getBenchmarkAssetProfiles(), this.benchmarkService.getBenchmarkAssetProfiles(),
this.getDemoAuthToken(), this.getDemoAuthToken(),
this.propertyService.isUserSignupEnabled(), this.propertyService.isUserSignupEnabled(),
this.platformService.getPlatforms({
orderBy: { name: 'asc' }
}),
this.getStatistics(), this.getStatistics(),
this.subscriptionService.getSubscriptionOffer({ key: 'default' }) this.subscriptionService.getSubscriptionOffer({ key: 'default' })
]); ]);
@ -127,7 +119,6 @@ export class InfoService {
demoAuthToken, demoAuthToken,
globalPermissions, globalPermissions,
isReadOnlyMode, isReadOnlyMode,
platforms,
statistics, statistics,
subscriptionOffer, subscriptionOffer,
baseCurrency: DEFAULT_CURRENCY, baseCurrency: DEFAULT_CURRENCY,
@ -156,68 +147,6 @@ export class InfoService {
}); });
} }
private async countDockerHubPulls(): Promise<number> {
try {
const { pull_count } = (await fetch(
'https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio',
{
headers: { 'User-Agent': 'request' },
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}
).then((res) => res.json())) as { pull_count: number };
return pull_count;
} catch (error) {
Logger.error(error, 'InfoService - DockerHub');
return undefined;
}
}
private async countGitHubContributors(): Promise<number> {
try {
const body = await fetch('https://github.com/ghostfolio/ghostfolio', {
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}).then((res) => res.text());
const $ = cheerio.load(body);
return extractNumberFromString({
value: $(
'a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter'
).text()
});
} catch (error) {
Logger.error(error, 'InfoService - GitHub');
return undefined;
}
}
private async countGitHubStargazers(): Promise<number> {
try {
const { stargazers_count } = (await fetch(
'https://api.github.com/repos/ghostfolio/ghostfolio',
{
headers: { 'User-Agent': 'request' },
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}
).then((res) => res.json())) as { stargazers_count: number };
return stargazers_count;
} catch (error) {
Logger.error(error, 'InfoService - GitHub');
return undefined;
}
}
private async countNewUsers(aDays: number) { private async countNewUsers(aDays: number) {
return this.userService.count({ return this.userService.count({
where: { where: {
@ -237,12 +166,6 @@ export class InfoService {
}); });
} }
private async countSlackCommunityUsers() {
return await this.propertyService.getByKey<string>(
PROPERTY_SLACK_COMMUNITY_USERS
);
}
private async getDemoAuthToken() { private async getDemoAuthToken() {
const demoUserId = await this.propertyService.getByKey<string>( const demoUserId = await this.propertyService.getByKey<string>(
PROPERTY_DEMO_USER_ID PROPERTY_DEMO_USER_ID
@ -274,65 +197,56 @@ export class InfoService {
} }
} catch {} } catch {}
const activeUsers1d = await this.countActiveUsers(1); const [
const activeUsers30d = await this.countActiveUsers(30);
const newUsers30d = await this.countNewUsers(30);
const dockerHubPulls = await this.countDockerHubPulls();
const gitHubContributors = await this.countGitHubContributors();
const gitHubStargazers = await this.countGitHubStargazers();
const slackCommunityUsers = await this.countSlackCommunityUsers();
const uptime = await this.getUptime();
statistics = {
activeUsers1d, activeUsers1d,
activeUsers30d, activeUsers30d,
newUsers30d,
dockerHubPulls, dockerHubPulls,
gitHubContributors, gitHubContributors,
gitHubStargazers, gitHubStargazers,
newUsers30d,
slackCommunityUsers, slackCommunityUsers,
uptime uptime
] = await Promise.all([
this.countActiveUsers(1),
this.countActiveUsers(30),
this.countNewUsers(30),
this.propertyService.getByKey<string>(PROPERTY_DOCKER_HUB_PULLS),
this.propertyService.getByKey<string>(PROPERTY_GITHUB_CONTRIBUTORS),
this.propertyService.getByKey<string>(PROPERTY_GITHUB_STARGAZERS),
this.propertyService.getByKey<string>(PROPERTY_SLACK_COMMUNITY_USERS),
this.propertyService.getByKey<string>(PROPERTY_UPTIME)
]);
statistics = {
activeUsers1d,
activeUsers30d,
newUsers30d,
dockerHubPulls: dockerHubPulls
? Number.parseInt(dockerHubPulls, 10)
: undefined,
gitHubContributors: gitHubContributors
? Number.parseInt(gitHubContributors, 10)
: undefined,
gitHubStargazers: gitHubStargazers
? Number.parseInt(gitHubStargazers, 10)
: undefined,
slackCommunityUsers: slackCommunityUsers
? Number.parseInt(slackCommunityUsers, 10)
: undefined,
uptime: uptime ? Number.parseFloat(uptime) : undefined
}; };
await this.redisCacheService.set( if (
InfoService.CACHE_KEY_STATISTICS, Object.values(statistics).every((value) => {
JSON.stringify(statistics) return !isNil(value);
); })
) {
await this.redisCacheService.set(
InfoService.CACHE_KEY_STATISTICS,
JSON.stringify(statistics)
);
}
return statistics; return statistics;
} }
private async getUptime(): Promise<number> {
{
try {
const monitorId = await this.propertyService.getByKey<string>(
PROPERTY_BETTER_UPTIME_MONITOR_ID
);
const { data } = await fetch(
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
subDays(new Date(), 90),
DATE_FORMAT
)}&to${format(new Date(), DATE_FORMAT)}`,
{
headers: {
[HEADER_KEY_TOKEN]: `Bearer ${this.configurationService.get(
'API_KEY_BETTER_UPTIME'
)}`
},
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}
).then((res) => res.json());
return data.attributes.availability / 100;
} catch (error) {
Logger.error(error, 'InfoService - Better Stack');
return undefined;
}
}
}
} }

85
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 { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { AssetSubClass } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { plainToClass } from 'class-transformer'; import { plainToClass } from 'class-transformer';
import { import {
@ -52,6 +53,7 @@ import {
isBefore, isBefore,
isWithinInterval, isWithinInterval,
min, min,
startOfDay,
startOfYear, startOfYear,
subDays subDays
} from 'date-fns'; } from 'date-fns';
@ -119,6 +121,7 @@ export abstract class PortfolioCalculator {
({ ({
date, date,
feeInAssetProfileCurrency, feeInAssetProfileCurrency,
feeInBaseCurrency,
quantity, quantity,
SymbolProfile, SymbolProfile,
tags = [], tags = [],
@ -141,6 +144,7 @@ export abstract class PortfolioCalculator {
type, type,
date: format(date, DATE_FORMAT), date: format(date, DATE_FORMAT),
fee: new Big(feeInAssetProfileCurrency), fee: new Big(feeInAssetProfileCurrency),
feeInBaseCurrency: new Big(feeInBaseCurrency),
quantity: new Big(quantity), quantity: new Big(quantity),
unitPrice: new Big(unitPriceInAssetProfileCurrency) unitPrice: new Big(unitPriceInAssetProfileCurrency)
}; };
@ -154,13 +158,13 @@ export abstract class PortfolioCalculator {
this.redisCacheService = redisCacheService; this.redisCacheService = redisCacheService;
this.userId = userId; this.userId = userId;
const { endDate, startDate } = getIntervalFromDateRange( const { endDate, startDate } = getIntervalFromDateRange({
'max', dateRange: 'max',
subDays(dateOfFirstActivity, 1) startDate: subDays(dateOfFirstActivity, 1)
); });
this.endDate = endDate; this.endDate = endOfDay(endDate);
this.startDate = startDate; this.startDate = startOfDay(startDate);
this.computeTransactionPoints(); this.computeTransactionPoints();
@ -233,7 +237,7 @@ export abstract class PortfolioCalculator {
const exchangeRatesByCurrency = const exchangeRatesByCurrency =
await this.exchangeRateDataService.getExchangeRatesByCurrency({ await this.exchangeRateDataService.getExchangeRatesByCurrency({
currencies: Array.from(new Set(Object.values(currencies))), currencies: Array.from(new Set(Object.values(currencies))),
endDate: endOfDay(this.endDate), endDate: this.endDate,
startDate: this.startDate, startDate: this.startDate,
targetCurrency: this.currency targetCurrency: this.currency
}); });
@ -335,12 +339,6 @@ export abstract class PortfolioCalculator {
} = {}; } = {};
for (const item of lastTransactionPoint.items) { for (const item of lastTransactionPoint.items) {
const feeInBaseCurrency = item.fee.mul(
exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[
lastTransactionPoint.date
] ?? 1
);
const marketPriceInBaseCurrency = ( const marketPriceInBaseCurrency = (
marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice
).mul( ).mul(
@ -389,29 +387,36 @@ export abstract class PortfolioCalculator {
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors; hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
valuesBySymbol[item.symbol] = { const includeInTotalAssetValue =
currentValues, item.assetSubClass !== AssetSubClass.CASH;
currentValuesWithCurrencyEffect,
investmentValuesAccumulated, if (includeInTotalAssetValue) {
investmentValuesAccumulatedWithCurrencyEffect, valuesBySymbol[item.symbol] = {
investmentValuesWithCurrencyEffect, currentValues,
netPerformanceValues, currentValuesWithCurrencyEffect,
netPerformanceValuesWithCurrencyEffect, investmentValuesAccumulated,
timeWeightedInvestmentValues, investmentValuesAccumulatedWithCurrencyEffect,
timeWeightedInvestmentValuesWithCurrencyEffect investmentValuesWithCurrencyEffect,
}; netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect
};
}
positions.push({ positions.push({
feeInBaseCurrency, includeInTotalAssetValue,
timeWeightedInvestment, timeWeightedInvestment,
timeWeightedInvestmentWithCurrencyEffect, timeWeightedInvestmentWithCurrencyEffect,
dividend: totalDividend, activitiesCount: item.activitiesCount,
dividendInBaseCurrency: totalDividendInBaseCurrency,
averagePrice: item.averagePrice, averagePrice: item.averagePrice,
currency: item.currency, currency: item.currency,
dataSource: item.dataSource, dataSource: item.dataSource,
dateOfFirstActivity: item.dateOfFirstActivity,
dividend: totalDividend,
dividendInBaseCurrency: totalDividendInBaseCurrency,
fee: item.fee, fee: item.fee,
firstBuyDate: item.firstBuyDate, feeInBaseCurrency: item.feeInBaseCurrency,
grossPerformance: !hasErrors ? (grossPerformance ?? null) : null, grossPerformance: !hasErrors ? (grossPerformance ?? null) : null,
grossPerformancePercentage: !hasErrors grossPerformancePercentage: !hasErrors
? (grossPerformancePercentage ?? null) ? (grossPerformancePercentage ?? null)
@ -426,9 +431,8 @@ export abstract class PortfolioCalculator {
investment: totalInvestment, investment: totalInvestment,
investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect, investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect,
marketPrice: marketPrice:
marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? null, marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? 1,
marketPriceInBaseCurrency: marketPriceInBaseCurrency: marketPriceInBaseCurrency?.toNumber() ?? 1,
marketPriceInBaseCurrency?.toNumber() ?? null,
netPerformance: !hasErrors ? (netPerformance ?? null) : null, netPerformance: !hasErrors ? (netPerformance ?? null) : null,
netPerformancePercentage: !hasErrors netPerformancePercentage: !hasErrors
? (netPerformancePercentage ?? null) ? (netPerformancePercentage ?? null)
@ -442,7 +446,6 @@ export abstract class PortfolioCalculator {
quantity: item.quantity, quantity: item.quantity,
symbol: item.symbol, symbol: item.symbol,
tags: item.tags, tags: item.tags,
transactionCount: item.transactionCount,
valueInBaseCurrency: new Big(marketPriceInBaseCurrency).mul( valueInBaseCurrency: new Big(marketPriceInBaseCurrency).mul(
item.quantity item.quantity
) )
@ -882,7 +885,7 @@ export abstract class PortfolioCalculator {
// Make sure some key dates are present // Make sure some key dates are present
for (const dateRange of ['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd']) { for (const dateRange of ['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd']) {
const { endDate: dateRangeEnd, startDate: dateRangeStart } = const { endDate: dateRangeEnd, startDate: dateRangeStart } =
getIntervalFromDateRange(dateRange); getIntervalFromDateRange({ dateRange });
if ( if (
!isBefore(dateRangeStart, startDate) && !isBefore(dateRangeStart, startDate) &&
@ -931,6 +934,7 @@ export abstract class PortfolioCalculator {
for (const { for (const {
date, date,
fee, fee,
feeInBaseCurrency,
quantity, quantity,
SymbolProfile, SymbolProfile,
tags, tags,
@ -990,16 +994,18 @@ export abstract class PortfolioCalculator {
investment, investment,
skipErrors, skipErrors,
symbol, symbol,
activitiesCount: oldAccumulatedSymbol.activitiesCount + 1,
averagePrice: newQuantity.eq(0) averagePrice: newQuantity.eq(0)
? new Big(0) ? new Big(0)
: investment.div(newQuantity).abs(), : investment.div(newQuantity).abs(),
dateOfFirstActivity: oldAccumulatedSymbol.dateOfFirstActivity,
dividend: new Big(0), dividend: new Big(0),
fee: oldAccumulatedSymbol.fee.plus(fee), fee: oldAccumulatedSymbol.fee.plus(fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate, feeInBaseCurrency:
oldAccumulatedSymbol.feeInBaseCurrency.plus(feeInBaseCurrency),
includeInHoldings: oldAccumulatedSymbol.includeInHoldings, includeInHoldings: oldAccumulatedSymbol.includeInHoldings,
quantity: newQuantity, quantity: newQuantity,
tags: oldAccumulatedSymbol.tags.concat(tags), tags: oldAccumulatedSymbol.tags.concat(tags)
transactionCount: oldAccumulatedSymbol.transactionCount + 1
}; };
} else { } else {
currentTransactionPointItem = { currentTransactionPointItem = {
@ -1007,16 +1013,17 @@ export abstract class PortfolioCalculator {
currency, currency,
dataSource, dataSource,
fee, fee,
feeInBaseCurrency,
skipErrors, skipErrors,
symbol, symbol,
tags, tags,
activitiesCount: 1,
averagePrice: unitPrice, averagePrice: unitPrice,
dateOfFirstActivity: date,
dividend: new Big(0), dividend: new Big(0),
firstBuyDate: date,
includeInHoldings: INVESTMENT_ACTIVITY_TYPES.includes(type), includeInHoldings: INVESTMENT_ACTIVITY_TYPES.includes(type),
investment: unitPrice.mul(quantity).mul(factor), investment: unitPrice.mul(quantity).mul(factor),
quantity: quantity.mul(factor), quantity: quantity.mul(factor)
transactionCount: 1
}; };
} }

16
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts

@ -87,6 +87,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-22'), date: new Date('2021-11-22'),
feeInAssetProfileCurrency: 1.55, feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2, quantity: 2,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -102,6 +103,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-30'), date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.65, feeInAssetProfileCurrency: 1.65,
feeInBaseCurrency: 1.65,
quantity: 2, quantity: 2,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -131,20 +133,26 @@ describe('PortfolioCalculator', () => {
groupBy: 'month' groupBy: 'month'
}); });
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot).toMatchObject({ expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('595.6'), currentValueInBaseCurrency: new Big('595.6'),
errors: [], errors: [],
hasErrors: false, hasErrors: false,
positions: [ positions: [
{ {
activitiesCount: 2,
averagePrice: new Big('139.75'), averagePrice: new Big('139.75'),
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
dateOfFirstActivity: '2021-11-22',
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'), fee: new Big('3.2'),
feeInBaseCurrency: new Big('3.2'), feeInBaseCurrency: new Big('3.2'),
firstBuyDate: '2021-11-22',
grossPerformance: new Big('36.6'), grossPerformance: new Big('36.6'),
grossPerformancePercentage: new Big('0.07706261539956593567'), grossPerformancePercentage: new Big('0.07706261539956593567'),
grossPerformancePercentageWithCurrencyEffect: new Big( grossPerformancePercentageWithCurrencyEffect: new Big(
@ -170,7 +178,6 @@ describe('PortfolioCalculator', () => {
timeWeightedInvestmentWithCurrencyEffect: new Big( timeWeightedInvestmentWithCurrencyEffect: new Big(
'474.93846153846153846154' '474.93846153846153846154'
), ),
transactionCount: 2,
valueInBaseCurrency: new Big('595.6') valueInBaseCurrency: new Big('595.6')
} }
], ],
@ -187,6 +194,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 0.07032490039195362, netPerformanceInPercentage: 0.07032490039195362,
netPerformanceInPercentageWithCurrencyEffect: 0.07032490039195362, netPerformanceInPercentageWithCurrencyEffect: 0.07032490039195362,
netPerformanceWithCurrencyEffect: 33.4, netPerformanceWithCurrencyEffect: 33.4,
totalInvestment: 559,
totalInvestmentValueWithCurrencyEffect: 559 totalInvestmentValueWithCurrencyEffect: 559
}) })
); );
@ -200,6 +208,10 @@ describe('PortfolioCalculator', () => {
{ date: '2021-11-01', investment: 559 }, { date: '2021-11-01', investment: 559 },
{ date: '2021-12-01', investment: 0 } { date: '2021-12-01', investment: 0 }
]); ]);
expect(investmentsByYear).toEqual([
{ date: '2021-01-01', investment: 559 }
]);
}); });
}); });
}); });

17
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, ...activityDummyData,
date: new Date('2021-11-22'), date: new Date('2021-11-22'),
feeInAssetProfileCurrency: 1.55, feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2, quantity: 2,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -102,6 +103,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-30'), date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.65, feeInAssetProfileCurrency: 1.65,
feeInBaseCurrency: 1.65,
quantity: 1, quantity: 1,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -117,6 +119,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-30'), date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 0, feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1, quantity: 1,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -146,20 +149,26 @@ describe('PortfolioCalculator', () => {
groupBy: 'month' groupBy: 'month'
}); });
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot).toMatchObject({ expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'), currentValueInBaseCurrency: new Big('0'),
errors: [], errors: [],
hasErrors: false, hasErrors: false,
positions: [ positions: [
{ {
activitiesCount: 3,
averagePrice: new Big('0'), averagePrice: new Big('0'),
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
dateOfFirstActivity: '2021-11-22',
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'), fee: new Big('3.2'),
feeInBaseCurrency: new Big('3.2'), feeInBaseCurrency: new Big('3.2'),
firstBuyDate: '2021-11-22',
grossPerformance: new Big('-12.6'), grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.04408677396780965649'), grossPerformancePercentage: new Big('-0.04408677396780965649'),
grossPerformancePercentageWithCurrencyEffect: new Big( grossPerformancePercentageWithCurrencyEffect: new Big(
@ -183,7 +192,6 @@ describe('PortfolioCalculator', () => {
timeWeightedInvestmentWithCurrencyEffect: new Big( timeWeightedInvestmentWithCurrencyEffect: new Big(
'285.80000000000000396627' '285.80000000000000396627'
), ),
transactionCount: 3,
valueInBaseCurrency: new Big('0') valueInBaseCurrency: new Big('0')
} }
], ],
@ -200,6 +208,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: -0.05528341497550734703, netPerformanceInPercentage: -0.05528341497550734703,
netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703, netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703,
netPerformanceWithCurrencyEffect: -15.8, netPerformanceWithCurrencyEffect: -15.8,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0 totalInvestmentValueWithCurrencyEffect: 0
}) })
); );
@ -213,6 +222,10 @@ describe('PortfolioCalculator', () => {
{ date: '2021-11-01', investment: 0 }, { date: '2021-11-01', investment: 0 },
{ date: '2021-12-01', investment: 0 } { date: '2021-12-01', investment: 0 }
]); ]);
expect(investmentsByYear).toEqual([
{ date: '2021-01-01', investment: 0 }
]);
}); });
}); });
}); });

16
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts

@ -87,6 +87,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-22'), date: new Date('2021-11-22'),
feeInAssetProfileCurrency: 1.55, feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2, quantity: 2,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -102,6 +103,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-30'), date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.65, feeInAssetProfileCurrency: 1.65,
feeInBaseCurrency: 1.65,
quantity: 2, quantity: 2,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -131,20 +133,26 @@ describe('PortfolioCalculator', () => {
groupBy: 'month' groupBy: 'month'
}); });
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot).toMatchObject({ expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'), currentValueInBaseCurrency: new Big('0'),
errors: [], errors: [],
hasErrors: false, hasErrors: false,
positions: [ positions: [
{ {
activitiesCount: 2,
averagePrice: new Big('0'), averagePrice: new Big('0'),
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
dateOfFirstActivity: '2021-11-22',
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'), fee: new Big('3.2'),
feeInBaseCurrency: new Big('3.2'), feeInBaseCurrency: new Big('3.2'),
firstBuyDate: '2021-11-22',
grossPerformance: new Big('-12.6'), grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.0440867739678096571'), grossPerformancePercentage: new Big('-0.0440867739678096571'),
grossPerformancePercentageWithCurrencyEffect: new Big( grossPerformancePercentageWithCurrencyEffect: new Big(
@ -168,7 +176,6 @@ describe('PortfolioCalculator', () => {
tags: [], tags: [],
timeWeightedInvestment: new Big('285.8'), timeWeightedInvestment: new Big('285.8'),
timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'), timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'),
transactionCount: 2,
valueInBaseCurrency: new Big('0') valueInBaseCurrency: new Big('0')
} }
], ],
@ -185,6 +192,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: -0.05528341497550734703, netPerformanceInPercentage: -0.05528341497550734703,
netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703, netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703,
netPerformanceWithCurrencyEffect: -15.8, netPerformanceWithCurrencyEffect: -15.8,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0 totalInvestmentValueWithCurrencyEffect: 0
}) })
); );
@ -198,6 +206,10 @@ describe('PortfolioCalculator', () => {
{ date: '2021-11-01', investment: 0 }, { date: '2021-11-01', investment: 0 },
{ date: '2021-12-01', investment: 0 } { date: '2021-12-01', investment: 0 }
]); ]);
expect(investmentsByYear).toEqual([
{ date: '2021-01-01', investment: 0 }
]);
}); });
}); });
}); });

17
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts

@ -87,6 +87,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-30'), date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.55, feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2, quantity: 2,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -122,20 +123,26 @@ describe('PortfolioCalculator', () => {
groupBy: 'month' groupBy: 'month'
}); });
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot).toMatchObject({ expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('297.8'), currentValueInBaseCurrency: new Big('297.8'),
errors: [], errors: [],
hasErrors: false, hasErrors: false,
positions: [ positions: [
{ {
activitiesCount: 1,
averagePrice: new Big('136.6'), averagePrice: new Big('136.6'),
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
dateOfFirstActivity: '2021-11-30',
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('1.55'), fee: new Big('1.55'),
feeInBaseCurrency: new Big('1.55'), feeInBaseCurrency: new Big('1.55'),
firstBuyDate: '2021-11-30',
grossPerformance: new Big('24.6'), grossPerformance: new Big('24.6'),
grossPerformancePercentage: new Big('0.09004392386530014641'), grossPerformancePercentage: new Big('0.09004392386530014641'),
grossPerformancePercentageWithCurrencyEffect: new Big( grossPerformancePercentageWithCurrencyEffect: new Big(
@ -165,7 +172,6 @@ describe('PortfolioCalculator', () => {
tags: [], tags: [],
timeWeightedInvestment: new Big('273.2'), timeWeightedInvestment: new Big('273.2'),
timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'), timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'),
transactionCount: 1,
valueInBaseCurrency: new Big('297.8') valueInBaseCurrency: new Big('297.8')
} }
], ],
@ -186,6 +192,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 0.08437042459736457, netPerformanceInPercentage: 0.08437042459736457,
netPerformanceInPercentageWithCurrencyEffect: 0.08437042459736457, netPerformanceInPercentageWithCurrencyEffect: 0.08437042459736457,
netPerformanceWithCurrencyEffect: 23.05, netPerformanceWithCurrencyEffect: 23.05,
totalInvestment: 273.2,
totalInvestmentValueWithCurrencyEffect: 273.2 totalInvestmentValueWithCurrencyEffect: 273.2
}) })
); );
@ -198,6 +205,10 @@ describe('PortfolioCalculator', () => {
{ date: '2021-11-01', investment: 273.2 }, { date: '2021-11-01', investment: 273.2 },
{ date: '2021-12-01', investment: 0 } { 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 () => { it.only('with BALN.SW buy (with unit price lower than closing price)', async () => {
@ -208,6 +219,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-30'), date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.55, feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2, quantity: 2,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -247,6 +259,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-30'), date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.55, feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2, quantity: 2,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts

@ -110,6 +110,7 @@ describe('PortfolioCalculator', () => {
...activity, ...activity,
date: parseDate(activity.date), date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46, feeInAssetProfileCurrency: 4.46,
feeInBaseCurrency: 3.94,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
currency: 'USD', currency: 'USD',

15
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts

@ -98,6 +98,7 @@ describe('PortfolioCalculator', () => {
...activity, ...activity,
date: parseDate(activity.date), date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46, feeInAssetProfileCurrency: 4.46,
feeInBaseCurrency: 4.46,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
@ -131,6 +132,11 @@ describe('PortfolioCalculator', () => {
groupBy: 'month' groupBy: 'month'
}); });
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot.historicalData[0]).toEqual({ expect(portfolioSnapshot.historicalData[0]).toEqual({
date: '2021-12-11', date: '2021-12-11',
investmentValueWithCurrencyEffect: 0, investmentValueWithCurrencyEffect: 0,
@ -189,14 +195,15 @@ describe('PortfolioCalculator', () => {
hasErrors: false, hasErrors: false,
positions: [ positions: [
{ {
activitiesCount: 1,
averagePrice: new Big('44558.42'), averagePrice: new Big('44558.42'),
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
dateOfFirstActivity: '2021-12-12',
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('4.46'), fee: new Big('4.46'),
feeInBaseCurrency: new Big('4.46'), feeInBaseCurrency: new Big('4.46'),
firstBuyDate: '2021-12-12',
grossPerformance: new Big('-1458.72'), grossPerformance: new Big('-1458.72'),
grossPerformancePercentage: new Big('-0.03273724696701543726'), grossPerformancePercentage: new Big('-0.03273724696701543726'),
grossPerformancePercentageWithCurrencyEffect: new Big( grossPerformancePercentageWithCurrencyEffect: new Big(
@ -220,7 +227,6 @@ describe('PortfolioCalculator', () => {
tags: [], tags: [],
timeWeightedInvestment: new Big('44558.42'), timeWeightedInvestment: new Big('44558.42'),
timeWeightedInvestmentWithCurrencyEffect: new Big('44558.42'), timeWeightedInvestmentWithCurrencyEffect: new Big('44558.42'),
transactionCount: 1,
valueInBaseCurrency: new Big('43099.7') valueInBaseCurrency: new Big('43099.7')
} }
], ],
@ -244,6 +250,11 @@ describe('PortfolioCalculator', () => {
{ date: '2021-12-01', investment: 44558.42 }, { date: '2021-12-01', investment: 44558.42 },
{ date: '2022-01-01', investment: 0 } { date: '2022-01-01', investment: 0 }
]); ]);
expect(investmentsByYear).toEqual([
{ date: '2021-01-01', investment: 44558.42 },
{ date: '2022-01-01', investment: 0 }
]);
}); });
}); });
}); });

19
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts

@ -100,6 +100,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData, ...activityDummyData,
date: new Date('2015-01-01'), date: new Date('2015-01-01'),
feeInAssetProfileCurrency: 0, feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 2, quantity: 2,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -115,6 +116,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData, ...activityDummyData,
date: new Date('2017-12-31'), date: new Date('2017-12-31'),
feeInAssetProfileCurrency: 0, feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1, quantity: 1,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -144,6 +146,11 @@ describe('PortfolioCalculator', () => {
groupBy: 'month' groupBy: 'month'
}); });
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot).toMatchObject({ expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('13298.425356'), currentValueInBaseCurrency: new Big('13298.425356'),
errors: [], errors: [],
@ -151,14 +158,15 @@ describe('PortfolioCalculator', () => {
hasErrors: false, hasErrors: false,
positions: [ positions: [
{ {
activitiesCount: 2,
averagePrice: new Big('320.43'), averagePrice: new Big('320.43'),
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
dateOfFirstActivity: '2015-01-01',
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('0'), fee: new Big('0'),
feeInBaseCurrency: new Big('0'), feeInBaseCurrency: new Big('0'),
firstBuyDate: '2015-01-01',
grossPerformance: new Big('27172.74').mul(0.97373), grossPerformance: new Big('27172.74').mul(0.97373),
grossPerformancePercentage: new Big('0.4241983590271396608571'), grossPerformancePercentage: new Big('0.4241983590271396608571'),
grossPerformancePercentageWithCurrencyEffect: new Big( grossPerformancePercentageWithCurrencyEffect: new Big(
@ -186,7 +194,6 @@ describe('PortfolioCalculator', () => {
timeWeightedInvestmentWithCurrencyEffect: new Big( timeWeightedInvestmentWithCurrencyEffect: new Big(
'636.79389574611155533947' '636.79389574611155533947'
), ),
transactionCount: 2,
valueInBaseCurrency: new Big('13298.425356') valueInBaseCurrency: new Big('13298.425356')
} }
], ],
@ -203,6 +210,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 42.41983590271396609433, netPerformanceInPercentage: 42.41983590271396609433,
netPerformanceInPercentageWithCurrencyEffect: 41.64017412624815597854, netPerformanceInPercentageWithCurrencyEffect: 41.64017412624815597854,
netPerformanceWithCurrencyEffect: 26516.208701400000064086, netPerformanceWithCurrencyEffect: 26516.208701400000064086,
totalInvestment: 318.542667299999967957,
totalInvestmentValueWithCurrencyEffect: 318.542667299999967957 totalInvestmentValueWithCurrencyEffect: 318.542667299999967957
}) })
); );
@ -251,6 +259,13 @@ describe('PortfolioCalculator', () => {
{ date: '2017-12-01', investment: -318.54266729999995 }, { date: '2017-12-01', investment: -318.54266729999995 },
{ date: '2018-01-01', investment: 0 } { 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 }
]);
}); });
}); });
}); });

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts

@ -98,6 +98,7 @@ describe('PortfolioCalculator', () => {
...activity, ...activity,
date: parseDate(activity.date), date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee, feeInAssetProfileCurrency: activity.fee,
feeInBaseCurrency: activity.fee,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
currency: 'USD', currency: 'USD',

15
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts

@ -98,6 +98,7 @@ describe('PortfolioCalculator', () => {
...activity, ...activity,
date: parseDate(activity.date), date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46, feeInAssetProfileCurrency: 4.46,
feeInBaseCurrency: 4.46,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
@ -131,6 +132,11 @@ describe('PortfolioCalculator', () => {
groupBy: 'month' groupBy: 'month'
}); });
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot.historicalData[0]).toEqual({ expect(portfolioSnapshot.historicalData[0]).toEqual({
date: '2021-12-11', date: '2021-12-11',
investmentValueWithCurrencyEffect: 0, investmentValueWithCurrencyEffect: 0,
@ -189,14 +195,15 @@ describe('PortfolioCalculator', () => {
hasErrors: false, hasErrors: false,
positions: [ positions: [
{ {
activitiesCount: 1,
averagePrice: new Big('44558.42'), averagePrice: new Big('44558.42'),
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
dateOfFirstActivity: '2021-12-12',
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('4.46'), fee: new Big('4.46'),
feeInBaseCurrency: new Big('4.46'), feeInBaseCurrency: new Big('4.46'),
firstBuyDate: '2021-12-12',
grossPerformance: new Big('-1458.72'), grossPerformance: new Big('-1458.72'),
grossPerformancePercentage: new Big('-0.03273724696701543726'), grossPerformancePercentage: new Big('-0.03273724696701543726'),
grossPerformancePercentageWithCurrencyEffect: new Big( grossPerformancePercentageWithCurrencyEffect: new Big(
@ -220,7 +227,6 @@ describe('PortfolioCalculator', () => {
tags: [], tags: [],
timeWeightedInvestment: new Big('44558.42'), timeWeightedInvestment: new Big('44558.42'),
timeWeightedInvestmentWithCurrencyEffect: new Big('44558.42'), timeWeightedInvestmentWithCurrencyEffect: new Big('44558.42'),
transactionCount: 1,
valueInBaseCurrency: new Big('43099.7') valueInBaseCurrency: new Big('43099.7')
} }
], ],
@ -244,6 +250,11 @@ describe('PortfolioCalculator', () => {
{ date: '2021-12-01', investment: 44558.42 }, { date: '2021-12-01', investment: 44558.42 },
{ date: '2022-01-01', investment: 0 } { date: '2022-01-01', investment: 0 }
]); ]);
expect(investmentsByYear).toEqual([
{ date: '2021-01-01', investment: 44558.42 },
{ date: '2022-01-01', investment: 0 }
]);
}); });
}); });
}); });

161
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts

@ -1,6 +1,6 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service'; 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 { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
@ -14,10 +14,11 @@ import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-r
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.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 { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces'; import { TimelinePosition } from '@ghostfolio/common/models';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { Big } from 'big.js';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
@ -61,11 +62,11 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let accountBalanceService: AccountBalanceService; let accountBalanceService: AccountBalanceService;
let accountService: AccountService; let accountService: AccountService;
let activitiesService: ActivitiesService;
let configurationService: ConfigurationService; let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let dataProviderService: DataProviderService; let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let orderService: OrderService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory; let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService; let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
@ -105,13 +106,13 @@ describe('PortfolioCalculator', () => {
); );
currentRateService = new CurrentRateService( currentRateService = new CurrentRateService(
dataProviderService,
null, null,
dataProviderService,
null, null,
null null
); );
orderService = new OrderService( activitiesService = new ActivitiesService(
accountBalanceService, accountBalanceService,
accountService, accountService,
null, null,
@ -182,17 +183,17 @@ describe('PortfolioCalculator', () => {
.spyOn(dataProviderService, 'getDataSourceForExchangeRates') .spyOn(dataProviderService, 'getDataSourceForExchangeRates')
.mockReturnValue(DataSource.YAHOO); .mockReturnValue(DataSource.YAHOO);
jest.spyOn(orderService, 'getOrders').mockResolvedValue({ jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({
activities: [], activities: [],
count: 0 count: 0
}); });
const { activities } = await orderService.getOrdersForPortfolioCalculator( const { activities } =
{ await activitiesService.getActivitiesForPortfolioCalculator({
userCurrency: 'CHF', userCurrency: 'CHF',
userId: userDummyData.id userId: userDummyData.id,
} withCash: true
); });
jest.spyOn(currentRateService, 'getValues').mockResolvedValue({ jest.spyOn(currentRateService, 'getValues').mockResolvedValue({
dataProviderInfos: [], dataProviderInfos: [],
@ -200,90 +201,88 @@ describe('PortfolioCalculator', () => {
values: [] values: []
}); });
const accountBalanceItems =
await accountBalanceService.getAccountBalanceItems({
userCurrency: 'CHF',
userId: userDummyData.id
});
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
accountBalanceItems,
activities, activities,
calculationType: PerformanceCalculationType.ROAI, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: 'CHF',
userId: userDummyData.id userId: userDummyData.id
}); });
const { historicalData } = await portfolioCalculator.computeSnapshot(); const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const historicalData20231231 = historicalData.find(({ date }) => {
return date === '2023-12-31';
});
const historicalData20240101 = historicalData.find(({ date }) => {
return date === '2024-01-01';
});
const historicalData20241231 = historicalData.find(({ date }) => {
return date === '2024-12-31';
});
/** const position = portfolioSnapshot.positions.find(({ symbol }) => {
* Investment value with currency effect: 1000 USD * 0.85 = 850 CHF return symbol === 'USD';
* Total investment: 1000 USD * 0.91 = 910 CHF
* Value (current): 1000 USD * 0.91 = 910 CHF
* Value with currency effect: 1000 USD * 0.85 = 850 CHF
*/
expect(historicalData20231231).toMatchObject({
date: '2023-12-31',
investmentValueWithCurrencyEffect: 850,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 850,
totalAccountBalance: 0,
totalInvestment: 910,
totalInvestmentValueWithCurrencyEffect: 850,
value: 910,
valueWithCurrencyEffect: 850
}); });
/** /**
* Net performance with currency effect: (1000 * 0.86) - (1000 * 0.85) = 10 CHF * Investment: 2000 USD * 0.91 = 1820 CHF
* Total investment: 1000 USD * 0.91 = 910 CHF * Investment value with currency effect: (1000 USD * 0.85) + (1000 USD * 0.90) = 1750 CHF
* Total investment value with currency effect: 1000 USD * 0.85 = 850 CHF * Net performance: (1000 USD * 1.0) - (1000 USD * 1.0) = 0 CHF
* Value (current): 1000 USD * 0.91 = 910 CHF * Total account balance: 2000 USD * 0.85 = 1700 CHF (using the exchange rate on 2024-12-31)
* Value with currency effect: 1000 USD * 0.86 = 860 CHF * Value in base currency: 2000 USD * 0.91 = 1820 CHF
*/ */
expect(historicalData20240101).toMatchObject({ expect(position).toMatchObject<TimelinePosition>({
date: '2024-01-01', activitiesCount: 2,
investmentValueWithCurrencyEffect: 0, averagePrice: new Big(1),
netPerformance: 0, currency: 'USD',
netPerformanceInPercentage: 0, dataSource: DataSource.YAHOO,
netPerformanceInPercentageWithCurrencyEffect: 0.011764705882352941, dateOfFirstActivity: '2023-12-31',
netPerformanceWithCurrencyEffect: 10, dividend: new Big(0),
netWorth: 860, dividendInBaseCurrency: new Big(0),
totalAccountBalance: 0, fee: new Big(0),
totalInvestment: 910, feeInBaseCurrency: new Big(0),
totalInvestmentValueWithCurrencyEffect: 850, grossPerformance: new Big(0),
value: 910, grossPerformancePercentage: new Big(0),
valueWithCurrencyEffect: 860 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({
* Investment value with currency effect: 1000 USD * 0.90 = 900 CHF hasErrors: false,
* Net performance: (1000 USD * 1.0) - (1000 USD * 1.0) = 0 CHF totalFeesWithCurrencyEffect: new Big(0),
* Net performance with currency effect: (1000 USD * 0.9) - (1000 USD * 0.85) = 50 CHF totalInterestWithCurrencyEffect: new Big(0),
* Total investment: 2000 USD * 0.91 = 1820 CHF totalLiabilitiesWithCurrencyEffect: new Big(0)
* Total investment value with currency effect: (1000 USD * 0.85) + (1000 USD * 0.90) = 1750 CHF
* Value (current): 2000 USD * 0.91 = 1820 CHF
* Value with currency effect: 2000 USD * 0.9 = 1800 CHF
*/
expect(historicalData20241231).toMatchObject<HistoricalDataItem>({
date: '2024-12-31',
investmentValueWithCurrencyEffect: 900,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0.058823529411764705,
netPerformanceWithCurrencyEffect: 50,
netWorth: 1800,
totalAccountBalance: 0,
totalInvestment: 1820,
totalInvestmentValueWithCurrencyEffect: 1750,
value: 1820,
valueWithCurrencyEffect: 1800
}); });
}); });
}); });

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts

@ -87,6 +87,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData, ...activityDummyData,
date: new Date('2021-09-01'), date: new Date('2021-09-01'),
feeInAssetProfileCurrency: 49, feeInAssetProfileCurrency: 49,
feeInBaseCurrency: 49,
quantity: 0, quantity: 0,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -127,6 +128,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 0, netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0, netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0 totalInvestmentValueWithCurrencyEffect: 0
}) })
); );

15
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts

@ -99,6 +99,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData, ...activityDummyData,
date: new Date('2023-01-03'), date: new Date('2023-01-03'),
feeInAssetProfileCurrency: 1, feeInAssetProfileCurrency: 1,
feeInBaseCurrency: 0.9238,
quantity: 1, quantity: 1,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -128,20 +129,26 @@ describe('PortfolioCalculator', () => {
groupBy: 'month' groupBy: 'month'
}); });
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot).toMatchObject({ expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('103.10483'), currentValueInBaseCurrency: new Big('103.10483'),
errors: [], errors: [],
hasErrors: false, hasErrors: false,
positions: [ positions: [
{ {
activitiesCount: 1,
averagePrice: new Big('89.12'), averagePrice: new Big('89.12'),
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
dateOfFirstActivity: '2023-01-03',
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('1'), fee: new Big('1'),
feeInBaseCurrency: new Big('0.9238'), feeInBaseCurrency: new Big('0.9238'),
firstBuyDate: '2023-01-03',
grossPerformance: new Big('27.33').mul(0.8854), grossPerformance: new Big('27.33').mul(0.8854),
grossPerformancePercentage: new Big('0.3066651705565529623'), grossPerformancePercentage: new Big('0.3066651705565529623'),
grossPerformancePercentageWithCurrencyEffect: new Big( grossPerformancePercentageWithCurrencyEffect: new Big(
@ -165,7 +172,6 @@ describe('PortfolioCalculator', () => {
tags: [], tags: [],
timeWeightedInvestment: new Big('89.12').mul(0.8854), timeWeightedInvestment: new Big('89.12').mul(0.8854),
timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'), timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'),
transactionCount: 1,
valueInBaseCurrency: new Big('103.10483') valueInBaseCurrency: new Big('103.10483')
} }
], ],
@ -182,6 +188,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 0.29544434470377019749, netPerformanceInPercentage: 0.29544434470377019749,
netPerformanceInPercentageWithCurrencyEffect: 0.24112962014285697628, netPerformanceInPercentageWithCurrencyEffect: 0.24112962014285697628,
netPerformanceWithCurrencyEffect: 19.851974, netPerformanceWithCurrencyEffect: 19.851974,
totalInvestment: new Big('89.12').mul(0.8854).toNumber(),
totalInvestmentValueWithCurrencyEffect: 82.329056 totalInvestmentValueWithCurrencyEffect: 82.329056
}) })
); );
@ -217,6 +224,10 @@ describe('PortfolioCalculator', () => {
investment: 0 investment: 0
} }
]); ]);
expect(investmentsByYear).toEqual([
{ date: '2023-01-01', investment: 82.329056 }
]);
}); });
}); });
}); });

190
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 }
]);
});
});
});

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts

@ -87,6 +87,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData, ...activityDummyData,
date: new Date('2023-01-01'), // Date in future date: new Date('2023-01-01'), // Date in future
feeInAssetProfileCurrency: 0, feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1, quantity: 1,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,

7
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts

@ -80,6 +80,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData, ...activityDummyData,
date: new Date('2024-03-08'), date: new Date('2024-03-08'),
feeInAssetProfileCurrency: 0, feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 0.3333333333333333, quantity: 0.3333333333333333,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -94,8 +95,9 @@ describe('PortfolioCalculator', () => {
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2024-03-13'), date: new Date('2024-03-13'),
quantity: 0.6666666666666666,
feeInAssetProfileCurrency: 0, feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 0.6666666666666666,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
@ -109,8 +111,9 @@ describe('PortfolioCalculator', () => {
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2024-03-14'), date: new Date('2024-03-14'),
quantity: 1,
feeInAssetProfileCurrency: 0, feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
currency: 'USD', currency: 'USD',

9
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts

@ -87,6 +87,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData, ...activityDummyData,
date: new Date('2021-09-16'), date: new Date('2021-09-16'),
feeInAssetProfileCurrency: 19, feeInAssetProfileCurrency: 19,
feeInBaseCurrency: 19,
quantity: 1, quantity: 1,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -102,6 +103,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-16'), date: new Date('2021-11-16'),
feeInAssetProfileCurrency: 0, feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1, quantity: 1,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -129,13 +131,14 @@ describe('PortfolioCalculator', () => {
hasErrors: false, hasErrors: false,
positions: [ positions: [
{ {
activitiesCount: 2,
averagePrice: new Big('298.58'), averagePrice: new Big('298.58'),
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
dateOfFirstActivity: '2021-09-16',
dividend: new Big('0.62'), dividend: new Big('0.62'),
dividendInBaseCurrency: new Big('0.62'), dividendInBaseCurrency: new Big('0.62'),
fee: new Big('19'), fee: new Big('19'),
firstBuyDate: '2021-09-16',
grossPerformance: new Big('33.25'), grossPerformance: new Big('33.25'),
grossPerformancePercentage: new Big('0.11136043941322258691'), grossPerformancePercentage: new Big('0.11136043941322258691'),
grossPerformancePercentageWithCurrencyEffect: new Big( grossPerformancePercentageWithCurrencyEffect: new Big(
@ -159,8 +162,7 @@ describe('PortfolioCalculator', () => {
}, },
quantity: new Big('1'), quantity: new Big('1'),
symbol: 'MSFT', symbol: 'MSFT',
tags: [], tags: []
transactionCount: 2
} }
], ],
totalFeesWithCurrencyEffect: new Big('19'), totalFeesWithCurrencyEffect: new Big('19'),
@ -172,6 +174,7 @@ describe('PortfolioCalculator', () => {
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({ expect.objectContaining({
totalInvestment: 298.58,
totalInvestmentValueWithCurrencyEffect: 298.58 totalInvestmentValueWithCurrencyEffect: 298.58
}) })
); );

7
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts

@ -93,6 +93,11 @@ describe('PortfolioCalculator', () => {
groupBy: 'month' groupBy: 'month'
}); });
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot).toMatchObject({ expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big(0), currentValueInBaseCurrency: new Big(0),
hasErrors: false, hasErrors: false,
@ -108,6 +113,8 @@ describe('PortfolioCalculator', () => {
expect(investments).toEqual([]); expect(investments).toEqual([]);
expect(investmentsByMonth).toEqual([]); expect(investmentsByMonth).toEqual([]);
expect(investmentsByYear).toEqual([]);
}); });
}); });
}); });

15
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts

@ -101,6 +101,7 @@ describe('PortfolioCalculator', () => {
...activity, ...activity,
date: parseDate(activity.date), date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee, feeInAssetProfileCurrency: activity.fee,
feeInBaseCurrency: activity.fee,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
currency: activity.currency, currency: activity.currency,
@ -128,20 +129,26 @@ describe('PortfolioCalculator', () => {
groupBy: 'month' groupBy: 'month'
}); });
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot).toMatchObject({ expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('87.8'), currentValueInBaseCurrency: new Big('87.8'),
errors: [], errors: [],
hasErrors: false, hasErrors: false,
positions: [ positions: [
{ {
activitiesCount: 2,
averagePrice: new Big('75.80'), averagePrice: new Big('75.80'),
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
dateOfFirstActivity: '2022-03-07',
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('4.25'), fee: new Big('4.25'),
feeInBaseCurrency: new Big('4.25'), feeInBaseCurrency: new Big('4.25'),
firstBuyDate: '2022-03-07',
grossPerformance: new Big('21.93'), grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.15113417083448194384'), grossPerformancePercentage: new Big('0.15113417083448194384'),
grossPerformancePercentageWithCurrencyEffect: new Big( grossPerformancePercentageWithCurrencyEffect: new Big(
@ -167,7 +174,6 @@ describe('PortfolioCalculator', () => {
timeWeightedInvestmentWithCurrencyEffect: new Big( timeWeightedInvestmentWithCurrencyEffect: new Big(
'145.10285714285714285714' '145.10285714285714285714'
), ),
transactionCount: 2,
valueInBaseCurrency: new Big('87.8') valueInBaseCurrency: new Big('87.8')
} }
], ],
@ -184,6 +190,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 0.12184460284330327256, netPerformanceInPercentage: 0.12184460284330327256,
netPerformanceInPercentageWithCurrencyEffect: 0.12184460284330327256, netPerformanceInPercentageWithCurrencyEffect: 0.12184460284330327256,
netPerformanceWithCurrencyEffect: 17.68, netPerformanceWithCurrencyEffect: 17.68,
totalInvestment: 75.8,
totalInvestmentValueWithCurrencyEffect: 75.8 totalInvestmentValueWithCurrencyEffect: 75.8
}) })
); );
@ -197,6 +204,10 @@ describe('PortfolioCalculator', () => {
{ date: '2022-03-01', investment: 151.6 }, { date: '2022-03-01', investment: 151.6 },
{ date: '2022-04-01', investment: -75.8 } { date: '2022-04-01', investment: -75.8 }
]); ]);
expect(investmentsByYear).toEqual([
{ date: '2022-01-01', investment: 75.8 }
]);
}); });
}); });
}); });

15
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts

@ -101,6 +101,7 @@ describe('PortfolioCalculator', () => {
...activity, ...activity,
date: parseDate(activity.date), date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee, feeInAssetProfileCurrency: activity.fee,
feeInBaseCurrency: activity.fee,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
currency: activity.currency, currency: activity.currency,
@ -128,6 +129,11 @@ describe('PortfolioCalculator', () => {
groupBy: 'month' groupBy: 'month'
}); });
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot.historicalData[0]).toEqual({ expect(portfolioSnapshot.historicalData[0]).toEqual({
date: '2022-03-06', date: '2022-03-06',
investmentValueWithCurrencyEffect: 0, investmentValueWithCurrencyEffect: 0,
@ -187,14 +193,15 @@ describe('PortfolioCalculator', () => {
hasErrors: false, hasErrors: false,
positions: [ positions: [
{ {
activitiesCount: 2,
averagePrice: new Big('0'), averagePrice: new Big('0'),
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
dateOfFirstActivity: '2022-03-07',
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('0'), fee: new Big('0'),
feeInBaseCurrency: new Big('0'), feeInBaseCurrency: new Big('0'),
firstBuyDate: '2022-03-07',
grossPerformance: new Big('19.86'), grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'), grossPerformancePercentage: new Big('0.13100263852242744063'),
grossPerformancePercentageWithCurrencyEffect: new Big( grossPerformancePercentageWithCurrencyEffect: new Big(
@ -218,7 +225,6 @@ describe('PortfolioCalculator', () => {
tags: [], tags: [],
timeWeightedInvestment: new Big('151.6'), timeWeightedInvestment: new Big('151.6'),
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
transactionCount: 2,
valueInBaseCurrency: new Big('0') valueInBaseCurrency: new Big('0')
} }
], ],
@ -235,6 +241,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 0.13100263852242744063, netPerformanceInPercentage: 0.13100263852242744063,
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063, netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063,
netPerformanceWithCurrencyEffect: 19.86, netPerformanceWithCurrencyEffect: 19.86,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0 totalInvestmentValueWithCurrencyEffect: 0
}) })
); );
@ -248,6 +255,10 @@ describe('PortfolioCalculator', () => {
{ date: '2022-03-01', investment: 151.6 }, { date: '2022-03-01', investment: 151.6 },
{ date: '2022-04-01', investment: -151.6 } { date: '2022-04-01', investment: -151.6 }
]); ]);
expect(investmentsByYear).toEqual([
{ date: '2022-01-01', investment: 0 }
]);
}); });
}); });
}); });

8
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts

@ -87,6 +87,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData, ...activityDummyData,
date: new Date('2022-01-01'), date: new Date('2022-01-01'),
feeInAssetProfileCurrency: 0, feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1, quantity: 1,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -115,21 +116,22 @@ describe('PortfolioCalculator', () => {
hasErrors: false, hasErrors: false,
positions: [ positions: [
{ {
activitiesCount: 1,
averagePrice: new Big('500000'), averagePrice: new Big('500000'),
currency: 'USD', currency: 'USD',
dataSource: 'MANUAL', dataSource: 'MANUAL',
dateOfFirstActivity: '2022-01-01',
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('0'), fee: new Big('0'),
feeInBaseCurrency: new Big('0'), feeInBaseCurrency: new Big('0'),
firstBuyDate: '2022-01-01',
grossPerformance: new Big('0'), grossPerformance: new Big('0'),
grossPerformancePercentage: new Big('0'), grossPerformancePercentage: new Big('0'),
grossPerformancePercentageWithCurrencyEffect: new Big('0'), grossPerformancePercentageWithCurrencyEffect: new Big('0'),
grossPerformanceWithCurrencyEffect: new Big('0'), grossPerformanceWithCurrencyEffect: new Big('0'),
investment: new Big('500000'), investment: new Big('500000'),
investmentWithCurrencyEffect: new Big('500000'), investmentWithCurrencyEffect: new Big('500000'),
marketPrice: null, marketPrice: 1,
marketPriceInBaseCurrency: 500000, marketPriceInBaseCurrency: 500000,
netPerformance: new Big('0'), netPerformance: new Big('0'),
netPerformancePercentage: new Big('0'), netPerformancePercentage: new Big('0'),
@ -144,7 +146,6 @@ describe('PortfolioCalculator', () => {
tags: [], tags: [],
timeWeightedInvestment: new Big('500000'), timeWeightedInvestment: new Big('500000'),
timeWeightedInvestmentWithCurrencyEffect: new Big('500000'), timeWeightedInvestmentWithCurrencyEffect: new Big('500000'),
transactionCount: 1,
valueInBaseCurrency: new Big('500000') valueInBaseCurrency: new Big('500000')
} }
], ],
@ -161,6 +162,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 0, netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0, netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0,
totalInvestment: 500000,
totalInvestmentValueWithCurrencyEffect: 500000 totalInvestmentValueWithCurrencyEffect: 500000
}) })
); );

41
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 { Logger } from '@nestjs/common';
import { Big } from 'big.js'; 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'; import { cloneDeep, sortBy } from 'lodash';
export class RoaiPortfolioCalculator extends PortfolioCalculator { export class RoaiPortfolioCalculator extends PortfolioCalculator {
@ -34,7 +41,11 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
let totalTimeWeightedInvestment = new Big(0); let totalTimeWeightedInvestment = new Big(0);
let totalTimeWeightedInvestmentWithCurrencyEffect = 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) { if (currentPosition.feeInBaseCurrency) {
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus( totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
currentPosition.feeInBaseCurrency currentPosition.feeInBaseCurrency
@ -615,6 +626,13 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
totalQuantityFromBuyTransactions 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) { if (PortfolioCalculator.ENABLE_LOGGING) {
console.log( console.log(
'grossPerformanceFromSells', 'grossPerformanceFromSells',
@ -833,17 +851,16 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
'max', 'max',
'mtd', 'mtd',
'wtd', 'wtd',
'ytd' 'ytd',
// TODO: ...eachYearOfInterval({ end, start })
// ...eachYearOfInterval({ end, start }) .filter((date) => {
// .filter((date) => { return !isThisYear(date);
// return !isThisYear(date); })
// }) .map((date) => {
// .map((date) => { return format(date, 'yyyy');
// return format(date, 'yyyy'); })
// })
] as DateRange[]) { ] as DateRange[]) {
const dateInterval = getIntervalFromDateRange(dateRange); const dateInterval = getIntervalFromDateRange({ dateRange });
const endDate = dateInterval.endDate; const endDate = dateInterval.endDate;
let startDate = dateInterval.startDate; let startDate = dateInterval.startDate;

11
apps/api/src/app/portfolio/current-rate.service.mock.ts

@ -64,6 +64,17 @@ function mockGetValue(symbol: string, date: Date) {
return { marketPrice: 0 }; 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': case 'MSFT':
if (isSameDay(parseDate('2021-09-16'), date)) { if (isSameDay(parseDate('2021-09-16'), date)) {
return { marketPrice: 89.12 }; return { marketPrice: 89.12 };

2
apps/api/src/app/portfolio/current-rate.service.spec.ts

@ -114,9 +114,9 @@ describe('CurrentRateService', () => {
marketDataService = new MarketDataService(null); marketDataService = new MarketDataService(null);
currentRateService = new CurrentRateService( currentRateService = new CurrentRateService(
null,
dataProviderService, dataProviderService,
marketDataService, marketDataService,
null,
null null
); );
}); });

13
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 { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.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; private static readonly MARKET_DATA_PAGE_SIZE = 50000;
public constructor( public constructor(
private readonly activitiesService: ActivitiesService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@ -129,10 +129,11 @@ export class CurrentRateService {
if (!value) { if (!value) {
// Fallback to unit price of latest activity // Fallback to unit price of latest activity
const latestActivity = await this.orderService.getLatestOrder({ const latestActivity =
dataSource, await this.activitiesService.getLatestActivity({
symbol dataSource,
}); symbol
});
value = { value = {
dataSource, dataSource,

1
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'; import { PortfolioOrder } from './portfolio-order.interface';
export interface PortfolioOrderItem extends PortfolioOrder { export interface PortfolioOrderItem extends PortfolioOrder {
feeInBaseCurrency?: Big;
feeInBaseCurrencyWithCurrencyEffect?: Big; feeInBaseCurrencyWithCurrencyEffect?: Big;
itemType?: 'end' | 'start'; itemType?: 'end' | 'start';
unitPriceFromMarketData?: Big; unitPriceFromMarketData?: Big;

1
apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts

@ -3,6 +3,7 @@ import { Activity } from '@ghostfolio/common/interfaces';
export interface PortfolioOrder extends Pick<Activity, 'tags' | 'type'> { export interface PortfolioOrder extends Pick<Activity, 'tags' | 'type'> {
date: string; date: string;
fee: Big; fee: Big;
feeInBaseCurrency: Big;
quantity: Big; quantity: Big;
SymbolProfile: Pick< SymbolProfile: Pick<
Activity['SymbolProfile'], Activity['SymbolProfile'],

5
apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts

@ -2,18 +2,19 @@ import { AssetSubClass, DataSource, Tag } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
export interface TransactionPointSymbol { export interface TransactionPointSymbol {
activitiesCount: number;
assetSubClass: AssetSubClass; assetSubClass: AssetSubClass;
averagePrice: Big; averagePrice: Big;
currency: string; currency: string;
dataSource: DataSource; dataSource: DataSource;
dateOfFirstActivity: string;
dividend: Big; dividend: Big;
fee: Big; fee: Big;
firstBuyDate: string; feeInBaseCurrency: Big;
includeInHoldings: boolean; includeInHoldings: boolean;
investment: Big; investment: Big;
quantity: Big; quantity: Big;
skipErrors: boolean; skipErrors: boolean;
symbol: string; symbol: string;
tags?: Tag[]; tags?: Tag[];
transactionCount: number;
} }

16
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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { import {
@ -63,10 +63,10 @@ import { UpdateHoldingTagsDto } from './update-holding-tags.dto';
@Controller('portfolio') @Controller('portfolio')
export class PortfolioController { export class PortfolioController {
public constructor( public constructor(
private readonly activitiesService: ActivitiesService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@ -187,7 +187,6 @@ export class PortfolioController {
portfolioSummary = nullifyValuesInObject(summary, [ portfolioSummary = nullifyValuesInObject(summary, [
'cash', 'cash',
'committedFunds',
'currentNetWorth', 'currentNetWorth',
'currentValueInBaseCurrency', 'currentValueInBaseCurrency',
'dividendInBaseCurrency', 'dividendInBaseCurrency',
@ -195,15 +194,18 @@ export class PortfolioController {
'excludedAccountsAndActivities', 'excludedAccountsAndActivities',
'fees', 'fees',
'filteredValueInBaseCurrency', 'filteredValueInBaseCurrency',
'fireWealth',
'grossPerformance', 'grossPerformance',
'grossPerformanceWithCurrencyEffect', 'grossPerformanceWithCurrencyEffect',
'interestInBaseCurrency', 'interestInBaseCurrency',
'items', 'items',
'liabilities', 'liabilities',
'liabilitiesInBaseCurrency',
'netPerformance', 'netPerformance',
'netPerformanceWithCurrencyEffect', 'netPerformanceWithCurrencyEffect',
'totalBuy', 'totalBuy',
'totalInvestment', 'totalInvestment',
'totalInvestmentValueWithCurrencyEffect',
'totalSell', 'totalSell',
'totalValueInBaseCurrency' 'totalValueInBaseCurrency'
]); ]);
@ -318,9 +320,9 @@ export class PortfolioController {
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.settings.settings.baseCurrency; 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, endDate,
filters, filters,
startDate, startDate,
@ -329,7 +331,7 @@ export class PortfolioController {
types: ['DIVIDEND'] types: ['DIVIDEND']
}); });
let dividends = await this.portfolioService.getDividends({ let dividends = this.portfolioService.getDividends({
activities, activities,
groupBy groupBy
}); });
@ -637,7 +639,7 @@ export class PortfolioController {
return report; return report;
} }
@HasPermission(permissions.updateOrder) @HasPermission(permissions.updateActivity)
@Put('holding/:dataSource/:symbol/tags') @Put('holding/:dataSource/:symbol/tags')
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)

8
apps/api/src/app/portfolio/portfolio.module.ts

@ -1,7 +1,7 @@
import { AccessModule } from '@ghostfolio/api/app/access/access.module'; import { AccessModule } from '@ghostfolio/api/app/access/access.module';
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.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 { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { PerformanceLoggingModule } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.module'; import { PerformanceLoggingModule } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.module';
@ -17,7 +17,7 @@ import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
@ -34,16 +34,16 @@ import { RulesService } from './rules.service';
exports: [PortfolioService], exports: [PortfolioService],
imports: [ imports: [
AccessModule, AccessModule,
ActivitiesModule,
ApiModule, ApiModule,
BenchmarkModule, BenchmarkModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
I18nModule, I18nModule,
ImpersonationModule, ImpersonationModule,
MarketDataModule, MarketDataModule,
OrderModule,
PerformanceLoggingModule, PerformanceLoggingModule,
PortfolioSnapshotQueueModule, PortfolioSnapshotQueueModule,
PrismaModule, PrismaModule,

155
apps/api/src/app/portfolio/portfolio.service.ts

@ -1,7 +1,7 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; 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 { UserService } from '@ghostfolio/api/app/user/user.service';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; 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 { 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 { 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 { 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 { BuyingPower } from '@ghostfolio/api/models/rules/liquidity/buying-power';
import { RegionalMarketClusterRiskAsiaPacific } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/asia-pacific'; 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'; import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets';
@ -105,13 +105,13 @@ export class PortfolioService {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly activitiesService: ActivitiesService,
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly calculatorFactory: PortfolioCalculatorFactory, private readonly calculatorFactory: PortfolioCalculatorFactory,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly i18nService: I18nService, private readonly i18nService: I18nService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly rulesService: RulesService, private readonly rulesService: RulesService,
private readonly symbolProfileService: SymbolProfileService, private readonly symbolProfileService: SymbolProfileService,
@ -179,9 +179,9 @@ export class PortfolioService {
return Promise.all( return Promise.all(
accounts.map(async (account) => { accounts.map(async (account) => {
let activitiesCount = 0;
let dividendInBaseCurrency = 0; let dividendInBaseCurrency = 0;
let interestInBaseCurrency = 0; let interestInBaseCurrency = 0;
let transactionCount = 0;
for (const { for (const {
currency, currency,
@ -214,7 +214,7 @@ export class PortfolioService {
} }
if (!isDraft) { if (!isDraft) {
transactionCount += 1; activitiesCount += 1;
} }
} }
@ -223,9 +223,9 @@ export class PortfolioService {
const result = { const result = {
...account, ...account,
activitiesCount,
dividendInBaseCurrency, dividendInBaseCurrency,
interestInBaseCurrency, interestInBaseCurrency,
transactionCount,
valueInBaseCurrency, valueInBaseCurrency,
allocationInPercentage: 0, allocationInPercentage: 0,
balanceInBaseCurrency: this.exchangeRateDataService.toCurrency( balanceInBaseCurrency: this.exchangeRateDataService.toCurrency(
@ -262,6 +262,8 @@ export class PortfolioService {
withExcludedAccounts withExcludedAccounts
}); });
let activitiesCount = 0;
const searchQuery = filters.find(({ type }) => { const searchQuery = filters.find(({ type }) => {
return type === 'SEARCH_QUERY'; return type === 'SEARCH_QUERY';
})?.id; })?.id;
@ -281,9 +283,10 @@ export class PortfolioService {
let totalDividendInBaseCurrency = new Big(0); let totalDividendInBaseCurrency = new Big(0);
let totalInterestInBaseCurrency = new Big(0); let totalInterestInBaseCurrency = new Big(0);
let totalValueInBaseCurrency = new Big(0); let totalValueInBaseCurrency = new Big(0);
let transactionCount = 0;
for (const account of accounts) { for (const account of accounts) {
activitiesCount += account.activitiesCount;
totalBalanceInBaseCurrency = totalBalanceInBaseCurrency.plus( totalBalanceInBaseCurrency = totalBalanceInBaseCurrency.plus(
account.balanceInBaseCurrency account.balanceInBaseCurrency
); );
@ -296,7 +299,6 @@ export class PortfolioService {
totalValueInBaseCurrency = totalValueInBaseCurrency.plus( totalValueInBaseCurrency = totalValueInBaseCurrency.plus(
account.valueInBaseCurrency account.valueInBaseCurrency
); );
transactionCount += account.transactionCount;
} }
for (const account of accounts) { for (const account of accounts) {
@ -310,7 +312,7 @@ export class PortfolioService {
return { return {
accounts, accounts,
transactionCount, activitiesCount,
totalBalanceInBaseCurrency: totalBalanceInBaseCurrency.toNumber(), totalBalanceInBaseCurrency: totalBalanceInBaseCurrency.toNumber(),
totalDividendInBaseCurrency: totalDividendInBaseCurrency.toNumber(), totalDividendInBaseCurrency: totalDividendInBaseCurrency.toNumber(),
totalInterestInBaseCurrency: totalInterestInBaseCurrency.toNumber(), totalInterestInBaseCurrency: totalInterestInBaseCurrency.toNumber(),
@ -318,13 +320,13 @@ export class PortfolioService {
}; };
} }
public async getDividends({ public getDividends({
activities, activities,
groupBy groupBy
}: { }: {
activities: Activity[]; activities: Activity[];
groupBy?: GroupBy; groupBy?: GroupBy;
}): Promise<InvestmentItem[]> { }): InvestmentItem[] {
let dividends = activities.map(({ currency, date, value }) => { let dividends = activities.map(({ currency, date, value }) => {
return { return {
date: format(date, DATE_FORMAT), date: format(date, DATE_FORMAT),
@ -401,10 +403,10 @@ export class PortfolioService {
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
const { endDate, startDate } = getIntervalFromDateRange(dateRange); const { endDate, startDate } = getIntervalFromDateRange({ dateRange });
const { activities } = const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({ await this.activitiesService.getActivitiesForPortfolioCalculator({
filters, filters,
userCurrency, userCurrency,
userId userId
@ -488,7 +490,7 @@ export class PortfolioService {
); );
const { activities } = const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({ await this.activitiesService.getActivitiesForPortfolioCalculator({
filters, filters,
userCurrency, userCurrency,
userId userId
@ -567,9 +569,10 @@ export class PortfolioService {
} }
for (const { for (const {
activitiesCount,
currency, currency,
dateOfFirstActivity,
dividend, dividend,
firstBuyDate,
grossPerformance, grossPerformance,
grossPerformanceWithCurrencyEffect, grossPerformanceWithCurrencyEffect,
grossPerformancePercentage, grossPerformancePercentage,
@ -583,7 +586,6 @@ export class PortfolioService {
quantity, quantity,
symbol, symbol,
tags, tags,
transactionCount,
valueInBaseCurrency valueInBaseCurrency
} of positions) { } of positions) {
if (isFilteredByClosedHoldings === true) { if (isFilteredByClosedHoldings === true) {
@ -610,21 +612,43 @@ export class PortfolioService {
} }
holdings[symbol] = { holdings[symbol] = {
activitiesCount,
currency, currency,
markets, markets,
marketsAdvanced, marketsAdvanced,
marketPrice, marketPrice,
symbol, symbol,
tags, tags,
transactionCount,
allocationInPercentage: filteredValueInBaseCurrency.eq(0) allocationInPercentage: filteredValueInBaseCurrency.eq(0)
? 0 ? 0
: valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(), : valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(),
assetClass: assetProfile.assetClass, 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, assetSubClass: assetProfile.assetSubClass,
countries: assetProfile.countries, countries: assetProfile.countries,
dataSource: assetProfile.dataSource, dataSource: assetProfile.dataSource,
dateOfFirstActivity: parseDate(firstBuyDate), dateOfFirstActivity: parseDate(dateOfFirstActivity),
dividend: dividend?.toNumber() ?? 0, dividend: dividend?.toNumber() ?? 0,
grossPerformance: grossPerformance?.toNumber() ?? 0, grossPerformance: grossPerformance?.toNumber() ?? 0,
grossPerformancePercent: grossPerformancePercentage?.toNumber() ?? 0, grossPerformancePercent: grossPerformancePercentage?.toNumber() ?? 0,
@ -756,7 +780,7 @@ export class PortfolioService {
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
const { activities } = const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({ await this.activitiesService.getActivitiesForPortfolioCalculator({
userCurrency, userCurrency,
userId userId
}); });
@ -789,11 +813,12 @@ export class PortfolioService {
} }
const { const {
activitiesCount,
averagePrice, averagePrice,
currency, currency,
dateOfFirstActivity,
dividendInBaseCurrency, dividendInBaseCurrency,
fee, feeInBaseCurrency,
firstBuyDate,
grossPerformance, grossPerformance,
grossPerformancePercentage, grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect, grossPerformancePercentageWithCurrencyEffect,
@ -807,8 +832,7 @@ export class PortfolioService {
quantity, quantity,
tags, tags,
timeWeightedInvestment, timeWeightedInvestment,
timeWeightedInvestmentWithCurrencyEffect, timeWeightedInvestmentWithCurrencyEffect
transactionCount
} = holding; } = holding;
const activitiesOfHolding = activities.filter(({ SymbolProfile }) => { const activitiesOfHolding = activities.filter(({ SymbolProfile }) => {
@ -819,7 +843,10 @@ export class PortfolioService {
}); });
const dividendYieldPercent = getAnnualizedPerformancePercent({ const dividendYieldPercent = getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)), daysInMarket: differenceInDays(
new Date(),
parseDate(dateOfFirstActivity)
),
netPerformancePercentage: timeWeightedInvestment.eq(0) netPerformancePercentage: timeWeightedInvestment.eq(0)
? new Big(0) ? new Big(0)
: dividendInBaseCurrency.div(timeWeightedInvestment) : dividendInBaseCurrency.div(timeWeightedInvestment)
@ -827,7 +854,10 @@ export class PortfolioService {
const dividendYieldPercentWithCurrencyEffect = const dividendYieldPercentWithCurrencyEffect =
getAnnualizedPerformancePercent({ getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)), daysInMarket: differenceInDays(
new Date(),
parseDate(dateOfFirstActivity)
),
netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq(0) netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq(0)
? new Big(0) ? new Big(0)
: dividendInBaseCurrency.div(timeWeightedInvestmentWithCurrencyEffect) : dividendInBaseCurrency.div(timeWeightedInvestmentWithCurrencyEffect)
@ -836,7 +866,7 @@ export class PortfolioService {
const historicalData = await this.dataProviderService.getHistorical( const historicalData = await this.dataProviderService.getHistorical(
[{ dataSource, symbol }], [{ dataSource, symbol }],
'day', 'day',
parseISO(firstBuyDate), parseISO(dateOfFirstActivity),
new Date() new Date()
); );
@ -901,7 +931,7 @@ export class PortfolioService {
// Add historical entry for buy date, if no historical data available // Add historical entry for buy date, if no historical data available
historicalDataArray.push({ historicalDataArray.push({
averagePrice: activitiesOfHolding[0].unitPriceInAssetProfileCurrency, averagePrice: activitiesOfHolding[0].unitPriceInAssetProfileCurrency,
date: firstBuyDate, date: dateOfFirstActivity,
marketPrice: activitiesOfHolding[0].unitPriceInAssetProfileCurrency, marketPrice: activitiesOfHolding[0].unitPriceInAssetProfileCurrency,
quantity: activitiesOfHolding[0].quantity quantity: activitiesOfHolding[0].quantity
}); });
@ -914,25 +944,20 @@ export class PortfolioService {
); );
return { return {
firstBuyDate, activitiesCount,
dateOfFirstActivity,
marketPrice, marketPrice,
marketPriceMax, marketPriceMax,
marketPriceMin, marketPriceMin,
SymbolProfile, SymbolProfile,
tags, tags,
activities: activitiesOfHolding,
activitiesCount: transactionCount,
averagePrice: averagePrice.toNumber(), averagePrice: averagePrice.toNumber(),
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0], dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0],
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
dividendYieldPercent: dividendYieldPercent.toNumber(), dividendYieldPercent: dividendYieldPercent.toNumber(),
dividendYieldPercentWithCurrencyEffect: dividendYieldPercentWithCurrencyEffect:
dividendYieldPercentWithCurrencyEffect.toNumber(), dividendYieldPercentWithCurrencyEffect.toNumber(),
feeInBaseCurrency: this.exchangeRateDataService.toCurrency( feeInBaseCurrency: feeInBaseCurrency.toNumber(),
fee.toNumber(),
SymbolProfile.currency,
userCurrency
),
grossPerformance: grossPerformance?.toNumber(), grossPerformance: grossPerformance?.toNumber(),
grossPerformancePercent: grossPerformancePercentage?.toNumber(), grossPerformancePercent: grossPerformancePercentage?.toNumber(),
grossPerformancePercentWithCurrencyEffect: grossPerformancePercentWithCurrencyEffect:
@ -985,7 +1010,7 @@ export class PortfolioService {
userId, userId,
userCurrency userCurrency
}), }),
this.orderService.getOrdersForPortfolioCalculator({ this.activitiesService.getActivitiesForPortfolioCalculator({
filters, filters,
userCurrency, userCurrency,
userId userId
@ -1004,7 +1029,8 @@ export class PortfolioService {
netPerformancePercentage: 0, netPerformancePercentage: 0,
netPerformancePercentageWithCurrencyEffect: 0, netPerformancePercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0,
totalInvestment: 0 totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0
} }
}; };
} }
@ -1021,7 +1047,7 @@ export class PortfolioService {
const { errors, hasErrors, historicalData } = const { errors, hasErrors, historicalData } =
await portfolioCalculator.getSnapshot(); await portfolioCalculator.getSnapshot();
const { endDate, startDate } = getIntervalFromDateRange(dateRange); const { endDate, startDate } = getIntervalFromDateRange({ dateRange });
const { chart } = await portfolioCalculator.getPerformance({ const { chart } = await portfolioCalculator.getPerformance({
end: endDate, end: endDate,
@ -1035,6 +1061,7 @@ export class PortfolioService {
netPerformanceWithCurrencyEffect, netPerformanceWithCurrencyEffect,
netWorth, netWorth,
totalInvestment, totalInvestment,
totalInvestmentValueWithCurrencyEffect,
valueWithCurrencyEffect valueWithCurrencyEffect
} = chart?.at(-1) ?? { } = chart?.at(-1) ?? {
netPerformance: 0, netPerformance: 0,
@ -1055,6 +1082,7 @@ export class PortfolioService {
netPerformance, netPerformance,
netPerformanceWithCurrencyEffect, netPerformanceWithCurrencyEffect,
totalInvestment, totalInvestment,
totalInvestmentValueWithCurrencyEffect,
currentNetWorth: netWorth, currentNetWorth: netWorth,
currentValueInBaseCurrency: valueWithCurrencyEffect, currentValueInBaseCurrency: valueWithCurrencyEffect,
netPerformancePercentage: netPerformanceInPercentage, netPerformancePercentage: netPerformanceInPercentage,
@ -1303,11 +1331,11 @@ export class PortfolioService {
}), }),
rules: await this.rulesService.evaluate( rules: await this.rulesService.evaluate(
[ [
new FeeRatioInitialInvestment( new FeeRatioTotalInvestmentVolume(
this.exchangeRateDataService, this.exchangeRateDataService,
this.i18nService, this.i18nService,
userSettings.language, userSettings.language,
summary.committedFunds, summary.totalBuy + summary.totalSell,
summary.fees summary.fees
) )
], ],
@ -1343,7 +1371,12 @@ export class PortfolioService {
}) { }) {
userId = await this.getUserId(impersonationId, userId); 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<string, PortfolioPosition>): { private getAggregatedMarkets(holdings: Record<string, PortfolioPosition>): {
@ -1662,9 +1695,21 @@ export class PortfolioService {
}): PortfolioPosition { }): PortfolioPosition {
return { return {
currency, currency,
activitiesCount: 0,
allocationInPercentage: 0, allocationInPercentage: 0,
assetClass: AssetClass.LIQUIDITY, assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH, assetSubClass: AssetSubClass.CASH,
assetProfile: {
currency,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countries: [],
dataSource: undefined,
holdings: [],
name: currency,
sectors: [],
symbol: currency
},
countries: [], countries: [],
dataSource: undefined, dataSource: undefined,
dateOfFirstActivity: undefined, dateOfFirstActivity: undefined,
@ -1685,7 +1730,6 @@ export class PortfolioService {
sectors: [], sectors: [],
symbol: currency, symbol: currency,
tags: [], tags: [],
transactionCount: 0,
valueInBaseCurrency: balance valueInBaseCurrency: balance
}; };
} }
@ -1835,7 +1879,7 @@ export class PortfolioService {
userId = await this.getUserId(impersonationId, userId); userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const { activities } = await this.orderService.getOrders({ const { activities } = await this.activitiesService.getActivities({
userCurrency, userCurrency,
userId, userId,
withExcludedAccountsAndActivities: true withExcludedAccountsAndActivities: true
@ -1857,8 +1901,11 @@ export class PortfolioService {
} }
} }
const { currentValueInBaseCurrency, totalInvestment } = const {
await portfolioCalculator.getSnapshot(); currentValueInBaseCurrency,
totalInvestment,
totalInvestmentWithCurrencyEffect
} = await portfolioCalculator.getSnapshot();
const { performance } = await this.getPerformance({ const { performance } = await this.getPerformance({
impersonationId, impersonationId,
@ -1872,18 +1919,17 @@ export class PortfolioService {
netPerformanceWithCurrencyEffect netPerformanceWithCurrencyEffect
} = performance; } = performance;
const dividendInBaseCurrency =
await portfolioCalculator.getDividendInBaseCurrency();
const totalEmergencyFund = this.getTotalEmergencyFund({ const totalEmergencyFund = this.getTotalEmergencyFund({
emergencyFundHoldingsValueInBaseCurrency, emergencyFundHoldingsValueInBaseCurrency,
userSettings: user.settings?.settings as UserSettings 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 interest = await portfolioCalculator.getInterestInBaseCurrency();
const liabilities = const liabilities =
@ -1906,8 +1952,6 @@ export class PortfolioService {
.plus(emergencyFundHoldingsValueInBaseCurrency) .plus(emergencyFundHoldingsValueInBaseCurrency)
.toNumber(); .toNumber();
const committedFunds = new Big(totalBuy).minus(totalSell);
const totalOfExcludedActivities = this.getSumOfActivityType({ const totalOfExcludedActivities = this.getSumOfActivityType({
userCurrency, userCurrency,
activities: excludedActivities, activities: excludedActivities,
@ -1941,7 +1985,7 @@ export class PortfolioService {
.minus(liabilities) .minus(liabilities)
.toNumber(); .toNumber();
const daysInMarket = differenceInDays(new Date(), firstOrderDate); const daysInMarket = differenceInDays(new Date(), dateOfFirstActivity);
const annualizedPerformancePercent = getAnnualizedPerformancePercent({ const annualizedPerformancePercent = getAnnualizedPerformancePercent({
daysInMarket, daysInMarket,
@ -1960,6 +2004,7 @@ export class PortfolioService {
annualizedPerformancePercent, annualizedPerformancePercent,
annualizedPerformancePercentWithCurrencyEffect, annualizedPerformancePercentWithCurrencyEffect,
cash, cash,
dateOfFirstActivity,
excludedAccountsAndActivities, excludedAccountsAndActivities,
netPerformance, netPerformance,
netPerformancePercentage, netPerformancePercentage,
@ -1970,9 +2015,7 @@ export class PortfolioService {
activityCount: activities.filter(({ type }) => { activityCount: activities.filter(({ type }) => {
return ['BUY', 'SELL'].includes(type); return ['BUY', 'SELL'].includes(type);
}).length, }).length,
committedFunds: committedFunds.toNumber(),
currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(), currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(),
dateOfFirstActivity: firstOrderDate,
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
emergencyFund: { emergencyFund: {
assets: emergencyFundHoldingsValueInBaseCurrency, assets: emergencyFundHoldingsValueInBaseCurrency,
@ -2002,6 +2045,8 @@ export class PortfolioService {
interestInBaseCurrency: interest.toNumber(), interestInBaseCurrency: interest.toNumber(),
liabilitiesInBaseCurrency: liabilities.toNumber(), liabilitiesInBaseCurrency: liabilities.toNumber(),
totalInvestment: totalInvestment.toNumber(), totalInvestment: totalInvestment.toNumber(),
totalInvestmentValueWithCurrencyEffect:
totalInvestmentWithCurrencyEffect.toNumber(),
totalValueInBaseCurrency: netWorth totalValueInBaseCurrency: netWorth
}; };
} }

11
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 { Inject, Injectable, Logger } from '@nestjs/common';
import Keyv from 'keyv'; import Keyv from 'keyv';
import ms from 'ms'; import ms from 'ms';
import { createHash } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
@Injectable() @Injectable()
export class RedisCacheService { export class RedisCacheService {
@ -75,13 +75,16 @@ export class RedisCacheService {
} }
public async isHealthy() { 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(); const testValue = Date.now().toString();
try { try {
await Promise.race([ await Promise.race([
(async () => { (async () => {
await this.set(testKey, testValue, ms('1 second')); await this.set(testKey, testValue, HEALTH_CHECK_TIMEOUT);
const result = await this.get(testKey); const result = await this.get(testKey);
if (result !== testValue) { if (result !== testValue) {
@ -91,7 +94,7 @@ export class RedisCacheService {
new Promise((_, reject) => new Promise((_, reject) =>
setTimeout( setTimeout(
() => reject(new Error('Redis health check failed: timeout')), () => reject(new Error('Redis health check failed: timeout')),
ms('2 seconds') HEALTH_CHECK_TIMEOUT
) )
) )
]); ]);

3
apps/api/src/app/subscription/subscription.service.ts

@ -35,7 +35,7 @@ export class SubscriptionService {
this.stripe = new Stripe( this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'), this.configurationService.get('STRIPE_SECRET_KEY'),
{ {
apiVersion: '2025-12-15.clover' apiVersion: '2026-02-25.clover'
} }
); );
} }
@ -100,7 +100,6 @@ export class SubscriptionService {
); );
return { return {
sessionId: session.id,
sessionUrl: session.url sessionUrl: session.url
}; };
} }

23
apps/api/src/app/user/user.controller.ts

@ -1,8 +1,11 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; 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 { 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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { import {
DeleteOwnUserDto, DeleteOwnUserDto,
UpdateOwnAccessTokenDto, UpdateOwnAccessTokenDto,
@ -28,7 +31,8 @@ import {
Param, Param,
Post, Post,
Put, Put,
UseGuards UseGuards,
UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
@ -43,6 +47,7 @@ import { UserService } from './user.service';
export class UserController { export class UserController {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly impersonationService: ImpersonationService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
@ -107,13 +112,19 @@ export class UserController {
@Get() @Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
public async getUser( public async getUser(
@Headers('accept-language') acceptLanguage: string @Headers('accept-language') acceptLanguage: string,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
): Promise<User> { ): Promise<User> {
return this.userService.getUser( const impersonationUserId =
this.request.user, await this.impersonationService.validateImpersonationId(impersonationId);
acceptLanguage?.split(',')?.[0]
); return this.userService.getUser({
impersonationUserId,
locale: acceptLanguage?.split(',')?.[0],
user: this.request.user
});
} }
@Post() @Post()

8
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 { 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 { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.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 { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
@ -16,15 +18,17 @@ import { UserService } from './user.service';
controllers: [UserController], controllers: [UserController],
exports: [UserService], exports: [UserService],
imports: [ imports: [
ActivitiesModule,
ConfigurationModule, ConfigurationModule,
I18nModule, I18nModule,
ImpersonationModule,
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' } signOptions: { expiresIn: '30 days' }
}), }),
OrderModule,
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,
RedactValuesInResponseModule,
SubscriptionModule, SubscriptionModule,
TagModule TagModule
], ],

71
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 { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; 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 { 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 { 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 { 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 { BuyingPower } from '@ghostfolio/api/models/rules/liquidity/buying-power';
import { RegionalMarketClusterRiskAsiaPacific } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/asia-pacific'; 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'; import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets';
@ -30,7 +30,7 @@ import {
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SYSTEM_MESSAGE, PROPERTY_SYSTEM_MESSAGE,
TAG_ID_EXCLUDE_FROM_ANALYSIS, TAG_ID_EXCLUDE_FROM_ANALYSIS,
locale locale as defaultLocale
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
User as IUser, User as IUser,
@ -49,16 +49,16 @@ import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma, Role, User } from '@prisma/client'; import { Prisma, Role, User } from '@prisma/client';
import { differenceInDays, subDays } from 'date-fns'; import { differenceInDays, subDays } from 'date-fns';
import { sortBy, without } from 'lodash'; import { without } from 'lodash';
import { createHmac } from 'node:crypto'; import { createHmac } from 'node:crypto';
@Injectable() @Injectable()
export class UserService { export class UserService {
public constructor( public constructor(
private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
private readonly i18nService: I18nService, private readonly i18nService: I18nService,
private readonly orderService: OrderService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService,
@ -96,10 +96,17 @@ export class UserService {
return { accessToken, hashedAccessToken }; return { accessToken, hashedAccessToken };
} }
public async getUser( public async getUser({
{ accounts, id, permissions, settings, subscription }: UserWithSettings, impersonationUserId,
aLocale = locale locale = defaultLocale,
): Promise<IUser> { user
}: {
impersonationUserId: string;
locale?: string;
user: UserWithSettings;
}): Promise<IUser> {
const { id, permissions, settings, subscription } = user;
const userData = await Promise.all([ const userData = await Promise.all([
this.prismaService.access.findMany({ this.prismaService.access.findMany({
include: { include: {
@ -108,22 +115,31 @@ export class UserService {
orderBy: { alias: 'asc' }, orderBy: { alias: 'asc' },
where: { granteeUserId: id } where: { granteeUserId: id }
}), }),
this.prismaService.account.findMany({
orderBy: {
name: 'asc'
},
where: {
userId: impersonationUserId || user.id
}
}),
this.prismaService.order.count({ this.prismaService.order.count({
where: { userId: id } where: { userId: impersonationUserId || user.id }
}), }),
this.prismaService.order.findFirst({ this.prismaService.order.findFirst({
orderBy: { orderBy: {
date: 'asc' 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 access = userData[0];
const activitiesCount = userData[1]; const accounts = userData[1];
const firstActivity = userData[2]; const activitiesCount = userData[2];
let tags = userData[3].filter((tag) => { const firstActivity = userData[3];
let tags = userData[4].filter((tag) => {
return tag.id !== TAG_ID_EXCLUDE_FROM_ANALYSIS; return tag.id !== TAG_ID_EXCLUDE_FROM_ANALYSIS;
}); });
@ -146,7 +162,6 @@ export class UserService {
} }
return { return {
accounts,
activitiesCount, activitiesCount,
id, id,
permissions, permissions,
@ -160,10 +175,13 @@ export class UserService {
permissions: accessItem.permissions permissions: accessItem.permissions
}; };
}), }),
accounts: accounts.sort((a, b) => {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
}),
dateOfFirstActivity: firstActivity?.date ?? new Date(), dateOfFirstActivity: firstActivity?.date ?? new Date(),
settings: { settings: {
...(settings.settings as UserSettings), ...(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,
undefined undefined
).getSettings(user.settings.settings), ).getSettings(user.settings.settings),
FeeRatioInitialInvestment: new FeeRatioInitialInvestment( FeeRatioTotalInvestmentVolume: new FeeRatioTotalInvestmentVolume(
undefined, undefined,
undefined, undefined,
undefined, undefined,
@ -512,13 +530,20 @@ export class UserService {
} }
} }
if (!environment.production && hasRole(user, Role.ADMIN)) { if (hasRole(user, Role.ADMIN)) {
currentPermissions.push(permissions.impersonateAllUsers); 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 }) => { user.accounts = user.accounts.sort((a, b) => {
return name.toLowerCase(); return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
}); });
user.permissions = currentPermissions.sort(); user.permissions = currentPermissions.sort();
return user; return user;
@ -624,7 +649,7 @@ export class UserService {
} catch {} } catch {}
try { try {
await this.orderService.deleteOrders({ await this.activitiesService.deleteActivities({
userId: where.id userId: where.id
}); });
} catch {} } catch {}

794
apps/api/src/assets/cryptocurrencies/cryptocurrencies.json

File diff suppressed because it is too large

1
apps/api/src/assets/cryptocurrencies/custom.json

@ -4,6 +4,7 @@
"LUNA1": "Terra", "LUNA1": "Terra",
"LUNA2": "Terra", "LUNA2": "Terra",
"SGB1": "Songbird", "SGB1": "Songbird",
"SKY33038": "Sky",
"SMURFCAT": "Real Smurf Cat", "SMURFCAT": "Real Smurf Cat",
"TON11419": "Toncoin", "TON11419": "Toncoin",
"UNI1": "Uniswap", "UNI1": "Uniswap",

25
apps/api/src/environments/environment.prod.ts

@ -1,7 +1,30 @@
import { DEFAULT_HOST, DEFAULT_PORT } from '@ghostfolio/common/config'; import { DEFAULT_HOST, DEFAULT_PORT } from '@ghostfolio/common/config';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const getVersion = () => {
if (process.env.APP_VERSION) {
return process.env.APP_VERSION;
}
if (process.env.npm_package_version) {
return process.env.npm_package_version;
}
try {
const packageJson = JSON.parse(
readFileSync(join(process.cwd(), 'package.json'), 'utf8')
);
return packageJson.version ?? 'dev';
} catch {
return 'dev';
}
};
export const environment = { export const environment = {
production: true, production: true,
rootUrl: `http://${DEFAULT_HOST}:${DEFAULT_PORT}`, rootUrl: `http://${DEFAULT_HOST}:${DEFAULT_PORT}`,
version: `${require('../../../../package.json').version}` version: getVersion()
}; };

12
apps/api/src/events/asset-profile-changed.event.ts

@ -8,4 +8,16 @@ export class AssetProfileChangedEvent {
public static getName(): string { public static getName(): string {
return 'assetProfile.changed'; return 'assetProfile.changed';
} }
public getCurrency() {
return this.data.currency;
}
public getDataSource() {
return this.data.dataSource;
}
public getSymbol() {
return this.data.symbol;
}
} }

65
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { 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 { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import { DataSource } from '@prisma/client';
import ms from 'ms';
import { AssetProfileChangedEvent } from './asset-profile-changed.event'; import { AssetProfileChangedEvent } from './asset-profile-changed.event';
@Injectable() @Injectable()
export class AssetProfileChangedListener { export class AssetProfileChangedListener {
private static readonly DEBOUNCE_DELAY = ms('5 seconds');
private debounceTimers = new Map<string, NodeJS.Timeout>();
public constructor( public constructor(
private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService
private readonly orderService: OrderService
) {} ) {}
@OnEvent(AssetProfileChangedEvent.getName()) @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( Logger.log(
`Asset profile of ${event.data.symbol} (${event.data.dataSource}) has changed`, `Asset profile of ${symbol} (${dataSource}) has changed`,
'AssetProfileChangedListener' 'AssetProfileChangedListener'
); );
@ -31,16 +76,16 @@ export class AssetProfileChangedListener {
this.configurationService.get( this.configurationService.get(
'ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES' 'ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES'
) === false || ) === false ||
event.data.currency === DEFAULT_CURRENCY currency === DEFAULT_CURRENCY
) { ) {
return; return;
} }
const existingCurrencies = this.exchangeRateDataService.getCurrencies(); const existingCurrencies = this.exchangeRateDataService.getCurrencies();
if (!existingCurrencies.includes(event.data.currency)) { if (!existingCurrencies.includes(currency)) {
Logger.log( Logger.log(
`New currency ${event.data.currency} has been detected`, `New currency ${currency} has been detected`,
'AssetProfileChangedListener' 'AssetProfileChangedListener'
); );
@ -48,13 +93,13 @@ export class AssetProfileChangedListener {
} }
const { dateOfFirstActivity } = const { dateOfFirstActivity } =
await this.orderService.getStatisticsByCurrency(event.data.currency); await this.activitiesService.getStatisticsByCurrency(currency);
if (dateOfFirstActivity) { if (dateOfFirstActivity) {
await this.dataGatheringService.gatherSymbol({ await this.dataGatheringService.gatherSymbol({
dataSource: this.dataProviderService.getDataSourceForExchangeRates(), dataSource: this.dataProviderService.getDataSourceForExchangeRates(),
date: dateOfFirstActivity, date: dateOfFirstActivity,
symbol: `${DEFAULT_CURRENCY}${event.data.currency}` symbol: `${DEFAULT_CURRENCY}${currency}`
}); });
} }
} }

8
apps/api/src/events/events.module.ts

@ -1,9 +1,9 @@
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 { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -12,11 +12,11 @@ import { PortfolioChangedListener } from './portfolio-changed.listener';
@Module({ @Module({
imports: [ imports: [
ActivitiesModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
OrderModule,
RedisCacheModule RedisCacheModule
], ],
providers: [AssetProfileChangedListener, PortfolioChangedListener] providers: [AssetProfileChangedListener, PortfolioChangedListener]

30
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 { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import ms from 'ms';
import { PortfolioChangedEvent } from './portfolio-changed.event'; import { PortfolioChangedEvent } from './portfolio-changed.event';
@Injectable() @Injectable()
export class PortfolioChangedListener { export class PortfolioChangedListener {
private static readonly DEBOUNCE_DELAY = ms('5 seconds');
private debounceTimers = new Map<string, NodeJS.Timeout>();
public constructor(private readonly redisCacheService: RedisCacheService) {} public constructor(private readonly redisCacheService: RedisCacheService) {}
@OnEvent(PortfolioChangedEvent.getName()) @OnEvent(PortfolioChangedEvent.getName())
handlePortfolioChangedEvent(event: PortfolioChangedEvent) { 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( Logger.log(
`Portfolio of user '${event.getUserId()}' has changed`, `Portfolio of user '${userId}' has changed`,
'PortfolioChangedListener' 'PortfolioChangedListener'
); );
this.redisCacheService.removePortfolioSnapshotsByUserId({ await this.redisCacheService.removePortfolioSnapshotsByUserId({ userId });
userId: event.getUserId()
});
} }
} }

143
apps/api/src/helper/object.helper.spec.ts

@ -1,4 +1,6 @@
import { query, redactAttributes } from './object.helper'; import { DEFAULT_REDACTED_PATHS } from '@ghostfolio/common/config';
import { query, redactPaths } from './object.helper';
describe('query', () => { describe('query', () => {
it('should get market price from stock API response', () => { it('should get market price from stock API response', () => {
@ -22,46 +24,38 @@ describe('query', () => {
describe('redactAttributes', () => { describe('redactAttributes', () => {
it('should redact provided attributes', () => { it('should redact provided attributes', () => {
expect(redactAttributes({ object: {}, options: [] })).toStrictEqual({}); expect(redactPaths({ object: {}, paths: [] })).toStrictEqual({});
expect( expect(redactPaths({ object: { value: 1000 }, paths: [] })).toStrictEqual({
redactAttributes({ object: { value: 1000 }, options: [] }) value: 1000
).toStrictEqual({ value: 1000 }); });
expect( expect(
redactAttributes({ redactPaths({
object: { value: 1000 }, object: { value: 1000 },
options: [{ attribute: 'value', valueMap: { '*': null } }] paths: ['value']
}) })
).toStrictEqual({ value: null }); ).toStrictEqual({ value: null });
expect( expect(
redactAttributes({ redactPaths({
object: { value: 'abc' }, object: { value: 'abc' },
options: [{ attribute: 'value', valueMap: { abc: 'xyz' } }] paths: ['value'],
valueMap: { abc: 'xyz' }
}) })
).toStrictEqual({ value: 'xyz' }); ).toStrictEqual({ value: 'xyz' });
expect( expect(
redactAttributes({ redactPaths({
object: { data: [{ value: 'a' }, { value: 'b' }] }, 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 }] }); ).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'); console.time('redactAttributes execution time');
expect( expect(
redactAttributes({ redactPaths({
object: { object: {
accounts: { accounts: {
'2e937c05-657c-4de9-8fb3-0813a2245f26': { '2e937c05-657c-4de9-8fb3-0813a2245f26': {
@ -117,6 +111,7 @@ describe('redactAttributes', () => {
hasError: false, hasError: false,
holdings: { holdings: {
'AAPL.US': { 'AAPL.US': {
activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -136,7 +131,6 @@ describe('redactAttributes', () => {
marketPrice: 220.79, marketPrice: 220.79,
symbol: 'AAPL.US', symbol: 'AAPL.US',
tags: [], tags: [],
transactionCount: 1,
allocationInPercentage: 0.044900865255793135, allocationInPercentage: 0.044900865255793135,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'STOCK', assetSubClass: 'STOCK',
@ -169,6 +163,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.0694356974830054 valueInPercentage: 0.0694356974830054
}, },
'ALV.DE': { 'ALV.DE': {
activitiesCount: 2,
currency: 'EUR', currency: 'EUR',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -188,7 +183,6 @@ describe('redactAttributes', () => {
marketPrice: 296.5, marketPrice: 296.5,
symbol: 'ALV.DE', symbol: 'ALV.DE',
tags: [], tags: [],
transactionCount: 2,
allocationInPercentage: 0.026912563036519527, allocationInPercentage: 0.026912563036519527,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'STOCK', assetSubClass: 'STOCK',
@ -216,6 +210,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.04161818652826481 valueInPercentage: 0.04161818652826481
}, },
AMZN: { AMZN: {
activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -235,7 +230,6 @@ describe('redactAttributes', () => {
marketPrice: 187.99, marketPrice: 187.99,
symbol: 'AMZN', symbol: 'AMZN',
tags: [], tags: [],
transactionCount: 1,
allocationInPercentage: 0.07646101417126275, allocationInPercentage: 0.07646101417126275,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'STOCK', assetSubClass: 'STOCK',
@ -268,6 +262,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.11824101426541227 valueInPercentage: 0.11824101426541227
}, },
bitcoin: { bitcoin: {
activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
UNKNOWN: 36985.0332704, UNKNOWN: 36985.0332704,
@ -293,7 +288,6 @@ describe('redactAttributes', () => {
userId: null userId: null
} }
], ],
transactionCount: 1,
allocationInPercentage: 0.15042891393226654, allocationInPercentage: 0.15042891393226654,
assetClass: 'LIQUIDITY', assetClass: 'LIQUIDITY',
assetSubClass: 'CRYPTOCURRENCY', assetSubClass: 'CRYPTOCURRENCY',
@ -319,6 +313,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.232626620912395 valueInPercentage: 0.232626620912395
}, },
BONDORA_GO_AND_GROW: { BONDORA_GO_AND_GROW: {
activitiesCount: 5,
currency: 'EUR', currency: 'EUR',
markets: { markets: {
UNKNOWN: 2231.644722160232, UNKNOWN: 2231.644722160232,
@ -344,7 +339,6 @@ describe('redactAttributes', () => {
userId: null userId: null
} }
], ],
transactionCount: 5,
allocationInPercentage: 0.009076749759365777, allocationInPercentage: 0.009076749759365777,
assetClass: 'FIXED_INCOME', assetClass: 'FIXED_INCOME',
assetSubClass: 'BOND', assetSubClass: 'BOND',
@ -370,6 +364,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.014036487867880205 valueInPercentage: 0.014036487867880205
}, },
FRANKLY95P: { FRANKLY95P: {
activitiesCount: 6,
currency: 'CHF', currency: 'CHF',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -395,7 +390,6 @@ describe('redactAttributes', () => {
userId: null userId: null
} }
], ],
transactionCount: 6,
allocationInPercentage: 0.09095764645669335, allocationInPercentage: 0.09095764645669335,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'ETF', assetSubClass: 'ETF',
@ -494,6 +488,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.14065892911313693 valueInPercentage: 0.14065892911313693
}, },
MSFT: { MSFT: {
activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -513,7 +508,6 @@ describe('redactAttributes', () => {
marketPrice: 428.02, marketPrice: 428.02,
symbol: 'MSFT', symbol: 'MSFT',
tags: [], tags: [],
transactionCount: 1,
allocationInPercentage: 0.05222646409742627, allocationInPercentage: 0.05222646409742627,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'STOCK', assetSubClass: 'STOCK',
@ -546,6 +540,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.08076416659271518 valueInPercentage: 0.08076416659271518
}, },
TSLA: { TSLA: {
activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -565,7 +560,6 @@ describe('redactAttributes', () => {
marketPrice: 260.46, marketPrice: 260.46,
symbol: 'TSLA', symbol: 'TSLA',
tags: [], tags: [],
transactionCount: 1,
allocationInPercentage: 0.1589050142378352, allocationInPercentage: 0.1589050142378352,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'STOCK', assetSubClass: 'STOCK',
@ -598,6 +592,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.2457342510950259 valueInPercentage: 0.2457342510950259
}, },
VTI: { VTI: {
activitiesCount: 5,
currency: 'USD', currency: 'USD',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -617,7 +612,6 @@ describe('redactAttributes', () => {
marketPrice: 282.05, marketPrice: 282.05,
symbol: 'VTI', symbol: 'VTI',
tags: [], tags: [],
transactionCount: 5,
allocationInPercentage: 0.057358979326040366, allocationInPercentage: 0.057358979326040366,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'ETF', assetSubClass: 'ETF',
@ -770,6 +764,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.08870120238725339 valueInPercentage: 0.08870120238725339
}, },
'VWRL.SW': { 'VWRL.SW': {
activitiesCount: 5,
currency: 'CHF', currency: 'CHF',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -789,7 +784,6 @@ describe('redactAttributes', () => {
marketPrice: 117.62, marketPrice: 117.62,
symbol: 'VWRL.SW', symbol: 'VWRL.SW',
tags: [], tags: [],
transactionCount: 5,
allocationInPercentage: 0.09386983901959013, allocationInPercentage: 0.09386983901959013,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'ETF', assetSubClass: 'ETF',
@ -1178,6 +1172,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.145162408515095 valueInPercentage: 0.145162408515095
}, },
'XDWD.DE': { 'XDWD.DE': {
activitiesCount: 1,
currency: 'EUR', currency: 'EUR',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -1197,7 +1192,6 @@ describe('redactAttributes', () => {
marketPrice: 105.72, marketPrice: 105.72,
symbol: 'XDWD.DE', symbol: 'XDWD.DE',
tags: [], tags: [],
transactionCount: 1,
allocationInPercentage: 0.03598477442100562, allocationInPercentage: 0.03598477442100562,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'ETF', assetSubClass: 'ETF',
@ -1456,6 +1450,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.055647656152211074 valueInPercentage: 0.055647656152211074
}, },
USD: { USD: {
activitiesCount: 0,
currency: 'USD', currency: 'USD',
allocationInPercentage: 0.20291717628620132, allocationInPercentage: 0.20291717628620132,
assetClass: 'LIQUIDITY', assetClass: 'LIQUIDITY',
@ -1478,7 +1473,6 @@ describe('redactAttributes', () => {
sectors: [], sectors: [],
symbol: 'USD', symbol: 'USD',
tags: [], tags: [],
transactionCount: 0,
valueInBaseCurrency: 49890, valueInBaseCurrency: 49890,
valueInPercentage: 0.3137956381563603 valueInPercentage: 0.3137956381563603
} }
@ -1546,7 +1540,6 @@ describe('redactAttributes', () => {
netPerformanceWithCurrencyEffect: null, netPerformanceWithCurrencyEffect: null,
totalBuy: null, totalBuy: null,
totalSell: null, totalSell: null,
committedFunds: null,
currentValueInBaseCurrency: null, currentValueInBaseCurrency: null,
dividendInBaseCurrency: null, dividendInBaseCurrency: null,
emergencyFund: null, emergencyFund: null,
@ -1560,38 +1553,12 @@ describe('redactAttributes', () => {
items: null, items: null,
liabilities: null, liabilities: null,
totalInvestment: null, totalInvestment: null,
totalInvestmentValueWithCurrencyEffect: null,
totalValueInBaseCurrency: null, totalValueInBaseCurrency: null,
currentNetWorth: null currentNetWorth: null
} }
}, },
options: [ paths: DEFAULT_REDACTED_PATHS
'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
}
};
})
}) })
).toStrictEqual({ ).toStrictEqual({
accounts: { accounts: {
@ -1648,6 +1615,7 @@ describe('redactAttributes', () => {
hasError: false, hasError: false,
holdings: { holdings: {
'AAPL.US': { 'AAPL.US': {
activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -1667,7 +1635,6 @@ describe('redactAttributes', () => {
marketPrice: 220.79, marketPrice: 220.79,
symbol: 'AAPL.US', symbol: 'AAPL.US',
tags: [], tags: [],
transactionCount: 1,
allocationInPercentage: 0.044900865255793135, allocationInPercentage: 0.044900865255793135,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'STOCK', assetSubClass: 'STOCK',
@ -1681,7 +1648,7 @@ describe('redactAttributes', () => {
], ],
dataSource: 'EOD_HISTORICAL_DATA', dataSource: 'EOD_HISTORICAL_DATA',
dateOfFirstActivity: '2021-11-30T23:00:00.000Z', dateOfFirstActivity: '2021-11-30T23:00:00.000Z',
dividend: 0, dividend: null,
grossPerformance: null, grossPerformance: null,
grossPerformancePercent: 0.3183066634822068, grossPerformancePercent: 0.3183066634822068,
grossPerformancePercentWithCurrencyEffect: 0.3183066634822068, grossPerformancePercentWithCurrencyEffect: 0.3183066634822068,
@ -1700,6 +1667,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.0694356974830054 valueInPercentage: 0.0694356974830054
}, },
'ALV.DE': { 'ALV.DE': {
activitiesCount: 2,
currency: 'EUR', currency: 'EUR',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -1719,7 +1687,6 @@ describe('redactAttributes', () => {
marketPrice: 296.5, marketPrice: 296.5,
symbol: 'ALV.DE', symbol: 'ALV.DE',
tags: [], tags: [],
transactionCount: 2,
allocationInPercentage: 0.026912563036519527, allocationInPercentage: 0.026912563036519527,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'STOCK', assetSubClass: 'STOCK',
@ -1728,7 +1695,7 @@ describe('redactAttributes', () => {
], ],
dataSource: 'YAHOO', dataSource: 'YAHOO',
dateOfFirstActivity: '2021-04-22T22:00:00.000Z', dateOfFirstActivity: '2021-04-22T22:00:00.000Z',
dividend: 192, dividend: null,
grossPerformance: null, grossPerformance: null,
grossPerformancePercent: 0.3719230057375532, grossPerformancePercent: 0.3719230057375532,
grossPerformancePercentWithCurrencyEffect: 0.2650716044872953, grossPerformancePercentWithCurrencyEffect: 0.2650716044872953,
@ -1747,6 +1714,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.04161818652826481 valueInPercentage: 0.04161818652826481
}, },
AMZN: { AMZN: {
activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -1766,7 +1734,6 @@ describe('redactAttributes', () => {
marketPrice: 187.99, marketPrice: 187.99,
symbol: 'AMZN', symbol: 'AMZN',
tags: [], tags: [],
transactionCount: 1,
allocationInPercentage: 0.07646101417126275, allocationInPercentage: 0.07646101417126275,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'STOCK', assetSubClass: 'STOCK',
@ -1780,7 +1747,7 @@ describe('redactAttributes', () => {
], ],
dataSource: 'YAHOO', dataSource: 'YAHOO',
dateOfFirstActivity: '2018-09-30T22:00:00.000Z', dateOfFirstActivity: '2018-09-30T22:00:00.000Z',
dividend: 0, dividend: null,
grossPerformance: null, grossPerformance: null,
grossPerformancePercent: 0.8594552890963852, grossPerformancePercent: 0.8594552890963852,
grossPerformancePercentWithCurrencyEffect: 0.8594552890963852, grossPerformancePercentWithCurrencyEffect: 0.8594552890963852,
@ -1799,6 +1766,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.11824101426541227 valueInPercentage: 0.11824101426541227
}, },
bitcoin: { bitcoin: {
activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
UNKNOWN: 36985.0332704, UNKNOWN: 36985.0332704,
@ -1824,14 +1792,13 @@ describe('redactAttributes', () => {
userId: null userId: null
} }
], ],
transactionCount: 1,
allocationInPercentage: 0.15042891393226654, allocationInPercentage: 0.15042891393226654,
assetClass: 'LIQUIDITY', assetClass: 'LIQUIDITY',
assetSubClass: 'CRYPTOCURRENCY', assetSubClass: 'CRYPTOCURRENCY',
countries: [], countries: [],
dataSource: 'COINGECKO', dataSource: 'COINGECKO',
dateOfFirstActivity: '2017-08-15T22:00:00.000Z', dateOfFirstActivity: '2017-08-15T22:00:00.000Z',
dividend: 0, dividend: null,
grossPerformance: null, grossPerformance: null,
grossPerformancePercent: 17.4925166352, grossPerformancePercent: 17.4925166352,
grossPerformancePercentWithCurrencyEffect: 17.4925166352, grossPerformancePercentWithCurrencyEffect: 17.4925166352,
@ -1850,6 +1817,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.232626620912395 valueInPercentage: 0.232626620912395
}, },
BONDORA_GO_AND_GROW: { BONDORA_GO_AND_GROW: {
activitiesCount: 5,
currency: 'EUR', currency: 'EUR',
markets: { markets: {
UNKNOWN: 2231.644722160232, UNKNOWN: 2231.644722160232,
@ -1875,14 +1843,13 @@ describe('redactAttributes', () => {
userId: null userId: null
} }
], ],
transactionCount: 5,
allocationInPercentage: 0.009076749759365777, allocationInPercentage: 0.009076749759365777,
assetClass: 'FIXED_INCOME', assetClass: 'FIXED_INCOME',
assetSubClass: 'BOND', assetSubClass: 'BOND',
countries: [], countries: [],
dataSource: 'MANUAL', dataSource: 'MANUAL',
dateOfFirstActivity: '2021-01-31T23:00:00.000Z', dateOfFirstActivity: '2021-01-31T23:00:00.000Z',
dividend: 11.45, dividend: null,
grossPerformance: null, grossPerformance: null,
grossPerformancePercent: 0, grossPerformancePercent: 0,
grossPerformancePercentWithCurrencyEffect: -0.06153834320225245, grossPerformancePercentWithCurrencyEffect: -0.06153834320225245,
@ -1901,6 +1868,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.014036487867880205 valueInPercentage: 0.014036487867880205
}, },
FRANKLY95P: { FRANKLY95P: {
activitiesCount: 6,
currency: 'CHF', currency: 'CHF',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -1926,7 +1894,6 @@ describe('redactAttributes', () => {
userId: null userId: null
} }
], ],
transactionCount: 6,
allocationInPercentage: 0.09095764645669335, allocationInPercentage: 0.09095764645669335,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'ETF', assetSubClass: 'ETF',
@ -1986,7 +1953,7 @@ describe('redactAttributes', () => {
], ],
dataSource: 'MANUAL', dataSource: 'MANUAL',
dateOfFirstActivity: '2021-03-31T22:00:00.000Z', dateOfFirstActivity: '2021-03-31T22:00:00.000Z',
dividend: 0, dividend: null,
grossPerformance: null, grossPerformance: null,
grossPerformancePercent: 0.27579517683678895, grossPerformancePercent: 0.27579517683678895,
grossPerformancePercentWithCurrencyEffect: 0.458553421589667, grossPerformancePercentWithCurrencyEffect: 0.458553421589667,
@ -2005,6 +1972,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.14065892911313693 valueInPercentage: 0.14065892911313693
}, },
MSFT: { MSFT: {
activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -2024,7 +1992,6 @@ describe('redactAttributes', () => {
marketPrice: 428.02, marketPrice: 428.02,
symbol: 'MSFT', symbol: 'MSFT',
tags: [], tags: [],
transactionCount: 1,
allocationInPercentage: 0.05222646409742627, allocationInPercentage: 0.05222646409742627,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'STOCK', assetSubClass: 'STOCK',
@ -2038,7 +2005,7 @@ describe('redactAttributes', () => {
], ],
dataSource: 'YAHOO', dataSource: 'YAHOO',
dateOfFirstActivity: '2023-01-02T23:00:00.000Z', dateOfFirstActivity: '2023-01-02T23:00:00.000Z',
dividend: 0, dividend: null,
grossPerformance: null, grossPerformance: null,
grossPerformancePercent: 0.7865431171216295, grossPerformancePercent: 0.7865431171216295,
grossPerformancePercentWithCurrencyEffect: 0.7865431171216295, grossPerformancePercentWithCurrencyEffect: 0.7865431171216295,
@ -2057,6 +2024,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.08076416659271518 valueInPercentage: 0.08076416659271518
}, },
TSLA: { TSLA: {
activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -2076,7 +2044,6 @@ describe('redactAttributes', () => {
marketPrice: 260.46, marketPrice: 260.46,
symbol: 'TSLA', symbol: 'TSLA',
tags: [], tags: [],
transactionCount: 1,
allocationInPercentage: 0.1589050142378352, allocationInPercentage: 0.1589050142378352,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'STOCK', assetSubClass: 'STOCK',
@ -2090,7 +2057,7 @@ describe('redactAttributes', () => {
], ],
dataSource: 'YAHOO', dataSource: 'YAHOO',
dateOfFirstActivity: '2017-01-02T23:00:00.000Z', dateOfFirstActivity: '2017-01-02T23:00:00.000Z',
dividend: 0, dividend: null,
grossPerformance: null, grossPerformance: null,
grossPerformancePercent: 17.184314638161936, grossPerformancePercent: 17.184314638161936,
grossPerformancePercentWithCurrencyEffect: 17.184314638161936, grossPerformancePercentWithCurrencyEffect: 17.184314638161936,
@ -2109,6 +2076,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.2457342510950259 valueInPercentage: 0.2457342510950259
}, },
VTI: { VTI: {
activitiesCount: 5,
currency: 'USD', currency: 'USD',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -2128,7 +2096,6 @@ describe('redactAttributes', () => {
marketPrice: 282.05, marketPrice: 282.05,
symbol: 'VTI', symbol: 'VTI',
tags: [], tags: [],
transactionCount: 5,
allocationInPercentage: 0.057358979326040366, allocationInPercentage: 0.057358979326040366,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'ETF', assetSubClass: 'ETF',
@ -2172,7 +2139,7 @@ describe('redactAttributes', () => {
], ],
dataSource: 'YAHOO', dataSource: 'YAHOO',
dateOfFirstActivity: '2019-02-28T23:00:00.000Z', dateOfFirstActivity: '2019-02-28T23:00:00.000Z',
dividend: 0, dividend: null,
grossPerformance: null, grossPerformance: null,
grossPerformancePercent: 0.8832083851170418, grossPerformancePercent: 0.8832083851170418,
grossPerformancePercentWithCurrencyEffect: 0.8832083851170418, grossPerformancePercentWithCurrencyEffect: 0.8832083851170418,
@ -2281,6 +2248,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.08870120238725339 valueInPercentage: 0.08870120238725339
}, },
'VWRL.SW': { 'VWRL.SW': {
activitiesCount: 5,
currency: 'CHF', currency: 'CHF',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -2300,7 +2268,6 @@ describe('redactAttributes', () => {
marketPrice: 117.62, marketPrice: 117.62,
symbol: 'VWRL.SW', symbol: 'VWRL.SW',
tags: [], tags: [],
transactionCount: 5,
allocationInPercentage: 0.09386983901959013, allocationInPercentage: 0.09386983901959013,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'ETF', assetSubClass: 'ETF',
@ -2567,7 +2534,7 @@ describe('redactAttributes', () => {
], ],
dataSource: 'YAHOO', dataSource: 'YAHOO',
dateOfFirstActivity: '2018-02-28T23:00:00.000Z', dateOfFirstActivity: '2018-02-28T23:00:00.000Z',
dividend: 0, dividend: null,
grossPerformance: null, grossPerformance: null,
grossPerformancePercent: 0.3683200415015591, grossPerformancePercent: 0.3683200415015591,
grossPerformancePercentWithCurrencyEffect: 0.5806366182968891, grossPerformancePercentWithCurrencyEffect: 0.5806366182968891,
@ -2681,6 +2648,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.145162408515095 valueInPercentage: 0.145162408515095
}, },
'XDWD.DE': { 'XDWD.DE': {
activitiesCount: 1,
currency: 'EUR', currency: 'EUR',
markets: { markets: {
UNKNOWN: 0, UNKNOWN: 0,
@ -2700,7 +2668,6 @@ describe('redactAttributes', () => {
marketPrice: 105.72, marketPrice: 105.72,
symbol: 'XDWD.DE', symbol: 'XDWD.DE',
tags: [], tags: [],
transactionCount: 1,
allocationInPercentage: 0.03598477442100562, allocationInPercentage: 0.03598477442100562,
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'ETF', assetSubClass: 'ETF',
@ -2846,7 +2813,7 @@ describe('redactAttributes', () => {
], ],
dataSource: 'YAHOO', dataSource: 'YAHOO',
dateOfFirstActivity: '2021-08-18T22:00:00.000Z', dateOfFirstActivity: '2021-08-18T22:00:00.000Z',
dividend: 0, dividend: null,
grossPerformance: null, grossPerformance: null,
grossPerformancePercent: 0.3474381850624522, grossPerformancePercent: 0.3474381850624522,
grossPerformancePercentWithCurrencyEffect: 0.28744846894552306, grossPerformancePercentWithCurrencyEffect: 0.28744846894552306,
@ -2959,12 +2926,13 @@ describe('redactAttributes', () => {
valueInPercentage: 0.055647656152211074 valueInPercentage: 0.055647656152211074
}, },
USD: { USD: {
activitiesCount: 0,
currency: 'USD', currency: 'USD',
allocationInPercentage: 0.20291717628620132, allocationInPercentage: 0.20291717628620132,
assetClass: 'LIQUIDITY', assetClass: 'LIQUIDITY',
assetSubClass: 'CASH', assetSubClass: 'CASH',
countries: [], countries: [],
dividend: 0, dividend: null,
grossPerformance: null, grossPerformance: null,
grossPerformancePercent: 0, grossPerformancePercent: 0,
grossPerformancePercentWithCurrencyEffect: 0, grossPerformancePercentWithCurrencyEffect: 0,
@ -2981,7 +2949,6 @@ describe('redactAttributes', () => {
sectors: [], sectors: [],
symbol: 'USD', symbol: 'USD',
tags: [], tags: [],
transactionCount: 0,
valueInBaseCurrency: null, valueInBaseCurrency: null,
valueInPercentage: 0.3137956381563603 valueInPercentage: 0.3137956381563603
} }
@ -3049,7 +3016,6 @@ describe('redactAttributes', () => {
netPerformanceWithCurrencyEffect: null, netPerformanceWithCurrencyEffect: null,
totalBuy: null, totalBuy: null,
totalSell: null, totalSell: null,
committedFunds: null,
currentValueInBaseCurrency: null, currentValueInBaseCurrency: null,
dividendInBaseCurrency: null, dividendInBaseCurrency: null,
emergencyFund: null, emergencyFund: null,
@ -3063,6 +3029,7 @@ describe('redactAttributes', () => {
items: null, items: null,
liabilities: null, liabilities: null,
totalInvestment: null, totalInvestment: null,
totalInvestmentValueWithCurrencyEffect: null,
totalValueInBaseCurrency: null, totalValueInBaseCurrency: null,
currentNetWorth: null currentNetWorth: null
} }

69
apps/api/src/helper/object.helper.ts

@ -1,6 +1,6 @@
import { Big } from 'big.js'; import fastRedact from 'fast-redact';
import jsonpath from 'jsonpath'; import jsonpath from 'jsonpath';
import { cloneDeep, isArray, isObject } from 'lodash'; import { cloneDeep, isObject } from 'lodash';
export function hasNotDefinedValuesInObject(aObject: Object): boolean { export function hasNotDefinedValuesInObject(aObject: Object): boolean {
for (const key in aObject) { for (const key in aObject) {
@ -42,60 +42,29 @@ export function query({
return jsonpath.query(object, pathExpression); return jsonpath.query(object, pathExpression);
} }
export function redactAttributes({ export function redactPaths({
isFirstRun = true,
object, object,
options paths,
valueMap
}: { }: {
isFirstRun?: boolean;
object: any; object: any;
options: { attribute: string; valueMap: { [key: string]: any } }[]; paths: fastRedact.RedactOptions['paths'];
valueMap?: { [key: string]: any };
}): any { }): any {
if (!object || !options?.length) { const redact = fastRedact({
return object; paths,
} censor: (value) => {
if (valueMap) {
// Create deep clone if (valueMap[value]) {
const redactedObject = isFirstRun return valueMap[value];
? JSON.parse(JSON.stringify(object)) } else {
: object; return value;
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]
});
} }
} else {
return null;
} }
} }
} });
return redactedObject; return JSON.parse(redact(object));
} }

42
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 { redactPaths } from '@ghostfolio/api/helper/object.helper';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import {
DEFAULT_REDACTED_PATHS,
HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config';
import { import {
hasReadRestrictedAccessPermission, hasReadRestrictedAccessPermission,
isRestrictedView isRestrictedView
@ -39,40 +42,9 @@ export class RedactValuesInResponseInterceptor<T> implements NestInterceptor<
}) || }) ||
isRestrictedView(user) isRestrictedView(user)
) { ) {
data = redactAttributes({ data = redactPaths({
object: data, object: data,
options: [ paths: DEFAULT_REDACTED_PATHS
'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
}
};
})
}); });
} }

22
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { encodeDataSource } from '@ghostfolio/common/helper'; import { encodeDataSource } from '@ghostfolio/common/helper';
@ -58,13 +58,21 @@ export class TransformDataSourceInResponseInterceptor<
} }
} }
data = redactAttributes({ data = redactPaths({
valueMap,
object: data, object: data,
options: [ paths: [
{ 'activities[*].dataSource',
valueMap, 'activities[*].SymbolProfile.dataSource',
attribute: 'dataSource' 'benchmarks[*].dataSource',
} 'errors[*].dataSource',
'fearAndGreedIndex.CRYPTOCURRENCIES.dataSource',
'fearAndGreedIndex.STOCKS.dataSource',
'holdings[*].assetProfile.dataSource',
'holdings[*].dataSource',
'items[*].dataSource',
'SymbolProfile.dataSource',
'watchlist[*].dataSource'
] ]
}); });
} }

6
apps/api/src/main.ts

@ -1,4 +1,5 @@
import { import {
BULL_BOARD_ROUTE,
DEFAULT_HOST, DEFAULT_HOST,
DEFAULT_PORT, DEFAULT_PORT,
STORYBOOK_PATH, STORYBOOK_PATH,
@ -14,6 +15,7 @@ import {
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express'; import type { NestExpressApplication } from '@nestjs/platform-express';
import cookieParser from 'cookie-parser';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
import helmet from 'helmet'; import helmet from 'helmet';
@ -46,6 +48,7 @@ async function bootstrap() {
}); });
app.setGlobalPrefix('api', { app.setGlobalPrefix('api', {
exclude: [ exclude: [
`${BULL_BOARD_ROUTE.substring(1)}{/*wildcard}`,
'sitemap.xml', 'sitemap.xml',
...SUPPORTED_LANGUAGE_CODES.map((languageCode) => { ...SUPPORTED_LANGUAGE_CODES.map((languageCode) => {
// Exclude language-specific routes with an optional wildcard // Exclude language-specific routes with an optional wildcard
@ -53,6 +56,7 @@ async function bootstrap() {
}) })
] ]
}); });
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
forbidNonWhitelisted: true, forbidNonWhitelisted: true,
@ -64,6 +68,8 @@ async function bootstrap() {
// Support 10mb csv/json files for importing activities // Support 10mb csv/json files for importing activities
app.useBodyParser('json', { limit: '10mb' }); app.useBodyParser('json', { limit: '10mb' });
app.use(cookieParser());
if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') { if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') {
app.use((req: Request, res: Response, next: NextFunction) => { app.use((req: Request, res: Response, next: NextFunction) => {
if (req.path.startsWith(STORYBOOK_PATH)) { if (req.path.startsWith(STORYBOOK_PATH)) {

28
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);
}
}

4
apps/api/src/models/rule.ts

@ -57,7 +57,7 @@ export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
previousValue + previousValue +
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
new Big(currentValue.quantity) new Big(currentValue.quantity)
.mul(currentValue.marketPrice) .mul(currentValue.marketPrice ?? 0)
.toNumber(), .toNumber(),
currentValue.currency, currentValue.currency,
baseCurrency baseCurrency
@ -70,8 +70,6 @@ export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
public abstract evaluate(aRuleSettings: T): EvaluationResult; public abstract evaluate(aRuleSettings: T): EvaluationResult;
public abstract getCategoryName(): string;
public abstract getConfiguration(): Partial< public abstract getConfiguration(): Partial<
PortfolioReportRule['configuration'] PortfolioReportRule['configuration']
>; >;

7
apps/api/src/models/rules/account-cluster-risk/current-investment.ts

@ -98,13 +98,6 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.accountClusterRisk.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

7
apps/api/src/models/rules/account-cluster-risk/single-account.ts

@ -57,13 +57,6 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.accountClusterRisk.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return undefined; return undefined;
} }

7
apps/api/src/models/rules/asset-class-cluster-risk/equity.ts

@ -85,13 +85,6 @@ export class AssetClassClusterRiskEquity extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.assetClassClusterRisk.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

7
apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts

@ -85,13 +85,6 @@ export class AssetClassClusterRiskFixedIncome extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.assetClassClusterRisk.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

7
apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts

@ -82,13 +82,6 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.currencyClusterRisk.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return undefined; return undefined;
} }

7
apps/api/src/models/rules/currency-cluster-risk/current-investment.ts

@ -75,13 +75,6 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.currencyClusterRisk.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

7
apps/api/src/models/rules/economic-market-cluster-risk/developed-markets.ts

@ -76,13 +76,6 @@ export class EconomicMarketClusterRiskDevelopedMarkets extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.economicMarketClusterRisk.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

7
apps/api/src/models/rules/economic-market-cluster-risk/emerging-markets.ts

@ -76,13 +76,6 @@ export class EconomicMarketClusterRiskEmergingMarkets extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.economicMarketClusterRisk.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

7
apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts

@ -40,13 +40,6 @@ export class EmergencyFundSetup extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.emergencyFund.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return undefined; return undefined;
} }

28
apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts → 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 { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { RuleSettings, UserSettings } from '@ghostfolio/common/interfaces'; import { RuleSettings, UserSettings } from '@ghostfolio/common/interfaces';
export class FeeRatioInitialInvestment extends Rule<Settings> { export class FeeRatioTotalInvestmentVolume extends Rule<Settings> {
private fees: number; private fees: number;
private totalInvestment: number; private totalInvestmentVolumeInBaseCurrency: number;
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
languageCode: string, languageCode: string,
totalInvestment: number, totalInvestmentVolumeInBaseCurrency: number,
fees: number fees: number
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
languageCode, languageCode,
key: FeeRatioInitialInvestment.name key: FeeRatioTotalInvestmentVolume.name
}); });
this.fees = fees; this.fees = fees;
this.totalInvestment = totalInvestment; this.totalInvestmentVolumeInBaseCurrency =
totalInvestmentVolumeInBaseCurrency;
} }
public evaluate(ruleSettings: Settings) { public evaluate(ruleSettings: Settings) {
const feeRatio = this.totalInvestment const feeRatio = this.totalInvestmentVolumeInBaseCurrency
? this.fees / this.totalInvestment ? this.fees / this.totalInvestmentVolumeInBaseCurrency
: 0; : 0;
if (feeRatio > ruleSettings.thresholdMax) { if (feeRatio > ruleSettings.thresholdMax) {
return { return {
evaluation: this.i18nService.getTranslation({ evaluation: this.i18nService.getTranslation({
id: 'rule.feeRatioInitialInvestment.false', id: 'rule.feeRatioTotalInvestmentVolume.false',
languageCode: this.getLanguageCode(), languageCode: this.getLanguageCode(),
placeholders: { placeholders: {
feeRatio: (ruleSettings.thresholdMax * 100).toFixed(2), feeRatio: (ruleSettings.thresholdMax * 100).toFixed(2),
@ -44,7 +45,7 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
return { return {
evaluation: this.i18nService.getTranslation({ evaluation: this.i18nService.getTranslation({
id: 'rule.feeRatioInitialInvestment.true', id: 'rule.feeRatioTotalInvestmentVolume.true',
languageCode: this.getLanguageCode(), languageCode: this.getLanguageCode(),
placeholders: { placeholders: {
feeRatio: (feeRatio * 100).toPrecision(3), feeRatio: (feeRatio * 100).toPrecision(3),
@ -55,13 +56,6 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.fees.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {
@ -76,7 +70,7 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
public getName() { public getName() {
return this.i18nService.getTranslation({ return this.i18nService.getTranslation({
id: 'rule.feeRatioInitialInvestment', id: 'rule.feeRatioTotalInvestmentVolume',
languageCode: this.getLanguageCode() languageCode: this.getLanguageCode()
}); });
} }

7
apps/api/src/models/rules/liquidity/buying-power.ts

@ -63,13 +63,6 @@ export class BuyingPower extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.liquidity.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

4
apps/api/src/models/rules/regional-market-cluster-risk/asia-pacific.ts

@ -70,10 +70,6 @@ export class RegionalMarketClusterRiskAsiaPacific extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return 'Regional Market Cluster Risk'; // TODO: Replace hardcoded text with i18n translation
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

4
apps/api/src/models/rules/regional-market-cluster-risk/emerging-markets.ts

@ -72,10 +72,6 @@ export class RegionalMarketClusterRiskEmergingMarkets extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return 'Regional Market Cluster Risk'; // TODO: Replace hardcoded text with i18n translation
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

4
apps/api/src/models/rules/regional-market-cluster-risk/europe.ts

@ -70,10 +70,6 @@ export class RegionalMarketClusterRiskEurope extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return 'Regional Market Cluster Risk'; // TODO: Replace hardcoded text with i18n translation
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save