Browse Source

Merge branch 'task/eliminate-on-destroy-lifecycle-hook-from-portfolio-activities-page-component' of https://github.com/AyushMishraa/ghostfolio into task/eliminate-on-destroy-lifecycle-hook-from-portfolio-activities-page-component

pull/6611/head
AyushMishra 2 weeks ago
parent
commit
f0c9f0c72e
  1. 2
      .github/workflows/build-code.yml
  2. 4
      .github/workflows/extract-locales.yml
  3. 4
      .vscode/extensions.json
  4. 2
      .vscode/settings.json
  5. 393
      CHANGELOG.md
  6. 62
      README.md
  7. 6
      apps/api/src/app/account/account.controller.ts
  8. 6
      apps/api/src/app/account/account.service.ts
  9. 90
      apps/api/src/app/activities/activities.controller.ts
  10. 12
      apps/api/src/app/activities/activities.module.ts
  11. 218
      apps/api/src/app/activities/activities.service.ts
  12. 9
      apps/api/src/app/admin/admin.controller.ts
  13. 4
      apps/api/src/app/admin/admin.module.ts
  14. 24
      apps/api/src/app/admin/admin.service.ts
  15. 40
      apps/api/src/app/app.module.ts
  16. 4
      apps/api/src/app/endpoints/ai/ai.module.ts
  17. 7
      apps/api/src/app/endpoints/assets/assets.controller.ts
  18. 6
      apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts
  19. 4
      apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts
  20. 7
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  21. 6
      apps/api/src/app/endpoints/market-data/market-data.module.ts
  22. 24
      apps/api/src/app/endpoints/platforms/platforms.controller.ts
  23. 11
      apps/api/src/app/endpoints/platforms/platforms.module.ts
  24. 7
      apps/api/src/app/endpoints/public/public.controller.ts
  25. 4
      apps/api/src/app/endpoints/public/public.module.ts
  26. 4
      apps/api/src/app/export/export.module.ts
  27. 11
      apps/api/src/app/export/export.service.ts
  28. 3
      apps/api/src/app/import/import.controller.ts
  29. 6
      apps/api/src/app/import/import.module.ts
  30. 225
      apps/api/src/app/import/import.service.ts
  31. 8
      apps/api/src/app/info/info.service.ts
  32. 2
      apps/api/src/app/platform/platform.controller.ts
  33. 108
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  34. 16
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts
  35. 17
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  36. 16
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts
  37. 17
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts
  38. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts
  39. 15
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts
  40. 19
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  41. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts
  42. 15
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts
  43. 289
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  44. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts
  45. 15
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts
  46. 190
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts
  47. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts
  48. 7
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts
  49. 9
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts
  50. 7
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts
  51. 15
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  52. 15
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  53. 8
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts
  54. 54
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  55. 11
      apps/api/src/app/portfolio/current-rate.service.mock.ts
  56. 2
      apps/api/src/app/portfolio/current-rate.service.spec.ts
  57. 13
      apps/api/src/app/portfolio/current-rate.service.ts
  58. 1
      apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts
  59. 3
      apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts
  60. 8
      apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts
  61. 16
      apps/api/src/app/portfolio/portfolio.controller.ts
  62. 4
      apps/api/src/app/portfolio/portfolio.module.ts
  63. 208
      apps/api/src/app/portfolio/portfolio.service.ts
  64. 11
      apps/api/src/app/redis-cache/redis-cache.service.ts
  65. 4
      apps/api/src/app/subscription/subscription.service.ts
  66. 23
      apps/api/src/app/user/user.controller.ts
  67. 8
      apps/api/src/app/user/user.module.ts
  68. 71
      apps/api/src/app/user/user.service.ts
  69. 544
      apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
  70. 1
      apps/api/src/assets/cryptocurrencies/custom.json
  71. 12
      apps/api/src/events/asset-profile-changed.event.ts
  72. 65
      apps/api/src/events/asset-profile-changed.listener.ts
  73. 4
      apps/api/src/events/events.module.ts
  74. 30
      apps/api/src/events/portfolio-changed.listener.ts
  75. 163
      apps/api/src/helper/object.helper.spec.ts
  76. 80
      apps/api/src/helper/object.helper.ts
  77. 42
      apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts
  78. 22
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  79. 6
      apps/api/src/main.ts
  80. 28
      apps/api/src/middlewares/bull-board-auth.middleware.ts
  81. 2
      apps/api/src/models/rule.ts
  82. 21
      apps/api/src/models/rules/fees/fee-ratio-total-investment-volume.ts
  83. 3
      apps/api/src/services/configuration/configuration.service.ts
  84. 7
      apps/api/src/services/cryptocurrency/cryptocurrency.module.ts
  85. 38
      apps/api/src/services/cryptocurrency/cryptocurrency.service.ts
  86. 2
      apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts
  87. 2
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.spec.ts
  88. 37
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts
  89. 155
      apps/api/src/services/data-provider/data-provider.service.ts
  90. 47
      apps/api/src/services/data-provider/manual/manual.service.ts
  91. 12
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts
  92. 4
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  93. 5
      apps/api/src/services/exchange-rate-data/interfaces/exchange-rate-data.interface.ts
  94. 6
      apps/api/src/services/i18n/i18n.service.ts
  95. 3
      apps/api/src/services/interfaces/environment.interface.ts
  96. 14
      apps/api/src/services/queues/data-gathering/data-gathering.module.ts
  97. 18
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts
  98. 9
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts
  99. 10
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  100. 16
      apps/api/src/services/tag/tag.service.ts

2
.github/workflows/build-code.yml

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

4
.github/workflows/extract-locales.yml

@ -33,8 +33,8 @@ jobs:
uses: peter-evans/create-pull-request@v7
with:
author: 'github-actions[bot] <github-actions[bot]@users.noreply.github.com>'
branch: 'feature/update-locales'
branch: 'task/update-locales'
commit-message: 'Update locales'
delete-branch: true
title: 'Feature/update locales'
title: 'Task/update locales'
token: ${{ secrets.GITHUB_TOKEN }}

4
.vscode/extensions.json

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

2
.vscode/settings.json

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

393
CHANGELOG.md

@ -7,15 +7,408 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Changed
- Improved the language localization for Polish (`pl`)
## 2.250.0 - 2026-03-17
### Added
- Added support for specific calendar year date ranges (`2025`, `2024`, `2023`, etc.) on the portfolio activities page
### Changed
- Consolidated the sign-out logic within the user service to unify cookie, state and token clearance
- Improved the language localization for Polish (`pl`)
- Upgraded `@ionic/angular` from version `8.7.3` to `8.8.1`
- Upgraded `replace-in-file` from version `8.3.0` to `8.4.0`
- Upgraded `svgmap` from version `2.14.0` to `2.19.2`
- Pinned the _Node.js_ version in the _Build code_ _GitHub Action_ to ensure environment consistency for tests
### Fixed
- Fixed an issue with the detection of the thousand separator for the `de-CH` locale
- Fixed an issue in the _Storybook_ stories of the symbol autocomplete component caused by a circular dependency
## 2.249.0 - 2026-03-10
### Added
- Integrated _Bull Dashboard_ for a detailed jobs queue view in the admin control panel (experimental)
- Added a debounce to the `PortfolioChangedListener` and `AssetProfileChangedListener` to minimize redundant _Redis_ and database operations
### Changed
- Improved the _Storybook_ stories of the value component
- Improved the language localization for Dutch (`nl`)
- Improved the language localization for German (`de`)
- Upgraded `class-validator` from version `0.14.3` to `0.15.1`
### Fixed
- Fixed false _Redis_ health check failures by using unique keys and increasing the timeout to 5s
## 2.248.0 - 2026-03-07
### Added
- Added support for column sorting to the data providers management of the admin control panel
### Changed
- Included asset profile data in the endpoint `GET api/v1/portfolio/holdings`
- Included asset profile data in the holdings of the public page
- Reused the value component in the platform management of the admin control panel
- Reused the value component in the tag management of the admin control panel
- Deprecated the `api/v1/order` endpoints in favor of the `api/v1/activities` endpoints
- Upgraded `jsonpath` from version `1.1.1` to `1.2.1`
### Fixed
- Fixed an issue in the _FIRE_ calculator to correctly calculate the projected total amount
## 2.247.0 - 2026-03-04
### Changed
- Upgraded `yahoo-finance2` from version `3.13.0` to `3.13.2`
## 2.246.0 - 2026-03-03
### Changed
- Removed the deprecated `committedFunds` from the summary of the portfolio details endpoint
- Upgraded `Nx` from version `22.4.5` to `22.5.3`
### Fixed
- Fixed an issue where the apply and reset filter buttons remained disabled in the assistant
## 2.245.0 - 2026-03-01
### Changed
- Excluded the scraper configuration from the import and export functionality
- Excluded the symbol mapping from the import and export functionality
- Improved the language localization for Dutch (`nl`)
- Improved the language localization for Italian (`it`)
- Improved the language localization for Spanish (`es`)
### Fixed
- Resolved the data source transformation in the errors of the performance endpoint
- Resolved the data source transformation in the export functionality
## 2.244.0 - 2026-02-28
### Changed
- Improved the usability of the asset profile details dialog in the admin control panel for currencies
- Removed the deprecated static portfolio analysis rule: _Fees_ (Fee Ratio)
- Refactored queries in the data provider service to use Prisma’s safe query methods
### Fixed
- Fixed an exception by adding a fallback for missing market price values on the _X-ray_ page
## 2.243.0 - 2026-02-23
### Changed
- Improved the language localization for Chinese (`zh`)
- Upgraded `nestjs` from version `11.1.8` to `11.1.14`
### Fixed
- Fixed an issue when creating activities of type `FEE`, `INTEREST` or `LIABILITY`
## 2.242.0 - 2026-02-22
### Changed
- Changed the account field to optional in the create or update activity dialog
### Fixed
- Fixed a validation issue for valuables used in the create and import activity logic
- Fixed the page size for presets in the historical market data table of the admin control panel
## 2.241.0 - 2026-02-21
### Changed
- Improved the usability of the portfolio summary tab on the home page in the _Presenter View_
- Refreshed the cryptocurrencies list
- Improved the language localization for German (`de`)
- Improved the language localization for Spanish (`es`)
### Fixed
- Fixed an issue with `balanceInBaseCurrency` of the accounts in the value redaction interceptor for the impersonation mode
- Fixed an issue with `comment` of the accounts in the value redaction interceptor for the impersonation mode
- Fixed an issue with `dividendInBaseCurrency` of the accounts in the value redaction interceptor for the impersonation mode
- Fixed an issue with `interestInBaseCurrency` of the accounts in the value redaction interceptor for the impersonation mode
- Fixed an issue with `value` of the accounts in the value redaction interceptor for the impersonation mode
## 2.240.0 - 2026-02-18
### Added
- Added a _No Activities_ preset to the historical market data table of the admin control panel
- Added support for custom cryptocurrencies defined in the database
- Added support for the cryptocurrency _Sky_
### Changed
- Harmonized the validation for the create activity endpoint with the existing import activity logic
- Upgraded `marked` from version `17.0.1` to `17.0.2`
- Upgraded `ngx-markdown` from version `21.0.1` to `21.1.0`
## 2.239.0 - 2026-02-15
### Added
- Added a new static portfolio analysis rule based on the total investment volume: _Fees_ (Fee Ratio)
- Extended the content of the _Self-Hosting_ section on the Frequently Asked Questions (FAQ) page with information on derived currencies
### Changed
- Deprecated the existing static portfolio analysis rule: _Fees_ (Fee Ratio)
- Ignored nested ETFs when fetching top holdings for ETF and mutual fund assets from _Yahoo Finance_
- Improved the scraper configuration with more detailed error messages
- Improved the language localization for German (`de`)
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `13.1.0` to `13.2.2`
- Upgraded `cheerio` from version `1.0.0` to `1.2.0`
### Fixed
- Fixed the investment value by including currency effects in the portfolio summary tab on the home page
- Added the missing `valueInBaseCurrency` to the response of the import activities endpoint
## 2.238.0 - 2026-02-12
### Changed
- Upgraded `ngx-skeleton-loader` from version `11.3.0` to `12.0.0`
- Upgraded `twitter-api-v2` from version `1.27.0` to `1.29.0`
### Fixed
- Fixed a performance calculation issue by resetting tracking variables when a holding is fully closed
- Fixed an issue in the annualized performance calculation
- Fixed an issue with the exchange rate calculation by expanding the date range to cover the full day (start to end of day)
## 2.237.0 - 2026-02-08
### Changed
- Removed the deprecated `transactionCount` in the portfolio calculator and service
- Refreshed the cryptocurrencies list
- Upgraded `Nx` from version `22.4.1` to `22.4.5`
### Fixed
- Fixed the accounts of the assistant for the impersonation mode
- Fixed the tags of the assistant for the impersonation mode
## 2.236.0 - 2026-02-05
### Changed
- Removed the deprecated `transactionCount` in the endpoint `GET api/v1/admin`
- Upgraded `stripe` from version `20.1.0` to `20.3.0`
### Fixed
- Fixed an exception when fetching the top holdings for ETF and mutual fund assets from _Yahoo Finance_
## 2.235.0 - 2026-02-03
### Added
- Added the ability to fetch top holdings for ETF and mutual fund assets from _Yahoo Finance_
- Added support for the impersonation mode in the endpoint `GET api/v1/account/:id/balances`
- Added an action menu to the user detail dialog in the users section of the admin control panel
### Changed
- Optimized the value redaction interceptor for the impersonation mode by introducing `fast-redact`
- Refactored `showTransactions` in favor of `showActivitiesCount` in the accounts table component
- Refactored `transactionCount` in favor of `activitiesCount` in the accounts table component
- Deprecated `transactionCount` in favor of `activitiesCount` in the endpoint `GET api/v1/admin`
- Removed the deprecated `firstBuyDate` in the portfolio calculator
- Upgraded `yahoo-finance2` from version `3.11.2` to `3.13.0`
## 2.234.0 - 2026-01-30
### Changed
- Improved the usability of the create asset profile dialog in the market data section of the admin control panel
- Improved the language localization for Chinese (`zh`)
- Improved the language localization for German (`de`)
- Improved the language localization for Spanish (`es`)
- Upgraded `angular` from version `21.0.6` to `21.1.1`
- Upgraded `lodash` from version `4.17.21` to `4.17.23`
- Upgraded `Nx` from version `22.3.3` to `22.4.1`
- Upgraded `prettier` from version `3.8.0` to `3.8.1`
## 2.233.0 - 2026-01-23
### Changed
- Deprecated `firstBuyDate` in favor of `dateOfFirstActivity` in the portfolio calculator
- Deprecated `transactionCount` in favor of `activitiesCount` in the portfolio calculator and service
- Removed the deprecated `firstBuyDate` from the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol`
- Refreshed the cryptocurrencies list
- Upgraded `prettier` from version `3.7.4` to `3.8.0`
## 2.232.0 - 2026-01-19
### Added
- Extended the analysis page to include the total amount, change and performance with currency effects (experimental)
### Changed
- Deprecated `firstBuyDate` in favor of `dateOfFirstActivity` in the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol`
- Improved the language localization for German (`de`)
- Upgraded `countries-list` from version `3.2.0` to `3.2.2`
## 2.231.0 - 2026-01-17
### Changed
- Removed the deprecated platforms from the info service
- Removed the deprecated activities from the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol`
### Fixed
- Fixed a numeric parsing error related to cash positions on the _X-ray_ page
- Fixed the total fee calculation in the holding detail dialog related to activities in a custom currency
- Fixed the total fee calculation in the summary related to activities in a custom currency
## 2.230.0 - 2026-01-14
### Added
- Set up the language localization for Korean (`ko`)
### Changed
- Restored the support for specific calendar year date ranges (`2024`, `2023`, `2022`, etc.) in the holdings table (experimental)
### Fixed
- Fixed the total fee calculation in the holding detail dialog related to activities in a custom currency
- Fixed the total fee calculation in the summary related to activities in a custom currency
## 2.229.0 - 2026-01-11
### Changed
- Set the active sort column in the accounts table component
- Deprecated `activities` in the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol`
- Moved the admin service to `@ghostfolio/ui/services`
- Moved the data service to `@ghostfolio/ui/services`
- Refactored the dividend import
- Refreshed the cryptocurrencies list
### Fixed
- Fixed the net worth calculation to prevent the double counting of cash positions
- Fixed the filtering by asset class in the endpoint `GET api/v1/portfolio/holdings`
- Fixed the case-insensitive sorting in the accounts table component
- Fixed the case-insensitive sorting in the benchmark component
- Fixed the case-insensitive sorting in the holdings table component
## 2.228.0 - 2026-01-03
### Added
- Extended the portfolio holdings to include performance with currency effects for cash positions
### Changed
- Integrated the endpoint to get all platforms (`GET api/v1/platforms`) into the create or update account dialog
- Extracted the scraper configuration to a dedicated tab in the asset profile details dialog of the admin control panel
- Improved the language localization for German (`de`)
- Upgraded `@date-fns/utc` from version `2.1.0` to `2.1.1`
### Fixed
- Improved the table headers’ alignment of the accounts table on mobile
## 2.227.0 - 2026-01-02
### Changed
- Initialized the input properties in the _FIRE_ calculator
- Removed the deprecated public _Stripe_ key
- Upgraded `stripe` from version `18.5.0` to `20.1.0`
### Fixed
- Fixed the import of `jsonpath` to support REST APIs (`JSON`) via the scraper configuration
## 2.226.0 - 2026-01-01
### Added
- Extended the content of the _Self-Hosting_ section by information about additional data providers on the Frequently Asked Questions (FAQ) page
### Changed
- Upgraded `class-validator` from version `0.14.2` to `0.14.3`
- Upgraded `yahoo-finance2` from version `3.10.2` to `3.11.2`
## 2.225.0 - 2025-12-31
### Added
- Added a new endpoint to get all platforms (`GET api/v1/platforms`)
- Added the session url to the endpoint response of the _Stripe_ checkout
### Changed
- Improved the routing of the user detail dialog in the users section of the admin control panel
- Lifted the asset profile identifier editing restriction for `MANUAL` data sources in the asset profile details dialog of the admin control panel
- Deprecated the public _Stripe_ key
- Improved the language localization for German (`de`)
- Eliminated `ngx-stripe`
- Upgraded `angular` from version `20.2.4` to `21.0.6`
- Upgraded `marked` from version `15.0.4` to `17.0.1`
- Upgraded `ngx-device-detector` from version `10.1.0` to `11.0.0`
- Upgraded `ng-extract-i18n-merge` from `3.1.0` to `3.2.1`
- Upgraded `ngx-markdown` from version `20.0.0` to `21.0.1`
- Upgraded `Nx` from version `21.5.1` to `22.3.3`
- Upgraded `shx` from version `0.3.4` to `0.4.0`
- Upgraded `storybook` from version `9.1.5` to `10.1.10`
- Upgraded `zone.js` from version `0.15.1` to `0.16.0`
### Fixed
- Added the missing currency suffix to the cash balance field in the create or update account dialog
- Fixed the time in market display of the portfolio summary tab on the home page for the impersonation mode
- Fixed the delete button in the asset profile details dialog of the admin control panel by providing the missing `watchedByCount` parameter
## 2.224.2 - 2025-12-20
### Added
- Included the calendar year boundaries in the portfolio calculations
- Added the ISIN number to the asset profile details dialog of the admin control panel
### Changed
- Restored the support for specific calendar year date ranges (`2024`, `2023`, `2022`, etc.) in the assistant (experimental)
- Removed the deprecated _Angular CLI_ decorator (`decorate-angular-cli.js`)
- Refreshed the cryptocurrencies list
### Fixed
- Localized date formatting across the _FIRE_ section
## 2.223.0 - 2025-12-14
### Added

62
README.md

@ -85,31 +85,32 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
### Supported Environment Variables
| Name | Type | Default Value | Description |
| ------------------------ | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` |
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | `string` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `REDIS_PORT` | `number` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. |
| Name | Type | Default Value | Description |
| --------------------------- | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token |
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` |
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | `string` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `REDIS_PORT` | `number` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. |
#### OpenID Connect OIDC (Experimental)
| Name | Type | Default Value | Description |
| -------------------------- | --------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------- |
| `ENABLE_FEATURE_AUTH_OIDC` | `boolean` (optional) | `false` | Enables _OpenID Connect_ authentication |
| `ENABLE_FEATURE_AUTH_OIDC` | `boolean` (optional) | `false` | Enables authentication via _OpenID Connect_ |
| `OIDC_AUTHORIZATION_URL` | `string` (optional) | | Manual override for the OIDC authorization endpoint (falls back to the discovery from the issuer) |
| `OIDC_CALLBACK_URL` | `string` (optional) | `${ROOT_URL}/api/auth/oidc/callback` | The OIDC callback URL |
| `OIDC_CLIENT_ID` | `string` | | The OIDC client ID |
@ -241,7 +242,7 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
| `accountId` | `string` (optional) | Id of the account |
| `comment` | `string` (optional) | Comment of the activity |
| `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. |
| `dataSource` | `string` | `COINGECKO` \| `MANUAL` \| `YAHOO` |
| `dataSource` | `string` | `COINGECKO` \| `GHOSTFOLIO` [^1] \| `MANUAL` \| `YAHOO` |
| `date` | `string` | Date in the format `ISO-8601` |
| `fee` | `number` | Fee of the activity |
| `quantity` | `number` | Quantity of the activity |
@ -312,17 +313,16 @@ Ghostfolio is **100% free** and **open source**. We encourage and support an act
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
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">
<p>
Browser testing via<br />
<a href="https://www.lambdatest.com?utm_medium=sponsor&utm_source=ghostfolio" target="_blank" title="LambdaTest - AI Powered Testing Tool">
<img alt="LambdaTest Logo" height="45" width="250" src="https://www.lambdatest.com/blue-logo.png" />
</a>
</p>
<a href="https://www.testmuai.com?utm_medium=sponsor&utm_source=ghostfolio" target="_blank" title="TestMu AI - AI Powered Testing Tool">
<img alt="TestMu AI Logo" height="45" src="https://assets.testmuai.com/resources/images/logos/logo.svg" />
</a>
</div>
## Analytics
@ -331,6 +331,8 @@ If you like to support this project, become a [**Sponsor**](https://github.com/s
## License
© 2021 - 2025 [Ghostfolio](https://ghostfol.io)
© 2021 - 2026 [Ghostfolio](https://ghostfol.io)
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
[^1]: Available with [**Ghostfolio Premium**](https://ghostfol.io/en/pricing).

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

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

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

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

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

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

@ -15,12 +15,12 @@ import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/sym
import { Module } from '@nestjs/common';
import { OrderController } from './order.controller';
import { OrderService } from './order.service';
import { ActivitiesController } from './activities.controller';
import { ActivitiesService } from './activities.service';
@Module({
controllers: [OrderController],
exports: [OrderService],
controllers: [ActivitiesController],
exports: [ActivitiesService],
imports: [
ApiModule,
CacheModule,
@ -35,6 +35,6 @@ import { OrderService } from './order.service';
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
],
providers: [AccountBalanceService, AccountService, OrderService]
providers: [AccountBalanceService, AccountService, ActivitiesService]
})
export class OrderModule {}
export class ActivitiesModule {}

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

@ -1,7 +1,10 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
@ -16,6 +19,7 @@ import {
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import {
ActivitiesResponse,
Activity,
AssetProfileIdentifier,
EnhancedSymbolProfile,
Filter
@ -40,10 +44,12 @@ import { groupBy, uniqBy } from 'lodash';
import { randomUUID } from 'node:crypto';
@Injectable()
export class OrderService {
export class ActivitiesService {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService,
@ -56,7 +62,7 @@ export class OrderService {
tags,
userId
}: { tags: Tag[]; userId: string } & AssetProfileIdentifier) {
const orders = await this.prismaService.order.findMany({
const activities = await this.prismaService.order.findMany({
where: {
userId,
SymbolProfile: {
@ -67,7 +73,7 @@ export class OrderService {
});
await Promise.all(
orders.map(({ id }) =>
activities.map(({ id }) =>
this.prismaService.order.update({
data: {
tags: {
@ -90,7 +96,7 @@ export class OrderService {
);
}
public async createOrder(
public async createActivity(
data: Prisma.OrderCreateInput & {
accountId?: string;
assetClass?: AssetClass;
@ -195,7 +201,7 @@ export class OrderService {
? false
: isAfter(data.date as Date, endOfToday());
const order = await this.prismaService.order.create({
const activity = await this.prismaService.order.create({
data: {
...orderData,
account,
@ -229,56 +235,56 @@ export class OrderService {
this.eventEmitter.emit(
AssetProfileChangedEvent.getName(),
new AssetProfileChangedEvent({
currency: order.SymbolProfile.currency,
dataSource: order.SymbolProfile.dataSource,
symbol: order.SymbolProfile.symbol
currency: activity.SymbolProfile.currency,
dataSource: activity.SymbolProfile.dataSource,
symbol: activity.SymbolProfile.symbol
})
);
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: order.userId
userId: activity.userId
})
);
return order;
return activity;
}
public async deleteOrder(
public async deleteActivity(
where: Prisma.OrderWhereUniqueInput
): Promise<Order> {
const order = await this.prismaService.order.delete({
const activity = await this.prismaService.order.delete({
where
});
const [symbolProfile] =
await this.symbolProfileService.getSymbolProfilesByIds([
order.symbolProfileId
activity.symbolProfileId
]);
if (symbolProfile.activitiesCount === 0) {
await this.symbolProfileService.deleteById(order.symbolProfileId);
await this.symbolProfileService.deleteById(activity.symbolProfileId);
}
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: order.userId
userId: activity.userId
})
);
return order;
return activity;
}
public async deleteOrders({
public async deleteActivities({
filters,
userId
}: {
filters?: Filter[];
userId: string;
}): Promise<number> {
const { activities } = await this.getOrders({
const { activities } = await this.getActivities({
filters,
userId,
includeDrafts: true,
@ -317,7 +323,135 @@ export class OrderService {
return count;
}
public async getLatestOrder({ dataSource, symbol }: AssetProfileIdentifier) {
/**
* Generates synthetic activities for cash holdings based on account balance history.
* Treat currencies as assets with a fixed unit price of 1.0 (in their own currency) to allow
* performance tracking based on exchange rate fluctuations.
*
* @param cashDetails - The cash balance details.
* @param filters - Optional filters to apply.
* @param userCurrency - The base currency of the user.
* @param userId - The ID of the user.
* @returns A response containing the list of synthetic cash activities.
*/
public async getCashActivities({
cashDetails,
filters = [],
userCurrency,
userId
}: {
cashDetails: CashDetails;
filters?: Filter[];
userCurrency: string;
userId: string;
}): Promise<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[] = [];
for (const account of cashDetails.accounts) {
const { balances } = await this.accountBalanceService.getAccountBalances({
userCurrency,
userId,
filters: [{ id: account.id, type: 'ACCOUNT' }]
});
let currentBalance = 0;
let currentBalanceInBaseCurrency = 0;
for (const balanceItem of balances) {
const syntheticActivityTemplate: Activity = {
userId,
accountId: account.id,
accountUserId: account.userId,
comment: account.name,
createdAt: new Date(balanceItem.date),
currency: account.currency,
date: new Date(balanceItem.date),
fee: 0,
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
id: balanceItem.id,
isDraft: false,
quantity: 1,
SymbolProfile: {
activitiesCount: 0,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countries: [],
createdAt: new Date(balanceItem.date),
currency: account.currency,
dataSource:
this.dataProviderService.getDataSourceForExchangeRates(),
holdings: [],
id: account.currency,
isActive: true,
name: account.currency,
sectors: [],
symbol: account.currency,
updatedAt: new Date(balanceItem.date)
},
symbolProfileId: account.currency,
type: ActivityType.BUY,
unitPrice: 1,
unitPriceInAssetProfileCurrency: 1,
updatedAt: new Date(balanceItem.date),
valueInBaseCurrency: 0,
value: 0
};
if (currentBalance < balanceItem.value) {
// BUY
activities.push({
...syntheticActivityTemplate,
quantity: balanceItem.value - currentBalance,
type: ActivityType.BUY,
value: balanceItem.value - currentBalance,
valueInBaseCurrency:
balanceItem.valueInBaseCurrency - currentBalanceInBaseCurrency
});
} else if (currentBalance > balanceItem.value) {
// SELL
activities.push({
...syntheticActivityTemplate,
quantity: currentBalance - balanceItem.value,
type: ActivityType.SELL,
value: currentBalance - balanceItem.value,
valueInBaseCurrency:
currentBalanceInBaseCurrency - balanceItem.valueInBaseCurrency
});
}
currentBalance = balanceItem.value;
currentBalanceInBaseCurrency = balanceItem.valueInBaseCurrency;
}
}
return {
activities,
count: activities.length
};
}
public async getLatestActivity({
dataSource,
symbol
}: AssetProfileIdentifier) {
return this.prismaService.order.findFirst({
orderBy: {
date: 'desc'
@ -328,7 +462,7 @@ export class OrderService {
});
}
public async getOrders({
public async getActivities({
endDate,
filters,
includeDrafts = false,
@ -610,22 +744,52 @@ export class OrderService {
return { activities, count };
}
/**
* Retrieves all activities required for the portfolio calculator, including both standard asset activities
* and optional synthetic activities representing cash activities.
*/
@LogPerformance
public async getOrdersForPortfolioCalculator({
public async getActivitiesForPortfolioCalculator({
filters,
userCurrency,
userId
userId,
withCash = false
}: {
/** Optional filters to apply to the activities. */
filters?: Filter[];
/** The base currency of the user. */
userCurrency: string;
/** The ID of the user. */
userId: string;
/** Whether to include cash activities in the result. */
withCash?: boolean;
}) {
return this.getOrders({
const activities = await this.getActivities({
filters,
userCurrency,
userId,
withExcludedAccountsAndActivities: false // TODO
});
if (withCash) {
const cashDetails = await this.accountService.getCashDetails({
filters,
userId,
currency: userCurrency
});
const cashActivities = await this.getCashActivities({
cashDetails,
filters,
userCurrency,
userId
});
activities.activities.push(...cashActivities.activities);
activities.count += cashActivities.count;
}
return activities;
}
public async getStatisticsByCurrency(
@ -656,7 +820,7 @@ export class OrderService {
});
}
public async updateOrder({
public async updateActivity({
data,
where
}: {
@ -721,7 +885,7 @@ export class OrderService {
data: { tags: { set: [] } }
});
const order = await this.prismaService.order.update({
const activity = await this.prismaService.order.update({
where,
data: {
...data,
@ -735,11 +899,11 @@ export class OrderService {
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: order.userId
userId: activity.userId
})
);
return order;
return activity;
}
private async orders(params: {

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

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

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

@ -1,4 +1,4 @@
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
@ -20,6 +20,7 @@ import { QueueModule } from './queue/queue.module';
@Module({
imports: [
ActivitiesModule,
ApiModule,
BenchmarkModule,
ConfigurationModule,
@ -28,7 +29,6 @@ import { QueueModule } from './queue/queue.module';
DemoModule,
ExchangeRateDataModule,
MarketDataModule,
OrderModule,
PrismaModule,
PropertyModule,
QueueModule,

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

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

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

@ -1,4 +1,5 @@
import { EventsModule } from '@ghostfolio/api/events/events.module';
import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.middleware';
import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { CronModule } from '@ghostfolio/api/services/cron/cron.module';
@ -10,10 +11,13 @@ import { PropertyModule } from '@ghostfolio/api/services/property/property.modul
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import {
BULL_BOARD_ROUTE,
DEFAULT_LANGUAGE_CODE,
SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config';
import { ExpressAdapter } from '@bull-board/express';
import { BullBoardModule } from '@bull-board/nestjs';
import { BullModule } from '@nestjs/bull';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@ -25,6 +29,7 @@ import { join } from 'node:path';
import { AccessModule } from './access/access.module';
import { AccountModule } from './account/account.module';
import { ActivitiesModule } from './activities/activities.module';
import { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller';
import { AssetModule } from './asset/asset.module';
@ -37,6 +42,7 @@ import { AssetsModule } from './endpoints/assets/assets.module';
import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module';
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
import { MarketDataModule } from './endpoints/market-data/market-data.module';
import { PlatformsModule } from './endpoints/platforms/platforms.module';
import { PublicModule } from './endpoints/public/public.module';
import { SitemapModule } from './endpoints/sitemap/sitemap.module';
import { TagsModule } from './endpoints/tags/tags.module';
@ -47,7 +53,6 @@ import { HealthModule } from './health/health.module';
import { ImportModule } from './import/import.module';
import { InfoModule } from './info/info.module';
import { LogoModule } from './logo/logo.module';
import { OrderModule } from './order/order.module';
import { PlatformModule } from './platform/platform.module';
import { PortfolioModule } from './portfolio/portfolio.module';
import { RedisCacheModule } from './redis-cache/redis-cache.module';
@ -61,6 +66,7 @@ import { UserModule } from './user/user.module';
AdminModule,
AccessModule,
AccountModule,
ActivitiesModule,
AiModule,
ApiKeysModule,
AssetModule,
@ -68,6 +74,29 @@ import { UserModule } from './user/user.module';
AuthDeviceModule,
AuthModule,
BenchmarksModule,
...(process.env.ENABLE_FEATURE_BULL_BOARD === 'true'
? [
BullBoardModule.forRoot({
adapter: ExpressAdapter,
boardOptions: {
uiConfig: {
boardLogo: {
height: 0,
path: '',
width: 0
},
boardTitle: 'Job Queues',
favIcon: {
alternative: '/assets/favicon-32x32.png',
default: '/assets/favicon-32x32.png'
}
}
},
middleware: BullBoardAuthMiddleware,
route: BULL_BOARD_ROUTE
})
]
: []),
BullModule.forRoot({
redis: {
db: parseInt(process.env.REDIS_DB ?? '0', 10),
@ -93,8 +122,8 @@ import { UserModule } from './user/user.module';
InfoModule,
LogoModule,
MarketDataModule,
OrderModule,
PlatformModule,
PlatformsModule,
PortfolioModule,
PortfolioSnapshotQueueModule,
PrismaModule,
@ -103,7 +132,12 @@ import { UserModule } from './user/user.module';
RedisCacheModule,
ScheduleModule.forRoot(),
ServeStaticModule.forRoot({
exclude: ['/.well-known/*wildcard', '/api/*wildcard', '/sitemap.xml'],
exclude: [
`${BULL_BOARD_ROUTE}/*wildcard`,
'/.well-known/*wildcard',
'/api/*wildcard',
'/sitemap.xml'
],
rootPath: join(__dirname, '..', 'client'),
serveStaticOptions: {
setHeaders: (res) => {

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

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

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

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

@ -126,10 +126,10 @@ export class BenchmarksController {
@Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false'
): Promise<BenchmarkMarketDataDetailsResponse> {
const { endDate, startDate } = getIntervalFromDateRange(
const { endDate, startDate } = getIntervalFromDateRange({
dateRange,
new Date(startDateString)
);
startDate: new Date(startDateString)
});
const filters = this.apiService.buildFiltersFromQueryParams({
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 { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
@ -32,6 +32,7 @@ import { BenchmarksService } from './benchmarks.service';
@Module({
controllers: [BenchmarksController],
imports: [
ActivitiesModule,
ApiModule,
ConfigurationModule,
DataProviderModule,
@ -39,7 +40,6 @@ import { BenchmarksService } from './benchmarks.service';
I18nModule,
ImpersonationModule,
MarketDataModule,
OrderModule,
PortfolioSnapshotQueueModule,
PrismaModule,
PropertyModule,

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

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

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

@ -0,0 +1,24 @@
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { PlatformsResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { Controller, Get, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Controller('platforms')
export class PlatformsController {
public constructor(private readonly platformService: PlatformService) {}
@Get()
@HasPermission(permissions.readPlatforms)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPlatforms(): Promise<PlatformsResponse> {
const platforms = await this.platformService.getPlatforms({
orderBy: { name: 'asc' }
});
return { platforms };
}
}

11
apps/api/src/app/endpoints/platforms/platforms.module.ts

@ -0,0 +1,11 @@
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
import { Module } from '@nestjs/common';
import { PlatformsController } from './platforms.controller';
@Module({
controllers: [PlatformsController],
imports: [PlatformModule]
})
export class PlatformsModule {}

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

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

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

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

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

@ -1,5 +1,5 @@
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
@ -14,9 +14,9 @@ import { ExportService } from './export.service';
controllers: [ExportController],
imports: [
AccountModule,
ActivitiesModule,
ApiModule,
MarketDataModule,
OrderModule,
TagModule,
TransformDataSourceInRequestModule
],

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

@ -1,5 +1,5 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { environment } from '@ghostfolio/api/environments/environment';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
@ -17,8 +17,8 @@ import { groupBy, uniqBy } from 'lodash';
export class ExportService {
public constructor(
private readonly accountService: AccountService,
private readonly activitiesService: ActivitiesService,
private readonly marketDataService: MarketDataService,
private readonly orderService: OrderService,
private readonly tagService: TagService
) {}
@ -38,7 +38,7 @@ export class ExportService {
});
const platformsMap: { [platformId: string]: Platform } = {};
let { activities } = await this.orderService.getOrders({
let { activities } = await this.activitiesService.getActivities({
filters,
userId,
includeDrafts: true,
@ -182,10 +182,8 @@ export class ExportService {
isActive,
isin,
name,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
url
}) => {
return {
@ -204,11 +202,8 @@ export class ExportService {
isin,
marketData: marketDataByAssetProfile[id],
name,
scraperConfiguration:
scraperConfiguration as unknown as Prisma.JsonArray,
sectors: sectors as unknown as Prisma.JsonArray,
symbol,
symbolMapping,
url
};
}

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

@ -38,7 +38,7 @@ export class ImportController {
@Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@HasPermission(permissions.createOrder)
@HasPermission(permissions.createActivity)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async import(
@ -103,6 +103,7 @@ export class ImportController {
const activities = await this.importService.getDividends({
dataSource,
symbol,
userCurrency: this.request.user.settings.settings.baseCurrency,
userId: this.request.user.id
});

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

@ -1,11 +1,12 @@
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
@ -24,13 +25,14 @@ import { ImportService } from './import.service';
controllers: [ImportController],
imports: [
AccountModule,
ActivitiesModule,
ApiModule,
CacheModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
MarketDataModule,
OrderModule,
PlatformModule,
PortfolioModule,
PrismaModule,

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

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

8
apps/api/src/app/info/info.service.ts

@ -1,4 +1,3 @@
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
@ -38,7 +37,6 @@ export class InfoService {
private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly jwtService: JwtService,
private readonly platformService: PlatformService,
private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService,
private readonly subscriptionService: SubscriptionService,
@ -93,7 +91,6 @@ export class InfoService {
(await this.propertyService.getByKey<string[]>(
PROPERTY_COUNTRIES_OF_SUBSCRIBERS
)) ?? [];
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
}
if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) {
@ -104,16 +101,12 @@ export class InfoService {
benchmarks,
demoAuthToken,
isUserSignupEnabled,
platforms,
statistics,
subscriptionOffer
] = await Promise.all([
this.benchmarkService.getBenchmarkAssetProfiles(),
this.getDemoAuthToken(),
this.propertyService.isUserSignupEnabled(),
this.platformService.getPlatforms({
orderBy: { name: 'asc' }
}),
this.getStatistics(),
this.subscriptionService.getSubscriptionOffer({ key: 'default' })
]);
@ -128,7 +121,6 @@ export class InfoService {
demoAuthToken,
globalPermissions,
isReadOnlyMode,
platforms,
statistics,
subscriptionOffer,
baseCurrency: DEFAULT_CURRENCY,

2
apps/api/src/app/platform/platform.controller.ts

@ -25,7 +25,7 @@ export class PlatformController {
public constructor(private readonly platformService: PlatformService) {}
@Get()
@HasPermission(permissions.readPlatforms)
@HasPermission(permissions.readPlatformsWithAccountCount)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPlatforms() {
return this.platformService.getPlatformsWithAccountCount();

108
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts

@ -39,6 +39,7 @@ import { GroupBy } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Logger } from '@nestjs/common';
import { AssetSubClass } from '@prisma/client';
import { Big } from 'big.js';
import { plainToClass } from 'class-transformer';
import {
@ -52,6 +53,7 @@ import {
isBefore,
isWithinInterval,
min,
startOfDay,
startOfYear,
subDays
} from 'date-fns';
@ -119,6 +121,7 @@ export abstract class PortfolioCalculator {
({
date,
feeInAssetProfileCurrency,
feeInBaseCurrency,
quantity,
SymbolProfile,
tags = [],
@ -141,6 +144,7 @@ export abstract class PortfolioCalculator {
type,
date: format(date, DATE_FORMAT),
fee: new Big(feeInAssetProfileCurrency),
feeInBaseCurrency: new Big(feeInBaseCurrency),
quantity: new Big(quantity),
unitPrice: new Big(unitPriceInAssetProfileCurrency)
};
@ -154,13 +158,13 @@ export abstract class PortfolioCalculator {
this.redisCacheService = redisCacheService;
this.userId = userId;
const { endDate, startDate } = getIntervalFromDateRange(
'max',
subDays(dateOfFirstActivity, 1)
);
const { endDate, startDate } = getIntervalFromDateRange({
dateRange: 'max',
startDate: subDays(dateOfFirstActivity, 1)
});
this.endDate = endDate;
this.startDate = startDate;
this.endDate = endOfDay(endDate);
this.startDate = startOfDay(startDate);
this.computeTransactionPoints();
@ -203,13 +207,19 @@ export abstract class PortfolioCalculator {
let totalInterestWithCurrencyEffect = new Big(0);
let totalLiabilitiesWithCurrencyEffect = new Big(0);
for (const { currency, dataSource, symbol } of transactionPoints[
firstIndex - 1
].items) {
dataGatheringItems.push({
dataSource,
symbol
});
for (const {
assetSubClass,
currency,
dataSource,
symbol
} of transactionPoints[firstIndex - 1].items) {
// Gather data for all assets except CASH
if (assetSubClass !== 'CASH') {
dataGatheringItems.push({
dataSource,
symbol
});
}
currencies[symbol] = currency;
}
@ -227,7 +237,7 @@ export abstract class PortfolioCalculator {
const exchangeRatesByCurrency =
await this.exchangeRateDataService.getExchangeRatesByCurrency({
currencies: Array.from(new Set(Object.values(currencies))),
endDate: endOfDay(this.endDate),
endDate: this.endDate,
startDate: this.startDate,
targetCurrency: this.currency
});
@ -329,12 +339,6 @@ export abstract class PortfolioCalculator {
} = {};
for (const item of lastTransactionPoint.items) {
const feeInBaseCurrency = item.fee.mul(
exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[
lastTransactionPoint.date
] ?? 1
);
const marketPriceInBaseCurrency = (
marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice
).mul(
@ -383,29 +387,36 @@ export abstract class PortfolioCalculator {
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
valuesBySymbol[item.symbol] = {
currentValues,
currentValuesWithCurrencyEffect,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect
};
const includeInTotalAssetValue =
item.assetSubClass !== AssetSubClass.CASH;
if (includeInTotalAssetValue) {
valuesBySymbol[item.symbol] = {
currentValues,
currentValuesWithCurrencyEffect,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect
};
}
positions.push({
feeInBaseCurrency,
includeInTotalAssetValue,
timeWeightedInvestment,
timeWeightedInvestmentWithCurrencyEffect,
dividend: totalDividend,
dividendInBaseCurrency: totalDividendInBaseCurrency,
activitiesCount: item.activitiesCount,
averagePrice: item.averagePrice,
currency: item.currency,
dataSource: item.dataSource,
dateOfFirstActivity: item.dateOfFirstActivity,
dividend: totalDividend,
dividendInBaseCurrency: totalDividendInBaseCurrency,
fee: item.fee,
firstBuyDate: item.firstBuyDate,
feeInBaseCurrency: item.feeInBaseCurrency,
grossPerformance: !hasErrors ? (grossPerformance ?? null) : null,
grossPerformancePercentage: !hasErrors
? (grossPerformancePercentage ?? null)
@ -420,9 +431,8 @@ export abstract class PortfolioCalculator {
investment: totalInvestment,
investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect,
marketPrice:
marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? null,
marketPriceInBaseCurrency:
marketPriceInBaseCurrency?.toNumber() ?? null,
marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? 1,
marketPriceInBaseCurrency: marketPriceInBaseCurrency?.toNumber() ?? 1,
netPerformance: !hasErrors ? (netPerformance ?? null) : null,
netPerformancePercentage: !hasErrors
? (netPerformancePercentage ?? null)
@ -436,7 +446,6 @@ export abstract class PortfolioCalculator {
quantity: item.quantity,
symbol: item.symbol,
tags: item.tags,
transactionCount: item.transactionCount,
valueInBaseCurrency: new Big(marketPriceInBaseCurrency).mul(
item.quantity
)
@ -876,7 +885,7 @@ export abstract class PortfolioCalculator {
// Make sure some key dates are present
for (const dateRange of ['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd']) {
const { endDate: dateRangeEnd, startDate: dateRangeStart } =
getIntervalFromDateRange(dateRange);
getIntervalFromDateRange({ dateRange });
if (
!isBefore(dateRangeStart, startDate) &&
@ -925,6 +934,7 @@ export abstract class PortfolioCalculator {
for (const {
date,
fee,
feeInBaseCurrency,
quantity,
SymbolProfile,
tags,
@ -933,6 +943,7 @@ export abstract class PortfolioCalculator {
} of this.activities) {
let currentTransactionPointItem: TransactionPointSymbol;
const assetSubClass = SymbolProfile.assetSubClass;
const currency = SymbolProfile.currency;
const dataSource = SymbolProfile.dataSource;
const factor = getFactor(type);
@ -977,37 +988,42 @@ export abstract class PortfolioCalculator {
}
currentTransactionPointItem = {
assetSubClass,
currency,
dataSource,
investment,
skipErrors,
symbol,
activitiesCount: oldAccumulatedSymbol.activitiesCount + 1,
averagePrice: newQuantity.eq(0)
? new Big(0)
: investment.div(newQuantity).abs(),
dateOfFirstActivity: oldAccumulatedSymbol.dateOfFirstActivity,
dividend: new Big(0),
fee: oldAccumulatedSymbol.fee.plus(fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
feeInBaseCurrency:
oldAccumulatedSymbol.feeInBaseCurrency.plus(feeInBaseCurrency),
includeInHoldings: oldAccumulatedSymbol.includeInHoldings,
quantity: newQuantity,
tags: oldAccumulatedSymbol.tags.concat(tags),
transactionCount: oldAccumulatedSymbol.transactionCount + 1
tags: oldAccumulatedSymbol.tags.concat(tags)
};
} else {
currentTransactionPointItem = {
assetSubClass,
currency,
dataSource,
fee,
feeInBaseCurrency,
skipErrors,
symbol,
tags,
activitiesCount: 1,
averagePrice: unitPrice,
dateOfFirstActivity: date,
dividend: new Big(0),
firstBuyDate: date,
includeInHoldings: INVESTMENT_ACTIVITY_TYPES.includes(type),
investment: unitPrice.mul(quantity).mul(factor),
quantity: quantity.mul(factor),
transactionCount: 1
quantity: quantity.mul(factor)
};
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -0,0 +1,289 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { TimelinePosition } from '@ghostfolio/common/models';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { DataSource } from '@prisma/client';
import { Big } from 'big.js';
import { randomUUID } from 'node:crypto';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return ExchangeRateDataServiceMock;
})
};
}
);
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let accountBalanceService: AccountBalanceService;
let accountService: AccountService;
let activitiesService: ActivitiesService;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
accountBalanceService = new AccountBalanceService(
null,
exchangeRateDataService,
null
);
accountService = new AccountService(
accountBalanceService,
null,
exchangeRateDataService,
null
);
redisCacheService = new RedisCacheService(null, configurationService);
dataProviderService = new DataProviderService(
configurationService,
null,
null,
null,
null,
redisCacheService
);
currentRateService = new CurrentRateService(
null,
dataProviderService,
null,
null
);
activitiesService = new ActivitiesService(
accountBalanceService,
accountService,
null,
dataProviderService,
null,
exchangeRateDataService,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
describe('Cash Performance', () => {
it('should calculate performance for cash assets in CHF default currency', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2025-01-01').getTime());
const accountId = randomUUID();
jest
.spyOn(accountBalanceService, 'getAccountBalances')
.mockResolvedValue({
balances: [
{
accountId,
id: randomUUID(),
date: parseDate('2023-12-31'),
value: 1000,
valueInBaseCurrency: 850
},
{
accountId,
id: randomUUID(),
date: parseDate('2024-12-31'),
value: 2000,
valueInBaseCurrency: 1800
}
]
});
jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({
accounts: [
{
balance: 2000,
comment: null,
createdAt: parseDate('2023-12-31'),
currency: 'USD',
id: accountId,
isExcluded: false,
name: 'USD',
platformId: null,
updatedAt: parseDate('2023-12-31'),
userId: userDummyData.id
}
],
balanceInBaseCurrency: 1820
});
jest
.spyOn(dataProviderService, 'getDataSourceForExchangeRates')
.mockReturnValue(DataSource.YAHOO);
jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({
activities: [],
count: 0
});
const { activities } =
await activitiesService.getActivitiesForPortfolioCalculator({
userCurrency: 'CHF',
userId: userDummyData.id,
withCash: true
});
jest.spyOn(currentRateService, 'getValues').mockResolvedValue({
dataProviderInfos: [],
errors: [],
values: []
});
const accountBalanceItems =
await accountBalanceService.getAccountBalanceItems({
userCurrency: 'CHF',
userId: userDummyData.id
});
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
accountBalanceItems,
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const position = portfolioSnapshot.positions.find(({ symbol }) => {
return symbol === 'USD';
});
/**
* Investment: 2000 USD * 0.91 = 1820 CHF
* Investment value with currency effect: (1000 USD * 0.85) + (1000 USD * 0.90) = 1750 CHF
* Net performance: (1000 USD * 1.0) - (1000 USD * 1.0) = 0 CHF
* Total account balance: 2000 USD * 0.85 = 1700 CHF (using the exchange rate on 2024-12-31)
* Value in base currency: 2000 USD * 0.91 = 1820 CHF
*/
expect(position).toMatchObject<TimelinePosition>({
activitiesCount: 2,
averagePrice: new Big(1),
currency: 'USD',
dataSource: DataSource.YAHOO,
dateOfFirstActivity: '2023-12-31',
dividend: new Big(0),
dividendInBaseCurrency: new Big(0),
fee: new Big(0),
feeInBaseCurrency: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.08211603004634809014'
),
grossPerformanceWithCurrencyEffect: new Big(70),
includeInTotalAssetValue: false,
investment: new Big(1820),
investmentWithCurrencyEffect: new Big(1750),
marketPrice: 1,
marketPriceInBaseCurrency: 0.91,
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {
'1d': new Big('0.01111111111111111111'),
'1y': new Big('0.06937181021989792704'),
'5y': new Big('0.0818817546090273363'),
max: new Big('0.0818817546090273363'),
mtd: new Big('0.01111111111111111111'),
wtd: new Big('-0.05517241379310344828'),
ytd: new Big('0.01111111111111111111')
},
netPerformanceWithCurrencyEffectMap: {
'1d': new Big(20),
'1y': new Big(60),
'5y': new Big(70),
max: new Big(70),
mtd: new Big(20),
wtd: new Big(-80),
ytd: new Big(20)
},
quantity: new Big(2000),
symbol: 'USD',
timeWeightedInvestment: new Big('912.47956403269754768392'),
timeWeightedInvestmentWithCurrencyEffect: new Big(
'852.45231607629427792916'
),
valueInBaseCurrency: new Big(1820)
});
expect(portfolioSnapshot).toMatchObject({
hasErrors: false,
totalFeesWithCurrencyEffect: new Big(0),
totalInterestWithCurrencyEffect: new Big(0),
totalLiabilitiesWithCurrencyEffect: new Big(0)
});
});
});
});

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

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

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

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

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,
date: new Date('2023-01-01'), // Date in future
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,

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

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

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

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

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

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

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

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

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

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

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

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

54
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts

@ -13,7 +13,14 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance
import { Logger } from '@nestjs/common';
import { Big } from 'big.js';
import { addMilliseconds, differenceInDays, format, isBefore } from 'date-fns';
import {
addMilliseconds,
differenceInDays,
eachYearOfInterval,
format,
isBefore,
isThisYear
} from 'date-fns';
import { cloneDeep, sortBy } from 'lodash';
export class RoaiPortfolioCalculator extends PortfolioCalculator {
@ -34,7 +41,11 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
let totalTimeWeightedInvestment = new Big(0);
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
for (const currentPosition of positions) {
for (const currentPosition of positions.filter(
({ includeInTotalAssetValue }) => {
return includeInTotalAssetValue;
}
)) {
if (currentPosition.feeInBaseCurrency) {
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
currentPosition.feeInBaseCurrency
@ -188,6 +199,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
})
);
const isCash = orders[0]?.SymbolProfile?.assetSubClass === 'CASH';
if (orders.length <= 0) {
return {
currentValues: {},
@ -244,6 +257,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
// For BUY / SELL activities with a MANUAL data source where no historical market price is available,
// the calculation should fall back to using the activity’s unit price.
unitPriceAtEndDate = latestActivity.unitPrice;
} else if (isCash) {
unitPriceAtEndDate = new Big(1);
}
if (
@ -295,7 +310,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
symbol,
assetSubClass: isCash ? 'CASH' : undefined
},
type: 'BUY',
unitPrice: unitPriceAtStartDate
@ -308,7 +324,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
itemType: 'end',
SymbolProfile: {
dataSource,
symbol
symbol,
assetSubClass: isCash ? 'CASH' : undefined
},
quantity: new Big(0),
type: 'BUY',
@ -348,7 +365,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
symbol,
assetSubClass: isCash ? 'CASH' : undefined
},
type: 'BUY',
unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice,
@ -608,6 +626,13 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
totalQuantityFromBuyTransactions
);
if (totalUnits.eq(0)) {
// Reset tracking variables when position is fully closed
totalInvestmentFromBuyTransactions = new Big(0);
totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0);
totalQuantityFromBuyTransactions = new Big(0);
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log(
'grossPerformanceFromSells',
@ -826,17 +851,16 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
'max',
'mtd',
'wtd',
'ytd'
// TODO:
// ...eachYearOfInterval({ end, start })
// .filter((date) => {
// return !isThisYear(date);
// })
// .map((date) => {
// return format(date, 'yyyy');
// })
'ytd',
...eachYearOfInterval({ end, start })
.filter((date) => {
return !isThisYear(date);
})
.map((date) => {
return format(date, 'yyyy');
})
] as DateRange[]) {
const dateInterval = getIntervalFromDateRange(dateRange);
const dateInterval = getIntervalFromDateRange({ dateRange });
const endDate = dateInterval.endDate;
let startDate = dateInterval.startDate;

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

@ -64,6 +64,17 @@ function mockGetValue(symbol: string, date: Date) {
return { marketPrice: 0 };
case 'JNUG':
if (isSameDay(parseDate('2025-12-10'), date)) {
return { marketPrice: 204.5599975585938 };
} else if (isSameDay(parseDate('2025-12-17'), date)) {
return { marketPrice: 203.9700012207031 };
} else if (isSameDay(parseDate('2025-12-28'), date)) {
return { marketPrice: 237.8000030517578 };
}
return { marketPrice: 0 };
case 'MSFT':
if (isSameDay(parseDate('2021-09-16'), date)) {
return { marketPrice: 89.12 };

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

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

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

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';
export interface PortfolioOrderItem extends PortfolioOrder {
feeInBaseCurrency?: Big;
feeInBaseCurrencyWithCurrencyEffect?: Big;
itemType?: 'end' | 'start';
unitPriceFromMarketData?: Big;

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

@ -3,10 +3,11 @@ import { Activity } from '@ghostfolio/common/interfaces';
export interface PortfolioOrder extends Pick<Activity, 'tags' | 'type'> {
date: string;
fee: Big;
feeInBaseCurrency: Big;
quantity: Big;
SymbolProfile: Pick<
Activity['SymbolProfile'],
'currency' | 'dataSource' | 'name' | 'symbol' | 'userId'
'assetSubClass' | 'currency' | 'dataSource' | 'name' | 'symbol' | 'userId'
>;
unitPrice: Big;
}

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

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

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

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

@ -1,7 +1,7 @@
import { AccessModule } from '@ghostfolio/api/app/access/access.module';
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { PerformanceLoggingModule } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.module';
@ -34,6 +34,7 @@ import { RulesService } from './rules.service';
exports: [PortfolioService],
imports: [
AccessModule,
ActivitiesModule,
ApiModule,
BenchmarkModule,
ConfigurationModule,
@ -43,7 +44,6 @@ import { RulesService } from './rules.service';
I18nModule,
ImpersonationModule,
MarketDataModule,
OrderModule,
PerformanceLoggingModule,
PortfolioSnapshotQueueModule,
PrismaModule,

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

@ -1,7 +1,7 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
@ -13,7 +13,7 @@ import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rul
import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/developed-markets';
import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets';
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { FeeRatioTotalInvestmentVolume } from '@ghostfolio/api/models/rules/fees/fee-ratio-total-investment-volume';
import { BuyingPower } from '@ghostfolio/api/models/rules/liquidity/buying-power';
import { RegionalMarketClusterRiskAsiaPacific } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/asia-pacific';
import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets';
@ -105,13 +105,13 @@ export class PortfolioService {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService,
private readonly activitiesService: ActivitiesService,
private readonly benchmarkService: BenchmarkService,
private readonly calculatorFactory: PortfolioCalculatorFactory,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly i18nService: I18nService,
private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly rulesService: RulesService,
private readonly symbolProfileService: SymbolProfileService,
@ -179,9 +179,9 @@ export class PortfolioService {
return Promise.all(
accounts.map(async (account) => {
let activitiesCount = 0;
let dividendInBaseCurrency = 0;
let interestInBaseCurrency = 0;
let transactionCount = 0;
for (const {
currency,
@ -214,7 +214,7 @@ export class PortfolioService {
}
if (!isDraft) {
transactionCount += 1;
activitiesCount += 1;
}
}
@ -223,9 +223,9 @@ export class PortfolioService {
const result = {
...account,
activitiesCount,
dividendInBaseCurrency,
interestInBaseCurrency,
transactionCount,
valueInBaseCurrency,
allocationInPercentage: 0,
balanceInBaseCurrency: this.exchangeRateDataService.toCurrency(
@ -262,6 +262,8 @@ export class PortfolioService {
withExcludedAccounts
});
let activitiesCount = 0;
const searchQuery = filters.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
@ -281,9 +283,10 @@ export class PortfolioService {
let totalDividendInBaseCurrency = new Big(0);
let totalInterestInBaseCurrency = new Big(0);
let totalValueInBaseCurrency = new Big(0);
let transactionCount = 0;
for (const account of accounts) {
activitiesCount += account.activitiesCount;
totalBalanceInBaseCurrency = totalBalanceInBaseCurrency.plus(
account.balanceInBaseCurrency
);
@ -296,7 +299,6 @@ export class PortfolioService {
totalValueInBaseCurrency = totalValueInBaseCurrency.plus(
account.valueInBaseCurrency
);
transactionCount += account.transactionCount;
}
for (const account of accounts) {
@ -310,7 +312,7 @@ export class PortfolioService {
return {
accounts,
transactionCount,
activitiesCount,
totalBalanceInBaseCurrency: totalBalanceInBaseCurrency.toNumber(),
totalDividendInBaseCurrency: totalDividendInBaseCurrency.toNumber(),
totalInterestInBaseCurrency: totalInterestInBaseCurrency.toNumber(),
@ -318,13 +320,13 @@ export class PortfolioService {
};
}
public async getDividends({
public getDividends({
activities,
groupBy
}: {
activities: Activity[];
groupBy?: GroupBy;
}): Promise<InvestmentItem[]> {
}): InvestmentItem[] {
let dividends = activities.map(({ currency, date, value }) => {
return {
date: format(date, DATE_FORMAT),
@ -401,10 +403,10 @@ export class PortfolioService {
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
const { endDate, startDate } = getIntervalFromDateRange(dateRange);
const { endDate, startDate } = getIntervalFromDateRange({ dateRange });
const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({
await this.activitiesService.getActivitiesForPortfolioCalculator({
filters,
userCurrency,
userId
@ -488,7 +490,7 @@ export class PortfolioService {
);
const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({
await this.activitiesService.getActivitiesForPortfolioCalculator({
filters,
userCurrency,
userId
@ -522,10 +524,6 @@ export class PortfolioService {
return type === 'ACCOUNT';
}) ?? false;
const isFilteredByCash = filters?.some(({ id, type }) => {
return id === AssetClass.LIQUIDITY && type === 'ASSET_CLASS';
});
const isFilteredByClosedHoldings =
filters?.some(({ id, type }) => {
return id === 'CLOSED' && type === 'HOLDING_TYPE';
@ -557,6 +555,9 @@ export class PortfolioService {
assetProfileIdentifiers
);
const cashSymbolProfiles = this.getCashSymbolProfiles(cashDetails);
symbolProfiles.push(...cashSymbolProfiles);
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
for (const symbolProfile of symbolProfiles) {
symbolProfileMap[symbolProfile.symbol] = symbolProfile;
@ -568,9 +569,10 @@ export class PortfolioService {
}
for (const {
activitiesCount,
currency,
dateOfFirstActivity,
dividend,
firstBuyDate,
grossPerformance,
grossPerformanceWithCurrencyEffect,
grossPerformancePercentage,
@ -584,7 +586,6 @@ export class PortfolioService {
quantity,
symbol,
tags,
transactionCount,
valueInBaseCurrency
} of positions) {
if (isFilteredByClosedHoldings === true) {
@ -611,21 +612,43 @@ export class PortfolioService {
}
holdings[symbol] = {
activitiesCount,
currency,
markets,
marketsAdvanced,
marketPrice,
symbol,
tags,
transactionCount,
allocationInPercentage: filteredValueInBaseCurrency.eq(0)
? 0
: valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(),
assetClass: assetProfile.assetClass,
assetProfile: {
assetClass: assetProfile.assetClass,
assetSubClass: assetProfile.assetSubClass,
countries: assetProfile.countries,
currency: assetProfile.currency,
dataSource: assetProfile.dataSource,
holdings: assetProfile.holdings.map(
({ allocationInPercentage, name }) => {
return {
allocationInPercentage,
name,
valueInBaseCurrency: valueInBaseCurrency
.mul(allocationInPercentage)
.toNumber()
};
}
),
name: assetProfile.name,
sectors: assetProfile.sectors,
symbol: assetProfile.symbol,
url: assetProfile.url
},
assetSubClass: assetProfile.assetSubClass,
countries: assetProfile.countries,
dataSource: assetProfile.dataSource,
dateOfFirstActivity: parseDate(firstBuyDate),
dateOfFirstActivity: parseDate(dateOfFirstActivity),
dividend: dividend?.toNumber() ?? 0,
grossPerformance: grossPerformance?.toNumber() ?? 0,
grossPerformancePercent: grossPerformancePercentage?.toNumber() ?? 0,
@ -661,18 +684,6 @@ export class PortfolioService {
};
}
if (filters?.length === 0 || isFilteredByAccount || isFilteredByCash) {
const cashPositions = this.getCashPositions({
cashDetails,
userCurrency,
value: filteredValueInBaseCurrency
});
for (const symbol of Object.keys(cashPositions)) {
holdings[symbol] = cashPositions[symbol];
}
}
const { accounts, platforms } = await this.getValueOfAccountsAndPlatforms({
activities,
filters,
@ -769,7 +780,7 @@ export class PortfolioService {
const userCurrency = this.getUserCurrency(user);
const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({
await this.activitiesService.getActivitiesForPortfolioCalculator({
userCurrency,
userId
});
@ -802,11 +813,12 @@ export class PortfolioService {
}
const {
activitiesCount,
averagePrice,
currency,
dateOfFirstActivity,
dividendInBaseCurrency,
fee,
firstBuyDate,
feeInBaseCurrency,
grossPerformance,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
@ -820,8 +832,7 @@ export class PortfolioService {
quantity,
tags,
timeWeightedInvestment,
timeWeightedInvestmentWithCurrencyEffect,
transactionCount
timeWeightedInvestmentWithCurrencyEffect
} = holding;
const activitiesOfHolding = activities.filter(({ SymbolProfile }) => {
@ -832,7 +843,10 @@ export class PortfolioService {
});
const dividendYieldPercent = getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
daysInMarket: differenceInDays(
new Date(),
parseDate(dateOfFirstActivity)
),
netPerformancePercentage: timeWeightedInvestment.eq(0)
? new Big(0)
: dividendInBaseCurrency.div(timeWeightedInvestment)
@ -840,7 +854,10 @@ export class PortfolioService {
const dividendYieldPercentWithCurrencyEffect =
getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
daysInMarket: differenceInDays(
new Date(),
parseDate(dateOfFirstActivity)
),
netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq(0)
? new Big(0)
: dividendInBaseCurrency.div(timeWeightedInvestmentWithCurrencyEffect)
@ -849,7 +866,7 @@ export class PortfolioService {
const historicalData = await this.dataProviderService.getHistorical(
[{ dataSource, symbol }],
'day',
parseISO(firstBuyDate),
parseISO(dateOfFirstActivity),
new Date()
);
@ -914,7 +931,7 @@ export class PortfolioService {
// Add historical entry for buy date, if no historical data available
historicalDataArray.push({
averagePrice: activitiesOfHolding[0].unitPriceInAssetProfileCurrency,
date: firstBuyDate,
date: dateOfFirstActivity,
marketPrice: activitiesOfHolding[0].unitPriceInAssetProfileCurrency,
quantity: activitiesOfHolding[0].quantity
});
@ -927,25 +944,20 @@ export class PortfolioService {
);
return {
firstBuyDate,
activitiesCount,
dateOfFirstActivity,
marketPrice,
marketPriceMax,
marketPriceMin,
SymbolProfile,
tags,
activities: activitiesOfHolding,
activitiesCount: transactionCount,
averagePrice: averagePrice.toNumber(),
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0],
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
dividendYieldPercent: dividendYieldPercent.toNumber(),
dividendYieldPercentWithCurrencyEffect:
dividendYieldPercentWithCurrencyEffect.toNumber(),
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
fee.toNumber(),
SymbolProfile.currency,
userCurrency
),
feeInBaseCurrency: feeInBaseCurrency.toNumber(),
grossPerformance: grossPerformance?.toNumber(),
grossPerformancePercent: grossPerformancePercentage?.toNumber(),
grossPerformancePercentWithCurrencyEffect:
@ -998,7 +1010,7 @@ export class PortfolioService {
userId,
userCurrency
}),
this.orderService.getOrdersForPortfolioCalculator({
this.activitiesService.getActivitiesForPortfolioCalculator({
filters,
userCurrency,
userId
@ -1017,7 +1029,8 @@ export class PortfolioService {
netPerformancePercentage: 0,
netPerformancePercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
totalInvestment: 0
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0
}
};
}
@ -1034,7 +1047,7 @@ export class PortfolioService {
const { errors, hasErrors, historicalData } =
await portfolioCalculator.getSnapshot();
const { endDate, startDate } = getIntervalFromDateRange(dateRange);
const { endDate, startDate } = getIntervalFromDateRange({ dateRange });
const { chart } = await portfolioCalculator.getPerformance({
end: endDate,
@ -1048,6 +1061,7 @@ export class PortfolioService {
netPerformanceWithCurrencyEffect,
netWorth,
totalInvestment,
totalInvestmentValueWithCurrencyEffect,
valueWithCurrencyEffect
} = chart?.at(-1) ?? {
netPerformance: 0,
@ -1068,6 +1082,7 @@ export class PortfolioService {
netPerformance,
netPerformanceWithCurrencyEffect,
totalInvestment,
totalInvestmentValueWithCurrencyEffect,
currentNetWorth: netWorth,
currentValueInBaseCurrency: valueWithCurrencyEffect,
netPerformancePercentage: netPerformanceInPercentage,
@ -1316,11 +1331,11 @@ export class PortfolioService {
}),
rules: await this.rulesService.evaluate(
[
new FeeRatioInitialInvestment(
new FeeRatioTotalInvestmentVolume(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
summary.committedFunds,
summary.totalBuy + summary.totalSell,
summary.fees
)
],
@ -1356,7 +1371,12 @@ export class PortfolioService {
}) {
userId = await this.getUserId(impersonationId, userId);
await this.orderService.assignTags({ dataSource, symbol, tags, userId });
await this.activitiesService.assignTags({
dataSource,
symbol,
tags,
userId
});
}
private getAggregatedMarkets(holdings: Record<string, PortfolioPosition>): {
@ -1548,6 +1568,37 @@ export class PortfolioService {
return cashPositions;
}
private getCashSymbolProfiles(cashDetails: CashDetails) {
const cashSymbols = [
...new Set(cashDetails.accounts.map(({ currency }) => currency))
];
return cashSymbols.map<EnhancedSymbolProfile>((currency) => {
const account = cashDetails.accounts.find(
({ currency: accountCurrency }) => {
return accountCurrency === currency;
}
);
return {
currency,
activitiesCount: 0,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countries: [],
createdAt: account.createdAt,
dataSource: DataSource.MANUAL,
holdings: [],
id: currency,
isActive: true,
name: currency,
sectors: [],
symbol: currency,
updatedAt: account.updatedAt
};
});
}
private getDividendsByGroup({
dividends,
groupBy
@ -1644,9 +1695,21 @@ export class PortfolioService {
}): PortfolioPosition {
return {
currency,
activitiesCount: 0,
allocationInPercentage: 0,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
assetProfile: {
currency,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countries: [],
dataSource: undefined,
holdings: [],
name: currency,
sectors: [],
symbol: currency
},
countries: [],
dataSource: undefined,
dateOfFirstActivity: undefined,
@ -1667,7 +1730,6 @@ export class PortfolioService {
sectors: [],
symbol: currency,
tags: [],
transactionCount: 0,
valueInBaseCurrency: balance
};
}
@ -1817,7 +1879,7 @@ export class PortfolioService {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId });
const { activities } = await this.orderService.getOrders({
const { activities } = await this.activitiesService.getActivities({
userCurrency,
userId,
withExcludedAccountsAndActivities: true
@ -1839,8 +1901,11 @@ export class PortfolioService {
}
}
const { currentValueInBaseCurrency, totalInvestment } =
await portfolioCalculator.getSnapshot();
const {
currentValueInBaseCurrency,
totalInvestment,
totalInvestmentWithCurrencyEffect
} = await portfolioCalculator.getSnapshot();
const { performance } = await this.getPerformance({
impersonationId,
@ -1854,18 +1919,17 @@ export class PortfolioService {
netPerformanceWithCurrencyEffect
} = performance;
const dividendInBaseCurrency =
await portfolioCalculator.getDividendInBaseCurrency();
const totalEmergencyFund = this.getTotalEmergencyFund({
emergencyFundHoldingsValueInBaseCurrency,
userSettings: user.settings?.settings as UserSettings
});
const fees = await portfolioCalculator.getFeesInBaseCurrency();
const dateOfFirstActivity = portfolioCalculator.getStartDate();
const firstOrderDate = portfolioCalculator.getStartDate();
const dividendInBaseCurrency =
await portfolioCalculator.getDividendInBaseCurrency();
const fees = await portfolioCalculator.getFeesInBaseCurrency();
const interest = await portfolioCalculator.getInterestInBaseCurrency();
const liabilities =
@ -1888,8 +1952,6 @@ export class PortfolioService {
.plus(emergencyFundHoldingsValueInBaseCurrency)
.toNumber();
const committedFunds = new Big(totalBuy).minus(totalSell);
const totalOfExcludedActivities = this.getSumOfActivityType({
userCurrency,
activities: excludedActivities,
@ -1923,7 +1985,7 @@ export class PortfolioService {
.minus(liabilities)
.toNumber();
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
const daysInMarket = differenceInDays(new Date(), dateOfFirstActivity);
const annualizedPerformancePercent = getAnnualizedPerformancePercent({
daysInMarket,
@ -1942,6 +2004,7 @@ export class PortfolioService {
annualizedPerformancePercent,
annualizedPerformancePercentWithCurrencyEffect,
cash,
dateOfFirstActivity,
excludedAccountsAndActivities,
netPerformance,
netPerformancePercentage,
@ -1952,7 +2015,6 @@ export class PortfolioService {
activityCount: activities.filter(({ type }) => {
return ['BUY', 'SELL'].includes(type);
}).length,
committedFunds: committedFunds.toNumber(),
currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(),
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
emergencyFund: {
@ -1983,6 +2045,8 @@ export class PortfolioService {
interestInBaseCurrency: interest.toNumber(),
liabilitiesInBaseCurrency: liabilities.toNumber(),
totalInvestment: totalInvestment.toNumber(),
totalInvestmentValueWithCurrencyEffect:
totalInvestmentWithCurrencyEffect.toNumber(),
totalValueInBaseCurrency: netWorth
};
}
@ -2157,7 +2221,7 @@ export class PortfolioService {
accounts[account?.id || UNKNOWN_KEY] = {
balance: 0,
currency: account?.currency,
name: account.name,
name: account?.name,
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency
};
}
@ -2171,7 +2235,7 @@ export class PortfolioService {
platforms[account?.platformId || UNKNOWN_KEY] = {
balance: 0,
currency: account?.currency,
name: account.platform?.name,
name: account?.platform?.name,
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency
};
}

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

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

@ -35,7 +35,7 @@ export class SubscriptionService {
this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'),
{
apiVersion: '2025-08-27.basil'
apiVersion: '2026-01-28.clover'
}
);
}
@ -100,7 +100,7 @@ export class SubscriptionService {
);
return {
sessionId: session.id
sessionUrl: session.url
};
}

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

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

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

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

544
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",
"LUNA2": "Terra",
"SGB1": "Songbird",
"SKY33038": "Sky",
"SMURFCAT": "Real Smurf Cat",
"TON11419": "Toncoin",
"UNI1": "Uniswap",

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

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

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

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

@ -1,4 +1,4 @@
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -12,11 +12,11 @@ import { PortfolioChangedListener } from './portfolio-changed.listener';
@Module({
imports: [
ActivitiesModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
OrderModule,
RedisCacheModule
],
providers: [AssetProfileChangedListener, PortfolioChangedListener]

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

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

@ -1,47 +1,61 @@
import { redactAttributes } from './object.helper';
import { DEFAULT_REDACTED_PATHS } from '@ghostfolio/common/config';
import { query, redactPaths } from './object.helper';
describe('query', () => {
it('should get market price from stock API response', () => {
const object = {
currency: 'USD',
market: {
previousClose: 273.04,
price: 271.86
},
symbol: 'AAPL'
};
const result = query({
object,
pathExpression: '$.market.price'
})[0];
expect(result).toBe(271.86);
});
});
describe('redactAttributes', () => {
it('should redact provided attributes', () => {
expect(redactAttributes({ object: {}, options: [] })).toStrictEqual({});
expect(redactPaths({ object: {}, paths: [] })).toStrictEqual({});
expect(
redactAttributes({ object: { value: 1000 }, options: [] })
).toStrictEqual({ value: 1000 });
expect(redactPaths({ object: { value: 1000 }, paths: [] })).toStrictEqual({
value: 1000
});
expect(
redactAttributes({
redactPaths({
object: { value: 1000 },
options: [{ attribute: 'value', valueMap: { '*': null } }]
paths: ['value']
})
).toStrictEqual({ value: null });
expect(
redactAttributes({
redactPaths({
object: { value: 'abc' },
options: [{ attribute: 'value', valueMap: { abc: 'xyz' } }]
paths: ['value'],
valueMap: { abc: 'xyz' }
})
).toStrictEqual({ value: 'xyz' });
expect(
redactAttributes({
redactPaths({
object: { data: [{ value: 'a' }, { value: 'b' }] },
options: [{ attribute: 'value', valueMap: { a: 1, b: 2 } }]
paths: ['data[*].value'],
valueMap: { a: 1, b: 2 }
})
).toStrictEqual({ data: [{ value: 1 }, { value: 2 }] });
expect(
redactAttributes({
object: { value1: 'a', value2: 'b' },
options: [
{ attribute: 'value1', valueMap: { a: 'x' } },
{ attribute: 'value2', valueMap: { '*': 'y' } }
]
})
).toStrictEqual({ value1: 'x', value2: 'y' });
console.time('redactAttributes execution time');
expect(
redactAttributes({
redactPaths({
object: {
accounts: {
'2e937c05-657c-4de9-8fb3-0813a2245f26': {
@ -97,6 +111,7 @@ describe('redactAttributes', () => {
hasError: false,
holdings: {
'AAPL.US': {
activitiesCount: 1,
currency: 'USD',
markets: {
UNKNOWN: 0,
@ -116,7 +131,6 @@ describe('redactAttributes', () => {
marketPrice: 220.79,
symbol: 'AAPL.US',
tags: [],
transactionCount: 1,
allocationInPercentage: 0.044900865255793135,
assetClass: 'EQUITY',
assetSubClass: 'STOCK',
@ -149,6 +163,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.0694356974830054
},
'ALV.DE': {
activitiesCount: 2,
currency: 'EUR',
markets: {
UNKNOWN: 0,
@ -168,7 +183,6 @@ describe('redactAttributes', () => {
marketPrice: 296.5,
symbol: 'ALV.DE',
tags: [],
transactionCount: 2,
allocationInPercentage: 0.026912563036519527,
assetClass: 'EQUITY',
assetSubClass: 'STOCK',
@ -196,6 +210,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.04161818652826481
},
AMZN: {
activitiesCount: 1,
currency: 'USD',
markets: {
UNKNOWN: 0,
@ -215,7 +230,6 @@ describe('redactAttributes', () => {
marketPrice: 187.99,
symbol: 'AMZN',
tags: [],
transactionCount: 1,
allocationInPercentage: 0.07646101417126275,
assetClass: 'EQUITY',
assetSubClass: 'STOCK',
@ -248,6 +262,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.11824101426541227
},
bitcoin: {
activitiesCount: 1,
currency: 'USD',
markets: {
UNKNOWN: 36985.0332704,
@ -273,7 +288,6 @@ describe('redactAttributes', () => {
userId: null
}
],
transactionCount: 1,
allocationInPercentage: 0.15042891393226654,
assetClass: 'LIQUIDITY',
assetSubClass: 'CRYPTOCURRENCY',
@ -299,6 +313,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.232626620912395
},
BONDORA_GO_AND_GROW: {
activitiesCount: 5,
currency: 'EUR',
markets: {
UNKNOWN: 2231.644722160232,
@ -324,7 +339,6 @@ describe('redactAttributes', () => {
userId: null
}
],
transactionCount: 5,
allocationInPercentage: 0.009076749759365777,
assetClass: 'FIXED_INCOME',
assetSubClass: 'BOND',
@ -350,6 +364,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.014036487867880205
},
FRANKLY95P: {
activitiesCount: 6,
currency: 'CHF',
markets: {
UNKNOWN: 0,
@ -375,7 +390,6 @@ describe('redactAttributes', () => {
userId: null
}
],
transactionCount: 6,
allocationInPercentage: 0.09095764645669335,
assetClass: 'EQUITY',
assetSubClass: 'ETF',
@ -474,6 +488,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.14065892911313693
},
MSFT: {
activitiesCount: 1,
currency: 'USD',
markets: {
UNKNOWN: 0,
@ -493,7 +508,6 @@ describe('redactAttributes', () => {
marketPrice: 428.02,
symbol: 'MSFT',
tags: [],
transactionCount: 1,
allocationInPercentage: 0.05222646409742627,
assetClass: 'EQUITY',
assetSubClass: 'STOCK',
@ -526,6 +540,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.08076416659271518
},
TSLA: {
activitiesCount: 1,
currency: 'USD',
markets: {
UNKNOWN: 0,
@ -545,7 +560,6 @@ describe('redactAttributes', () => {
marketPrice: 260.46,
symbol: 'TSLA',
tags: [],
transactionCount: 1,
allocationInPercentage: 0.1589050142378352,
assetClass: 'EQUITY',
assetSubClass: 'STOCK',
@ -578,6 +592,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.2457342510950259
},
VTI: {
activitiesCount: 5,
currency: 'USD',
markets: {
UNKNOWN: 0,
@ -597,7 +612,6 @@ describe('redactAttributes', () => {
marketPrice: 282.05,
symbol: 'VTI',
tags: [],
transactionCount: 5,
allocationInPercentage: 0.057358979326040366,
assetClass: 'EQUITY',
assetSubClass: 'ETF',
@ -750,6 +764,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.08870120238725339
},
'VWRL.SW': {
activitiesCount: 5,
currency: 'CHF',
markets: {
UNKNOWN: 0,
@ -769,7 +784,6 @@ describe('redactAttributes', () => {
marketPrice: 117.62,
symbol: 'VWRL.SW',
tags: [],
transactionCount: 5,
allocationInPercentage: 0.09386983901959013,
assetClass: 'EQUITY',
assetSubClass: 'ETF',
@ -1158,6 +1172,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.145162408515095
},
'XDWD.DE': {
activitiesCount: 1,
currency: 'EUR',
markets: {
UNKNOWN: 0,
@ -1177,7 +1192,6 @@ describe('redactAttributes', () => {
marketPrice: 105.72,
symbol: 'XDWD.DE',
tags: [],
transactionCount: 1,
allocationInPercentage: 0.03598477442100562,
assetClass: 'EQUITY',
assetSubClass: 'ETF',
@ -1436,6 +1450,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.055647656152211074
},
USD: {
activitiesCount: 0,
currency: 'USD',
allocationInPercentage: 0.20291717628620132,
assetClass: 'LIQUIDITY',
@ -1458,7 +1473,6 @@ describe('redactAttributes', () => {
sectors: [],
symbol: 'USD',
tags: [],
transactionCount: 0,
valueInBaseCurrency: 49890,
valueInPercentage: 0.3137956381563603
}
@ -1526,7 +1540,6 @@ describe('redactAttributes', () => {
netPerformanceWithCurrencyEffect: null,
totalBuy: null,
totalSell: null,
committedFunds: null,
currentValueInBaseCurrency: null,
dividendInBaseCurrency: null,
emergencyFund: null,
@ -1540,38 +1553,12 @@ describe('redactAttributes', () => {
items: null,
liabilities: null,
totalInvestment: null,
totalInvestmentValueWithCurrencyEffect: null,
totalValueInBaseCurrency: null,
currentNetWorth: null
}
},
options: [
'balance',
'balanceInBaseCurrency',
'comment',
'convertedBalance',
'dividendInBaseCurrency',
'fee',
'feeInBaseCurrency',
'grossPerformance',
'grossPerformanceWithCurrencyEffect',
'investment',
'netPerformance',
'netPerformanceWithCurrencyEffect',
'quantity',
'symbolMapping',
'totalBalanceInBaseCurrency',
'totalValueInBaseCurrency',
'unitPrice',
'value',
'valueInBaseCurrency'
].map((attribute) => {
return {
attribute,
valueMap: {
'*': null
}
};
})
paths: DEFAULT_REDACTED_PATHS
})
).toStrictEqual({
accounts: {
@ -1628,6 +1615,7 @@ describe('redactAttributes', () => {
hasError: false,
holdings: {
'AAPL.US': {
activitiesCount: 1,
currency: 'USD',
markets: {
UNKNOWN: 0,
@ -1647,7 +1635,6 @@ describe('redactAttributes', () => {
marketPrice: 220.79,
symbol: 'AAPL.US',
tags: [],
transactionCount: 1,
allocationInPercentage: 0.044900865255793135,
assetClass: 'EQUITY',
assetSubClass: 'STOCK',
@ -1661,7 +1648,7 @@ describe('redactAttributes', () => {
],
dataSource: 'EOD_HISTORICAL_DATA',
dateOfFirstActivity: '2021-11-30T23:00:00.000Z',
dividend: 0,
dividend: null,
grossPerformance: null,
grossPerformancePercent: 0.3183066634822068,
grossPerformancePercentWithCurrencyEffect: 0.3183066634822068,
@ -1680,6 +1667,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.0694356974830054
},
'ALV.DE': {
activitiesCount: 2,
currency: 'EUR',
markets: {
UNKNOWN: 0,
@ -1699,7 +1687,6 @@ describe('redactAttributes', () => {
marketPrice: 296.5,
symbol: 'ALV.DE',
tags: [],
transactionCount: 2,
allocationInPercentage: 0.026912563036519527,
assetClass: 'EQUITY',
assetSubClass: 'STOCK',
@ -1708,7 +1695,7 @@ describe('redactAttributes', () => {
],
dataSource: 'YAHOO',
dateOfFirstActivity: '2021-04-22T22:00:00.000Z',
dividend: 192,
dividend: null,
grossPerformance: null,
grossPerformancePercent: 0.3719230057375532,
grossPerformancePercentWithCurrencyEffect: 0.2650716044872953,
@ -1727,6 +1714,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.04161818652826481
},
AMZN: {
activitiesCount: 1,
currency: 'USD',
markets: {
UNKNOWN: 0,
@ -1746,7 +1734,6 @@ describe('redactAttributes', () => {
marketPrice: 187.99,
symbol: 'AMZN',
tags: [],
transactionCount: 1,
allocationInPercentage: 0.07646101417126275,
assetClass: 'EQUITY',
assetSubClass: 'STOCK',
@ -1760,7 +1747,7 @@ describe('redactAttributes', () => {
],
dataSource: 'YAHOO',
dateOfFirstActivity: '2018-09-30T22:00:00.000Z',
dividend: 0,
dividend: null,
grossPerformance: null,
grossPerformancePercent: 0.8594552890963852,
grossPerformancePercentWithCurrencyEffect: 0.8594552890963852,
@ -1779,6 +1766,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.11824101426541227
},
bitcoin: {
activitiesCount: 1,
currency: 'USD',
markets: {
UNKNOWN: 36985.0332704,
@ -1804,14 +1792,13 @@ describe('redactAttributes', () => {
userId: null
}
],
transactionCount: 1,
allocationInPercentage: 0.15042891393226654,
assetClass: 'LIQUIDITY',
assetSubClass: 'CRYPTOCURRENCY',
countries: [],
dataSource: 'COINGECKO',
dateOfFirstActivity: '2017-08-15T22:00:00.000Z',
dividend: 0,
dividend: null,
grossPerformance: null,
grossPerformancePercent: 17.4925166352,
grossPerformancePercentWithCurrencyEffect: 17.4925166352,
@ -1830,6 +1817,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.232626620912395
},
BONDORA_GO_AND_GROW: {
activitiesCount: 5,
currency: 'EUR',
markets: {
UNKNOWN: 2231.644722160232,
@ -1855,14 +1843,13 @@ describe('redactAttributes', () => {
userId: null
}
],
transactionCount: 5,
allocationInPercentage: 0.009076749759365777,
assetClass: 'FIXED_INCOME',
assetSubClass: 'BOND',
countries: [],
dataSource: 'MANUAL',
dateOfFirstActivity: '2021-01-31T23:00:00.000Z',
dividend: 11.45,
dividend: null,
grossPerformance: null,
grossPerformancePercent: 0,
grossPerformancePercentWithCurrencyEffect: -0.06153834320225245,
@ -1881,6 +1868,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.014036487867880205
},
FRANKLY95P: {
activitiesCount: 6,
currency: 'CHF',
markets: {
UNKNOWN: 0,
@ -1906,7 +1894,6 @@ describe('redactAttributes', () => {
userId: null
}
],
transactionCount: 6,
allocationInPercentage: 0.09095764645669335,
assetClass: 'EQUITY',
assetSubClass: 'ETF',
@ -1966,7 +1953,7 @@ describe('redactAttributes', () => {
],
dataSource: 'MANUAL',
dateOfFirstActivity: '2021-03-31T22:00:00.000Z',
dividend: 0,
dividend: null,
grossPerformance: null,
grossPerformancePercent: 0.27579517683678895,
grossPerformancePercentWithCurrencyEffect: 0.458553421589667,
@ -1985,6 +1972,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.14065892911313693
},
MSFT: {
activitiesCount: 1,
currency: 'USD',
markets: {
UNKNOWN: 0,
@ -2004,7 +1992,6 @@ describe('redactAttributes', () => {
marketPrice: 428.02,
symbol: 'MSFT',
tags: [],
transactionCount: 1,
allocationInPercentage: 0.05222646409742627,
assetClass: 'EQUITY',
assetSubClass: 'STOCK',
@ -2018,7 +2005,7 @@ describe('redactAttributes', () => {
],
dataSource: 'YAHOO',
dateOfFirstActivity: '2023-01-02T23:00:00.000Z',
dividend: 0,
dividend: null,
grossPerformance: null,
grossPerformancePercent: 0.7865431171216295,
grossPerformancePercentWithCurrencyEffect: 0.7865431171216295,
@ -2037,6 +2024,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.08076416659271518
},
TSLA: {
activitiesCount: 1,
currency: 'USD',
markets: {
UNKNOWN: 0,
@ -2056,7 +2044,6 @@ describe('redactAttributes', () => {
marketPrice: 260.46,
symbol: 'TSLA',
tags: [],
transactionCount: 1,
allocationInPercentage: 0.1589050142378352,
assetClass: 'EQUITY',
assetSubClass: 'STOCK',
@ -2070,7 +2057,7 @@ describe('redactAttributes', () => {
],
dataSource: 'YAHOO',
dateOfFirstActivity: '2017-01-02T23:00:00.000Z',
dividend: 0,
dividend: null,
grossPerformance: null,
grossPerformancePercent: 17.184314638161936,
grossPerformancePercentWithCurrencyEffect: 17.184314638161936,
@ -2089,6 +2076,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.2457342510950259
},
VTI: {
activitiesCount: 5,
currency: 'USD',
markets: {
UNKNOWN: 0,
@ -2108,7 +2096,6 @@ describe('redactAttributes', () => {
marketPrice: 282.05,
symbol: 'VTI',
tags: [],
transactionCount: 5,
allocationInPercentage: 0.057358979326040366,
assetClass: 'EQUITY',
assetSubClass: 'ETF',
@ -2152,7 +2139,7 @@ describe('redactAttributes', () => {
],
dataSource: 'YAHOO',
dateOfFirstActivity: '2019-02-28T23:00:00.000Z',
dividend: 0,
dividend: null,
grossPerformance: null,
grossPerformancePercent: 0.8832083851170418,
grossPerformancePercentWithCurrencyEffect: 0.8832083851170418,
@ -2261,6 +2248,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.08870120238725339
},
'VWRL.SW': {
activitiesCount: 5,
currency: 'CHF',
markets: {
UNKNOWN: 0,
@ -2280,7 +2268,6 @@ describe('redactAttributes', () => {
marketPrice: 117.62,
symbol: 'VWRL.SW',
tags: [],
transactionCount: 5,
allocationInPercentage: 0.09386983901959013,
assetClass: 'EQUITY',
assetSubClass: 'ETF',
@ -2547,7 +2534,7 @@ describe('redactAttributes', () => {
],
dataSource: 'YAHOO',
dateOfFirstActivity: '2018-02-28T23:00:00.000Z',
dividend: 0,
dividend: null,
grossPerformance: null,
grossPerformancePercent: 0.3683200415015591,
grossPerformancePercentWithCurrencyEffect: 0.5806366182968891,
@ -2661,6 +2648,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.145162408515095
},
'XDWD.DE': {
activitiesCount: 1,
currency: 'EUR',
markets: {
UNKNOWN: 0,
@ -2680,7 +2668,6 @@ describe('redactAttributes', () => {
marketPrice: 105.72,
symbol: 'XDWD.DE',
tags: [],
transactionCount: 1,
allocationInPercentage: 0.03598477442100562,
assetClass: 'EQUITY',
assetSubClass: 'ETF',
@ -2826,7 +2813,7 @@ describe('redactAttributes', () => {
],
dataSource: 'YAHOO',
dateOfFirstActivity: '2021-08-18T22:00:00.000Z',
dividend: 0,
dividend: null,
grossPerformance: null,
grossPerformancePercent: 0.3474381850624522,
grossPerformancePercentWithCurrencyEffect: 0.28744846894552306,
@ -2939,12 +2926,13 @@ describe('redactAttributes', () => {
valueInPercentage: 0.055647656152211074
},
USD: {
activitiesCount: 0,
currency: 'USD',
allocationInPercentage: 0.20291717628620132,
assetClass: 'LIQUIDITY',
assetSubClass: 'CASH',
countries: [],
dividend: 0,
dividend: null,
grossPerformance: null,
grossPerformancePercent: 0,
grossPerformancePercentWithCurrencyEffect: 0,
@ -2961,7 +2949,6 @@ describe('redactAttributes', () => {
sectors: [],
symbol: 'USD',
tags: [],
transactionCount: 0,
valueInBaseCurrency: null,
valueInPercentage: 0.3137956381563603
}
@ -3029,7 +3016,6 @@ describe('redactAttributes', () => {
netPerformanceWithCurrencyEffect: null,
totalBuy: null,
totalSell: null,
committedFunds: null,
currentValueInBaseCurrency: null,
dividendInBaseCurrency: null,
emergencyFund: null,
@ -3043,6 +3029,7 @@ describe('redactAttributes', () => {
items: null,
liabilities: null,
totalInvestment: null,
totalInvestmentValueWithCurrencyEffect: null,
totalValueInBaseCurrency: null,
currentNetWorth: null
}

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

@ -1,5 +1,6 @@
import { Big } from 'big.js';
import { cloneDeep, isArray, isObject } from 'lodash';
import fastRedact from 'fast-redact';
import jsonpath from 'jsonpath';
import { cloneDeep, isObject } from 'lodash';
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
for (const key in aObject) {
@ -31,60 +32,39 @@ export function nullifyValuesInObjects<T>(aObjects: T[], keys: string[]): T[] {
});
}
export function redactAttributes({
isFirstRun = true,
export function query({
object,
options
pathExpression
}: {
object: object;
pathExpression: string;
}) {
return jsonpath.query(object, pathExpression);
}
export function redactPaths({
object,
paths,
valueMap
}: {
isFirstRun?: boolean;
object: any;
options: { attribute: string; valueMap: { [key: string]: any } }[];
paths: fastRedact.RedactOptions['paths'];
valueMap?: { [key: string]: any };
}): any {
if (!object || !options?.length) {
return object;
}
// Create deep clone
const redactedObject = isFirstRun
? JSON.parse(JSON.stringify(object))
: object;
for (const option of options) {
if (redactedObject.hasOwnProperty(option.attribute)) {
if (option.valueMap['*'] || option.valueMap['*'] === null) {
redactedObject[option.attribute] = option.valueMap['*'];
} else if (option.valueMap[redactedObject[option.attribute]]) {
redactedObject[option.attribute] =
option.valueMap[redactedObject[option.attribute]];
}
} else {
// If the attribute is not present on the current object,
// check if it exists on any nested objects
for (const property in redactedObject) {
if (isArray(redactedObject[property])) {
redactedObject[property] = redactedObject[property].map(
(currentObject) => {
return redactAttributes({
options,
isFirstRun: false,
object: currentObject
});
}
);
} else if (
isObject(redactedObject[property]) &&
!(redactedObject[property] instanceof Big)
) {
// Recursively call the function on the nested object
redactedObject[property] = redactAttributes({
options,
isFirstRun: false,
object: redactedObject[property]
});
const redact = fastRedact({
paths,
censor: (value) => {
if (valueMap) {
if (valueMap[value]) {
return valueMap[value];
} else {
return value;
}
} else {
return null;
}
}
}
});
return redactedObject;
return JSON.parse(redact(object));
}

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 { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { redactPaths } from '@ghostfolio/api/helper/object.helper';
import {
DEFAULT_REDACTED_PATHS,
HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config';
import {
hasReadRestrictedAccessPermission,
isRestrictedView
@ -39,40 +42,9 @@ export class RedactValuesInResponseInterceptor<T> implements NestInterceptor<
}) ||
isRestrictedView(user)
) {
data = redactAttributes({
data = redactPaths({
object: data,
options: [
'balance',
'balanceInBaseCurrency',
'comment',
'convertedBalance',
'dividendInBaseCurrency',
'fee',
'feeInBaseCurrency',
'grossPerformance',
'grossPerformanceWithCurrencyEffect',
'interestInBaseCurrency',
'investment',
'netPerformance',
'netPerformanceWithCurrencyEffect',
'quantity',
'symbolMapping',
'totalBalanceInBaseCurrency',
'totalDividendInBaseCurrency',
'totalInterestInBaseCurrency',
'totalValueInBaseCurrency',
'unitPrice',
'unitPriceInAssetProfileCurrency',
'value',
'valueInBaseCurrency'
].map((attribute) => {
return {
attribute,
valueMap: {
'*': null
}
};
})
paths: DEFAULT_REDACTED_PATHS
});
}

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

6
apps/api/src/main.ts

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

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

@ -57,7 +57,7 @@ export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
previousValue +
this.exchangeRateDataService.toCurrency(
new Big(currentValue.quantity)
.mul(currentValue.marketPrice)
.mul(currentValue.marketPrice ?? 0)
.toNumber(),
currentValue.currency,
baseCurrency

21
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 { RuleSettings, UserSettings } from '@ghostfolio/common/interfaces';
export class FeeRatioInitialInvestment extends Rule<Settings> {
export class FeeRatioTotalInvestmentVolume extends Rule<Settings> {
private fees: number;
private totalInvestment: number;
private totalInvestmentVolumeInBaseCurrency: number;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
languageCode: string,
totalInvestment: number,
totalInvestmentVolumeInBaseCurrency: number,
fees: number
) {
super(exchangeRateDataService, {
languageCode,
key: FeeRatioInitialInvestment.name
key: FeeRatioTotalInvestmentVolume.name
});
this.fees = fees;
this.totalInvestment = totalInvestment;
this.totalInvestmentVolumeInBaseCurrency =
totalInvestmentVolumeInBaseCurrency;
}
public evaluate(ruleSettings: Settings) {
const feeRatio = this.totalInvestment
? this.fees / this.totalInvestment
const feeRatio = this.totalInvestmentVolumeInBaseCurrency
? this.fees / this.totalInvestmentVolumeInBaseCurrency
: 0;
if (feeRatio > ruleSettings.thresholdMax) {
return {
evaluation: this.i18nService.getTranslation({
id: 'rule.feeRatioInitialInvestment.false',
id: 'rule.feeRatioTotalInvestmentVolume.false',
languageCode: this.getLanguageCode(),
placeholders: {
feeRatio: (ruleSettings.thresholdMax * 100).toFixed(2),
@ -44,7 +45,7 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
return {
evaluation: this.i18nService.getTranslation({
id: 'rule.feeRatioInitialInvestment.true',
id: 'rule.feeRatioTotalInvestmentVolume.true',
languageCode: this.getLanguageCode(),
placeholders: {
feeRatio: (feeRatio * 100).toPrecision(3),
@ -76,7 +77,7 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
public getName() {
return this.i18nService.getTranslation({
id: 'rule.feeRatioInitialInvestment',
id: 'rule.feeRatioTotalInvestmentVolume',
languageCode: this.getLanguageCode()
});
}

3
apps/api/src/services/configuration/configuration.service.ts

@ -30,6 +30,7 @@ export class ConfigurationService {
API_KEY_FINANCIAL_MODELING_PREP: str({ default: '' }),
API_KEY_OPEN_FIGI: str({ default: '' }),
API_KEY_RAPID_API: str({ default: '' }),
BULL_BOARD_IS_READ_ONLY: bool({ default: true }),
CACHE_QUOTES_TTL: num({ default: ms('1 minute') }),
CACHE_TTL: num({ default: CACHE_TTL_NO_CACHE }),
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),
@ -43,6 +44,7 @@ export class ConfigurationService {
ENABLE_FEATURE_AUTH_GOOGLE: bool({ default: false }),
ENABLE_FEATURE_AUTH_OIDC: bool({ default: false }),
ENABLE_FEATURE_AUTH_TOKEN: bool({ default: true }),
ENABLE_FEATURE_BULL_BOARD: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }),
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
@ -102,7 +104,6 @@ export class ConfigurationService {
ROOT_URL: url({
default: environment.rootUrl
}),
STRIPE_PUBLIC_KEY: str({ default: '' }),
STRIPE_SECRET_KEY: str({ default: '' }),
TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }),
TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }),

7
apps/api/src/services/cryptocurrency/cryptocurrency.module.ts

@ -1,9 +1,12 @@
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common';
import { CryptocurrencyService } from './cryptocurrency.service';
@Module({
providers: [CryptocurrencyService],
exports: [CryptocurrencyService]
exports: [CryptocurrencyService],
imports: [PropertyModule],
providers: [CryptocurrencyService]
})
export class CryptocurrencyModule {}

38
apps/api/src/services/cryptocurrency/cryptocurrency.service.ts

@ -1,31 +1,39 @@
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
DEFAULT_CURRENCY,
PROPERTY_CUSTOM_CRYPTOCURRENCIES
} from '@ghostfolio/common/config';
import { Injectable } from '@nestjs/common';
import { Injectable, OnModuleInit } from '@nestjs/common';
const cryptocurrencies = require('../../assets/cryptocurrencies/cryptocurrencies.json');
const customCryptocurrencies = require('../../assets/cryptocurrencies/custom.json');
@Injectable()
export class CryptocurrencyService {
export class CryptocurrencyService implements OnModuleInit {
private combinedCryptocurrencies: string[];
public constructor(private readonly propertyService: PropertyService) {}
public async onModuleInit() {
const customCryptocurrenciesFromDatabase =
await this.propertyService.getByKey<Record<string, string>>(
PROPERTY_CUSTOM_CRYPTOCURRENCIES
);
this.combinedCryptocurrencies = [
...Object.keys(cryptocurrencies),
...Object.keys(customCryptocurrencies),
...Object.keys(customCryptocurrenciesFromDatabase ?? {})
];
}
public isCryptocurrency(aSymbol = '') {
const cryptocurrencySymbol = aSymbol.substring(0, aSymbol.length - 3);
return (
aSymbol.endsWith(DEFAULT_CURRENCY) &&
this.getCryptocurrencies().includes(cryptocurrencySymbol)
this.combinedCryptocurrencies.includes(cryptocurrencySymbol)
);
}
private getCryptocurrencies() {
if (!this.combinedCryptocurrencies) {
this.combinedCryptocurrencies = [
...Object.keys(cryptocurrencies),
...Object.keys(customCryptocurrencies)
];
}
return this.combinedCryptocurrencies;
}
}

2
apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts

@ -18,7 +18,7 @@ import {
import { Injectable } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import * as Alphavantage from 'alphavantage';
import Alphavantage from 'alphavantage';
import { format, isAfter, isBefore, parse } from 'date-fns';
import { AlphaVantageHistoricalResponse } from './interfaces/interfaces';

2
apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.spec.ts

@ -29,7 +29,7 @@ describe('YahooFinanceDataEnhancerService', () => {
let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService;
beforeAll(async () => {
cryptocurrencyService = new CryptocurrencyService();
cryptocurrencyService = new CryptocurrencyService(null);
yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService(
cryptocurrencyService

37
apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts

@ -135,10 +135,10 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
shortName,
symbol
}: {
longName: Price['longName'];
quoteType: Price['quoteType'];
shortName: Price['shortName'];
symbol: Price['symbol'];
longName?: Price['longName'];
quoteType?: Price['quoteType'];
shortName?: Price['shortName'];
symbol?: Price['symbol'];
}) {
let name = longName;
@ -206,17 +206,28 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
);
if (['ETF', 'MUTUALFUND'].includes(assetSubClass)) {
response.sectors = [];
for (const sectorWeighting of assetProfile.topHoldings
?.sectorWeightings ?? []) {
for (const [sector, weight] of Object.entries(sectorWeighting)) {
response.sectors.push({
response.holdings =
assetProfile.topHoldings?.holdings
?.filter(({ holdingName }) => {
return !holdingName?.includes('ETF');
})
?.map(({ holdingName, holdingPercent }) => {
return {
name: this.formatName({ longName: holdingName }),
weight: holdingPercent
};
}) ?? [];
response.sectors = (
assetProfile.topHoldings?.sectorWeightings ?? []
).flatMap((sectorWeighting) => {
return Object.entries(sectorWeighting).map(([sector, weight]) => {
return {
name: this.parseSector(sector),
weight: weight as number
});
}
}
};
});
});
} else if (
assetSubClass === 'STOCK' &&
assetProfile.summaryProfile?.country

155
apps/api/src/services/data-provider/data-provider.service.ts

@ -1,3 +1,4 @@
import { ImportDataDto } from '@ghostfolio/api/app/import/import-data.dto';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
@ -10,8 +11,10 @@ import {
PROPERTY_API_KEY_GHOSTFOLIO,
PROPERTY_DATA_SOURCE_MAPPING
} from '@ghostfolio/common/config';
import { CreateOrderDto } from '@ghostfolio/common/dtos';
import {
DATE_FORMAT,
getAssetProfileIdentifier,
getCurrencyFromSymbol,
getStartOfUtcDate,
isCurrency,
@ -27,7 +30,7 @@ import {
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js';
import { eachDayOfInterval, format, isValid } from 'date-fns';
import { groupBy, isEmpty, isNumber, uniqWith } from 'lodash';
@ -185,6 +188,125 @@ export class DataProviderService implements OnModuleInit {
return dataSources.sort();
}
public async validateActivities({
activitiesDto,
assetProfilesWithMarketDataDto,
maxActivitiesToImport,
user
}: {
activitiesDto: Pick<
Partial<CreateOrderDto>,
'currency' | 'dataSource' | 'symbol' | 'type'
>[];
assetProfilesWithMarketDataDto?: ImportDataDto['assetProfiles'];
maxActivitiesToImport: number;
user: UserWithSettings;
}) {
if (activitiesDto?.length > maxActivitiesToImport) {
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
}
const assetProfiles: {
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
} = {};
const dataSources = await this.getDataSources();
for (const [
index,
{ currency, dataSource, symbol, type }
] of activitiesDto.entries()) {
const activityPath =
maxActivitiesToImport === 1 ? 'activity' : `activities.${index}`;
if (!dataSources.includes(dataSource)) {
throw new Error(
`${activityPath}.dataSource ("${dataSource}") is not valid`
);
}
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
user.subscription.type === 'Basic'
) {
const dataProvider = this.getDataProvider(DataSource[dataSource]);
if (dataProvider.getDataProviderInfo().isPremium) {
throw new Error(
`${activityPath}.dataSource ("${dataSource}") is not valid`
);
}
}
const assetProfileIdentifier = getAssetProfileIdentifier({
dataSource,
symbol
});
if (!assetProfiles[assetProfileIdentifier]) {
if (
(dataSource === DataSource.MANUAL && type === 'BUY') ||
['FEE', 'INTEREST', 'LIABILITY'].includes(type)
) {
const assetProfileInImport = assetProfilesWithMarketDataDto?.find(
(assetProfile) => {
return (
assetProfile.dataSource === dataSource &&
assetProfile.symbol === symbol
);
}
);
assetProfiles[assetProfileIdentifier] = {
currency,
dataSource,
symbol,
name: assetProfileInImport?.name ?? symbol
};
continue;
}
let assetProfile: Partial<SymbolProfile> = { currency };
try {
assetProfile = (
await this.getAssetProfiles([
{
dataSource,
symbol
}
])
)?.[symbol];
} catch {}
if (!assetProfile?.name) {
const assetProfileInImport = assetProfilesWithMarketDataDto?.find(
(profile) => {
return (
profile.dataSource === dataSource && profile.symbol === symbol
);
}
);
if (assetProfileInImport) {
Object.assign(assetProfile, assetProfileInImport);
}
}
if (!assetProfile?.name) {
throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
);
}
assetProfiles[assetProfileIdentifier] = assetProfile;
}
}
return assetProfiles;
}
public async getDividends({
dataSource,
from,
@ -225,36 +347,35 @@ export class DataProviderService implements OnModuleInit {
const granularityQuery =
aGranularity === 'month'
? `AND (date_part('day', date) = 1 OR date >= TIMESTAMP 'yesterday')`
: '';
? Prisma.sql`AND (date_part('day', date) = 1 OR date >= TIMESTAMP 'yesterday')`
: Prisma.empty;
const rangeQuery =
from && to
? `AND date >= '${format(from, DATE_FORMAT)}' AND date <= '${format(
? Prisma.sql`AND date >= ${format(from, DATE_FORMAT)}::timestamp AND date <= ${format(
to,
DATE_FORMAT
)}'`
: '';
)}::timestamp`
: Prisma.empty;
const dataSources = aItems.map(({ dataSource }) => {
return dataSource;
});
const symbols = aItems.map(({ symbol }) => {
return symbol;
});
try {
const queryRaw = `
SELECT *
FROM "MarketData"
WHERE "dataSource" IN ('${dataSources.join(`','`)}')
AND "symbol" IN ('${symbols.join(
`','`
)}') ${granularityQuery} ${rangeQuery}
ORDER BY date;`;
const marketDataByGranularity: MarketData[] =
await this.prismaService.$queryRawUnsafe(queryRaw);
const marketDataByGranularity: MarketData[] = await this.prismaService
.$queryRaw`
SELECT *
FROM "MarketData"
WHERE "dataSource"::text IN (${Prisma.join(dataSources)})
AND "symbol" IN (${Prisma.join(symbols)})
${granularityQuery}
${rangeQuery}
ORDER BY date;`;
response = marketDataByGranularity.reduce((r, marketData) => {
const { date, marketPrice, symbol } = marketData;

47
apps/api/src/services/data-provider/manual/manual.service.ts

@ -1,3 +1,4 @@
import { query } from '@ghostfolio/api/helper/object.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
DataProviderInterface,
@ -26,7 +27,6 @@ import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import * as cheerio from 'cheerio';
import { addDays, format, isBefore } from 'date-fns';
import * as jsonpath from 'jsonpath';
@Injectable()
export class ManualService implements DataProviderInterface {
@ -105,7 +105,10 @@ export class ManualService implements DataProviderInterface {
return {};
}
const value = await this.scrape(symbolProfile.scraperConfiguration);
const value = await this.scrape({
symbol,
scraperConfiguration: symbolProfile.scraperConfiguration
});
return {
[symbol]: {
@ -170,7 +173,10 @@ export class ManualService implements DataProviderInterface {
symbolProfilesWithScraperConfigurationAndInstantMode.map(
async ({ scraperConfiguration, symbol }) => {
try {
const marketPrice = await this.scrape(scraperConfiguration);
const marketPrice = await this.scrape({
scraperConfiguration,
symbol
});
return { marketPrice, symbol };
} catch (error) {
Logger.error(
@ -267,13 +273,23 @@ export class ManualService implements DataProviderInterface {
};
}
public async test(scraperConfiguration: ScraperConfiguration) {
return this.scrape(scraperConfiguration);
public async test({
scraperConfiguration,
symbol
}: {
scraperConfiguration: ScraperConfiguration;
symbol: string;
}) {
return this.scrape({ scraperConfiguration, symbol });
}
private async scrape(
scraperConfiguration: ScraperConfiguration
): Promise<number> {
private async scrape({
scraperConfiguration,
symbol
}: {
scraperConfiguration: ScraperConfiguration;
symbol: string;
}): Promise<number> {
let locale = scraperConfiguration.locale;
const response = await fetch(scraperConfiguration.url, {
@ -283,12 +299,23 @@ export class ManualService implements DataProviderInterface {
)
});
if (!response.ok) {
throw new Error(
`Failed to scrape the market price for ${symbol} (${this.getName()}): ${response.status} ${response.statusText} at ${scraperConfiguration.url}`
);
}
let value: string;
if (response.headers.get('content-type')?.includes('application/json')) {
const data = await response.json();
const object = await response.json();
value = String(jsonpath.query(data, scraperConfiguration.selector)[0]);
value = String(
query({
object,
pathExpression: scraperConfiguration.selector
})[0]
);
} else {
const $ = cheerio.load(await response.text());

12
apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts

@ -1,5 +1,7 @@
export const ExchangeRateDataServiceMock = {
getExchangeRatesByCurrency: ({ targetCurrency }): Promise<any> => {
import { ExchangeRateDataService } from './exchange-rate-data.service';
export const ExchangeRateDataServiceMock: Partial<ExchangeRateDataService> = {
getExchangeRatesByCurrency: ({ targetCurrency }) => {
if (targetCurrency === 'CHF') {
return Promise.resolve({
CHFCHF: {
@ -14,7 +16,11 @@ export const ExchangeRateDataServiceMock = {
'2017-12-31': 0.9787,
'2018-01-01': 0.97373,
'2023-01-03': 0.9238,
'2023-07-10': 0.8854
'2023-07-10': 0.8854,
'2023-12-31': 0.85,
'2024-01-01': 0.86,
'2024-12-31': 0.9,
'2025-01-01': 0.91
}
});
} else if (targetCurrency === 'EUR') {

4
apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts

@ -26,6 +26,8 @@ import {
import { isNumber } from 'lodash';
import ms from 'ms';
import { ExchangeRatesByCurrency } from './interfaces/exchange-rate-data.interface';
@Injectable()
export class ExchangeRateDataService {
private currencies: string[] = [];
@ -59,7 +61,7 @@ export class ExchangeRateDataService {
endDate?: Date;
startDate: Date;
targetCurrency: string;
}) {
}): Promise<ExchangeRatesByCurrency> {
if (!startDate) {
return {};
}

5
apps/api/src/services/exchange-rate-data/interfaces/exchange-rate-data.interface.ts

@ -0,0 +1,5 @@
export interface ExchangeRatesByCurrency {
[currency: string]: {
[dateString: string]: number;
};
}

6
apps/api/src/services/i18n/i18n.service.ts

@ -1,16 +1,16 @@
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import * as cheerio from 'cheerio';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
@Injectable()
export class I18nService {
export class I18nService implements OnModuleInit {
private localesPath = join(__dirname, 'assets', 'locales');
private translations: { [locale: string]: cheerio.CheerioAPI } = {};
public constructor() {
public onModuleInit() {
this.loadFiles();
}

3
apps/api/src/services/interfaces/environment.interface.ts

@ -10,6 +10,7 @@ export interface Environment extends CleanedEnvAccessors {
API_KEY_FINANCIAL_MODELING_PREP: string;
API_KEY_OPEN_FIGI: string;
API_KEY_RAPID_API: string;
BULL_BOARD_IS_READ_ONLY: boolean;
CACHE_QUOTES_TTL: number;
CACHE_TTL: number;
DATA_SOURCE_EXCHANGE_RATES: string;
@ -19,6 +20,7 @@ export interface Environment extends CleanedEnvAccessors {
ENABLE_FEATURE_AUTH_GOOGLE: boolean;
ENABLE_FEATURE_AUTH_OIDC: boolean;
ENABLE_FEATURE_AUTH_TOKEN: boolean;
ENABLE_FEATURE_BULL_BOARD: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean;
ENABLE_FEATURE_READ_ONLY_MODE: boolean;
@ -52,7 +54,6 @@ export interface Environment extends CleanedEnvAccessors {
REDIS_PORT: number;
REQUEST_TIMEOUT: number;
ROOT_URL: string;
STRIPE_PUBLIC_KEY: string;
STRIPE_SECRET_KEY: string;
TWITTER_ACCESS_TOKEN: string;
TWITTER_ACCESS_TOKEN_SECRET: string;

14
apps/api/src/services/queues/data-gathering/data-gathering.module.ts

@ -9,6 +9,8 @@ import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathe
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config';
import { BullAdapter } from '@bull-board/api/bullAdapter';
import { BullBoardModule } from '@bull-board/nestjs';
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import ms from 'ms';
@ -17,6 +19,18 @@ import { DataGatheringProcessor } from './data-gathering.processor';
@Module({
imports: [
...(process.env.ENABLE_FEATURE_BULL_BOARD === 'true'
? [
BullBoardModule.forFeature({
adapter: BullAdapter,
name: DATA_GATHERING_QUEUE,
options: {
displayName: 'Data Gathering',
readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false'
}
})
]
: []),
BullModule.registerQueue({
limiter: {
duration: ms('4 seconds'),

18
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts

@ -1,5 +1,5 @@
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
@ -13,6 +13,8 @@ import {
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE
} from '@ghostfolio/common/config';
import { BullAdapter } from '@bull-board/api/bullAdapter';
import { BullBoardModule } from '@bull-board/nestjs';
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
@ -22,6 +24,19 @@ import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor';
exports: [BullModule, PortfolioSnapshotService],
imports: [
AccountBalanceModule,
ActivitiesModule,
...(process.env.ENABLE_FEATURE_BULL_BOARD === 'true'
? [
BullBoardModule.forFeature({
adapter: BullAdapter,
name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
options: {
displayName: 'Portfolio Snapshot Computation',
readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false'
}
})
]
: []),
BullModule.registerQueue({
name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
settings: {
@ -36,7 +51,6 @@ import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor';
DataProviderModule,
ExchangeRateDataModule,
MarketDataModule,
OrderModule,
RedisCacheModule
],
providers: [

9
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts

@ -1,5 +1,5 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
@ -23,9 +23,9 @@ import { PortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue
export class PortfolioSnapshotProcessor {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly activitiesService: ActivitiesService,
private readonly calculatorFactory: PortfolioCalculatorFactory,
private readonly configurationService: ConfigurationService,
private readonly orderService: OrderService,
private readonly redisCacheService: RedisCacheService
) {}
@ -47,10 +47,11 @@ export class PortfolioSnapshotProcessor {
);
const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({
await this.activitiesService.getActivitiesForPortfolioCalculator({
filters: job.data.filters,
userCurrency: job.data.userCurrency,
userId: job.data.userId
userId: job.data.userId,
withCash: true
});
const accountBalanceItems =

10
apps/api/src/services/symbol-profile/symbol-profile.service.ts

@ -77,7 +77,7 @@ export class SymbolProfileService {
.findMany({
include: {
_count: {
select: { activities: true }
select: { activities: true, watchedBy: true }
},
activities: {
orderBy: {
@ -109,7 +109,7 @@ export class SymbolProfileService {
.findMany({
include: {
_count: {
select: { activities: true }
select: { activities: true, watchedBy: true }
},
SymbolProfileOverrides: true
},
@ -184,7 +184,7 @@ export class SymbolProfileService {
private enhanceSymbolProfiles(
symbolProfiles: (SymbolProfile & {
_count: { activities: number };
_count: { activities: number; watchedBy?: number };
activities?: {
date: Date;
}[];
@ -206,10 +206,12 @@ export class SymbolProfileService {
sectors: this.getSectors(
symbolProfile?.sectors as unknown as Prisma.JsonArray
),
symbolMapping: this.getSymbolMapping(symbolProfile)
symbolMapping: this.getSymbolMapping(symbolProfile),
watchedByCount: 0
};
item.activitiesCount = symbolProfile._count.activities;
item.watchedByCount = symbolProfile._count.watchedBy ?? 0;
delete item._count;
item.dateOfFirstActivity = symbolProfile.activities?.[0]?.date;

16
apps/api/src/services/tag/tag.service.ts

@ -75,12 +75,16 @@ export class TagService {
}
});
return tags.map(({ _count, id, name, userId }) => ({
id,
name,
userId,
isUsed: _count.activities > 0
}));
return tags
.map(({ _count, id, name, userId }) => ({
id,
name,
userId,
isUsed: _count.activities > 0
}))
.sort((a, b) => {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
}
public async getTagsWithActivityCount() {

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

Loading…
Cancel
Save